'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 Link from 'next/link'; import { useTheme } from '@/contexts/ThemeContext'; import { useConfig } from '@/contexts/ConfigContext'; import { ChevronUpDownIcon, CheckIcon, SettingsIcon } from '@/components/icons/Icons'; import { getAppConfig, getFirstVisit, setFirstVisit } from '@/lib/client/dexie'; import { useDocuments } from '@/contexts/DocumentContext'; import { ConfirmDialog } from '@/components/ConfirmDialog'; import { ProgressPopup } from '@/components/ProgressPopup'; import { useTimeEstimation } from '@/hooks/useTimeEstimation'; import { THEMES } from '@/contexts/ThemeContext'; import { DocumentSelectionModal } from '@/components/documents/DocumentSelectionModal'; import { BaseDocument } from '@/types/documents'; import { getAuthClient } from '@/lib/client/auth-client'; import { useAuthSession } from '@/hooks/useAuthSession'; import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; import { useRouter } from 'next/navigation'; import { showPrivacyModal } from '@/components/PrivacyModal'; import { deleteDocuments, mimeTypeForDoc, uploadDocuments } from '@/lib/client/api/documents'; import { cacheStoredDocumentFromBytes, clearDocumentCache } from '@/lib/client/cache/documents'; import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from '@/lib/client/cache/previews'; const enableDestructiveDelete = process.env.NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS !== 'false'; const showAllDeepInfra = process.env.NEXT_PUBLIC_SHOW_ALL_DEEPINFRA_MODELS !== 'false'; const themes = THEMES.map(id => ({ id, name: id.charAt(0).toUpperCase() + id.slice(1) })); export function SettingsModal({ className = '' }: { className?: string }) { const [isOpen, setIsOpen] = useState(false); const { theme, setTheme } = useTheme(); const { apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, updateConfig, updateConfigKey } = useConfig(); const { refreshDocuments } = useDocuments(); const [localApiKey, setLocalApiKey] = useState(apiKey); const [localBaseUrl, setLocalBaseUrl] = useState(baseUrl); const [localTTSProvider, setLocalTTSProvider] = useState(ttsProvider); const [modelValue, setModelValue] = useState(ttsModel); const [customModelInput, setCustomModelInput] = useState(''); const [localTTSInstructions, setLocalTTSInstructions] = useState(ttsInstructions); const [isImportingLibrary, setIsImportingLibrary] = useState(false); const [isSelectionModalOpen, setIsSelectionModalOpen] = useState(false); const [selectionModalProps, setSelectionModalProps] = useState<{ title: string; confirmLabel: string; defaultSelected: boolean; initialFiles?: BaseDocument[]; fetcher?: () => Promise; }>({ title: '', confirmLabel: '', defaultSelected: false }); const [showProgress, setShowProgress] = useState(false); const [statusMessage, setStatusMessage] = useState(''); const [abortController, setAbortController] = useState(null); const selectedTheme = themes.find(t => t.id === theme) || themes[0]; const [showDeleteDocsConfirm, setShowDeleteDocsConfirm] = useState(false); const [deleteDocsMode, setDeleteDocsMode] = useState<'user' | 'unclaimed'>('user'); const [showDeleteAccountConfirm, setShowDeleteAccountConfirm] = useState(false); const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation(); const { authEnabled, baseUrl: authBaseUrl, allowAnonymousAuthSessions } = useAuthConfig(); const { data: session } = useAuthSession(); const router = useRouter(); const isBusy = isImportingLibrary; const ttsProviders = useMemo(() => [ { id: 'custom-openai', name: 'Custom OpenAI-Like' }, { id: 'deepinfra', name: 'Deepinfra' }, { id: 'openai', name: 'OpenAI' } ], []); const ttsModels = useMemo(() => { switch (localTTSProvider) { case 'openai': return [ { id: 'tts-1', name: 'TTS-1' }, { id: 'tts-1-hd', name: 'TTS-1 HD' }, { id: 'gpt-4o-mini-tts', name: 'GPT-4o Mini TTS' } ]; case 'custom-openai': return [ { id: 'kokoro', name: 'Kokoro' }, { id: 'orpheus', name: 'Orpheus' }, { id: 'custom', name: 'Other' } ]; case 'deepinfra': // In production without an API key, limit to free tier model if (!showAllDeepInfra && !localApiKey) { return [ { id: 'hexgrad/Kokoro-82M', name: 'hexgrad/Kokoro-82M' } ]; } // In dev or with an API key, allow all models return [ { id: 'hexgrad/Kokoro-82M', name: 'hexgrad/Kokoro-82M' }, { id: 'canopylabs/orpheus-3b-0.1-ft', name: 'canopylabs/orpheus-3b-0.1-ft' }, { id: 'sesame/csm-1b', name: 'sesame/csm-1b' }, { id: 'ResembleAI/chatterbox', name: 'ResembleAI/chatterbox' }, { id: 'Zyphra/Zonos-v0.1-hybrid', name: 'Zyphra/Zonos-v0.1-hybrid' }, { id: 'Zyphra/Zonos-v0.1-transformer', name: 'Zyphra/Zonos-v0.1-transformer' }, { id: 'custom', name: 'Other' } ]; default: return [ { id: 'tts-1', name: 'TTS-1' } ]; } }, [localTTSProvider, localApiKey]); const supportsCustom = useMemo(() => localTTSProvider !== 'openai', [localTTSProvider]); const selectedModelId = useMemo( () => { const isPreset = ttsModels.some(m => m.id === modelValue); if (isPreset) return modelValue; return supportsCustom ? 'custom' : (ttsModels[0]?.id ?? ''); }, [ttsModels, modelValue, supportsCustom] ); const canSubmit = useMemo( () => selectedModelId !== 'custom' || (supportsCustom && customModelInput.trim().length > 0), [selectedModelId, supportsCustom, customModelInput] ); // Open settings on first visit (stored in Dexie app config) const checkFirstVist = useCallback(async () => { const appConfig = await getAppConfig(); if (!appConfig?.privacyAccepted) { return; } const firstVisit = await getFirstVisit(); if (!firstVisit) { await setFirstVisit(true); setIsOpen(true); } }, [setIsOpen]); useEffect(() => { checkFirstVist().catch((err) => { console.error('First visit check failed:', err); }); setLocalApiKey(apiKey); setLocalBaseUrl(baseUrl); setLocalTTSProvider(ttsProvider); setModelValue(ttsModel); setLocalTTSInstructions(ttsInstructions); }, [apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, checkFirstVist]); useEffect(() => { const onPrivacyAccepted = () => { checkFirstVist().catch((err) => { console.error('First visit check after privacy acceptance failed:', err); }); }; window.addEventListener('openreader:privacyAccepted', onPrivacyAccepted); return () => { window.removeEventListener('openreader:privacyAccepted', onPrivacyAccepted); }; }, [checkFirstVist]); // Detect if current model is custom (not in presets) and mirror it in the input field useEffect(() => { if (!ttsModels.some(m => m.id === modelValue) && modelValue !== '') { setCustomModelInput(modelValue); } else { setCustomModelInput(''); } }, [modelValue, ttsModels]); const handleRefresh = async () => { try { clearInMemoryDocumentPreviewCache(); await refreshDocuments(); } catch (error) { console.error('Failed to refresh documents:', error); } }; const handleClearCache = async () => { try { await Promise.all([ clearDocumentCache(), clearAllDocumentPreviewCaches(), ]); } catch (error) { console.error('Failed to clear cache:', error); } }; const handleImportLibrary = async () => { setSelectionModalProps({ title: 'Import from Library', confirmLabel: 'Import', defaultSelected: false, fetcher: async () => { const res = await fetch('/api/documents/library?limit=10000'); if (!res.ok) throw new Error('Failed to list library documents'); const data = await res.json(); return data.documents || []; } }); setIsSelectionModalOpen(true); }; const handleModalConfirm = async (selectedFiles: BaseDocument[]) => { const controller = new AbortController(); setAbortController(controller); // Close modal? Maybe keep open until started? // Let's close it here, process starts. // Actually we keep it open if we want to show loading state INSIDE modal? // But existing UI uses a separate ProgressPopup. // So close modal, show popup. setIsSelectionModalOpen(false); try { setShowProgress(true); setProgress(0); setIsImportingLibrary(true); for (let i = 0; i < selectedFiles.length; i++) { if (controller.signal.aborted) break; const doc = selectedFiles[i]; setStatusMessage(`Importing ${i + 1}/${selectedFiles.length}: ${doc.name}`); setProgress((i / Math.max(1, selectedFiles.length)) * 90); const contentResponse = await fetch(`/api/documents/library/content?id=${encodeURIComponent(doc.id)}`, { signal: controller.signal, }); if (!contentResponse.ok) { console.warn(`Failed to download library document: ${doc.name}`); continue; } const bytes = await contentResponse.arrayBuffer(); const file = new File([bytes], doc.name, { type: mimeTypeForDoc(doc), lastModified: doc.lastModified, }); const uploaded = await uploadDocuments([file], { signal: controller.signal }); const stored = uploaded[0]; if (stored) { await cacheStoredDocumentFromBytes(stored, bytes).catch((err) => { console.warn('Failed to cache imported document:', stored.id, err); }); } } if (!controller.signal.aborted) { setProgress(95); await refreshDocuments(); setProgress(100); setStatusMessage('Import complete'); } } catch (error) { if (controller.signal.aborted) { console.log('library import cancelled'); setStatusMessage('Operation cancelled'); } else { console.error('library import failed:', error); setStatusMessage('Import failed. Please try again.'); } } finally { setIsImportingLibrary(false); setShowProgress(false); setProgress(0); setStatusMessage(''); setAbortController(null); } }; const handleDeleteDocs = async () => { try { if (deleteDocsMode === 'user') { await deleteDocuments(); await refreshDocuments().catch(() => { }); } else if (deleteDocsMode === 'unclaimed') { await deleteDocuments({ scope: 'unclaimed' }); await refreshDocuments().catch(() => { }); } } catch (error) { console.error('Delete failed:', error); } finally { setShowDeleteDocsConfirm(false); } }; const handleSignOut = async () => { const client = getAuthClient(authBaseUrl); await client.signOut(); router.push('/signin'); }; const handleDeleteAccount = async () => { try { const res = await fetch('/api/account/delete', { method: 'DELETE' }); if (!res.ok) throw new Error('Failed to delete account'); // Sign out locally const client = getAuthClient(authBaseUrl); await client.signOut(); window.location.href = '/signup'; } catch (error) { console.error('Failed to delete account:', error); } setShowDeleteAccountConfirm(false); }; const handleInputChange = (type: 'apiKey' | 'baseUrl', value: string) => { if (type === 'apiKey') { setLocalApiKey(value === '' ? '' : value); } else if (type === 'baseUrl') { setLocalBaseUrl(value === '' ? '' : value); } }; const resetToCurrent = useCallback(() => { setIsOpen(false); setLocalApiKey(apiKey); setLocalBaseUrl(baseUrl); setLocalTTSProvider(ttsProvider); setModelValue(ttsModel); setLocalTTSInstructions(ttsInstructions); if (!ttsModels.some(m => m.id === ttsModel) && ttsModel !== '') { setCustomModelInput(ttsModel); } else { setCustomModelInput(''); } }, [apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, ttsModels]); const tabs = [ { name: 'API', icon: '🔑' }, { name: 'Theme', icon: '✨' }, { name: 'Docs', icon: '📄' }, ...(authEnabled ? [{ name: 'User', icon: '👤' }] : []) ]; const isAnonymous = Boolean(session?.user?.isAnonymous); const userDeleteLabel = authEnabled ? (isAnonymous ? 'Delete anonymous docs' : 'Delete all user docs') : 'Delete server docs'; const userDeleteTitle = authEnabled ? (isAnonymous ? 'Delete Anonymous Docs' : 'Delete All User Docs') : 'Delete Server Docs'; const userDeleteMessage = authEnabled ? (isAnonymous ? 'Are you sure you want to delete all anonymous-session documents? This action cannot be undone.' : 'Are you sure you want to delete all of your documents from the server? This action cannot be undone.') : 'Are you sure you want to delete all documents from the server? This action cannot be undone.'; const [unclaimedCounts, setUnclaimedCounts] = useState<{ documents: number; audiobooks: number } | null>(null); const canDeleteUnclaimed = authEnabled && session?.user && !isAnonymous && (unclaimedCounts?.documents ?? 0) > 0; useEffect(() => { if (!authEnabled) return; if (!session?.user) return; if (session.user.isAnonymous) return; // fetch claimable counts (unclaimed docs/audiobooks) fetch('/api/user/claim') .then((r) => (r.ok ? r.json() : null)) .then((data) => { if (data && typeof data.documents === 'number' && typeof data.audiobooks === 'number') { setUnclaimedCounts({ documents: data.documents, audiobooks: data.audiobooks }); } }) .catch(() => { }); }, [authEnabled, session?.user]); return ( <>
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-background shadow' : 'text-foreground hover:text-accent' }` } > {tab.icon} {tab.name} ))}
p.id === localTTSProvider) || ttsProviders[0]} onChange={(provider) => { setLocalTTSProvider(provider.id); // Set default model and base_url for each provider if (provider.id === 'openai') { setModelValue('tts-1'); setLocalBaseUrl('https://api.openai.com/v1'); } else if (provider.id === 'custom-openai') { setModelValue('kokoro'); // Clear baseUrl for custom provider setLocalBaseUrl(''); } else if (provider.id === 'deepinfra') { setModelValue('hexgrad/Kokoro-82M'); setLocalBaseUrl('https://api.deepinfra.com/v1/openai'); } setCustomModelInput(''); }} > {ttsProviders.find(p => p.id === localTTSProvider)?.name || 'Select Provider'} {ttsProviders.map((provider) => ( `relative cursor-pointer select-none py-1.5 pl-10 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground' }` } value={provider} > {({ selected }) => ( <> {provider.name} {selected ? ( ) : null} )} ))}
{(localTTSProvider === 'custom-openai' || !localBaseUrl || localBaseUrl === '') && (
handleInputChange('baseUrl', e.target.value)} placeholder="Using environment variable" className="w-full rounded-lg bg-background py-1.5 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" />
)}
handleInputChange('apiKey', e.target.value)} placeholder={!showAllDeepInfra && localTTSProvider === 'deepinfra' ? "Deepinfra free or use your API key" : "Using environment variable"} className="w-full rounded-lg bg-background py-1.5 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" />
m.id === selectedModelId) || ttsModels[0]} onChange={(model) => { if (model.id === 'custom') { // Switch to custom: keep the current custom input (or empty) setModelValue(customModelInput); } else { setModelValue(model.id); setCustomModelInput(''); } }} > {ttsModels.find(m => m.id === selectedModelId)?.name || 'Select Model'} {ttsModels.map((model) => ( `relative cursor-pointer select-none py-1.5 pl-10 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground' }` } value={model} > {({ selected }) => ( <> {model.name} {selected ? ( ) : null} )} ))} {supportsCustom && selectedModelId === 'custom' && ( { setCustomModelInput(e.target.value); setModelValue(e.target.value); }} placeholder="Enter custom model name" className="w-full rounded-lg bg-background py-1.5 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" /> )}
{modelValue === 'gpt-4o-mini-tts' && (