diff --git a/package-lock.json b/package-lock.json index 14c9ec9..195f214 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@headlessui/react": "^2.2.0", "@types/string-similarity": "^4.0.2", + "@types/uuid": "^10.0.0", "compromise": "^14.14.4", "lru-cache": "^11.0.2", "next": "15.1.5", @@ -19,7 +20,8 @@ "react-dom": "^19.0.0", "react-dropzone": "^14.3.5", "react-pdf": "^9.2.1", - "string-similarity": "^4.0.4" + "string-similarity": "^4.0.4", + "uuid": "^11.0.5" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -1165,6 +1167,12 @@ "integrity": "sha512-LkJQ/jsXtCVMK+sKYAmX/8zEq+/46f1PTQw7YtmQwb74jemS1SlNLmARM2Zml9DgdDTWKAtc5L13WorpHPDjDA==", "license": "MIT" }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.20.0.tgz", @@ -6751,6 +6759,19 @@ "devOptional": true, "license": "MIT" }, + "node_modules/uuid": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.5.tgz", + "integrity": "sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/warning": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", diff --git a/package.json b/package.json index ecf2827..7526023 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dependencies": { "@headlessui/react": "^2.2.0", "@types/string-similarity": "^4.0.2", + "@types/uuid": "^10.0.0", "compromise": "^14.14.4", "lru-cache": "^11.0.2", "next": "15.1.5", @@ -20,7 +21,8 @@ "react-dom": "^19.0.0", "react-dropzone": "^14.3.5", "react-pdf": "^9.2.1", - "string-similarity": "^4.0.4" + "string-similarity": "^4.0.4", + "uuid": "^11.0.5" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/src/app/pdf/[id]/page.tsx b/src/app/pdf/[id]/page.tsx index be8405c..b01a6b5 100644 --- a/src/app/pdf/[id]/page.tsx +++ b/src/app/pdf/[id]/page.tsx @@ -4,13 +4,53 @@ import { PDFViewer } from '@/components/PDFViewer'; import { usePDF } from '@/context/PDFContext'; import { useParams, useRouter } from 'next/navigation'; import Link from 'next/link'; +import { useEffect, useState } from 'react'; +import { PDFSkeleton } from '@/components/PDFSkeleton'; export default function PDFViewerPage() { const { id } = useParams(); const { getDocument } = usePDF(); const router = useRouter(); + const [document, setDocument] = useState<{ name: string; data: Blob } | null>(null); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); - const document = getDocument(id as string); + useEffect(() => { + async function loadDocument() { + try { + const doc = await getDocument(id as string); + if (!doc) { + setError('Document not found'); + return; + } + setDocument(doc); + } catch (err) { + console.error('Error loading document:', err); + setError('Failed to load document'); + } finally { + setIsLoading(false); + } + } + + loadDocument(); + }, [id, getDocument]); + + if (error) { + return ( +
+

{error}

