diff --git a/README.md b/README.md index 81d6962..b759b28 100644 --- a/README.md +++ b/README.md @@ -36,15 +36,13 @@ https://github.com/user-attachments/assets/7a3457ba-feda-4939-928a-cb587b1c0884 1. Clone the repository: ```bash -git clone [repository-url] -cd openreader-webui +git clone https://github.com/richardr1126/OpenReader-WebUI.git +cd OpenReader-WebUI ``` 2. Install dependencies: ```bash npm install -# or -yarn install ``` 3. Set up environment variables: diff --git a/src/app/pdf/[id]/page.tsx b/src/app/pdf/[id]/page.tsx index 2ae2d69..9deec96 100644 --- a/src/app/pdf/[id]/page.tsx +++ b/src/app/pdf/[id]/page.tsx @@ -2,7 +2,7 @@ import dynamic from 'next/dynamic'; import { usePDF } from '@/contexts/PDFContext'; -import { useParams, useRouter } from 'next/navigation'; +import { useParams } from 'next/navigation'; import Link from 'next/link'; import { useEffect, useState } from 'react'; import { PDFSkeleton } from '@/components/PDFSkeleton'; @@ -21,7 +21,6 @@ const PDFViewer = dynamic( export default function PDFViewerPage() { const { id } = useParams(); const { getDocument } = usePDF(); - const router = useRouter(); const { setText, stop } = useTTS(); const [document, setDocument] = useState<{ name: string; data: Blob } | null>(null); const [error, setError] = useState(null); @@ -86,7 +85,7 @@ export default function PDFViewerPage() { Documents -

+

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

