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:
parent
fe05de9fec
commit
1deb8c21f7
9 changed files with 711 additions and 509 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
})();
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ export function Providers({ children, authEnabled, authBaseUrl, allowAnonymousAu
|
|||
<AuthLoader>
|
||||
<>
|
||||
{children}
|
||||
<PrivacyModal authEnabled={authEnabled} />
|
||||
{authEnabled && <PrivacyModal />}
|
||||
</>
|
||||
</AuthLoader>
|
||||
</ThemeProvider>
|
||||
|
|
@ -64,7 +64,7 @@ export function Providers({ children, authEnabled, authBaseUrl, allowAnonymousAu
|
|||
<HTMLProvider>
|
||||
<>
|
||||
{children}
|
||||
<PrivacyModal authEnabled={authEnabled} />
|
||||
{authEnabled && <PrivacyModal />}
|
||||
<DexieMigrationModal />
|
||||
</>
|
||||
</HTMLProvider>
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
|||
</p>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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<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,
|
||||
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 }) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [activeSection, setActiveSection] = useState<SectionId>('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<AbortController | null>(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(() => { });
|
||||
}
|
||||
} 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,59 +426,83 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
leaveFrom="opacity-100 scale-100"
|
||||
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">
|
||||
<div className="flex items-center justify-between gap-3 mb-4">
|
||||
<DialogTitle
|
||||
as="h3"
|
||||
className="text-lg font-semibold leading-6 text-foreground"
|
||||
>
|
||||
<DialogPanel className="w-full max-w-3xl transform rounded-2xl bg-base text-left align-middle shadow-xl transition-all overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-offbase">
|
||||
<DialogTitle as="h3" className="text-lg font-semibold leading-6 text-foreground">
|
||||
Settings
|
||||
</DialogTitle>
|
||||
|
||||
{authEnabled && (
|
||||
<Button
|
||||
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
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TabGroup>
|
||||
<TabList className="flex flex-col sm:flex-col-none sm:flex-row gap-1 rounded-xl bg-background p-1 mb-4">
|
||||
{tabs.map((tab) => (
|
||||
<Tab
|
||||
key={tab.name}
|
||||
className={({ selected }) =>
|
||||
`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 */}
|
||||
<div className="grid grid-cols-2 gap-1 sm:hidden border-b border-offbase bg-background p-2">
|
||||
{visibleSections.map((section) => {
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<button
|
||||
key={section.id}
|
||||
onClick={() => 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'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<span>{tab.icon}</span>
|
||||
{tab.name}
|
||||
</span>
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
<TabPanels className="mt-2">
|
||||
<TabPanel className="space-y-2.5">
|
||||
<div className="space-y-1">
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{section.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row h-[450px]">
|
||||
{/* Desktop: vertical sidebar */}
|
||||
<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>
|
||||
<Listbox
|
||||
value={ttsProviders.find(p => 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');
|
||||
|
|
@ -493,7 +511,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
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">
|
||||
{ttsProviders.find(p => p.id === localTTSProvider)?.name || 'Select Provider'}
|
||||
</span>
|
||||
|
|
@ -507,13 +525,12 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
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 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) => (
|
||||
<ListboxOption
|
||||
key={provider.id}
|
||||
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}
|
||||
>
|
||||
|
|
@ -522,11 +539,11 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||
{provider.name}
|
||||
</span>
|
||||
{selected ? (
|
||||
{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>
|
||||
|
|
@ -537,47 +554,42 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
</div>
|
||||
|
||||
{(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">
|
||||
API Base URL
|
||||
{localBaseUrl && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="text"
|
||||
value={localBaseUrl}
|
||||
onChange={(e) => 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"
|
||||
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 className="space-y-1">
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
API Key
|
||||
{localApiKey && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="password"
|
||||
value={localApiKey}
|
||||
onChange={(e) => 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"
|
||||
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 className="space-y-1">
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-foreground">TTS Model</label>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Listbox
|
||||
value={ttsModels.find(m => 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);
|
||||
|
|
@ -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">
|
||||
{ttsModels.find(m => m.id === selectedModelId)?.name || 'Select Model'}
|
||||
</span>
|
||||
|
|
@ -599,13 +611,12 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
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 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) => (
|
||||
<ListboxOption
|
||||
key={model.id}
|
||||
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}
|
||||
>
|
||||
|
|
@ -614,11 +625,11 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
|
||||
{model.name}
|
||||
</span>
|
||||
{selected ? (
|
||||
{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>
|
||||
|
|
@ -636,20 +647,20 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
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"
|
||||
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>
|
||||
|
||||
{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>
|
||||
<textarea
|
||||
value={localTTSInstructions}
|
||||
onChange={(e) => setLocalTTSInstructions(e.target.value)}
|
||||
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>
|
||||
)}
|
||||
|
|
@ -657,10 +668,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
<div className="pt-4 flex justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
className="inline-flex justify-center rounded-lg bg-background px-3 py-1.5 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
|
||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent"
|
||||
className={`${btnSecondary} px-4 py-2`}
|
||||
onClick={async () => {
|
||||
setLocalApiKey('');
|
||||
setLocalBaseUrl('');
|
||||
|
|
@ -674,10 +682,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="inline-flex justify-center rounded-lg bg-accent px-3 py-1.5 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
|
||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-background"
|
||||
className={`${btnPrimary} px-4 py-2`}
|
||||
disabled={!canSubmit}
|
||||
onClick={async () => {
|
||||
await updateConfig({
|
||||
|
|
@ -694,138 +699,187 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
Save
|
||||
</Button>
|
||||
</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>
|
||||
</TabPanel>
|
||||
)}
|
||||
|
||||
<TabPanel className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm font-medium text-foreground">Server Library Import</label>
|
||||
<div className="flex gap-2">
|
||||
{/* Theme / Appearance Section */}
|
||||
{activeSection === 'theme' && (
|
||||
<div className="space-y-4">
|
||||
{/* 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
|
||||
onClick={handleImportLibrary}
|
||||
disabled={isBusy}
|
||||
className="justify-center rounded-lg bg-background px-3 py-1.5 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
|
||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
|
||||
disabled:opacity-50"
|
||||
className={`${btnOutline} px-4 py-2 disabled:opacity-50`}
|
||||
>
|
||||
{isImportingLibrary ? `Importing... ${Math.round(progress)}%` : 'Import from library'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="block text-sm font-medium text-foreground">Documents</label>
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-foreground">Cache & Data</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
onClick={handleRefresh}
|
||||
disabled={isBusy}
|
||||
className="justify-center rounded-lg bg-background px-3 py-1.5 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
|
||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
|
||||
disabled:opacity-50"
|
||||
className={`${btnOutline} px-4 py-2 disabled:opacity-50`}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleClearCache}
|
||||
disabled={isBusy}
|
||||
className="justify-center rounded-lg bg-background px-3 py-1.5 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
|
||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.04] hover:text-accent
|
||||
disabled:opacity-50"
|
||||
className={`${btnOutline} px-4 py-2 disabled:opacity-50`}
|
||||
>
|
||||
Clear cache
|
||||
</Button>
|
||||
{enableDestructiveDelete && (
|
||||
<div className="flex w-full gap-2">
|
||||
{enableDestructiveDelete && !authEnabled && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDeleteDocsMode('user');
|
||||
setShowDeleteDocsConfirm(true);
|
||||
}}
|
||||
onClick={() => 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]"
|
||||
className={`${btnBase} bg-red-600 text-white hover:bg-red-700 hover:scale-[1.04] px-4 py-2`}
|
||||
>
|
||||
{userDeleteLabel}
|
||||
</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
|
||||
Delete all data
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
{authEnabled && (
|
||||
<TabPanel className="space-y-4">
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg bg-offbase p-4 space-y-3">
|
||||
<h4 className="font-medium text-foreground">Current Session</h4>
|
||||
{/* Account Section */}
|
||||
{activeSection === 'account' && authEnabled && (
|
||||
<div className="space-y-2">
|
||||
{/* Session info */}
|
||||
<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">
|
||||
<p className="text-muted">Logged in as:</p>
|
||||
{session?.user ? (
|
||||
|
|
@ -848,47 +902,45 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* Export Data */}
|
||||
{session?.user && (
|
||||
<Button
|
||||
<button
|
||||
onClick={() => {
|
||||
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
|
||||
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]"
|
||||
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"
|
||||
>
|
||||
<span>Export My Data</span>
|
||||
<span className="text-xs text-muted font-normal mt-0.5">Download metadata, uploaded documents, and generated audiobooks (ZIP)</span>
|
||||
</Button>
|
||||
<div className="flex-shrink-0 w-10 h-10 rounded-lg bg-offbase flex items-center justify-center group-hover:bg-background transition-colors">
|
||||
<DownloadIcon className="w-5 h-5 text-accent" />
|
||||
</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 ? (
|
||||
<>
|
||||
<Button
|
||||
onClick={handleSignOut}
|
||||
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
|
||||
transform transition-transform duration-200 ease-in-out hover:scale-[1.02]"
|
||||
className={`${btnOutline} px-4 py-2`}
|
||||
>
|
||||
Disconnect account
|
||||
</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>
|
||||
<Button
|
||||
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
|
||||
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]"
|
||||
className={`${btnBase} bg-red-600 text-white border border-red-700 hover:bg-red-700 hover:scale-[1.02] px-4 py-2`}
|
||||
>
|
||||
Delete Account
|
||||
</Button>
|
||||
<p className="text-xs text-muted mt-2 text-center">
|
||||
This will permanently delete your account and data. You will be redirected to sign up.
|
||||
<p className="text-xs text-muted mt-2">
|
||||
Permanently deletes your account and all data.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
|
|
@ -896,36 +948,33 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
<div className="pt-2 border-t border-offbase">
|
||||
<p className="text-sm text-muted mb-3">
|
||||
{session?.user?.isAnonymous
|
||||
? (allowAnonymousAuthSessions
|
||||
? 'You are using an anonymous session. Sign up to save your progress permanently.'
|
||||
: 'Anonymous sessions are disabled. Please sign in or create an account.')
|
||||
? 'You are using an anonymous session. Sign up to save your progress permanently, your current data is automatically transferred.'
|
||||
: 'No active session. Please sign in or create an account.'}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Link href="/signin" className="w-full">
|
||||
<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]">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link href="/signin">
|
||||
<Button className={`${btnOutline} px-4 py-2`}>
|
||||
Connect
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/signup" className="w-full">
|
||||
<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]">
|
||||
<Link href="/signup">
|
||||
<Button className={`${btnPrimary} px-4 py-2`}>
|
||||
Create account
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<Link href="/?redirect=false" className="block mt-3">
|
||||
<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]">
|
||||
<Link href="/?redirect=false">
|
||||
<Button className={`${btnOutline} px-4 py-2`}>
|
||||
Back to landing page
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabPanel>
|
||||
)}
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
</div>
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
|
|
@ -937,16 +986,8 @@ export function SettingsModal({ className = '' }: { className?: string }) {
|
|||
isOpen={showDeleteDocsConfirm}
|
||||
onClose={() => 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}
|
||||
|
|
|
|||
|
|
@ -510,6 +510,78 @@ export function GridIcon(props: React.SVGProps<SVGSVGElement>) {
|
|||
</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>) {
|
||||
return (
|
||||
<svg
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
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];
|
||||
|
||||
interface ThemeContextType {
|
||||
|
|
@ -17,6 +17,8 @@ const getSystemTheme = () => {
|
|||
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 => {
|
||||
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<Theme>('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,7 +55,7 @@ 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);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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"]');
|
||||
|
|
|
|||
Loading…
Reference in a new issue