+ + + + + Back to Documents + +
+ ); + } return ( <> @@ -25,10 +65,18 @@ export default function PDFViewerPage() { Documents -

{document?.name}

+

+ {isLoading ? 'Loading...' : document?.name} +

- + {isLoading ? ( +
+ +
+ ) : ( + + )} - ); + ); } diff --git a/src/components/DocumentList.tsx b/src/components/DocumentList.tsx index 1cf7cbb..564b15a 100644 --- a/src/components/DocumentList.tsx +++ b/src/components/DocumentList.tsx @@ -2,7 +2,23 @@ import { usePDF } from '@/context/PDFContext'; import Link from 'next/link'; export function DocumentList() { - const { documents, removeDocument } = usePDF(); + const { documents, removeDocument, isLoading, error } = usePDF(); + + if (isLoading) { + return ( +
+ Loading documents... +
+ ); + } + + if (error) { + return ( +
+ {error} +
+ ); + } if (documents.length === 0) { return ( @@ -30,17 +46,23 @@ export function DocumentList() { -
-

{doc.name}

-

+

+

{doc.name}

+

{(doc.size / 1024 / 1024).toFixed(2)} MB

); diff --git a/src/components/PDFViewer.tsx b/src/components/PDFViewer.tsx index 851d0f1..c1fcd4b 100644 --- a/src/components/PDFViewer.tsx +++ b/src/components/PDFViewer.tsx @@ -3,7 +3,7 @@ import { Document, Page, pdfjs } from 'react-pdf'; import 'react-pdf/dist/Page/AnnotationLayer.css'; import 'react-pdf/dist/Page/TextLayer.css'; -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import { PDFSkeleton } from './PDFSkeleton'; import { useTTS } from '@/context/TTSContext'; import stringSimilarity from 'string-similarity'; @@ -12,7 +12,7 @@ import stringSimilarity from 'string-similarity'; pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.mjs'; interface PDFViewerProps { - pdfFile: string | undefined; + pdfData: Blob | undefined; } interface TextHighlight { @@ -23,11 +23,98 @@ interface TextHighlight { }; } -export function PDFViewer({ pdfFile }: PDFViewerProps) { +export function PDFViewer({ pdfData }: PDFViewerProps) { const [numPages, setNumPages] = useState(); const { setText, currentSentence } = useTTS(); const [pdfText, setPdfText] = useState(''); const [highlights, setHighlights] = useState([]); + const [pdfDataUrl, setPdfDataUrl] = useState(); + const [loadingError, setLoadingError] = useState(); + + // Convert Blob to data URL when pdfData changes + useEffect(() => { + if (!pdfData) return; + + const reader = new FileReader(); + reader.onload = () => { + setPdfDataUrl(reader.result as string); + }; + reader.onerror = () => { + console.error('Error reading file:', reader.error); + setLoadingError('Failed to load PDF'); + }; + reader.readAsDataURL(pdfData); + + return () => { + setPdfDataUrl(undefined); + }; + }, [pdfData]); + + // Load PDF text content + useEffect(() => { + if (!pdfDataUrl) return; + + let isCurrentPdf = true; + let currentLoadingTask: any = null; + setLoadingError(undefined); + + const loadPdfText = async () => { + try { + // Create a typed array from the base64 data + const base64Data = pdfDataUrl.split(',')[1]; + const binaryData = atob(base64Data); + const length = binaryData.length; + const bytes = new Uint8Array(length); + + for (let i = 0; i < length; i++) { + bytes[i] = binaryData.charCodeAt(i); + } + + const loadingTask = pdfjs.getDocument({ + data: bytes, + disableAutoFetch: true, + disableStream: false, + }); + + currentLoadingTask = loadingTask; + const pdf = await loadingTask.promise; + + if (!isCurrentPdf) return; + + let fullText = ''; + for (let i = 1; i <= pdf.numPages; i++) { + if (!isCurrentPdf) break; + + const page = await pdf.getPage(i); + const textContent = await page.getTextContent(); + const pageText = textContent.items + .map((item: any) => item.str) + .join(' '); + fullText += pageText + ' '; + } + + if (!isCurrentPdf) return; + + console.log('Loaded PDF text sample:', fullText.substring(0, 100)); + setPdfText(fullText); + setText(fullText); + } catch (error) { + if (!isCurrentPdf) return; + console.error('Error loading PDF text:', error); + setLoadingError('Failed to extract PDF text'); + } + }; + + loadPdfText(); + + return () => { + isCurrentPdf = false; + if (currentLoadingTask) { + currentLoadingTask.destroy(); + } + setPdfText(''); + }; + }, [pdfDataUrl, setText]); // Function to clear all highlights const clearHighlights = useCallback(() => { @@ -137,12 +224,12 @@ export function PDFViewer({ pdfFile }: PDFViewerProps) { const lengthPenalty = lengthDiff / patternLength; // Normalized length difference const adjustedRating = similarity * (1 - lengthPenalty * 0.5); // Reduce score based on length difference - console.log('Comparing:', { - text: combinedText, - similarity, - lengthDiff, - adjustedRating - }); + // console.log('Comparing:', { + // text: combinedText, + // similarity, + // lengthDiff, + // adjustedRating + // }); // Update best match if we have better adjusted rating if (adjustedRating > bestMatch.rating) { @@ -181,37 +268,6 @@ export function PDFViewer({ pdfFile }: PDFViewerProps) { } }, [clearHighlights]); - useEffect(() => { - if (pdfFile) { - const loadPdfText = async () => { - try { - const pdf = await pdfjs.getDocument(pdfFile).promise; - let fullText = ''; - - for (let i = 1; i <= pdf.numPages; i++) { - const page = await pdf.getPage(i); - const textContent = await page.getTextContent(); - const pageText = textContent.items - .map((item: any) => item.str) - .join(' '); - fullText += pageText + ' '; - } - - console.log('Loaded PDF text sample:', fullText.substring(0, 100)); - setPdfText(fullText); - setText(fullText); - } catch (error) { - console.error('Error loading PDF text:', error); - } - }; - - loadPdfText(); - } - - // Clear highlights when PDF changes - return () => clearHighlights(); - }, [pdfFile, setText, clearHighlights]); - // Update highlights when current sentence changes useEffect(() => { console.log('Current sentence changed:', currentSentence); @@ -229,11 +285,14 @@ export function PDFViewer({ pdfFile }: PDFViewerProps) { return (
+ {loadingError ? ( +
{loadingError}
+ ) : null} } noData={} + file={pdfDataUrl} + onLoadSuccess={onDocumentLoadSuccess} className="flex flex-col items-center" > {Array.from( diff --git a/src/context/PDFContext.tsx b/src/context/PDFContext.tsx index 97096b7..c3b304c 100644 --- a/src/context/PDFContext.tsx +++ b/src/context/PDFContext.tsx @@ -1,72 +1,90 @@ 'use client'; import { createContext, useContext, useState, ReactNode, useEffect } from 'react'; - -interface PDFDocument { - id: string; - name: string; - size: number; - lastModified: number; - data: string; // Base64 encoded file data -} +import { indexedDBService, type PDFDocument } from '@/services/indexedDB'; +import { v4 as uuidv4 } from 'uuid'; interface PDFContextType { documents: PDFDocument[]; addDocument: (file: File) => Promise; - getDocument: (id: string) => PDFDocument | undefined; - removeDocument: (id: string) => void; + getDocument: (id: string) => Promise; + removeDocument: (id: string) => Promise; + isLoading: boolean; + error: string | null; } const PDFContext = createContext(undefined); -const LOCAL_STORAGE_KEY = 'pdf-documents'; - export function PDFProvider({ children }: { children: ReactNode }) { const [documents, setDocuments] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); - // Load documents from local storage on mount useEffect(() => { - const storedDocs = localStorage.getItem(LOCAL_STORAGE_KEY); - if (storedDocs) { - setDocuments(JSON.parse(storedDocs)); - } + const loadDocuments = async () => { + try { + setError(null); + await indexedDBService.init(); + const docs = await indexedDBService.getAllDocuments(); + setDocuments(docs); + } catch (error) { + console.error('Failed to load documents:', error); + setError('Failed to initialize document storage. Please check if your browser supports IndexedDB.'); + } finally { + setIsLoading(false); + } + }; + + loadDocuments(); }, []); - // Save documents to local storage whenever they change - useEffect(() => { - localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(documents)); - }, [documents]); - const addDocument = async (file: File): Promise => { - return new Promise((resolve) => { - const reader = new FileReader(); - reader.onloadend = () => { - const base64Data = reader.result as string; - const newDoc: PDFDocument = { - id: `${file.name.replace(/[^a-zA-Z0-9]/g, '-').replace(/-+/g, '-')}-${Date.now()}`, - name: file.name, - size: file.size, - lastModified: file.lastModified, - data: base64Data - }; - - setDocuments(prev => [...prev, newDoc]); - resolve(newDoc.id); - }; - reader.readAsDataURL(file); - }); + setError(null); + const id = uuidv4(); + const newDoc: PDFDocument = { + id, + name: file.name, + size: file.size, + lastModified: file.lastModified, + data: new Blob([file], { type: file.type }) + }; + + try { + await indexedDBService.addDocument(newDoc); + setDocuments(prev => [...prev, newDoc]); + return id; + } catch (error) { + console.error('Failed to add document:', error); + setError('Failed to save the document. Please try again.'); + throw error; + } }; - const getDocument = (id: string) => { - return documents.find(doc => doc.id === id); + const getDocument = async (id: string): Promise => { + setError(null); + try { + return await indexedDBService.getDocument(id); + } catch (error) { + console.error('Failed to get document:', error); + setError('Failed to retrieve the document. Please try again.'); + return undefined; + } }; - const removeDocument = (id: string) => { - setDocuments(prev => prev.filter(doc => doc.id !== id)); + const removeDocument = async (id: string): Promise => { + setError(null); + try { + await indexedDBService.removeDocument(id); + setDocuments(prev => prev.filter(doc => doc.id !== id)); + } catch (error) { + console.error('Failed to remove document:', error); + setError('Failed to remove the document. Please try again.'); + throw error; + } }; return ( - + {children} ); diff --git a/src/context/TTSContext.tsx b/src/context/TTSContext.tsx index 08846a0..191a8c5 100644 --- a/src/context/TTSContext.tsx +++ b/src/context/TTSContext.tsx @@ -49,6 +49,8 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { const [currentAudioIndex, setCurrentAudioIndex] = useState(0); const [isProcessing, setIsProcessing] = useState(false); const skipTriggeredRef = useRef(false); + const skipTimeoutRef = useRef(null); + const isPausingRef = useRef(false); // Create OpenAI instance const [openai] = useState( @@ -151,9 +153,11 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { source.onended = () => { setActiveSource(null); setIsProcessing(false); - if (isPlaying && !skipTriggeredRef.current) { + // Only advance if we're playing and not pausing or skipping + if (isPlaying && !skipTriggeredRef.current && !isPausingRef.current) { processNextSentence(); } + isPausingRef.current = false; // Reset pause flag }; source.start(0); @@ -173,19 +177,24 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { const togglePlay = useCallback(() => { setIsPlaying((prev) => { if (!prev) { - return true; // Start playing + isPausingRef.current = false; + return true; } else { - // Pause playback if (activeSource) { - activeSource.stop(); // Stop the audio source - setActiveSource(null); // Clear the active source + isPausingRef.current = true; + activeSource.stop(); + setActiveSource(null); } - return false; // Set isPlaying to false + return false; } }); }, [activeSource]); const skipForward = useCallback(() => { + if (skipTimeoutRef.current) { + clearTimeout(skipTimeoutRef.current); + } + skipTriggeredRef.current = true; // Cancel any ongoing request if (currentRequestRef.current) { @@ -206,12 +215,16 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { }); // Reset skip flag after a short delay - setTimeout(() => { + skipTimeoutRef.current = setTimeout(() => { skipTriggeredRef.current = false; }, 100); }, [sentences, activeSource]); const skipBackward = useCallback(() => { + if (skipTimeoutRef.current) { + clearTimeout(skipTimeoutRef.current); + } + skipTriggeredRef.current = true; // Cancel any ongoing request if (currentRequestRef.current) { @@ -232,7 +245,7 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { }); // Reset skip flag after a short delay - setTimeout(() => { + skipTimeoutRef.current = setTimeout(() => { skipTriggeredRef.current = false; }, 100); }, [sentences, activeSource]); diff --git a/src/services/indexedDB.ts b/src/services/indexedDB.ts new file mode 100644 index 0000000..5315235 --- /dev/null +++ b/src/services/indexedDB.ts @@ -0,0 +1,169 @@ +const DB_NAME = 'openreader-db'; +const DB_VERSION = 1; +const STORE_NAME = 'pdf-documents'; + +export interface PDFDocument { + id: string; + name: string; + size: number; + lastModified: number; + data: Blob; +} + +class IndexedDBService { + private db: IDBDatabase | null = null; + + async init(): Promise { + if (!window.indexedDB) { + throw new Error('IndexedDB is not supported in this browser'); + } + + return new Promise((resolve, reject) => { + console.log('Initializing IndexedDB...'); + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = (event) => { + const error = (event.target as IDBRequest).error; + console.error('IndexedDB initialization error:', error); + reject(error); + }; + + request.onsuccess = (event) => { + console.log('IndexedDB initialized successfully'); + this.db = (event.target as IDBOpenDBRequest).result; + resolve(); + }; + + request.onupgradeneeded = (event) => { + console.log('Upgrading IndexedDB schema...'); + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + console.log('Creating PDF documents store...'); + db.createObjectStore(STORE_NAME, { keyPath: 'id' }); + } + }; + }); + } + + async addDocument(document: PDFDocument): Promise { + if (!this.db) { + console.log('Database not initialized, initializing now...'); + await this.init(); + } + + return new Promise((resolve, reject) => { + try { + console.log('Adding document to IndexedDB:', document.name); + const transaction = this.db!.transaction([STORE_NAME], 'readwrite'); + const store = transaction.objectStore(STORE_NAME); + const request = store.put(document); + + request.onerror = (event) => { + const error = (event.target as IDBRequest).error; + console.error('Error adding document:', error); + reject(error); + }; + + transaction.oncomplete = () => { + console.log('Document added successfully:', document.name); + resolve(); + }; + } catch (error) { + console.error('Error in addDocument transaction:', error); + reject(error); + } + }); + } + + async getDocument(id: string): Promise { + if (!this.db) { + console.log('Database not initialized, initializing now...'); + await this.init(); + } + + return new Promise((resolve, reject) => { + try { + console.log('Fetching document:', id); + const transaction = this.db!.transaction([STORE_NAME], 'readonly'); + const store = transaction.objectStore(STORE_NAME); + const request = store.get(id); + + request.onerror = (event) => { + const error = (event.target as IDBRequest).error; + console.error('Error fetching document:', error); + reject(error); + }; + + request.onsuccess = () => { + console.log('Document fetch result:', request.result ? 'found' : 'not found'); + resolve(request.result); + }; + } catch (error) { + console.error('Error in getDocument transaction:', error); + reject(error); + } + }); + } + + async getAllDocuments(): Promise { + if (!this.db) { + console.log('Database not initialized, initializing now...'); + await this.init(); + } + + return new Promise((resolve, reject) => { + try { + console.log('Fetching all documents'); + const transaction = this.db!.transaction([STORE_NAME], 'readonly'); + const store = transaction.objectStore(STORE_NAME); + const request = store.getAll(); + + request.onerror = (event) => { + const error = (event.target as IDBRequest).error; + console.error('Error fetching all documents:', error); + reject(error); + }; + + request.onsuccess = () => { + console.log('Retrieved documents count:', request.result?.length || 0); + resolve(request.result || []); + }; + } catch (error) { + console.error('Error in getAllDocuments transaction:', error); + reject(error); + } + }); + } + + async removeDocument(id: string): Promise { + if (!this.db) { + console.log('Database not initialized, initializing now...'); + await this.init(); + } + + return new Promise((resolve, reject) => { + try { + console.log('Removing document:', id); + const transaction = this.db!.transaction([STORE_NAME], 'readwrite'); + const store = transaction.objectStore(STORE_NAME); + const request = store.delete(id); + + request.onerror = (event) => { + const error = (event.target as IDBRequest).error; + console.error('Error removing document:', error); + reject(error); + }; + + transaction.oncomplete = () => { + console.log('Document removed successfully:', id); + resolve(); + }; + } catch (error) { + console.error('Error in removeDocument transaction:', error); + reject(error); + } + }); + } +} + +export const indexedDBService = new IndexedDBService();