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.
This commit is contained in:
Richard R 2026-03-19 10:24:16 -06:00
parent fe05de9fec
commit 1deb8c21f7
9 changed files with 711 additions and 509 deletions

View file

@ -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 { body {
color: var(--foreground); color: var(--foreground);
background: var(--background); background: var(--background);

View file

@ -14,7 +14,8 @@ const figtree = Figtree({
const themeInitScript = ` 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 root = document.documentElement;
const stored = localStorage.getItem('theme'); const stored = localStorage.getItem('theme');
const selected = stored && themes.includes(stored) ? stored : 'system'; const selected = stored && themes.includes(stored) ? stored : 'system';
@ -23,7 +24,7 @@ const themeInitScript = `
: selected; : selected;
root.classList.remove(...themes); root.classList.remove(...themes);
root.classList.add(effective); root.classList.add(effective);
root.style.colorScheme = effective === 'dark' ? 'dark' : 'light'; root.style.colorScheme = lightThemes.has(effective) ? 'light' : 'dark';
})(); })();
`; `;

View file

@ -39,7 +39,7 @@ export function Providers({ children, authEnabled, authBaseUrl, allowAnonymousAu
<AuthLoader> <AuthLoader>
<> <>
{children} {children}
<PrivacyModal authEnabled={authEnabled} /> {authEnabled && <PrivacyModal />}
</> </>
</AuthLoader> </AuthLoader>
</ThemeProvider> </ThemeProvider>
@ -64,7 +64,7 @@ export function Providers({ children, authEnabled, authBaseUrl, allowAnonymousAu
<HTMLProvider> <HTMLProvider>
<> <>
{children} {children}
<PrivacyModal authEnabled={authEnabled} /> {authEnabled && <PrivacyModal />}
<DexieMigrationModal /> <DexieMigrationModal />
</> </>
</HTMLProvider> </HTMLProvider>

View file

@ -13,7 +13,6 @@ import { updateAppConfig, getAppConfig } from '@/lib/client/dexie';
interface PrivacyModalProps { interface PrivacyModalProps {
onAccept?: () => void; onAccept?: () => void;
authEnabled?: boolean;
} }
function PrivacyModalBody({ origin }: { origin: string }) { function PrivacyModalBody({ origin }: { origin: string }) {
@ -32,7 +31,8 @@ function PrivacyModalBody({ origin }: { origin: string }) {
</p> </p>
<p className="leading-relaxed"> <p className="leading-relaxed">
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{' '}
<a href="/privacy" target="_blank" className="font-semibold text-accent hover:underline">Privacy Policy</a>.
</p> </p>
</div> </div>
); );

View file

@ -13,16 +13,11 @@ import {
ListboxOption, ListboxOption,
Button, Button,
Input, Input,
TabGroup,
TabList,
Tab,
TabPanels,
TabPanel,
} from '@headlessui/react'; } from '@headlessui/react';
import Link from 'next/link'; import Link from 'next/link';
import { useTheme } from '@/contexts/ThemeContext'; import { useTheme } from '@/contexts/ThemeContext';
import { useConfig } from '@/contexts/ConfigContext'; 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 { getAppConfig, getFirstVisit, setFirstVisit } from '@/lib/client/dexie';
import { useDocuments } from '@/contexts/DocumentContext'; import { useDocuments } from '@/contexts/DocumentContext';
import { ConfirmDialog } from '@/components/ConfirmDialog'; 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 enableDestructiveDelete = process.env.NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS !== 'false';
const showAllDeepInfra = process.env.NEXT_PUBLIC_SHOW_ALL_DEEPINFRA_MODELS !== '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<string, ThemeColorSet> = {
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, 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<React.SVGProps<SVGSVGElement>>; 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 }) { export function SettingsModal({ className = '' }: { className?: string }) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [activeSection, setActiveSection] = useState<SectionId>('api');
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const { apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, updateConfig, updateConfigKey } = useConfig(); 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 [showProgress, setShowProgress] = useState(false);
const [statusMessage, setStatusMessage] = useState(''); const [statusMessage, setStatusMessage] = useState('');
const [abortController, setAbortController] = useState<AbortController | null>(null); const [abortController, setAbortController] = useState<AbortController | null>(null);
const selectedTheme = themes.find(t => t.id === theme) || themes[0];
const [showDeleteDocsConfirm, setShowDeleteDocsConfirm] = useState(false); const [showDeleteDocsConfirm, setShowDeleteDocsConfirm] = useState(false);
const [deleteDocsMode, setDeleteDocsMode] = useState<'user' | 'unclaimed'>('user');
const [showDeleteAccountConfirm, setShowDeleteAccountConfirm] = useState(false); const [showDeleteAccountConfirm, setShowDeleteAccountConfirm] = useState(false);
const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation(); const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation();
const { authEnabled, baseUrl: authBaseUrl, allowAnonymousAuthSessions } = useAuthConfig(); const { authEnabled, baseUrl: authBaseUrl } = useAuthConfig();
const { data: session } = useAuthSession(); const { data: session } = useAuthSession();
const router = useRouter(); const router = useRouter();
const isBusy = isImportingLibrary; const isBusy = isImportingLibrary;
@ -110,13 +136,11 @@ export function SettingsModal({ className = '' }: { className?: string }) {
{ id: 'custom', name: 'Other' } { id: 'custom', name: 'Other' }
]; ];
case 'deepinfra': case 'deepinfra':
// In production without an API key, limit to free tier model
if (!showAllDeepInfra && !localApiKey) { if (!showAllDeepInfra && !localApiKey) {
return [ return [
{ id: 'hexgrad/Kokoro-82M', name: 'hexgrad/Kokoro-82M' } { id: 'hexgrad/Kokoro-82M', name: 'hexgrad/Kokoro-82M' }
]; ];
} }
// In dev or with an API key, allow all models
return [ return [
{ id: 'hexgrad/Kokoro-82M', name: 'hexgrad/Kokoro-82M' }, { id: 'hexgrad/Kokoro-82M', name: 'hexgrad/Kokoro-82M' },
{ id: 'canopylabs/orpheus-3b-0.1-ft', name: 'canopylabs/orpheus-3b-0.1-ft' }, { 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] [selectedModelId, supportsCustom, customModelInput]
); );
// Open settings on first visit (stored in Dexie app config)
const checkFirstVist = useCallback(async () => { const checkFirstVist = useCallback(async () => {
const appConfig = await getAppConfig(); const appConfig = await getAppConfig();
if (!appConfig?.privacyAccepted) { if (!appConfig?.privacyAccepted) {
@ -185,7 +208,6 @@ export function SettingsModal({ className = '' }: { className?: string }) {
}; };
}, [checkFirstVist]); }, [checkFirstVist]);
// Detect if current model is custom (not in presets) and mirror it in the input field
useEffect(() => { useEffect(() => {
if (!ttsModels.some(m => m.id === modelValue) && modelValue !== '') { if (!ttsModels.some(m => m.id === modelValue) && modelValue !== '') {
setCustomModelInput(modelValue); setCustomModelInput(modelValue);
@ -232,18 +254,11 @@ export function SettingsModal({ className = '' }: { className?: string }) {
const handleModalConfirm = async (selectedFiles: BaseDocument[]) => { const handleModalConfirm = async (selectedFiles: BaseDocument[]) => {
const controller = new AbortController(); const controller = new AbortController();
setAbortController(controller); 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); setIsSelectionModalOpen(false);
try { try {
setShowProgress(true); setShowProgress(true);
setProgress(0); setProgress(0);
setIsImportingLibrary(true); setIsImportingLibrary(true);
for (let i = 0; i < selectedFiles.length; i++) { for (let i = 0; i < selectedFiles.length; i++) {
@ -301,13 +316,8 @@ export function SettingsModal({ className = '' }: { className?: string }) {
const handleDeleteDocs = async () => { const handleDeleteDocs = async () => {
try { try {
if (deleteDocsMode === 'user') {
await deleteDocuments(); await deleteDocuments();
await refreshDocuments().catch(() => { }); await refreshDocuments().catch(() => { });
} else if (deleteDocsMode === 'unclaimed') {
await deleteDocuments({ scope: 'unclaimed' });
await refreshDocuments().catch(() => { });
}
} catch (error) { } catch (error) {
console.error('Delete failed:', error); console.error('Delete failed:', error);
} finally { } finally {
@ -315,8 +325,6 @@ export function SettingsModal({ className = '' }: { className?: string }) {
} }
}; };
const handleSignOut = async () => { const handleSignOut = async () => {
const client = getAuthClient(authBaseUrl); const client = getAuthClient(authBaseUrl);
await client.signOut(); await client.signOut();
@ -328,7 +336,6 @@ export function SettingsModal({ className = '' }: { className?: string }) {
const res = await fetch('/api/account/delete', { method: 'DELETE' }); const res = await fetch('/api/account/delete', { method: 'DELETE' });
if (!res.ok) throw new Error('Failed to delete account'); if (!res.ok) throw new Error('Failed to delete account');
// Sign out locally
const client = getAuthClient(authBaseUrl); const client = getAuthClient(authBaseUrl);
await client.signOut(); await client.signOut();
window.location.href = '/signup'; window.location.href = '/signup';
@ -360,41 +367,28 @@ export function SettingsModal({ className = '' }: { className?: string }) {
} }
}, [apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, ttsModels]); }, [apiKey, baseUrl, ttsProvider, ttsModel, ttsInstructions, ttsModels]);
const tabs = [ const [systemIsDark, setSystemIsDark] = useState(
{ name: 'API', icon: '🔑' }, typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
{ 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(() => { useEffect(() => {
if (!authEnabled) return; const mq = window.matchMedia('(prefers-color-scheme: dark)');
if (!session?.user) return; const handler = (e: MediaQueryListEvent) => setSystemIsDark(e.matches);
if (session.user.isAnonymous) return; mq.addEventListener('change', handler);
// fetch claimable counts (unclaimed docs/audiobooks) return () => mq.removeEventListener('change', handler);
fetch('/api/user/claim') }, []);
.then((r) => (r.ok ? r.json() : null))
.then((data) => { const getThemeColors = useCallback((id: string): ThemeColorSet => {
if (data && typeof data.documents === 'number' && typeof data.audiobooks === 'number') { if (id === 'system') return THEME_COLORS[systemIsDark ? 'dark' : 'light'];
setUnclaimedCounts({ documents: data.documents, audiobooks: data.audiobooks }); return THEME_COLORS[id] || THEME_COLORS.light;
} }, [systemIsDark]);
})
.catch(() => { }); const visibleSections = SIDEBAR_SECTIONS.filter(s => !s.authOnly || authEnabled);
}, [authEnabled, session?.user]);
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 ( return (
<> <>
@ -432,59 +426,83 @@ export function SettingsModal({ className = '' }: { className?: string }) {
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95" leaveTo="opacity-0 scale-95"
> >
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-base p-6 text-left align-middle shadow-xl transition-all"> <DialogPanel className="w-full max-w-3xl transform rounded-2xl bg-base text-left align-middle shadow-xl transition-all overflow-hidden">
<div className="flex items-center justify-between gap-3 mb-4"> {/* Header */}
<DialogTitle <div className="flex items-center justify-between px-6 py-4 border-b border-offbase">
as="h3" <DialogTitle as="h3" className="text-lg font-semibold leading-6 text-foreground">
className="text-lg font-semibold leading-6 text-foreground"
>
Settings Settings
</DialogTitle> </DialogTitle>
{authEnabled && (
<Button <Button
onClick={() => showPrivacyModal({ authEnabled })} onClick={() => showPrivacyModal({ authEnabled })}
className="text-sm font-medium text-muted hover:text-accent" className="text-sm font-medium text-muted hover:text-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
> >
Privacy Privacy
</Button> </Button>
)}
</div> </div>
<TabGroup> {/* Mobile: 2x2 grid nav */}
<TabList className="flex flex-col sm:flex-col-none sm:flex-row gap-1 rounded-xl bg-background p-1 mb-4"> <div className="grid grid-cols-2 gap-1 sm:hidden border-b border-offbase bg-background p-2">
{tabs.map((tab) => ( {visibleSections.map((section) => {
<Tab const Icon = section.icon;
key={tab.name} return (
className={({ selected }) => <button
`w-full rounded-lg py-1 text-sm font-medium key={section.id}
ring-accent/60 ring-offset-2 ring-offset-base onClick={() => setActiveSection(section.id)}
${selected className={`flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-lg text-xs font-medium transition-colors
? 'bg-accent text-background shadow' ${activeSection === section.id
: 'text-foreground hover:text-accent' ? 'bg-accent text-background'
}` : 'text-foreground hover:bg-offbase hover:text-accent'
} }`}
> >
<span className="flex items-center justify-center gap-2"> <Icon className="w-3.5 h-3.5" />
<span>{tab.icon}</span> {section.label}
{tab.name} </button>
</span> );
</Tab> })}
))} </div>
</TabList>
<TabPanels className="mt-2"> <div className="flex flex-row h-[450px]">
<TabPanel className="space-y-2.5"> {/* Desktop: vertical sidebar */}
<div className="space-y-1"> <nav className="hidden sm:block w-44 shrink-0 border-r border-offbase bg-background">
<div className="flex flex-col p-2 gap-1">
{visibleSections.map((section) => {
const Icon = section.icon;
return (
<button
key={section.id}
onClick={() => 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'
}`}
>
<Icon className="w-4 h-4 shrink-0" />
{section.label}
</button>
);
})}
</div>
</nav>
{/* Content */}
<div className="flex-1 p-4 overflow-y-auto">
{/* API Section */}
{activeSection === 'api' && (
<div className="space-y-4">
<div className="space-y-1.5">
<label className="block text-sm font-medium text-foreground">TTS Provider</label> <label className="block text-sm font-medium text-foreground">TTS Provider</label>
<Listbox <Listbox
value={ttsProviders.find(p => p.id === localTTSProvider) || ttsProviders[0]} value={ttsProviders.find(p => p.id === localTTSProvider) || ttsProviders[0]}
onChange={(provider) => { onChange={(provider) => {
setLocalTTSProvider(provider.id); setLocalTTSProvider(provider.id);
// Set default model and base_url for each provider
if (provider.id === 'openai') { if (provider.id === 'openai') {
setModelValue('tts-1'); setModelValue('tts-1');
setLocalBaseUrl('https://api.openai.com/v1'); setLocalBaseUrl('https://api.openai.com/v1');
} else if (provider.id === 'custom-openai') { } else if (provider.id === 'custom-openai') {
setModelValue('kokoro'); setModelValue('kokoro');
// Clear baseUrl for custom provider
setLocalBaseUrl(''); setLocalBaseUrl('');
} else if (provider.id === 'deepinfra') { } else if (provider.id === 'deepinfra') {
setModelValue('hexgrad/Kokoro-82M'); setModelValue('hexgrad/Kokoro-82M');
@ -493,7 +511,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
setCustomModelInput(''); setCustomModelInput('');
}} }}
> >
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-1.5 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.009] hover:text-accent hover:bg-offbase"> <ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-2 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.005] hover:text-accent hover:bg-offbase">
<span className="block truncate"> <span className="block truncate">
{ttsProviders.find(p => p.id === localTTSProvider)?.name || 'Select Provider'} {ttsProviders.find(p => p.id === localTTSProvider)?.name || 'Select Provider'}
</span> </span>
@ -507,13 +525,12 @@ export function SettingsModal({ className = '' }: { className?: string }) {
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<ListboxOptions className="absolute mt-1 w-full overflow-auto rounded-md bg-background py-1 shadow-lg ring-1 ring-black/5 focus:outline-none z-50"> <ListboxOptions className="absolute mt-1 w-full overflow-auto rounded-md bg-background py-1 shadow-lg ring-1 ring-offbase focus:outline-none z-50">
{ttsProviders.map((provider) => ( {ttsProviders.map((provider) => (
<ListboxOption <ListboxOption
key={provider.id} key={provider.id}
className={({ active }) => className={({ active }) =>
`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={provider} value={provider}
> >
@ -522,11 +539,11 @@ export function SettingsModal({ className = '' }: { className?: string }) {
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}> <span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{provider.name} {provider.name}
</span> </span>
{selected ? ( {selected && (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent"> <span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
<CheckIcon className="h-5 w-5" /> <CheckIcon className="h-5 w-5" />
</span> </span>
) : null} )}
</> </>
)} )}
</ListboxOption> </ListboxOption>
@ -537,47 +554,42 @@ export function SettingsModal({ className = '' }: { className?: string }) {
</div> </div>
{(localTTSProvider === 'custom-openai' || !localBaseUrl || localBaseUrl === '') && ( {(localTTSProvider === 'custom-openai' || !localBaseUrl || localBaseUrl === '') && (
<div className="space-y-1"> <div className="space-y-1.5">
<label className="block text-sm font-medium text-foreground"> <label className="block text-sm font-medium text-foreground">
API Base URL API Base URL
{localBaseUrl && <span className="ml-2 text-xs text-accent">(Overriding env)</span>} {localBaseUrl && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
</label> </label>
<div className="flex gap-2">
<Input <Input
type="text" type="text"
value={localBaseUrl} value={localBaseUrl}
onChange={(e) => handleInputChange('baseUrl', e.target.value)} onChange={(e) => handleInputChange('baseUrl', e.target.value)}
placeholder="Using environment variable" 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" className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
/> />
</div> </div>
</div>
)} )}
<div className="space-y-1"> <div className="space-y-1.5">
<label className="block text-sm font-medium text-foreground"> <label className="block text-sm font-medium text-foreground">
API Key API Key
{localApiKey && <span className="ml-2 text-xs text-accent">(Overriding env)</span>} {localApiKey && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
</label> </label>
<div className="flex gap-2">
<Input <Input
type="password" type="password"
value={localApiKey} value={localApiKey}
onChange={(e) => handleInputChange('apiKey', e.target.value)} onChange={(e) => handleInputChange('apiKey', e.target.value)}
placeholder={!showAllDeepInfra && localTTSProvider === 'deepinfra' ? "Deepinfra free or use your API key" : "Using environment variable"} 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" className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
/> />
</div> </div>
</div>
<div className="space-y-1"> <div className="space-y-1.5">
<label className="block text-sm font-medium text-foreground">TTS Model</label> <label className="block text-sm font-medium text-foreground">TTS Model</label>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Listbox <Listbox
value={ttsModels.find(m => m.id === selectedModelId) || ttsModels[0]} value={ttsModels.find(m => m.id === selectedModelId) || ttsModels[0]}
onChange={(model) => { onChange={(model) => {
if (model.id === 'custom') { if (model.id === 'custom') {
// Switch to custom: keep the current custom input (or empty)
setModelValue(customModelInput); setModelValue(customModelInput);
} else { } else {
setModelValue(model.id); setModelValue(model.id);
@ -585,7 +597,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
} }
}} }}
> >
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-1.5 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.009] hover:text-accent hover:bg-offbase"> <ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-2 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.005] hover:text-accent hover:bg-offbase">
<span className="block truncate"> <span className="block truncate">
{ttsModels.find(m => m.id === selectedModelId)?.name || 'Select Model'} {ttsModels.find(m => m.id === selectedModelId)?.name || 'Select Model'}
</span> </span>
@ -599,13 +611,12 @@ export function SettingsModal({ className = '' }: { className?: string }) {
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<ListboxOptions className="absolute mt-1 w-full overflow-auto rounded-md bg-background py-1 shadow-lg ring-1 ring-black/5 focus:outline-none z-50"> <ListboxOptions className="absolute mt-1 w-full overflow-auto rounded-md bg-background py-1 shadow-lg ring-1 ring-offbase focus:outline-none z-50">
{ttsModels.map((model) => ( {ttsModels.map((model) => (
<ListboxOption <ListboxOption
key={model.id} key={model.id}
className={({ active }) => className={({ active }) =>
`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={model}
> >
@ -614,11 +625,11 @@ export function SettingsModal({ className = '' }: { className?: string }) {
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}> <span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{model.name} {model.name}
</span> </span>
{selected ? ( {selected && (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent"> <span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
<CheckIcon className="h-5 w-5" /> <CheckIcon className="h-5 w-5" />
</span> </span>
) : null} )}
</> </>
)} )}
</ListboxOption> </ListboxOption>
@ -636,20 +647,20 @@ export function SettingsModal({ className = '' }: { className?: string }) {
setModelValue(e.target.value); setModelValue(e.target.value);
}} }}
placeholder="Enter custom model name" 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" className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
/> />
)} )}
</div> </div>
</div> </div>
{modelValue === 'gpt-4o-mini-tts' && ( {modelValue === 'gpt-4o-mini-tts' && (
<div className="space-y-1"> <div className="space-y-1.5">
<label className="block text-sm font-medium text-foreground">TTS Instructions</label> <label className="block text-sm font-medium text-foreground">TTS Instructions</label>
<textarea <textarea
value={localTTSInstructions} value={localTTSInstructions}
onChange={(e) => setLocalTTSInstructions(e.target.value)} onChange={(e) => setLocalTTSInstructions(e.target.value)}
placeholder="Enter instructions for the TTS model" placeholder="Enter instructions for the TTS model"
className="w-full h-24 rounded-lg bg-background py-1.5 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" className="w-full h-24 rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent resize-none"
/> />
</div> </div>
)} )}
@ -657,10 +668,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
<div className="pt-4 flex justify-end gap-2"> <div className="pt-4 flex justify-end gap-2">
<Button <Button
type="button" type="button"
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 text-sm className={`${btnSecondary} px-4 py-2`}
font-medium text-foreground hover:bg-offbase focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
onClick={async () => { onClick={async () => {
setLocalApiKey(''); setLocalApiKey('');
setLocalBaseUrl(''); setLocalBaseUrl('');
@ -674,10 +682,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
</Button> </Button>
<Button <Button
type="button" type="button"
className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 text-sm className={`${btnPrimary} px-4 py-2`}
font-medium text-background hover:bg-secondary-accent focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background"
disabled={!canSubmit} disabled={!canSubmit}
onClick={async () => { onClick={async () => {
await updateConfig({ await updateConfig({
@ -694,138 +699,187 @@ export function SettingsModal({ className = '' }: { className?: string }) {
Save Save
</Button> </Button>
</div> </div>
</TabPanel>
<TabPanel className="space-y-4 pb-3">
<div className="space-y-1">
<label className="block text-sm font-medium text-foreground">Theme</label>
<Listbox value={selectedTheme} onChange={(newTheme) => setTheme(newTheme.id)}>
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-1.5 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.009] hover:text-accent hover:bg-offbase">
<span className="block truncate">{selectedTheme.name}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-muted" />
</span>
</ListboxButton>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions className="absolute mt-1 w-full overflow-auto rounded-md bg-background py-1 shadow-lg ring-1 ring-black/5 focus:outline-none">
{themes.map((theme) => (
<ListboxOption
key={theme.id}
className={({ active }) =>
`relative cursor-pointer select-none py-1.5 pl-10 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'
}`
}
value={theme}
>
{({ selected }) => (
<>
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{theme.name}
</span>
{selected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
<CheckIcon className="h-5 w-5" />
</span>
) : null}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</Transition>
</Listbox>
</div> </div>
</TabPanel> )}
<TabPanel className="space-y-4"> {/* Theme / Appearance Section */}
<div className="space-y-1"> {activeSection === 'theme' && (
<label className="block text-sm font-medium text-foreground">Server Library Import</label> <div className="space-y-4">
<div className="flex gap-2"> {/* System */}
<div className="space-y-1.5">
{(() => {
const colors = getThemeColors(systemTheme.id);
const isActive = theme === systemTheme.id;
return (
<button
onClick={() => setTheme(systemTheme.id)}
className={`flex items-center gap-3 rounded-lg px-3 py-1.5 w-full text-left transition-all duration-200 ease-in-out transform hover:scale-[1.02] border
${isActive
? 'border-accent'
: 'border-offbase hover:border-muted'
}`}
style={{ backgroundColor: colors.base }}
>
{isActive ? (
<CheckIcon className="h-3.5 w-3.5 shrink-0 text-accent" />
) : (
<span className="w-3.5 shrink-0" />
)}
<span
className="text-xs font-medium w-16 shrink-0"
style={{ color: colors.foreground }}
>
{systemTheme.name}
</span>
<div className="flex gap-1 ml-auto">
<div className="w-4 h-4 rounded-full border border-offbase" style={{ backgroundColor: colors.background }} />
<div className="w-4 h-4 rounded-full border border-offbase" style={{ backgroundColor: colors.offbase }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.accent }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.secondaryAccent }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.muted }} />
</div>
</button>
);
})()}
</div>
{/* Light themes */}
<div className="space-y-1.5">
<label className="block text-xs font-medium text-muted uppercase tracking-wide">Light</label>
<div className="grid grid-cols-2 gap-1.5">
{lightThemes.map((t) => {
const colors = getThemeColors(t.id);
const isActive = theme === t.id;
return (
<button
key={t.id}
onClick={() => setTheme(t.id)}
className={`flex items-center gap-3 rounded-lg px-3 py-1.5 text-left transition-all duration-200 ease-in-out transform hover:scale-[1.02] border
${isActive
? 'border-accent'
: 'border-offbase hover:border-muted'
}`}
style={{ backgroundColor: colors.base }}
>
{isActive ? (
<CheckIcon className="h-3.5 w-3.5 shrink-0 text-accent" />
) : (
<span className="w-3.5 shrink-0" />
)}
<span
className="text-xs font-medium w-16 shrink-0"
style={{ color: colors.foreground }}
>
{t.name}
</span>
<div className="flex gap-1 ml-auto">
<div className="w-4 h-4 rounded-full border border-offbase" style={{ backgroundColor: colors.background }} />
<div className="w-4 h-4 rounded-full border border-offbase" style={{ backgroundColor: colors.offbase }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.accent }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.secondaryAccent }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.muted }} />
</div>
</button>
);
})}
</div>
</div>
{/* Dark themes */}
<div className="space-y-1.5">
<label className="block text-xs font-medium text-muted uppercase tracking-wide">Dark</label>
<div className="grid grid-cols-2 gap-1.5">
{darkThemes.map((t) => {
const colors = getThemeColors(t.id);
const isActive = theme === t.id;
return (
<button
key={t.id}
onClick={() => setTheme(t.id)}
className={`flex items-center gap-3 rounded-lg px-3 py-1.5 text-left transition-all duration-200 ease-in-out transform hover:scale-[1.02] border
${isActive
? 'border-accent'
: 'border-offbase hover:border-muted'
}`}
style={{ backgroundColor: colors.base }}
>
{isActive ? (
<CheckIcon className="h-3.5 w-3.5 shrink-0 text-accent" />
) : (
<span className="w-3.5 shrink-0" />
)}
<span
className="text-xs font-medium w-16 shrink-0"
style={{ color: colors.foreground }}
>
{t.name}
</span>
<div className="flex gap-1 ml-auto">
<div className="w-4 h-4 rounded-full border border-offbase" style={{ backgroundColor: colors.background }} />
<div className="w-4 h-4 rounded-full border border-offbase" style={{ backgroundColor: colors.offbase }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.accent }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.secondaryAccent }} />
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: colors.muted }} />
</div>
</button>
);
})}
</div>
</div>
</div>
)}
{/* Documents Section */}
{activeSection === 'docs' && (
<div className="space-y-5">
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">Server Library</label>
<Button <Button
onClick={handleImportLibrary} onClick={handleImportLibrary}
disabled={isBusy} disabled={isBusy}
className="justify-center rounded-lg bg-background px-3 py-1.5 text-sm className={`${btnOutline} px-4 py-2 disabled:opacity-50`}
font-medium text-foreground hover:bg-offbase focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
disabled:opacity-50"
> >
{isImportingLibrary ? `Importing... ${Math.round(progress)}%` : 'Import from library'} {isImportingLibrary ? `Importing... ${Math.round(progress)}%` : 'Import from library'}
</Button> </Button>
</div> </div>
</div>
<div className="space-y-1"> <div className="space-y-2">
<label className="block text-sm font-medium text-foreground">Documents</label> <label className="block text-sm font-medium text-foreground">Cache & Data</label>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button <Button
onClick={handleRefresh} onClick={handleRefresh}
disabled={isBusy} disabled={isBusy}
className="justify-center rounded-lg bg-background px-3 py-1.5 text-sm className={`${btnOutline} px-4 py-2 disabled:opacity-50`}
font-medium text-foreground hover:bg-offbase focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
disabled:opacity-50"
> >
Refresh Refresh
</Button> </Button>
<Button <Button
onClick={handleClearCache} onClick={handleClearCache}
disabled={isBusy} disabled={isBusy}
className="justify-center rounded-lg bg-background px-3 py-1.5 text-sm className={`${btnOutline} px-4 py-2 disabled:opacity-50`}
font-medium text-foreground hover:bg-offbase focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
disabled:opacity-50"
> >
Clear cache Clear cache
</Button> </Button>
{enableDestructiveDelete && ( {enableDestructiveDelete && !authEnabled && (
<div className="flex w-full gap-2">
<Button <Button
onClick={() => { onClick={() => setShowDeleteDocsConfirm(true)}
setDeleteDocsMode('user');
setShowDeleteDocsConfirm(true);
}}
disabled={isBusy} disabled={isBusy}
className="justify-center rounded-lg bg-red-500 px-3 py-1.5 text-sm className={`${btnBase} bg-red-600 text-white hover:bg-red-700 hover:scale-[1.04] px-4 py-2`}
font-medium text-background hover:bg-red-500/90 focus:outline-none
focus-visible:ring-2 focus-visible:bg-red-500 focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
> >
{userDeleteLabel} Delete all data
</Button>
{canDeleteUnclaimed && (
<Button
onClick={() => {
setDeleteDocsMode('unclaimed');
setShowDeleteDocsConfirm(true);
}}
disabled={isBusy}
className="justify-center rounded-lg bg-red-500 px-3 py-1.5 text-sm
font-medium text-background hover:bg-red-500/90 focus:outline-none
focus-visible:ring-2 focus-visible:bg-red-500 focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.04]"
>
Delete unclaimed docs
</Button> </Button>
)} )}
</div> </div>
</div>
</div>
)} )}
</div>
</div>
</TabPanel>
{authEnabled && ( {/* Account Section */}
<TabPanel className="space-y-4"> {activeSection === 'account' && authEnabled && (
<div className="space-y-4"> <div className="space-y-2">
<div className="rounded-lg bg-offbase p-4 space-y-3"> {/* Session info */}
<h4 className="font-medium text-foreground">Current Session</h4> <div className="rounded-lg bg-background border border-offbase p-4 space-y-2">
<h4 className="text-sm font-medium text-foreground">Current Session</h4>
<div className="text-sm space-y-1"> <div className="text-sm space-y-1">
<p className="text-muted">Logged in as:</p> <p className="text-muted">Logged in as:</p>
{session?.user ? ( {session?.user ? (
@ -848,47 +902,45 @@ export function SettingsModal({ className = '' }: { className?: string }) {
</div> </div>
</div> </div>
<div className="space-y-2"> {/* Export Data */}
{session?.user && ( {session?.user && (
<Button <button
onClick={() => { onClick={() => {
window.open('/api/user/export', '_blank'); window.open('/api/user/export', '_blank');
}} }}
className="w-full flex flex-col items-center justify-center rounded-lg bg-background border border-offbase px-3 py-2 text-sm className="w-full rounded-lg border border-offbase bg-background p-4 flex items-center gap-4 hover:bg-offbase transition-colors text-left group"
font-medium text-foreground hover:bg-offbase focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.02]"
> >
<span>Export My Data</span> <div className="flex-shrink-0 w-10 h-10 rounded-lg bg-offbase flex items-center justify-center group-hover:bg-background transition-colors">
<span className="text-xs text-muted font-normal mt-0.5">Download metadata, uploaded documents, and generated audiobooks (ZIP)</span> <DownloadIcon className="w-5 h-5 text-accent" />
</Button> </div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">Export My Data</p>
<p className="text-xs text-muted">Download all your data as a ZIP file</p>
</div>
</button>
)} )}
{/* Actions */}
<div className="space-y-2">
{session?.user && !session.user.isAnonymous ? ( {session?.user && !session.user.isAnonymous ? (
<> <>
<Button <Button
onClick={handleSignOut} onClick={handleSignOut}
className="w-full justify-center rounded-lg bg-background border border-offbase px-3 py-2 text-sm className={`${btnOutline} px-4 py-2`}
font-medium text-foreground hover:bg-offbase focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.02]"
> >
Disconnect account Disconnect account
</Button> </Button>
<div className="pt-4 border-t border-offbase"> <div className="pt-4 mt-4 border-t border-offbase">
<label className="block text-sm font-medium text-red-500 mb-2">Danger Zone</label> <label className="block text-sm font-medium text-red-500 mb-2">Danger Zone</label>
<Button <Button
onClick={() => setShowDeleteAccountConfirm(true)} onClick={() => setShowDeleteAccountConfirm(true)}
className="w-full justify-center rounded-lg bg-red-500/10 border border-red-500/20 px-3 py-2 text-sm className={`${btnBase} bg-red-600 text-white border border-red-700 hover:bg-red-700 hover:scale-[1.02] px-4 py-2`}
font-medium text-red-600 dark:text-red-400 hover:bg-red-500/20 focus:outline-none
focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2
transform transition-transform duration-200 ease-in-out hover:scale-[1.02]"
> >
Delete Account Delete Account
</Button> </Button>
<p className="text-xs text-muted mt-2 text-center"> <p className="text-xs text-muted mt-2">
This will permanently delete your account and data. You will be redirected to sign up. Permanently deletes your account and all data.
</p> </p>
</div> </div>
</> </>
@ -896,36 +948,33 @@ export function SettingsModal({ className = '' }: { className?: string }) {
<div className="pt-2 border-t border-offbase"> <div className="pt-2 border-t border-offbase">
<p className="text-sm text-muted mb-3"> <p className="text-sm text-muted mb-3">
{session?.user?.isAnonymous {session?.user?.isAnonymous
? (allowAnonymousAuthSessions ? 'You are using an anonymous session. Sign up to save your progress permanently, your current data is automatically transferred.'
? 'You are using an anonymous session. Sign up to save your progress permanently.'
: 'Anonymous sessions are disabled. Please sign in or create an account.')
: 'No active session. Please sign in or create an account.'} : 'No active session. Please sign in or create an account.'}
</p> </p>
<div className="grid grid-cols-2 gap-3"> <div className="flex flex-wrap gap-2">
<Link href="/signin" className="w-full"> <Link href="/signin">
<Button className="w-full justify-center rounded-lg bg-background border border-offbase px-3 py-2 text-sm font-medium text-foreground hover:bg-offbase focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-base transform transition-transform duration-200 ease-in-out hover:scale-[1.02]"> <Button className={`${btnOutline} px-4 py-2`}>
Connect Connect
</Button> </Button>
</Link> </Link>
<Link href="/signup" className="w-full"> <Link href="/signup">
<Button className="w-full justify-center rounded-lg bg-accent px-3 py-2 text-sm font-medium text-background hover:bg-secondary-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-base transform transition-transform duration-200 ease-in-out hover:scale-[1.02]"> <Button className={`${btnPrimary} px-4 py-2`}>
Create account Create account
</Button> </Button>
</Link> </Link>
</div> <Link href="/?redirect=false">
<Link href="/?redirect=false" className="block mt-3"> <Button className={`${btnOutline} px-4 py-2`}>
<Button className="w-full justify-center rounded-lg bg-background border border-offbase px-3 py-2 text-sm font-medium text-foreground hover:bg-offbase focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-base transform transition-transform duration-200 ease-in-out hover:scale-[1.02]">
Back to landing page Back to landing page
</Button> </Button>
</Link> </Link>
</div> </div>
</div>
)} )}
</div> </div>
</div> </div>
</TabPanel>
)} )}
</TabPanels> </div>
</TabGroup> </div>
</DialogPanel> </DialogPanel>
</TransitionChild> </TransitionChild>
</div> </div>
@ -937,16 +986,8 @@ export function SettingsModal({ className = '' }: { className?: string }) {
isOpen={showDeleteDocsConfirm} isOpen={showDeleteDocsConfirm}
onClose={() => setShowDeleteDocsConfirm(false)} onClose={() => setShowDeleteDocsConfirm(false)}
onConfirm={handleDeleteDocs} onConfirm={handleDeleteDocs}
title={ title="Delete All Data"
deleteDocsMode === 'unclaimed' message="Are you sure you want to delete all documents from the server? This action cannot be undone."
? 'Delete Unclaimed Docs'
: userDeleteTitle
}
message={
deleteDocsMode === 'unclaimed'
? 'Are you sure you want to delete all unclaimed documents? This action cannot be undone.'
: userDeleteMessage
}
confirmText="Delete" confirmText="Delete"
isDangerous={true} isDangerous={true}
/> />
@ -985,7 +1026,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
onConfirm={handleModalConfirm} onConfirm={handleModalConfirm}
title={selectionModalProps.title} title={selectionModalProps.title}
confirmLabel={selectionModalProps.confirmLabel} confirmLabel={selectionModalProps.confirmLabel}
isProcessing={false} // Processing happens in ProgressPopup after closing isProcessing={false}
defaultSelected={selectionModalProps.defaultSelected} defaultSelected={selectionModalProps.defaultSelected}
initialFiles={selectionModalProps.initialFiles} initialFiles={selectionModalProps.initialFiles}
fetcher={selectionModalProps.fetcher} fetcher={selectionModalProps.fetcher}

View file

@ -510,6 +510,78 @@ export function GridIcon(props: React.SVGProps<SVGSVGElement>) {
</svg> </svg>
); );
} }
export function KeyIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
className={props.className}
width={props.width || "1.5em"}
height={props.height || "1.5em"}
{...props}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
</svg>
);
}
export function PaletteIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
className={props.className}
width={props.width || "1.5em"}
height={props.height || "1.5em"}
{...props}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M4.098 19.902a3.75 3.75 0 005.304 0l6.401-6.402M6.75 21A3.75 3.75 0 013 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 003.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.88 2.88M6.75 17.25h.008v.008H6.75v-.008z" />
</svg>
);
}
export function UserIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
className={props.className}
width={props.width || "1.5em"}
height={props.height || "1.5em"}
{...props}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
</svg>
);
}
export function DocumentIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
className={props.className}
width={props.width || "1.5em"}
height={props.height || "1.5em"}
{...props}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
</svg>
);
}
export function FileSettingsIcon(props: React.SVGProps<SVGSVGElement>) { export function FileSettingsIcon(props: React.SVGProps<SVGSVGElement>) {
return ( return (
<svg <svg

View file

@ -2,7 +2,7 @@
import { createContext, useContext, useEffect, useState, ReactNode, useLayoutEffect } from 'react'; import { createContext, useContext, useEffect, useState, ReactNode, useLayoutEffect } from 'react';
const THEMES = ['system', 'light', 'dark', 'ocean', 'forest', 'sunset', 'sea', 'mint'] as const; const THEMES = ['system', 'light', 'dark', 'ocean', 'forest', 'sunset', 'sea', 'mint', 'lavender', 'rose', 'sand', 'sky', 'slate'] as const;
type Theme = (typeof THEMES)[number]; type Theme = (typeof THEMES)[number];
interface ThemeContextType { interface ThemeContextType {
@ -17,6 +17,8 @@ const getSystemTheme = () => {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}; };
const LIGHT_THEMES: ReadonlySet<string> = new Set(['light', 'lavender', 'rose', 'sand', 'sky', 'slate']);
const getEffectiveTheme = (theme: Theme): Theme => { const getEffectiveTheme = (theme: Theme): Theme => {
if (theme === 'system') { if (theme === 'system') {
return getSystemTheme(); return getSystemTheme();
@ -24,6 +26,10 @@ const getEffectiveTheme = (theme: Theme): Theme => {
return theme; return theme;
}; };
const getColorScheme = (theme: Theme): string => {
return LIGHT_THEMES.has(theme) ? 'light' : 'dark';
};
export function ThemeProvider({ children }: { children: ReactNode }) { export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>('system'); const [theme, setTheme] = useState<Theme>('system');
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
@ -36,7 +42,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const effectiveTheme = getEffectiveTheme(initialTheme); const effectiveTheme = getEffectiveTheme(initialTheme);
document.documentElement.classList.remove(...THEMES); document.documentElement.classList.remove(...THEMES);
document.documentElement.classList.add(effectiveTheme); document.documentElement.classList.add(effectiveTheme);
document.documentElement.style.colorScheme = effectiveTheme; document.documentElement.style.colorScheme = getColorScheme(effectiveTheme);
if (!stored) { if (!stored) {
localStorage.setItem('theme', initialTheme); localStorage.setItem('theme', initialTheme);
} }
@ -49,7 +55,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
root.classList.remove(...THEMES); root.classList.remove(...THEMES);
root.classList.add(effectiveTheme); root.classList.add(effectiveTheme);
root.style.colorScheme = effectiveTheme; root.style.colorScheme = getColorScheme(effectiveTheme);
localStorage.setItem('theme', newTheme); localStorage.setItem('theme', newTheme);
setTheme(newTheme); setTheme(newTheme);
@ -64,7 +70,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
const root = window.document.documentElement; const root = window.document.documentElement;
root.classList.remove(...THEMES); root.classList.remove(...THEMES);
root.classList.add(effectiveTheme); root.classList.add(effectiveTheme);
root.style.colorScheme = effectiveTheme; root.style.colorScheme = getColorScheme(effectiveTheme);
} }
}; };

View file

@ -28,6 +28,13 @@ test.describe('Document deletion flow', () => {
}); });
test('deletes all local documents from Settings modal', async ({ page }) => { 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) // Upload multiple docs (PDF + EPUB)
await uploadFile(page, 'sample.pdf'); await uploadFile(page, 'sample.pdf');
await uploadFile(page, 'sample.epub'); await uploadFile(page, 'sample.epub');

View file

@ -346,18 +346,18 @@ export async function deleteDocumentByName(page: Page, fileName: string) {
await confirmBtn.click(); 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) { export async function openSettingsDocumentsTab(page: Page) {
await page.getByRole('button', { name: 'Settings' }).click(); 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 // Delete all local documents through Settings and close dialogs
export async function deleteAllLocalDocuments(page: Page) { export async function deleteAllLocalDocuments(page: Page) {
await openSettingsDocumentsTab(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 }); await expect(heading).toBeVisible({ timeout: 10000 });
const confirmBtn = heading.locator('xpath=ancestor::*[@role="dialog"][1]//button[normalize-space()="Delete"]'); const confirmBtn = heading.locator('xpath=ancestor::*[@role="dialog"][1]//button[normalize-space()="Delete"]');