+
+
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