diff --git a/src/app/globals.css b/src/app/globals.css index 881ff04..24c7cb1 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -109,6 +109,81 @@ html.mint { ); } +html.lavender { + --background: #faf8ff; + --foreground: #3b2e5a; + --base: #f3effb; + --offbase: #e4daf0; + --accent: #7c3aed; + --secondary-accent: #a78bfa; + --muted: #8e7bab; + --prism-gradient: linear-gradient(90deg, + #c4b5fd, + #a78bfa, + #7c3aed + ); +} + +html.rose { + --background: #fff8f8; + --foreground: #4a2c2c; + --base: #fef1f1; + --offbase: #f5dada; + --accent: #e11d48; + --secondary-accent: #f472b6; + --muted: #b08a8a; + --prism-gradient: linear-gradient(90deg, + #fda4af, + #fb7185, + #e11d48 + ); +} + +html.sand { + --background: #fdfbf7; + --foreground: #44392a; + --base: #f7f2e8; + --offbase: #e8dfc9; + --accent: #b45309; + --secondary-accent: #d97706; + --muted: #9a8b74; + --prism-gradient: linear-gradient(90deg, + #fcd34d, + #f59e0b, + #b45309 + ); +} + +html.sky { + --background: #f6faff; + --foreground: #1e3a5f; + --base: #edf4fc; + --offbase: #d5e3f5; + --accent: #2563eb; + --secondary-accent: #3b82f6; + --muted: #6b8db5; + --prism-gradient: linear-gradient(90deg, + #93c5fd, + #60a5fa, + #2563eb + ); +} + +html.slate { + --background: #e8ecf0; + --foreground: #2c3440; + --base: #dde2e8; + --offbase: #c8ced6; + --accent: #5b7a9d; + --secondary-accent: #7393b0; + --muted: #7a8694; + --prism-gradient: linear-gradient(90deg, + #a3bdd4, + #7393b0, + #5b7a9d + ); +} + body { color: var(--foreground); background: var(--background); diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e55cca3..a91820d 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -14,7 +14,8 @@ const figtree = Figtree({ const themeInitScript = ` (() => { - const themes = ['system', 'light', 'dark', 'ocean', 'forest', 'sunset', 'sea', 'mint']; + const themes = ['system', 'light', 'dark', 'ocean', 'forest', 'sunset', 'sea', 'mint', 'lavender', 'rose', 'sand', 'sky', 'slate']; + const lightThemes = new Set(['light', 'lavender', 'rose', 'sand', 'sky', 'slate']); const root = document.documentElement; const stored = localStorage.getItem('theme'); const selected = stored && themes.includes(stored) ? stored : 'system'; @@ -23,7 +24,7 @@ const themeInitScript = ` : selected; root.classList.remove(...themes); root.classList.add(effective); - root.style.colorScheme = effective === 'dark' ? 'dark' : 'light'; + root.style.colorScheme = lightThemes.has(effective) ? 'light' : 'dark'; })(); `; diff --git a/src/app/providers.tsx b/src/app/providers.tsx index f6f61ca..29dd750 100644 --- a/src/app/providers.tsx +++ b/src/app/providers.tsx @@ -39,7 +39,7 @@ export function Providers({ children, authEnabled, authBaseUrl, allowAnonymousAu <> {children} - + {authEnabled && } @@ -64,7 +64,7 @@ export function Providers({ children, authEnabled, authBaseUrl, allowAnonymousAu <> {children} - + {authEnabled && } diff --git a/src/components/PrivacyModal.tsx b/src/components/PrivacyModal.tsx index f1b2c7a..1ea6746 100644 --- a/src/components/PrivacyModal.tsx +++ b/src/components/PrivacyModal.tsx @@ -13,7 +13,6 @@ import { updateAppConfig, getAppConfig } from '@/lib/client/dexie'; interface PrivacyModalProps { onAccept?: () => void; - authEnabled?: boolean; } function PrivacyModalBody({ origin }: { origin: string }) { @@ -32,7 +31,8 @@ function PrivacyModalBody({ origin }: { origin: string }) {

- For full details on data collection, processing, and your rights, please review our complete Privacy Policy. + For full details on data collection, processing, and your rights, please review our complete{' '} + Privacy Policy.

); diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index e8248ba..4b29354 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -13,16 +13,11 @@ import { 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 { 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'; @@ -43,14 +38,47 @@ import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from const enableDestructiveDelete = process.env.NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS !== 'false'; const showAllDeepInfra = process.env.NEXT_PUBLIC_SHOW_ALL_DEEPINFRA_MODELS !== '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 themes = THEMES.map(id => ({ +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) + 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('api'); const { theme, setTheme } = useTheme(); const { apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, updateConfig, updateConfigKey } = useConfig(); @@ -78,12 +106,10 @@ export function SettingsModal({ className = '' }: { className?: string }) { 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 { authEnabled, baseUrl: authBaseUrl } = useAuthConfig(); const { data: session } = useAuthSession(); const router = useRouter(); const isBusy = isImportingLibrary; @@ -110,13 +136,11 @@ export function SettingsModal({ className = '' }: { className?: string }) { { 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' }, @@ -149,7 +173,6 @@ export function SettingsModal({ className = '' }: { className?: string }) { [selectedModelId, supportsCustom, customModelInput] ); - // Open settings on first visit (stored in Dexie app config) const checkFirstVist = useCallback(async () => { const appConfig = await getAppConfig(); if (!appConfig?.privacyAccepted) { @@ -185,7 +208,6 @@ export function SettingsModal({ className = '' }: { className?: string }) { }; }, [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); @@ -232,18 +254,11 @@ export function SettingsModal({ className = '' }: { className?: string }) { 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++) { @@ -301,13 +316,8 @@ export function SettingsModal({ className = '' }: { className?: string }) { const handleDeleteDocs = async () => { try { - if (deleteDocsMode === 'user') { - await deleteDocuments(); - await refreshDocuments().catch(() => { }); - } else if (deleteDocsMode === 'unclaimed') { - await deleteDocuments({ scope: 'unclaimed' }); - await refreshDocuments().catch(() => { }); - } + await deleteDocuments(); + await refreshDocuments().catch(() => { }); } catch (error) { console.error('Delete failed:', error); } finally { @@ -315,8 +325,6 @@ export function SettingsModal({ className = '' }: { className?: string }) { } }; - - const handleSignOut = async () => { const client = getAuthClient(authBaseUrl); await client.signOut(); @@ -328,7 +336,6 @@ export function SettingsModal({ className = '' }: { className?: string }) { 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'; @@ -360,41 +367,28 @@ export function SettingsModal({ className = '' }: { className?: string }) { } }, [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; + const [systemIsDark, setSystemIsDark] = useState( + typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches + ); 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]); + 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 = SIDEBAR_SECTIONS.filter(s => !s.authOnly || authEnabled); + + 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 ( <> @@ -432,162 +426,94 @@ export function SettingsModal({ className = '' }: { className?: string }) { leaveFrom="opacity-100 scale-100" leaveTo="opacity-0 scale-95" > - -
- + + {/* Header */} +
+ Settings - - + {authEnabled && ( + + )}
- - - {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' - }` - } + {/* Mobile: 2x2 grid nav */} +
+ {visibleSections.map((section) => { + const Icon = section.icon; + return ( + + ); + })} +
+ +
+ {/* Desktop: vertical sidebar */} +
+ - {(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" - /> -
-
- -
- -
+ {/* Content */} +
+ {/* API Section */} + {activeSection === 'api' && ( +
+
+ 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(''); + value={ttsProviders.find(p => 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(''); }} > - + - {ttsModels.find(m => m.id === selectedModelId)?.name || 'Select Model'} + {ttsProviders.find(p => p.id === localTTSProvider)?.name || 'Select Provider'} @@ -599,26 +525,25 @@ export function SettingsModal({ className = '' }: { className?: string }) { leaveFrom="opacity-100" leaveTo="opacity-0" > - - {ttsModels.map((model) => ( + + {ttsProviders.map((provider) => ( - `relative cursor-pointer select-none py-1.5 pl-10 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground' - }` + `relative cursor-pointer select-none py-2 pl-10 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'}` } - value={model} + value={provider} > {({ selected }) => ( <> - {model.name} + {provider.name} - {selected ? ( + {selected && ( - ) : null} + )} )} @@ -626,327 +551,443 @@ export function SettingsModal({ className = '' }: { className?: string }) { +
- {supportsCustom && selectedModelId === 'custom' && ( + {(localTTSProvider === 'custom-openai' || !localBaseUrl || localBaseUrl === '') && ( +
+ { - 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" + value={localBaseUrl} + onChange={(e) => 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" /> - )} -
-
+
+ )} - {modelValue === 'gpt-4o-mini-tts' && ( -
- -