From 1deb8c21f7cd7302a7e72de07e219f03f2e66eb5 Mon Sep 17 00:00:00 2001 From: Richard R Date: Thu, 19 Mar 2026 10:24:16 -0600 Subject: [PATCH] feat(ui): add five new light themes and redesign settings modal Add five new light theme options (lavender, rose, sand, sky, slate) with dedicated color palettes. Redesign Settings modal with sidebar navigation replacing tabs, add visual theme color selector showing preview swatches, and introduce new icons (KeyIcon, PaletteIcon, UserIcon, DocumentIcon). Also update PrivacyModal to render conditionally based on authEnabled and add privacy policy link. Update tests to reflect new UI structure. --- src/app/globals.css | 75 +++ src/app/layout.tsx | 5 +- src/app/providers.tsx | 4 +- src/components/PrivacyModal.tsx | 4 +- src/components/SettingsModal.tsx | 1029 ++++++++++++++++-------------- src/components/icons/Icons.tsx | 72 +++ src/contexts/ThemeContext.tsx | 16 +- tests/delete.spec.ts | 7 + tests/helpers.ts | 8 +- 9 files changed, 711 insertions(+), 509 deletions(-) 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' && ( -
- -