diff --git a/src/app/providers.tsx b/src/app/providers.tsx index cfb8738..5f14bd2 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -3,16 +3,19 @@ import { PDFProvider } from '@/contexts/PDFContext'; 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 ( - - - {children} - - + + + + {children} + + + ); } diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 03af752..2565087 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -1,8 +1,9 @@ 'use client'; -import { Fragment } from 'react'; +import { Fragment, useState, useEffect } from 'react'; import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild, Listbox, ListboxButton, ListboxOptions, ListboxOption } from '@headlessui/react'; import { useTheme } from '@/contexts/ThemeContext'; +import { useConfig } from '@/contexts/ConfigContext'; interface SettingsModalProps { isOpen: boolean; @@ -17,8 +18,16 @@ const themes = [ export function SettingsModal({ isOpen, setIsOpen }: SettingsModalProps) { const { theme, setTheme } = useTheme(); + const { apiKey, baseUrl, updateConfig } = useConfig(); + const [localApiKey, setLocalApiKey] = useState(apiKey); + const [localBaseUrl, setLocalBaseUrl] = useState(baseUrl); const selectedTheme = themes.find(t => t.id === theme) || themes[0]; + useEffect(() => { + setLocalApiKey(apiKey); + setLocalBaseUrl(baseUrl); + }, [apiKey, baseUrl]); + return ( setIsOpen(false)}> @@ -122,19 +131,59 @@ export function SettingsModal({ isOpen, setIsOpen }: SettingsModalProps) { + +
+ + setLocalApiKey(e.target.value)} + className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" + /> +
+ +
+ + setLocalBaseUrl(e.target.value)} + className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" + /> +
-
+
+
diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx new file mode 100644 index 0000000..697958f --- /dev/null +++ b/src/contexts/ConfigContext.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { createContext, useContext, useEffect, useState } from 'react'; +import { getItem, indexedDBService, setItem } from '@/services/indexedDB'; + +interface ConfigContextType { + apiKey: string; + baseUrl: string; + updateConfig: (newConfig: Partial<{ apiKey: string; baseUrl: string }>) => Promise; + isLoading: boolean; + isDBReady: boolean; +} + +const ConfigContext = createContext(undefined); + +export function ConfigProvider({ children }: { children: React.ReactNode }) { + const [apiKey, setApiKey] = useState(''); + const [baseUrl, setBaseUrl] = useState(''); + const [isLoading, setIsLoading] = useState(true); + const [isDBReady, setIsDBReady] = useState(false); + + useEffect(() => { + const initializeDB = async () => { + try { + setIsLoading(true); + await indexedDBService.init(); + setIsDBReady(true); + + // Now load config + const cachedApiKey = await getItem('apiKey'); + const cachedBaseUrl = await getItem('baseUrl'); + + if (cachedApiKey) { + console.log('Cached API key found:', cachedApiKey); + } + if (cachedBaseUrl) { + console.log('Cached base URL found:', cachedBaseUrl); + } + + // If not in cache, use env variables + const defaultApiKey = process.env.NEXT_PUBLIC_OPENAI_API_KEY || ''; + const defaultBaseUrl = process.env.NEXT_PUBLIC_OPENAI_API_BASE || ''; + + // Set the values + setApiKey(cachedApiKey || defaultApiKey); + setBaseUrl(cachedBaseUrl || defaultBaseUrl); + + // If not in cache, save to cache + if (!cachedApiKey) { + await setItem('apiKey', defaultApiKey); + } + if (!cachedBaseUrl) { + await setItem('baseUrl', defaultBaseUrl); + } + + } catch (error) { + console.error('Error initializing:', error); + } finally { + setIsLoading(false); + } + }; + + initializeDB(); + }, []); + + const updateConfig = async (newConfig: Partial<{ apiKey: string; baseUrl: string }>) => { + try { + if (newConfig.apiKey !== undefined) { + await setItem('apiKey', newConfig.apiKey); + setApiKey(newConfig.apiKey); + } + if (newConfig.baseUrl !== undefined) { + await setItem('baseUrl', newConfig.baseUrl); + setBaseUrl(newConfig.baseUrl); + } + } catch (error) { + console.error('Error updating config:', error); + throw error; + } + }; + + return ( + + {children} + + ); +} + +export function useConfig() { + const context = useContext(ConfigContext); + if (context === undefined) { + throw new Error('useConfig must be used within a ConfigProvider'); + } + return context; +} \ No newline at end of file diff --git a/src/contexts/PDFContext.tsx b/src/contexts/PDFContext.tsx index 06fb524..85688a4 100644 --- a/src/contexts/PDFContext.tsx +++ b/src/contexts/PDFContext.tsx @@ -17,6 +17,7 @@ import nlp from 'compromise'; // Add the correct type import import type { TextContent, TextItem } from 'pdfjs-dist/types/src/display/api'; +import { useConfig } from '@/contexts/ConfigContext'; // Set worker from public directory pdfjs.GlobalWorkerOptions.workerSrc = '/pdf.worker.mjs'; @@ -43,28 +44,30 @@ interface PDFContextType { const PDFContext = createContext(undefined); export function PDFProvider({ children }: { children: ReactNode }) { + const { isDBReady } = useConfig(); const [documents, setDocuments] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - // Load documents from IndexedDB on mount + // Load documents from IndexedDB once DB is ready useEffect(() => { const loadDocuments = async () => { + if (!isDBReady) return; + 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.'); + setError('Failed to load documents. Please try again.'); } finally { setIsLoading(false); } }; loadDocuments(); - }, []); + }, [isDBReady]); // Add a new document to IndexedDB const addDocument = useCallback(async (file: File): Promise => { diff --git a/src/contexts/TTSContext.tsx b/src/contexts/TTSContext.tsx index e039b97..1f780c1 100644 --- a/src/contexts/TTSContext.tsx +++ b/src/contexts/TTSContext.tsx @@ -11,6 +11,7 @@ import React, { import nlp from 'compromise'; import OpenAI from 'openai'; import { LRUCache } from 'lru-cache'; // Import LRUCache directly +import { useConfig } from './ConfigContext'; // Add type declarations declare global { @@ -50,6 +51,12 @@ interface TTSContextType { const TTSContext = createContext(undefined); export function TTSProvider({ children }: { children: React.ReactNode }) { + const { apiKey: openApiKey, baseUrl: openApiBaseUrl, isLoading: configIsLoading } = useConfig(); + + // Move openai initialization to a ref to avoid breaking hooks rules + const openaiRef = useRef(null); + + // All existing state declarations and refs stay at the top const [isPlaying, setIsPlaying] = useState(false); const [currentText, setCurrentText] = useState(''); const [sentences, setSentences] = useState([]); @@ -66,20 +73,42 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { const [speed, setSpeed] = useState(1); const [voice, setVoice] = useState('alloy'); const [availableVoices, setAvailableVoices] = useState([]); - - // Create OpenAI instance - const [openai] = useState( - () => - new OpenAI({ - apiKey: process.env.NEXT_PUBLIC_OPENAI_API_KEY, - baseURL: process.env.NEXT_PUBLIC_OPENAI_API_BASE, - dangerouslyAllowBrowser: true, - }) - ); - + // Audio cache using LRUCache with a maximum size of 50 entries const audioCacheRef = useRef(new LRUCache({ max: 50 })); + // Initialize OpenAI instance when config loads + useEffect(() => { + const fetchVoices = async () => { + try { + const response = await fetch(`${openApiBaseUrl}/audio/voices`, { + headers: { + 'Authorization': `Bearer ${openApiKey}`, + 'Content-Type': 'application/json', + }, + }); + if (!response.ok) throw new Error('Failed to fetch voices'); + const data = await response.json(); + setAvailableVoices(data.voices || []); + } catch (error) { + console.error('Error fetching voices:', error); + + // Set available voices to default openai voices + // Supported voices are alloy, ash, coral, echo, fable, onyx, nova, sage and shimmer + setAvailableVoices(['alloy', 'ash', 'coral', 'echo', 'fable', 'onyx', 'nova', 'sage', 'shimmer']); + } + }; + + if (!configIsLoading && openApiKey && openApiBaseUrl) { + openaiRef.current = new OpenAI({ + apiKey: openApiKey, + baseURL: openApiBaseUrl, + dangerouslyAllowBrowser: true, + }); + fetchVoices(); + } + }, [configIsLoading, openApiKey, openApiBaseUrl]); + useEffect(() => { /* * Initializes the AudioContext for text-to-speech playback. @@ -101,31 +130,6 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { } }, [audioContext]); - useEffect(() => { - // Fetch available voices when component mounts - const fetchVoices = async () => { - try { - const response = await fetch(`${openai.baseURL}/audio/voices`, { - headers: { - 'Authorization': `Bearer ${process.env.NEXT_PUBLIC_OPENAI_API_KEY}`, - 'Content-Type': 'application/json', - }, - }); - if (!response.ok) throw new Error('Failed to fetch voices'); - const data = await response.json(); - setAvailableVoices(data.voices || []); - } catch (error) { - console.error('Error fetching voices:', error); - - // Set available voices to default openai voices - // Supported voices are alloy, ash, coral, echo, fable, onyx, nova, sage and shimmer - setAvailableVoices(['alloy', 'ash', 'coral', 'echo', 'fable', 'onyx', 'nova', 'sage', 'shimmer']); - } - }; - - fetchVoices(); - }, [openai.baseURL]); - // Text preprocessing function to clean and normalize text const preprocessText = (text: string): string => { return text @@ -165,7 +169,7 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { }, [isPlaying, currentIndex, sentences]); const processAndPlaySentence = async (sentence: string) => { - if (!audioContext || isProcessing) return; + if (!audioContext || isProcessing || !openaiRef.current) return; try { // Only set processing if we need to fetch from API @@ -193,7 +197,7 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { if (!audioBuffer) { console.log(' Processing TTS for sentence:', cleanedSentence.substring(0, 50) + '...'); const startTime = Date.now(); - const response = await openai.audio.speech.create({ + const response = await openaiRef.current.audio.speech.create({ model: 'tts-1', voice: voice as "alloy", input: cleanedSentence, @@ -414,13 +418,13 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { }, [isPlaying, currentIndex, sentences, isProcessing]); const preloadSentence = async (sentence: string) => { - if (!audioContext) return; + if (!audioContext || !openaiRef.current) return; if (audioCacheRef.current.has(sentence)) return; // Already cached try { console.log(' Preloading TTS for sentence:', sentence.substring(0, 50) + '...'); const startTime = Date.now(); - const response = await openai.audio.speech.create({ + const response = await openaiRef.current.audio.speech.create({ model: 'tts-1', voice: voice as "alloy", input: sentence, @@ -565,6 +569,10 @@ export function TTSProvider({ children }: { children: React.ReactNode }) { availableVoices, }; + if (configIsLoading) { + return null; + } + return ( {children} diff --git a/src/services/indexedDB.ts b/src/services/indexedDB.ts index 5315235..cdee46a 100644 --- a/src/services/indexedDB.ts +++ b/src/services/indexedDB.ts @@ -1,6 +1,7 @@ const DB_NAME = 'openreader-db'; const DB_VERSION = 1; -const STORE_NAME = 'pdf-documents'; +const PDF_STORE_NAME = 'pdf-documents'; +const CONFIG_STORE_NAME = 'config'; export interface PDFDocument { id: string; @@ -10,15 +11,25 @@ export interface PDFDocument { data: Blob; } +export interface Config { + key: string; + value: string; +} + class IndexedDBService { private db: IDBDatabase | null = null; + private initPromise: Promise | null = null; async init(): Promise { - if (!window.indexedDB) { - throw new Error('IndexedDB is not supported in this browser'); + if (this.initPromise) { + return this.initPromise; } - return new Promise((resolve, reject) => { + if (this.db) { + return Promise.resolve(); + } + + this.initPromise = new Promise((resolve, reject) => { console.log('Initializing IndexedDB...'); const request = indexedDB.open(DB_NAME, DB_VERSION); @@ -37,25 +48,33 @@ class IndexedDBService { request.onupgradeneeded = (event) => { console.log('Upgrading IndexedDB schema...'); const db = (event.target as IDBOpenDBRequest).result; - if (!db.objectStoreNames.contains(STORE_NAME)) { + + if (!db.objectStoreNames.contains(PDF_STORE_NAME)) { console.log('Creating PDF documents store...'); - db.createObjectStore(STORE_NAME, { keyPath: 'id' }); + db.createObjectStore(PDF_STORE_NAME, { keyPath: 'id' }); + } + + if (!db.objectStoreNames.contains(CONFIG_STORE_NAME)) { + console.log('Creating config store...'); + db.createObjectStore(CONFIG_STORE_NAME, { keyPath: 'key' }); } }; }); + + return this.initPromise; } + // PDF Document Methods 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 transaction = this.db!.transaction([PDF_STORE_NAME], 'readwrite'); + const store = transaction.objectStore(PDF_STORE_NAME); const request = store.put(document); request.onerror = (event) => { @@ -77,15 +96,14 @@ class IndexedDBService { 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 transaction = this.db!.transaction([PDF_STORE_NAME], 'readonly'); + const store = transaction.objectStore(PDF_STORE_NAME); const request = store.get(id); request.onerror = (event) => { @@ -107,15 +125,14 @@ class IndexedDBService { 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 transaction = this.db!.transaction([PDF_STORE_NAME], 'readonly'); + const store = transaction.objectStore(PDF_STORE_NAME); const request = store.getAll(); request.onerror = (event) => { @@ -137,15 +154,14 @@ class IndexedDBService { 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 transaction = this.db!.transaction([PDF_STORE_NAME], 'readwrite'); + const store = transaction.objectStore(PDF_STORE_NAME); const request = store.delete(id); request.onerror = (event) => { @@ -164,6 +180,140 @@ class IndexedDBService { } }); } + + // Config Methods + async setConfigItem(key: string, value: string): Promise { + if (!this.db) { + await this.init(); + } + + return new Promise((resolve, reject) => { + try { + console.log('Setting config item:', key); + const transaction = this.db!.transaction([CONFIG_STORE_NAME], 'readwrite'); + const store = transaction.objectStore(CONFIG_STORE_NAME); + const request = store.put({ key, value }); + + request.onerror = (event) => { + const error = (event.target as IDBRequest).error; + console.error('Error setting config item:', error); + reject(error); + }; + + transaction.oncomplete = () => { + console.log('Config item set successfully:', key); + resolve(); + }; + } catch (error) { + console.error('Error in setConfigItem transaction:', error); + reject(error); + } + }); + } + + async getConfigItem(key: string): Promise { + if (!this.db) { + await this.init(); + } + + return new Promise((resolve, reject) => { + try { + console.log('Fetching config item:', key); + const transaction = this.db!.transaction([CONFIG_STORE_NAME], 'readonly'); + const store = transaction.objectStore(CONFIG_STORE_NAME); + const request = store.get(key); + + request.onerror = (event) => { + const error = (event.target as IDBRequest).error; + console.error('Error fetching config item:', error); + reject(error); + }; + + request.onsuccess = () => { + const result = request.result as Config | undefined; + console.log('Config item fetch result:', result ? 'found' : 'not found'); + resolve(result ? result.value : null); + }; + } catch (error) { + console.error('Error in getConfigItem transaction:', error); + reject(error); + } + }); + } + + async getAllConfig(): Promise> { + if (!this.db) { + await this.init(); + } + + return new Promise((resolve, reject) => { + try { + console.log('Fetching all config items'); + const transaction = this.db!.transaction([CONFIG_STORE_NAME], 'readonly'); + const store = transaction.objectStore(CONFIG_STORE_NAME); + const request = store.getAll(); + + request.onerror = (event) => { + const error = (event.target as IDBRequest).error; + console.error('Error fetching all config items:', error); + reject(error); + }; + + request.onsuccess = () => { + const result = request.result as Config[]; + const config: Record = {}; + result.forEach((item) => { + config[item.key] = item.value; + }); + console.log('Retrieved config items count:', result.length); + resolve(config); + }; + } catch (error) { + console.error('Error in getAllConfig transaction:', error); + reject(error); + } + }); + } + + async removeConfigItem(key: string): Promise { + if (!this.db) { + await this.init(); + } + + return new Promise((resolve, reject) => { + try { + console.log('Removing config item:', key); + const transaction = this.db!.transaction([CONFIG_STORE_NAME], 'readwrite'); + const store = transaction.objectStore(CONFIG_STORE_NAME); + const request = store.delete(key); + + request.onerror = (event) => { + const error = (event.target as IDBRequest).error; + console.error('Error removing config item:', error); + reject(error); + }; + + transaction.oncomplete = () => { + console.log('Config item removed successfully:', key); + resolve(); + }; + } catch (error) { + console.error('Error in removeConfigItem transaction:', error); + reject(error); + } + }); + } } -export const indexedDBService = new IndexedDBService(); +// Make sure we export a singleton instance +const indexedDBServiceInstance = new IndexedDBService(); +export const indexedDBService = indexedDBServiceInstance; + +// Helper functions for the ConfigContext +export async function getItem(key: string): Promise { + return indexedDBService.getConfigItem(key); +} + +export async function setItem(key: string, value: string): Promise { + return indexedDBService.setConfigItem(key, value); +} \ No newline at end of file