'use client'; import { Fragment, useState, useEffect, useCallback, useMemo } from 'react'; import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild, Listbox, ListboxButton, ListboxOptions, ListboxOption, Button, Input, } from '@headlessui/react'; import Link from 'next/link'; import { useTheme } from '@/contexts/ThemeContext'; import { useConfig } from '@/contexts/ConfigContext'; import { ChevronUpDownIcon, CheckIcon, SettingsIcon, KeyIcon, PaletteIcon, DocumentIcon, UserIcon, DownloadIcon } 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'; import { resolveTtsSettingsViewModel } from '@/lib/client/settings/tts-settings'; import { supportsTtsInstructions } from '@/lib/shared/tts-provider-catalog'; const enableDestructiveDelete = process.env.NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS !== 'false'; const showAllDeepInfra = process.env.NEXT_PUBLIC_SHOW_ALL_DEEPINFRA_MODELS !== 'false'; const enableTTSProvidersTab = process.env.NEXT_PUBLIC_ENABLE_TTS_PROVIDERS_TAB !== 'false'; // Hard-coded theme color palettes for the visual theme selector type ThemeColorSet = { background: string; base: string; offbase: string; accent: string; secondaryAccent: string; foreground: string; muted: string }; const THEME_COLORS: Record = { light: { background: '#ffffff', base: '#f7fafc', offbase: '#e2e8f0', accent: '#ef4444', secondaryAccent: '#ed6868', foreground: '#2d3748', muted: '#718096' }, dark: { background: '#111111', base: '#171717', offbase: '#343434', accent: '#f87171', secondaryAccent: '#eb6262', foreground: '#ededed', muted: '#a3a3a3' }, ocean: { background: '#020617', base: '#0f172a', offbase: '#1e293b', accent: '#38bdf8', secondaryAccent: '#22d3ee', foreground: '#e2e8f0', muted: '#94a3b8' }, forest: { background: '#0a0f0c', base: '#111a15', offbase: '#1a2820', accent: '#4ade80', secondaryAccent: '#22c55e', foreground: '#d4e8d0', muted: '#7c8f85' }, sunset: { background: '#1a0f0f', base: '#2c1810', offbase: '#3d1f14', accent: '#ff6b6b', secondaryAccent: '#f59e0b', foreground: '#ffe4d6', muted: '#bc8f8f' }, sea: { background: '#0c1922', base: '#102c3d', offbase: '#1a3c52', accent: '#06b6d4', secondaryAccent: '#0ea5e9', foreground: '#e0f2fe', muted: '#7ca7c4' }, mint: { background: '#0f1916', base: '#132d27', offbase: '#1c3d35', accent: '#2dd4bf', secondaryAccent: '#10b981', foreground: '#dcfce7', muted: '#75a99c' }, lavender: { background: '#faf8ff', base: '#f3effb', offbase: '#e4daf0', accent: '#7c3aed', secondaryAccent: '#a78bfa', foreground: '#3b2e5a', muted: '#8e7bab' }, rose: { background: '#fff8f8', base: '#fef1f1', offbase: '#f5dada', accent: '#e11d48', secondaryAccent: '#f472b6', foreground: '#4a2c2c', muted: '#b08a8a' }, sand: { background: '#fdfbf7', base: '#f7f2e8', offbase: '#e8dfc9', accent: '#b45309', secondaryAccent: '#d97706', foreground: '#44392a', muted: '#9a8b74' }, sky: { background: '#f6faff', base: '#edf4fc', offbase: '#d5e3f5', accent: '#2563eb', secondaryAccent: '#3b82f6', foreground: '#1e3a5f', muted: '#6b8db5' }, slate: { background: '#e8ecf0', base: '#dde2e8', offbase: '#c8ced6', accent: '#5b7a9d', secondaryAccent: '#7393b0', foreground: '#2c3440', muted: '#7a8694' }, }; const LIGHT_THEME_IDS = new Set(['light', 'lavender', 'rose', 'sand', 'sky', 'slate']); const allThemes = THEMES.map(id => ({ id, name: id.charAt(0).toUpperCase() + id.slice(1), })); const systemTheme = allThemes.find(t => t.id === 'system')!; const lightThemes = allThemes.filter(t => LIGHT_THEME_IDS.has(t.id)); const darkThemes = allThemes.filter(t => t.id !== 'system' && !LIGHT_THEME_IDS.has(t.id)); type SectionId = 'api' | 'theme' | 'docs' | 'account'; const SIDEBAR_SECTIONS: { id: SectionId; label: string; icon: React.ComponentType>; authOnly?: boolean }[] = [ { id: 'api', label: 'TTS Provider', icon: KeyIcon }, { id: 'theme', label: 'Appearance', icon: PaletteIcon }, { id: 'docs', label: 'Documents', icon: DocumentIcon }, { id: 'account', label: 'Account', icon: UserIcon, authOnly: true }, ]; export function SettingsModal({ className = '' }: { className?: string }) { const [isOpen, setIsOpen] = useState(false); const [activeSection, setActiveSection] = useState(enableTTSProvidersTab ? 'api' : 'theme'); 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 [showDeleteDocsConfirm, setShowDeleteDocsConfirm] = useState(false); const [showDeleteAccountConfirm, setShowDeleteAccountConfirm] = useState(false); const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation(); const { authEnabled, baseUrl: authBaseUrl } = useAuthConfig(); const { data: session } = useAuthSession(); const router = useRouter(); const isBusy = isImportingLibrary; const { providers: ttsProviders, models: ttsModels, supportsCustomModel: supportsCustom, selectedModelId, canSubmit, } = useMemo(() => resolveTtsSettingsViewModel({ provider: localTTSProvider, apiKey: localApiKey, modelValue, customModelInput, showAllDeepInfra, }), [localTTSProvider, localApiKey, modelValue, customModelInput]); const checkFirstVist = useCallback(async () => { const appConfig = await getAppConfig(); if (authEnabled && !appConfig?.privacyAccepted) { return; } const firstVisit = await getFirstVisit(); if (!firstVisit) { await setFirstVisit(true); setIsOpen(true); } }, [authEnabled, 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(() => { if (!authEnabled) { return; } 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); }; }, [authEnabled, checkFirstVist]); 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); 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 { await deleteDocuments(); 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'); 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 [systemIsDark, setSystemIsDark] = useState( typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches ); useEffect(() => { const mq = window.matchMedia('(prefers-color-scheme: dark)'); const handler = (e: MediaQueryListEvent) => setSystemIsDark(e.matches); mq.addEventListener('change', handler); return () => mq.removeEventListener('change', handler); }, []); const getThemeColors = useCallback((id: string): ThemeColorSet => { if (id === 'system') return THEME_COLORS[systemIsDark ? 'dark' : 'light']; return THEME_COLORS[id] || THEME_COLORS.light; }, [systemIsDark]); const visibleSections = useMemo( () => SIDEBAR_SECTIONS.filter((section) => { if (section.id === 'api' && !enableTTSProvidersTab) { return false; } return !section.authOnly || authEnabled; }), [authEnabled] ); useEffect(() => { if (visibleSections.some(section => section.id === activeSection)) { return; } setActiveSection(visibleSections[0]?.id ?? 'theme'); }, [activeSection, visibleSections]); const btnBase = "inline-flex items-center justify-center rounded-lg text-sm font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 transform transition-transform duration-200 ease-in-out"; const btnPrimary = `${btnBase} bg-accent text-background hover:bg-secondary-accent hover:scale-[1.04]`; const btnSecondary = `${btnBase} bg-background text-foreground hover:bg-offbase hover:text-accent hover:scale-[1.04]`; const btnOutline = `${btnBase} bg-background border border-offbase text-foreground hover:bg-offbase hover:text-accent hover:scale-[1.02]`; return ( <>
{/* Header */}
Settings {authEnabled && ( )}
{/* Mobile: 2x2 grid nav */}
{visibleSections.map((section) => { const Icon = section.icon; return ( ); })}
{/* Desktop: vertical sidebar */} {/* Content */}
{/* API Section */} {activeSection === 'api' && (
p.id === localTTSProvider) || ttsProviders[0]} onChange={(provider) => { setLocalTTSProvider(provider.id); if (provider.id === 'openai') { setModelValue('tts-1'); setLocalBaseUrl('https://api.openai.com/v1'); } else if (provider.id === 'custom-openai') { setModelValue('kokoro'); 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-2 pl-10 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'}` } value={provider} > {({ selected }) => ( <> {provider.name} {selected && ( )} )} ))}
{(localTTSProvider === 'custom-openai' || !localBaseUrl || localBaseUrl === '') && (
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" />
)}
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-2 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') { 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-2 pl-10 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'}` } value={model} > {({ selected }) => ( <> {model.name} {selected && ( )} )} ))} {supportsCustom && selectedModelId === 'custom' && ( { setCustomModelInput(e.target.value); setModelValue(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" /> )}
{supportsTtsInstructions(modelValue) && (