'use client'; import { Fragment, useState, useEffect, useCallback, useMemo } from 'react'; import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild, Listbox, ListboxButton, ListboxOptions, ListboxOption, Button, Input, TabGroup, TabList, Tab, TabPanels, TabPanel, } from '@headlessui/react'; import { useTheme } from '@/contexts/ThemeContext'; import { useConfig } from '@/contexts/ConfigContext'; import { ChevronUpDownIcon, CheckIcon, SettingsIcon } from '@/components/icons/Icons'; import { indexedDBService } from '@/utils/indexedDB'; import { useDocuments } from '@/contexts/DocumentContext'; import { setItem, getItem } from '@/utils/indexedDB'; import { ConfirmDialog } from '@/components/ConfirmDialog'; import { THEMES } from '@/contexts/ThemeContext'; const isDev = process.env.NEXT_PUBLIC_NODE_ENV !== 'production' || process.env.NODE_ENV == null; const themes = THEMES.map(id => ({ id, name: id.charAt(0).toUpperCase() + id.slice(1) })); export function SettingsModal() { const [isOpen, setIsOpen] = useState(false); const { theme, setTheme } = useTheme(); const { apiKey, baseUrl, ttsModel, ttsInstructions, updateConfig, updateConfigKey } = useConfig(); const { refreshPDFs, refreshEPUBs, clearPDFs, clearEPUBs } = useDocuments(); const [localApiKey, setLocalApiKey] = useState(apiKey); const [localBaseUrl, setLocalBaseUrl] = useState(baseUrl); const [localTTSModel, setLocalTTSModel] = useState(ttsModel); const [customModel, setCustomModel] = useState(''); const [localTTSInstructions, setLocalTTSInstructions] = useState(ttsInstructions); const [isSyncing, setIsSyncing] = useState(false); const [isLoading, setIsLoading] = useState(false); const selectedTheme = themes.find(t => t.id === theme) || themes[0]; const [showClearLocalConfirm, setShowClearLocalConfirm] = useState(false); const [showClearServerConfirm, setShowClearServerConfirm] = useState(false); const ttsModels = useMemo(() => [ { id: 'tts-1', name: 'TTS-1' }, { id: 'tts-1-hd', name: '($$) TTS-1-HD' }, { id: 'gpt-4o-mini-tts', name: '($$$) GPT-4o Mini TTS' }, { id: 'kokoro', name: 'Kokoro' }, { id: 'custom', name: 'Custom Model' } ], []); // set firstVisit on initial load const checkFirstVist = useCallback(async () => { if (!isDev) return; const firstVisit = await getItem('firstVisit'); if (firstVisit == null) { await setItem('firstVisit', 'true'); setIsOpen(true); } }, [setIsOpen]); useEffect(() => { checkFirstVist(); setLocalApiKey(apiKey); setLocalBaseUrl(baseUrl); setLocalTTSModel(ttsModel); setLocalTTSInstructions(ttsInstructions); // Set custom model if current model is not in predefined list if (!ttsModels.some(m => m.id === ttsModel) && ttsModel !== '') { setCustomModel(ttsModel); setLocalTTSModel('custom'); } }, [apiKey, baseUrl, ttsModel, ttsModels, ttsInstructions, checkFirstVist]); const handleSync = async () => { try { setIsSyncing(true); await indexedDBService.syncToServer(); } catch (error) { console.error('Sync failed:', error); } finally { setIsSyncing(false); } }; const handleLoad = async () => { try { setIsLoading(true); await indexedDBService.loadFromServer(); await Promise.all([refreshPDFs(), refreshEPUBs()]); } catch (error) { console.error('Load failed:', error); } finally { setIsLoading(false); } }; const handleClearLocal = async () => { await clearPDFs(); await clearEPUBs(); setShowClearLocalConfirm(false); }; const handleClearServer = async () => { try { const response = await fetch('/api/documents', { method: 'DELETE', }); if (!response.ok) { throw new Error('Failed to delete server documents'); } } catch (error) { console.error('Delete failed:', error); } setShowClearServerConfirm(false); }; const handleInputChange = (type: 'apiKey' | 'baseUrl' | 'ttsModel', value: string) => { if (type === 'apiKey') { setLocalApiKey(value === '' ? '' : value); } else if (type === 'baseUrl') { setLocalBaseUrl(value === '' ? '' : value); } else if (type === 'ttsModel') { setLocalTTSModel(value === '' ? 'tts-1' : value); } }; const resetToCurrent = useCallback(() => { setIsOpen(false); setLocalApiKey(apiKey); setLocalBaseUrl(baseUrl); setLocalTTSModel(ttsModel); setLocalTTSInstructions(ttsInstructions); if (!ttsModels.some(m => m.id === ttsModel) && ttsModel !== '') { setCustomModel(ttsModel); setLocalTTSModel('custom'); } }, [apiKey, baseUrl, ttsModel, ttsInstructions, ttsModels]); const tabs = [ { name: 'Appearance', icon: '✨' }, { name: 'API', icon: '🔑' }, { name: 'Documents', icon: '📄' } ]; return ( <> setIsOpen(true)} className="rounded-full p-2 text-foreground hover:bg-offbase transform transition-transform duration-200 ease-in-out hover:scale-[1.1] hover:text-accent absolute top-1 left-1 sm:top-3 sm:left-3" aria-label="Settings" tabIndex={0} > Settings {tabs.map((tab) => ( `w-full rounded-lg py-1 text-sm font-medium ring-accent/60 ring-offset-2 ring-offset-base ${selected ? 'bg-accent text-white shadow' : 'text-foreground hover:bg-accent/[0.12] hover:text-accent' }` } > {tab.icon} {tab.name} ))} Theme setTheme(newTheme.id)}> {selectedTheme.name} {themes.map((theme) => ( `relative cursor-pointer select-none py-2 pl-10 pr-4 ${active ? 'bg-accent/10 text-accent' : 'text-foreground' }` } value={theme} > {({ selected }) => ( <> {theme.name} {selected ? ( ) : null} > )} ))} OpenAI API Key {localApiKey && (Overriding env)} handleInputChange('apiKey', e.target.value)} placeholder="Using environment variable" className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" /> OpenAI API Base URL {localBaseUrl && (Overriding env)} handleInputChange('baseUrl', e.target.value)} placeholder="Using environment variable" className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" /> TTS Model m.id === localTTSModel) || ttsModels[0]} onChange={(model) => { setLocalTTSModel(model.id); if (model.id !== 'custom') { setCustomModel(''); } }} > {ttsModels.find(m => m.id === localTTSModel)?.name || 'Select Model'} {ttsModels.map((model) => ( `relative cursor-pointer select-none py-2 pl-10 pr-4 ${ active ? 'bg-accent/10 text-accent' : 'text-foreground' }` } value={model} > {({ selected }) => ( <> {model.name} {selected ? ( ) : null} > )} ))} {localTTSModel === 'custom' && ( setCustomModel(e.target.value)} placeholder="Enter custom model name" className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" /> )} {localTTSModel === 'gpt-4o-mini-tts' && ( TTS Instructions setLocalTTSInstructions(e.target.value)} placeholder="Enter instructions for the TTS model" className="w-full h-24 rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" /> )} { setLocalApiKey(''); setLocalBaseUrl(''); setLocalTTSModel('tts-1'); setCustomModel(''); setLocalTTSInstructions(''); }} > Reset { await updateConfig({ apiKey: localApiKey || '', baseUrl: localBaseUrl || '', }); const finalModel = localTTSModel === 'custom' ? customModel : localTTSModel; await updateConfigKey('ttsModel', finalModel); await updateConfigKey('ttsInstructions', localTTSInstructions); setIsOpen(false); }} > Done {isDev && Document Sync {isLoading ? 'Loading...' : 'Load docs from Server'} {isSyncing ? 'Saving...' : 'Save local to Server'} } Bulk Delete setShowClearLocalConfirm(true)} className="justify-center rounded-lg bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2 transform transition-transform duration-200 ease-in-out hover:scale-[1.04]" > Delete local docs {isDev && setShowClearServerConfirm(true)} className="justify-center rounded-lg bg-red-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-red-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2 transform transition-transform duration-200 ease-in-out hover:scale-[1.04]" > Delete server docs } setShowClearLocalConfirm(false)} onConfirm={handleClearLocal} title="Delete Local Documents" message="Are you sure you want to delete all local documents? This action cannot be undone." confirmText="Delete" isDangerous={true} /> setShowClearServerConfirm(false)} onConfirm={handleClearServer} title="Delete Server Documents" message="Are you sure you want to delete all documents from the server? This action cannot be undone." confirmText="Delete" isDangerous={true} /> > ); }