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
-
- showPrivacyModal({ authEnabled })}
- className="text-sm font-medium text-muted hover:text-accent"
- >
- Privacy
-
+ {authEnabled && (
+ showPrivacyModal({ authEnabled })}
+ className="text-sm font-medium text-muted hover:text-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
+ >
+ Privacy
+
+ )}
-
-
- {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 (
+
setActiveSection(section.id)}
+ className={`flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-lg text-xs font-medium transition-colors
+ ${activeSection === section.id
+ ? 'bg-accent text-background'
+ : 'text-foreground hover:bg-offbase hover:text-accent'
+ }`}
>
-
- {tab.icon}
- {tab.name}
-
-
- ))}
-
-
-
-
- TTS Provider
- 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'}
-
-
-
-
-
-
+ {section.label}
+
+ );
+ })}
+
+
+
+ {/* Desktop: vertical sidebar */}
+
+
+ {visibleSections.map((section) => {
+ const Icon = section.icon;
+ return (
+ setActiveSection(section.id)}
+ className={`w-full flex items-center gap-2.5 text-left px-3 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap
+ ${activeSection === section.id
+ ? 'bg-accent text-background'
+ : 'text-foreground hover:bg-offbase hover:text-accent'
+ }`}
>
-
- {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}
- >
- )}
-
- ))}
-
-
-
-
+
+ {section.label}
+
+ );
+ })}
+
+
- {(localTTSProvider === 'custom-openai' || !localBaseUrl || localBaseUrl === '') && (
-
-
- API Base URL
- {localBaseUrl && (Overriding env) }
-
-
- 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"
- />
-
-
- )}
-
-
-
- API Key
- {localApiKey && (Overriding env) }
-
-
- 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"
- />
-
-
-
-
-
TTS Model
-
+ {/* Content */}
+
+ {/* API Section */}
+ {activeSection === 'api' && (
+
+
+ TTS Provider
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 === '') && (
+
+
+ API Base URL
+ {localBaseUrl && (Overriding env) }
+
{
- 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' && (
-
-
+
setShowDeleteDocsConfirm(false)}
onConfirm={handleDeleteDocs}
- title={
- deleteDocsMode === 'unclaimed'
- ? 'Delete Unclaimed Docs'
- : userDeleteTitle
- }
- message={
- deleteDocsMode === 'unclaimed'
- ? 'Are you sure you want to delete all unclaimed documents? This action cannot be undone.'
- : userDeleteMessage
- }
+ title="Delete All Data"
+ message="Are you sure you want to delete all documents from the server? This action cannot be undone."
confirmText="Delete"
isDangerous={true}
/>
@@ -985,7 +1026,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
onConfirm={handleModalConfirm}
title={selectionModalProps.title}
confirmLabel={selectionModalProps.confirmLabel}
- isProcessing={false} // Processing happens in ProgressPopup after closing
+ isProcessing={false}
defaultSelected={selectionModalProps.defaultSelected}
initialFiles={selectionModalProps.initialFiles}
fetcher={selectionModalProps.fetcher}
diff --git a/src/components/icons/Icons.tsx b/src/components/icons/Icons.tsx
index 9e0c5b1..97abb7a 100644
--- a/src/components/icons/Icons.tsx
+++ b/src/components/icons/Icons.tsx
@@ -510,6 +510,78 @@ export function GridIcon(props: React.SVGProps) {
);
}
+export function KeyIcon(props: React.SVGProps) {
+ return (
+
+
+
+ );
+}
+
+export function PaletteIcon(props: React.SVGProps) {
+ return (
+
+
+
+ );
+}
+
+export function UserIcon(props: React.SVGProps) {
+ return (
+
+
+
+ );
+}
+
+export function DocumentIcon(props: React.SVGProps) {
+ return (
+
+
+
+ );
+}
+
export function FileSettingsIcon(props: React.SVGProps) {
return (
{
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
};
+const LIGHT_THEMES: ReadonlySet = new Set(['light', 'lavender', 'rose', 'sand', 'sky', 'slate']);
+
const getEffectiveTheme = (theme: Theme): Theme => {
if (theme === 'system') {
return getSystemTheme();
@@ -24,6 +26,10 @@ const getEffectiveTheme = (theme: Theme): Theme => {
return theme;
};
+const getColorScheme = (theme: Theme): string => {
+ return LIGHT_THEMES.has(theme) ? 'light' : 'dark';
+};
+
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState('system');
const [mounted, setMounted] = useState(false);
@@ -36,7 +42,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const effectiveTheme = getEffectiveTheme(initialTheme);
document.documentElement.classList.remove(...THEMES);
document.documentElement.classList.add(effectiveTheme);
- document.documentElement.style.colorScheme = effectiveTheme;
+ document.documentElement.style.colorScheme = getColorScheme(effectiveTheme);
if (!stored) {
localStorage.setItem('theme', initialTheme);
}
@@ -49,8 +55,8 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
root.classList.remove(...THEMES);
root.classList.add(effectiveTheme);
- root.style.colorScheme = effectiveTheme;
-
+ root.style.colorScheme = getColorScheme(effectiveTheme);
+
localStorage.setItem('theme', newTheme);
setTheme(newTheme);
};
@@ -64,7 +70,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const root = window.document.documentElement;
root.classList.remove(...THEMES);
root.classList.add(effectiveTheme);
- root.style.colorScheme = effectiveTheme;
+ root.style.colorScheme = getColorScheme(effectiveTheme);
}
};
diff --git a/tests/delete.spec.ts b/tests/delete.spec.ts
index 8de7db8..6d8004b 100644
--- a/tests/delete.spec.ts
+++ b/tests/delete.spec.ts
@@ -28,6 +28,13 @@ test.describe('Document deletion flow', () => {
});
test('deletes all local documents from Settings modal', async ({ page }) => {
+ // This test only applies when auth is NOT enabled, since with auth
+ // the bulk-delete UI lives in the delete-account flow instead.
+ test.skip(
+ Boolean(process.env.AUTH_SECRET && process.env.BASE_URL),
+ 'Bulk document deletion is part of the delete-account flow when auth is enabled',
+ );
+
// Upload multiple docs (PDF + EPUB)
await uploadFile(page, 'sample.pdf');
await uploadFile(page, 'sample.epub');
diff --git a/tests/helpers.ts b/tests/helpers.ts
index 36e34ff..8cdba36 100644
--- a/tests/helpers.ts
+++ b/tests/helpers.ts
@@ -346,18 +346,18 @@ export async function deleteDocumentByName(page: Page, fileName: string) {
await confirmBtn.click();
}
-// Open Settings modal and navigate to Documents tab
+// Open Settings modal and navigate to Documents section
export async function openSettingsDocumentsTab(page: Page) {
await page.getByRole('button', { name: 'Settings' }).click();
- await page.getByRole('tab', { name: '📄 Docs' }).click();
+ await page.getByRole('button', { name: 'Documents' }).click();
}
// Delete all local documents through Settings and close dialogs
export async function deleteAllLocalDocuments(page: Page) {
await openSettingsDocumentsTab(page);
- await page.getByRole('button', { name: /Delete (anonymous docs|all user docs|server docs)/i }).click();
+ await page.getByRole('button', { name: /Delete all data/i }).click();
- const heading = page.getByRole('heading', { name: /Delete (Anonymous Docs|All User Docs|Server Docs)/i });
+ const heading = page.getByRole('heading', { name: /Delete All Data/i });
await expect(heading).toBeVisible({ timeout: 10000 });
const confirmBtn = heading.locator('xpath=ancestor::*[@role="dialog"][1]//button[normalize-space()="Delete"]');