refactor(ui): unify form primitives and simplify document selection modal

Move shared button, input, and listbox styles to a new formPrimitives module
used across admin panels, settings, and document settings. Update admin and
settings components to import these styles from the new location. Refactor
DocumentSelectionModal to use a direct files prop with loading and error
states, removing fetcher logic and internal fetch state. Add a custom hook
for library document queries. Clean up legacy admin/ui primitives and
standardize segmented controls and section layouts.
This commit is contained in:
Richard R 2026-05-13 18:42:31 -06:00
parent a296d6fb13
commit e27d20419d
8 changed files with 477 additions and 404 deletions

View file

@ -14,7 +14,6 @@ import {
Button, Button,
Input, Input,
} from '@headlessui/react'; } from '@headlessui/react';
import { useQueryClient } from '@tanstack/react-query';
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';
@ -52,6 +51,19 @@ import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext';
import { AdminProvidersPanel } from '@/components/admin/AdminProvidersPanel'; import { AdminProvidersPanel } from '@/components/admin/AdminProvidersPanel';
import { AdminFeaturesPanel } from '@/components/admin/AdminFeaturesPanel'; import { AdminFeaturesPanel } from '@/components/admin/AdminFeaturesPanel';
import { useSharedProviders } from '@/hooks/useSharedProviders'; import { useSharedProviders } from '@/hooks/useSharedProviders';
import { useLibraryDocumentsQuery } from '@/hooks/useLibraryDocumentsQuery';
import {
btnDanger,
btnOutline,
btnPrimary,
btnSecondary,
inputClass,
listboxButtonClass,
listboxOptionClass,
listboxOptionsClass,
segmentedButtonClass,
segmentedGroupClass,
} from '@/components/formPrimitives';
// Hard-coded theme color palettes for the visual theme selector // 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 }; type ThemeColorSet = { background: string; base: string; offbase: string; accent: string; secondaryAccent: string; foreground: string; muted: string };
@ -111,17 +123,8 @@ const SIDEBAR_SECTIONS: SidebarSection[] = [
]; ];
type AdminSubTab = 'providers' | 'features'; type AdminSubTab = 'providers' | 'features';
const LIBRARY_DOCUMENTS_QUERY_KEY = ['documents-library', 10000] as const;
async function fetchLibraryDocuments(): Promise<BaseDocument[]> {
const res = await fetch('/api/documents/library?limit=10000');
if (!res.ok) throw new Error('Failed to list library documents');
const data = (await res.json()) as { documents?: BaseDocument[] };
return data.documents || [];
}
export function SettingsModal({ className = '' }: { className?: string }) { export function SettingsModal({ className = '' }: { className?: string }) {
const queryClient = useQueryClient();
const runtimeConfig = useRuntimeConfig(); const runtimeConfig = useRuntimeConfig();
const enableDestructiveDelete = runtimeConfig.enableDestructiveDeleteActions; const enableDestructiveDelete = runtimeConfig.enableDestructiveDeleteActions;
const showAllDeepInfra = runtimeConfig.showAllDeepInfraModels; const showAllDeepInfra = runtimeConfig.showAllDeepInfraModels;
@ -149,8 +152,6 @@ export function SettingsModal({ className = '' }: { className?: string }) {
title: string; title: string;
confirmLabel: string; confirmLabel: string;
defaultSelected: boolean; defaultSelected: boolean;
initialFiles?: BaseDocument[];
fetcher?: () => Promise<BaseDocument[]>;
}>({ }>({
title: '', title: '',
confirmLabel: '', confirmLabel: '',
@ -167,13 +168,12 @@ export function SettingsModal({ className = '' }: { className?: string }) {
const { data: session } = useAuthSession(); const { data: session } = useAuthSession();
const router = useRouter(); const router = useRouter();
const isBusy = isImportingLibrary; const isBusy = isImportingLibrary;
const fetchLibraryDocumentsCached = useCallback(async () => { const {
return queryClient.ensureQueryData({ documents: libraryDocuments,
queryKey: LIBRARY_DOCUMENTS_QUERY_KEY, isLoading: isLibraryDocumentsLoading,
queryFn: fetchLibraryDocuments, errorMessage: libraryDocumentsErrorMessage,
staleTime: 60 * 1000, prefetch: prefetchLibraryDocuments,
}); } = useLibraryDocumentsQuery(isSelectionModalOpen);
}, [queryClient]);
const { providers: sharedProviders } = useSharedProviders(); const { providers: sharedProviders } = useSharedProviders();
const { const {
@ -299,17 +299,11 @@ export function SettingsModal({ className = '' }: { className?: string }) {
const handleImportLibrary = async () => { const handleImportLibrary = async () => {
// Start fetching as soon as the user opens the picker so cached data is // Start fetching as soon as the user opens the picker so cached data is
// often ready before the modal asks for it. // often ready before the modal asks for it.
void queryClient.prefetchQuery({ void prefetchLibraryDocuments();
queryKey: LIBRARY_DOCUMENTS_QUERY_KEY,
queryFn: fetchLibraryDocuments,
staleTime: 60 * 1000,
});
setSelectionModalProps({ setSelectionModalProps({
title: 'Import from Library', title: 'Import from Library',
confirmLabel: 'Import', confirmLabel: 'Import',
defaultSelected: false, defaultSelected: false,
initialFiles: queryClient.getQueryData(LIBRARY_DOCUMENTS_QUERY_KEY),
fetcher: fetchLibraryDocumentsCached,
}); });
setIsSelectionModalOpen(true); setIsSelectionModalOpen(true);
}; };
@ -481,10 +475,13 @@ export function SettingsModal({ className = '' }: { className?: string }) {
setActiveSection(visibleSections[0]?.id ?? 'theme'); setActiveSection(visibleSections[0]?.id ?? 'theme');
}, [activeSection, visibleSections]); }, [activeSection, visibleSections]);
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 fieldLabelClass = 'block text-[11px] font-semibold uppercase tracking-wide text-muted';
const btnPrimary = `${btnBase} bg-accent text-background hover:bg-secondary-accent hover:scale-[1.04]`; const sectionShellClass = 'space-y-2 pb-3 border-b border-offbase px-0.5';
const btnSecondary = `${btnBase} bg-background text-foreground hover:bg-offbase hover:text-accent hover:scale-[1.04]`; const sectionHeadingClass = 'text-sm font-semibold text-foreground';
const btnOutline = `${btnBase} bg-background border border-offbase text-foreground hover:bg-offbase hover:text-accent hover:scale-[1.02]`; const accountBtnBase = '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 accountBtnPrimary = `${accountBtnBase} bg-accent text-background hover:bg-secondary-accent hover:scale-[1.04]`;
const accountBtnOutline = `${accountBtnBase} bg-background border border-offbase text-foreground hover:bg-offbase hover:text-accent hover:scale-[1.04]`;
const accountBtnDanger = `${accountBtnBase} bg-red-600 text-white border border-red-700 hover:bg-red-700 hover:scale-[1.02]`;
const effectiveProviderType = resolveEffectiveProviderType({ const effectiveProviderType = resolveEffectiveProviderType({
providerRef: selectedProviderRef, providerRef: selectedProviderRef,
providerType: localProviderType, providerType: localProviderType,
@ -511,11 +508,11 @@ export function SettingsModal({ className = '' }: { className?: string }) {
<> <>
<Button <Button
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
className={`inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase transition-all duration-200 ease-in-out hover:scale-[1.09] hover:text-accent ${className}`} className={`inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase hover:text-accent transition-transform transition-colors duration-200 ease-out hover:scale-[1.08] ${className}`}
aria-label="Settings" aria-label="Settings"
tabIndex={0} tabIndex={0}
> >
<SettingsIcon className="w-4 h-4 transform transition-transform duration-200 ease-in-out hover:scale-[1.09] hover:rotate-45 hover:text-accent" /> <SettingsIcon className="w-4 h-4 transition-transform duration-200 ease-out hover:scale-[1.08] hover:rotate-45" />
</Button> </Button>
<Transition appear show={isOpen} as={Fragment}> <Transition appear show={isOpen} as={Fragment}>
@ -543,7 +540,7 @@ 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 data-testid="settings-modal" className="w-full max-w-3xl transform rounded-2xl bg-base text-left align-middle shadow-xl transition-all overflow-hidden"> <DialogPanel data-testid="settings-modal" className="w-full max-w-4xl transform rounded-xl bg-base text-left align-middle shadow-xl transition-all overflow-hidden border border-offbase">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-offbase"> <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"> <DialogTitle as="h3" className="text-lg font-semibold leading-6 text-foreground">
@ -552,7 +549,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
{authEnabled && ( {authEnabled && (
<Button <Button
onClick={() => showPrivacyModal({ authEnabled })} onClick={() => showPrivacyModal({ authEnabled })}
className="text-sm font-medium text-muted hover:text-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.04]" className="text-sm font-medium text-muted hover:text-accent transition-colors"
> >
Privacy Privacy
</Button> </Button>
@ -567,11 +564,11 @@ export function SettingsModal({ className = '' }: { className?: string }) {
<button <button
key={section.id} key={section.id}
onClick={() => setActiveSection(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 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 activeSection === section.id
? 'bg-accent text-background' ? 'bg-accent text-background'
: 'text-foreground hover:bg-offbase hover:text-accent' : 'text-foreground hover:bg-offbase hover:text-accent'
}`} }`}
> >
<Icon className="w-3.5 h-3.5" /> <Icon className="w-3.5 h-3.5" />
{section.label} {section.label}
@ -580,21 +577,22 @@ export function SettingsModal({ className = '' }: { className?: string }) {
})} })}
</div> </div>
<div className="flex flex-row h-[450px]"> <div className="flex flex-row h-[490px]">
{/* Desktop: vertical sidebar */} {/* Desktop: vertical sidebar */}
<nav className="hidden sm:block w-44 shrink-0 border-r border-offbase bg-background"> <nav className="hidden sm:block w-44 shrink-0 border-r border-offbase bg-background p-2">
<div className="flex flex-col p-2 gap-1"> <div className="flex flex-col gap-1">
{visibleSections.map((section) => { {visibleSections.map((section) => {
const Icon = section.icon; const Icon = section.icon;
const active = activeSection === section.id;
return ( return (
<button <button
key={section.id} key={section.id}
onClick={() => setActiveSection(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 className={`w-full flex items-center gap-2.5 text-left px-2.5 py-1.5 rounded-md text-sm font-medium transition-colors whitespace-nowrap ${
${activeSection === section.id active
? 'bg-accent text-background' ? 'bg-accent text-background'
: 'text-foreground hover:bg-offbase hover:text-accent' : 'text-foreground hover:bg-base hover:text-accent'
}`} }`}
> >
<Icon className="w-4 h-4 shrink-0" /> <Icon className="w-4 h-4 shrink-0" />
{section.label} {section.label}
@ -605,12 +603,16 @@ export function SettingsModal({ className = '' }: { className?: string }) {
</nav> </nav>
{/* Content */} {/* Content */}
<div className="flex-1 min-w-0 p-4 overflow-y-auto"> <div className={`flex-1 min-w-0 p-3 overflow-y-auto ${
activeSection === 'admin'
? 'bg-[radial-gradient(circle_at_top_right,rgba(239,68,68,0.08),transparent_35%)]'
: ''
}`}>
{/* API Section */} {/* API Section */}
{activeSection === 'api' && ( {activeSection === 'api' && (
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="block text-sm font-medium text-foreground">TTS Provider</label> <label className={fieldLabelClass}>TTS Provider</label>
{ttsProviders.length === 0 ? ( {ttsProviders.length === 0 ? (
<p className="text-xs text-amber-500"> <p className="text-xs text-amber-500">
User API keys are restricted and no shared provider is configured. Ask an admin to add one. User API keys are restricted and no shared provider is configured. Ask an admin to add one.
@ -638,7 +640,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
setCustomModelInput(''); setCustomModelInput('');
}} }}
> >
<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"> <ListboxButton className={listboxButtonClass}>
<span className="block truncate"> <span className="block truncate">
{selectedProviderOption?.name || 'Select Provider'} {selectedProviderOption?.name || 'Select Provider'}
</span> </span>
@ -654,14 +656,12 @@ export function SettingsModal({ className = '' }: { className?: string }) {
> >
<ListboxOptions <ListboxOptions
anchor="bottom start" anchor="bottom start"
className="z-50 w-[var(--button-width)] max-h-60 overflow-y-auto overscroll-contain rounded-md bg-background py-1 shadow-lg ring-1 ring-offbase focus:outline-none [--anchor-gap:0.25rem]" className={listboxOptionsClass}
> >
{ttsProviders.map((provider) => ( {ttsProviders.map((provider) => (
<ListboxOption <ListboxOption
key={provider.id} key={provider.id}
className={({ active }) => className={({ active }) => listboxOptionClass(active)}
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'}`
}
value={provider} value={provider}
> >
{({ selected }) => ( {({ selected }) => (
@ -691,7 +691,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
{shouldShowBaseUrl && ( {shouldShowBaseUrl && (
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="block text-sm font-medium text-foreground"> <label className={fieldLabelClass}>
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>
@ -700,14 +700,14 @@ export function SettingsModal({ className = '' }: { className?: string }) {
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-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" className={inputClass}
/> />
</div> </div>
)} )}
{shouldShowApiKey && ( {shouldShowApiKey && (
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="block text-sm font-medium text-foreground"> <label className={fieldLabelClass}>
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>
@ -716,7 +716,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
value={localApiKey} value={localApiKey}
onChange={(e) => handleInputChange('apiKey', e.target.value)} onChange={(e) => handleInputChange('apiKey', e.target.value)}
placeholder={!showAllDeepInfra && providerModelPolicy.providerType === 'deepinfra' ? "Deepinfra free or use your API key" : "Using environment variable"} placeholder={!showAllDeepInfra && providerModelPolicy.providerType === 'deepinfra' ? "Deepinfra free or use your API key" : "Using environment variable"}
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" className={inputClass}
/> />
</div> </div>
)} )}
@ -727,7 +727,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
)} )}
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="block text-sm font-medium text-foreground">TTS Model</label> <label className={fieldLabelClass}>TTS Model</label>
{!showAllProviderModels && ( {!showAllProviderModels && (
<p className="text-xs text-muted"> <p className="text-xs text-muted">
This instance restricts model selection to each provider&apos;s default model. This instance restricts model selection to each provider&apos;s default model.
@ -745,7 +745,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
} }
}} }}
> >
<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"> <ListboxButton className={listboxButtonClass}>
{selectedModel ? ( {selectedModel ? (
<span className="block"> <span className="block">
<span className="block truncate"> <span className="block truncate">
@ -772,14 +772,12 @@ export function SettingsModal({ className = '' }: { className?: string }) {
> >
<ListboxOptions <ListboxOptions
anchor="bottom start" anchor="bottom start"
className="z-50 w-[var(--button-width)] max-h-60 overflow-y-auto overscroll-contain rounded-md bg-background py-1 shadow-lg ring-1 ring-offbase focus:outline-none [--anchor-gap:0.25rem]" className={listboxOptionsClass}
> >
{ttsModels.map((model) => ( {ttsModels.map((model) => (
<ListboxOption <ListboxOption
key={model.id} key={model.id}
className={({ active }) => className={({ active }) => listboxOptionClass(active)}
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'}`
}
value={model} value={model}
> >
{({ selected }) => ( {({ selected }) => (
@ -814,7 +812,7 @@ 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-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent" className={inputClass}
/> />
)} )}
</div> </div>
@ -822,12 +820,12 @@ export function SettingsModal({ className = '' }: { className?: string }) {
{providerModelPolicy.supportsInstructions && ( {providerModelPolicy.supportsInstructions && (
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="block text-sm font-medium text-foreground">TTS Instructions</label> <label className={fieldLabelClass}>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-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent resize-none" className={`${inputClass} h-24 resize-none`}
/> />
</div> </div>
)} )}
@ -1116,8 +1114,8 @@ export function SettingsModal({ className = '' }: { className?: string }) {
{/* Documents Section */} {/* Documents Section */}
{activeSection === 'docs' && ( {activeSection === 'docs' && (
<div className="space-y-5"> <div className="space-y-5">
<div className="space-y-2"> <div className={sectionShellClass}>
<label className="block text-sm font-medium text-foreground">Server Library</label> <h4 className={sectionHeadingClass}>Server Library</h4>
<Button <Button
onClick={handleImportLibrary} onClick={handleImportLibrary}
disabled={isBusy} disabled={isBusy}
@ -1127,8 +1125,8 @@ export function SettingsModal({ className = '' }: { className?: string }) {
</Button> </Button>
</div> </div>
<div className="space-y-2"> <div className={sectionShellClass}>
<label className="block text-sm font-medium text-foreground">Cache & Data</label> <h4 className={sectionHeadingClass}>Cache & Data</h4>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button <Button
onClick={handleRefresh} onClick={handleRefresh}
@ -1148,7 +1146,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
<Button <Button
onClick={() => setShowDeleteDocsConfirm(true)} onClick={() => setShowDeleteDocsConfirm(true)}
disabled={isBusy} disabled={isBusy}
className={`${btnBase} bg-red-600 text-white hover:bg-red-700 hover:scale-[1.04] px-4 py-2`} className={`${btnDanger} px-4 py-2`}
> >
Delete all data Delete all data
</Button> </Button>
@ -1164,7 +1162,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
<div <div
role="radiogroup" role="radiogroup"
aria-label="Admin tab" aria-label="Admin tab"
className="grid grid-cols-2 gap-1 rounded-full border border-offbase bg-background p-1" className={`${segmentedGroupClass} grid-cols-2`}
> >
{([ {([
{ id: 'providers', label: 'Shared providers' }, { id: 'providers', label: 'Shared providers' },
@ -1178,11 +1176,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
role="radio" role="radio"
aria-checked={active} aria-checked={active}
onClick={() => setAdminSubTab(tab.id)} onClick={() => setAdminSubTab(tab.id)}
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent ${ className={segmentedButtonClass(active)}
active
? 'bg-accent text-background shadow-sm'
: 'text-muted hover:bg-base hover:text-foreground'
}`}
> >
{tab.label} {tab.label}
</button> </button>
@ -1246,7 +1240,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
<> <>
<Button <Button
onClick={handleSignOut} onClick={handleSignOut}
className={`${btnOutline} px-4 py-2`} className={`${accountBtnOutline} px-4 py-2`}
> >
Disconnect account Disconnect account
</Button> </Button>
@ -1255,7 +1249,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
<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={`${btnBase} bg-red-600 text-white border border-red-700 hover:bg-red-700 hover:scale-[1.02] px-4 py-2`} className={`${accountBtnDanger} px-4 py-2`}
> >
Delete Account Delete Account
</Button> </Button>
@ -1273,17 +1267,17 @@ export function SettingsModal({ className = '' }: { className?: string }) {
</p> </p>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Link href="/signin"> <Link href="/signin">
<Button className={`${btnOutline} px-4 py-2`}> <Button className={`${accountBtnOutline} px-4 py-2`}>
Connect Connect
</Button> </Button>
</Link> </Link>
<Link href="/signup"> <Link href="/signup">
<Button className={`${btnPrimary} px-4 py-2`}> <Button className={`${accountBtnPrimary} px-4 py-2`}>
Create account Create account
</Button> </Button>
</Link> </Link>
<Link href="/?redirect=false"> <Link href="/?redirect=false">
<Button className={`${btnOutline} px-4 py-2`}> <Button className={`${accountBtnOutline} px-4 py-2`}>
Back to landing page Back to landing page
</Button> </Button>
</Link> </Link>
@ -1348,8 +1342,9 @@ export function SettingsModal({ className = '' }: { className?: string }) {
confirmLabel={selectionModalProps.confirmLabel} confirmLabel={selectionModalProps.confirmLabel}
isProcessing={false} isProcessing={false}
defaultSelected={selectionModalProps.defaultSelected} defaultSelected={selectionModalProps.defaultSelected}
initialFiles={selectionModalProps.initialFiles} files={libraryDocuments}
fetcher={selectionModalProps.fetcher} isLoading={isLibraryDocumentsLoading}
errorMessage={libraryDocumentsErrorMessage}
/> />
</> </>
); );

View file

@ -5,7 +5,16 @@ import { Button, Listbox, ListboxButton, ListboxOption, ListboxOptions, Transiti
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons'; import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons';
import { Badge, Card, Section, ToggleRow, btnPrimary, btnSecondary } from '@/components/admin/ui'; import {
Badge,
Section,
ToggleRow,
btnPrimary,
btnSecondary,
listboxButtonClass,
listboxOptionClass,
listboxOptionsClass,
} from '@/components/formPrimitives';
import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog'; import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
import { useSharedProviders, type SharedProviderEntry } from '@/hooks/useSharedProviders'; import { useSharedProviders, type SharedProviderEntry } from '@/hooks/useSharedProviders';
@ -173,21 +182,22 @@ export function AdminFeaturesPanel() {
<div className="space-y-4"> <div className="space-y-4">
<Section <Section
title="TTS defaults" title="TTS defaults"
subtitle="What new users start with." subtitle="Defaults for new users."
action={<Badge tone="foreground">Defaults</Badge>}
> >
<Card className="space-y-1.5"> <div className="space-y-1.5 pb-2 border-b border-offbase">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium text-foreground">Default TTS provider</p> <p className="text-sm font-medium text-foreground">Default TTS provider</p>
<p className="text-xs text-muted mt-0.5"> <p className="text-xs text-muted mt-0.5">
Initial selection for new users. Model and instructions come from that shared provider configuration. Starting provider for new users.
</p> </p>
</div> </div>
<div className="shrink-0">{renderSource('defaultTtsProvider')}</div> <div className="shrink-0">{renderSource('defaultTtsProvider')}</div>
</div> </div>
{providerOptions.length > 0 ? ( {providerOptions.length > 0 ? (
<Listbox value={selectedProviderOption} onChange={handleProviderChange}> <Listbox value={selectedProviderOption} onChange={handleProviderChange}>
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-base border border-offbase py-1.5 pl-3 pr-10 text-left text-sm text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent hover:bg-offbase hover:text-accent transition-colors"> <ListboxButton className={listboxButtonClass}>
<span className="block truncate">{selectedProviderOption?.name ?? 'Select provider'}</span> <span className="block truncate">{selectedProviderOption?.name ?? 'Select provider'}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-4 w-4 text-muted" /> <ChevronUpDownIcon className="h-4 w-4 text-muted" />
@ -199,17 +209,12 @@ export function AdminFeaturesPanel() {
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<ListboxOptions <ListboxOptions anchor="bottom start" className={listboxOptionsClass}>
anchor="bottom start"
className="z-50 w-[var(--button-width)] max-h-60 overflow-y-auto overscroll-contain rounded-md bg-background py-1 shadow-lg ring-1 ring-offbase focus:outline-none [--anchor-gap:0.25rem]"
>
{providerOptions.map((opt) => ( {providerOptions.map((opt) => (
<ListboxOption <ListboxOption
key={opt.id} key={opt.id}
value={opt} value={opt}
className={({ active }) => className={({ active }) => listboxOptionClass(active)}
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'}`
}
> >
{({ selected }) => ( {({ selected }) => (
<> <>
@ -229,15 +234,15 @@ export function AdminFeaturesPanel() {
</Transition> </Transition>
</Listbox> </Listbox>
) : ( ) : (
<div className="rounded-lg border border-offbase bg-base px-3 py-2 text-sm text-muted"> <div className="px-0.5 py-2 text-sm text-muted">
No shared providers configured. Add one in the Shared providers tab first. No shared providers yet. Add one first.
</div> </div>
)} )}
</Card> </div>
<ToggleRow <ToggleRow
label="Restrict user API keys (recommended)" label="Restrict user API keys (recommended)"
description="When on, users cannot supply personal API keys/base URLs; TTS requests must use admin-configured shared providers." description="Only allow admin shared providers."
checked={Boolean(draft.restrictUserApiKeys)} checked={Boolean(draft.restrictUserApiKeys)}
onChange={(checked) => { onChange={(checked) => {
if (!checked) { if (!checked) {
@ -249,61 +254,70 @@ export function AdminFeaturesPanel() {
updateDraft('restrictUserApiKeys', checked); updateDraft('restrictUserApiKeys', checked);
}} }}
right={renderSource('restrictUserApiKeys')} right={renderSource('restrictUserApiKeys')}
variant="flat"
/> />
<ToggleRow <ToggleRow
label="Show TTS provider settings tab" label="Show TTS provider settings tab"
description="Lets users override the provider / API key per-user. Turn off to lock everyone to admin-configured shared default provider and model." description="Allow per-user provider overrides."
checked={Boolean(draft.enableTtsProvidersTab)} checked={Boolean(draft.enableTtsProvidersTab)}
onChange={(checked) => updateDraft('enableTtsProvidersTab', checked)} onChange={(checked) => updateDraft('enableTtsProvidersTab', checked)}
right={renderSource('enableTtsProvidersTab')} right={renderSource('enableTtsProvidersTab')}
variant="flat"
/> />
<ToggleRow <ToggleRow
label="Show all Deepinfra models" label="Show all Deepinfra models"
description="When off, restricts the Deepinfra picker to the free Kokoro-only subset for users without API keys." description="Show full Deepinfra model list."
checked={Boolean(draft.showAllDeepInfraModels)} checked={Boolean(draft.showAllDeepInfraModels)}
onChange={(checked) => updateDraft('showAllDeepInfraModels', checked)} onChange={(checked) => updateDraft('showAllDeepInfraModels', checked)}
right={renderSource('showAllDeepInfraModels')} right={renderSource('showAllDeepInfraModels')}
variant="flat"
/> />
<ToggleRow <ToggleRow
label="Show all provider models" label="Show all provider models"
description="When off, users are restricted to each provider's default model." description="Allow model selection beyond defaults."
checked={Boolean(draft.showAllProviderModels)} checked={Boolean(draft.showAllProviderModels)}
onChange={(checked) => updateDraft('showAllProviderModels', checked)} onChange={(checked) => updateDraft('showAllProviderModels', checked)}
right={renderSource('showAllProviderModels')} right={renderSource('showAllProviderModels')}
variant="flat"
/> />
</Section> </Section>
<Section <Section
title="Site features" title="Site features"
subtitle="Toggle features site-wide. Changes take effect for all users on the next page load." subtitle="Feature flags for all users."
action={<Badge tone="foreground">Feature Flags</Badge>}
> >
<ToggleRow <ToggleRow
label="Word-level highlighting" label="Word-level highlighting"
description="Use whisper.cpp alignment for word-by-word highlighting during TTS playback." description="Highlight words during TTS playback."
checked={Boolean(draft.enableWordHighlight)} checked={Boolean(draft.enableWordHighlight)}
onChange={(checked) => updateDraft('enableWordHighlight', checked)} onChange={(checked) => updateDraft('enableWordHighlight', checked)}
right={renderSource('enableWordHighlight')} right={renderSource('enableWordHighlight')}
variant="flat"
/> />
<ToggleRow <ToggleRow
label="Audiobook export" label="Audiobook export"
description='Show the "Export audiobook" feature on PDF / EPUB pages.' description='Show "Export audiobook" on PDF/EPUB pages.'
checked={Boolean(draft.enableAudiobookExport)} checked={Boolean(draft.enableAudiobookExport)}
onChange={(checked) => updateDraft('enableAudiobookExport', checked)} onChange={(checked) => updateDraft('enableAudiobookExport', checked)}
right={renderSource('enableAudiobookExport')} right={renderSource('enableAudiobookExport')}
variant="flat"
/> />
<ToggleRow <ToggleRow
label="DOCX upload conversion" label="DOCX upload conversion"
description="Accept .docx files in the document uploader (converted to PDF server-side)." description="Allow DOCX uploads (converted to PDF)."
checked={Boolean(draft.enableDocxConversion)} checked={Boolean(draft.enableDocxConversion)}
onChange={(checked) => updateDraft('enableDocxConversion', checked)} onChange={(checked) => updateDraft('enableDocxConversion', checked)}
right={renderSource('enableDocxConversion')} right={renderSource('enableDocxConversion')}
variant="flat"
/> />
<ToggleRow <ToggleRow
label="Destructive delete buttons" label="Destructive delete buttons"
description='Show "Delete all data" actions in the Documents tab (auth-disabled mode only).' description='Show "Delete all data" actions (auth-off mode).'
checked={Boolean(draft.enableDestructiveDeleteActions)} checked={Boolean(draft.enableDestructiveDeleteActions)}
onChange={(checked) => updateDraft('enableDestructiveDeleteActions', checked)} onChange={(checked) => updateDraft('enableDestructiveDeleteActions', checked)}
right={renderSource('enableDestructiveDeleteActions')} right={renderSource('enableDestructiveDeleteActions')}
variant="flat"
/> />
</Section> </Section>

View file

@ -9,7 +9,20 @@ import { ChevronUpDownIcon, CheckIcon, PlusIcon } from '@/components/icons/Icons
import { providerSupportsCustomModel, resolveProviderModels, type TtsModelDefinition, type TtsProviderId } from '@/lib/shared/tts-provider-catalog'; import { providerSupportsCustomModel, resolveProviderModels, type TtsModelDefinition, type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
import { defaultBaseUrlForProviderType, defaultModelForProviderType, resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy'; import { defaultBaseUrlForProviderType, defaultModelForProviderType, resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext'; import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext';
import { Badge, Card, Field, Section, ToggleRow, btnDanger, btnOutline, btnPrimary, btnSecondary, inputClass } from '@/components/admin/ui'; import {
Badge,
Field,
Section,
ToggleRow,
btnDanger,
btnOutline,
btnPrimary,
btnSecondary,
inputClass,
listboxButtonClass,
listboxOptionClass,
listboxOptionsClass,
} from '@/components/formPrimitives';
type ProviderType = TtsProviderId; type ProviderType = TtsProviderId;
@ -246,20 +259,23 @@ export function AdminProvidersPanel() {
title="Shared TTS providers" title="Shared TTS providers"
subtitle="Server-side providers visible to all users. API keys are encrypted at rest and never sent to the client." subtitle="Server-side providers visible to all users. API keys are encrypted at rest and never sent to the client."
action={ action={
!editingId ? ( <div className="flex items-center gap-2">
<Button <Badge tone="foreground">Shared</Badge>
onClick={startCreate} {!editingId ? (
className={`${btnPrimary} h-7 w-7 p-0 inline-flex items-center justify-center`} <Button
aria-label="Add provider" onClick={startCreate}
title="Add provider" className={`${btnPrimary} h-7 w-7 p-0 inline-flex items-center justify-center`}
> aria-label="Add provider"
<PlusIcon className="h-3.5 w-3.5" aria-hidden="true" /> title="Add provider"
</Button> >
) : null <PlusIcon className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
) : null}
</div>
} }
> >
{editingId && ( {editingId && (
<Card className="space-y-2.5"> <div className="space-y-2.5 pb-3 border-b border-offbase">
<div className="flex items-baseline justify-between gap-3"> <div className="flex items-baseline justify-between gap-3">
<h4 className="text-sm font-semibold text-foreground"> <h4 className="text-sm font-semibold text-foreground">
{isEditingExisting ? `Edit "${editingProvider?.slug}"` : 'New provider'} {isEditingExisting ? `Edit "${editingProvider?.slug}"` : 'New provider'}
@ -303,7 +319,7 @@ export function AdminProvidersPanel() {
setCustomModelInput(''); setCustomModelInput('');
}} }}
> >
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-base border border-offbase py-1.5 pl-3 pr-10 text-left text-sm text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent hover:bg-offbase hover:text-accent transition-colors"> <ListboxButton className={listboxButtonClass}>
<span className="block truncate">{selectedProviderType.label}</span> <span className="block truncate">{selectedProviderType.label}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-4 w-4 text-muted" /> <ChevronUpDownIcon className="h-4 w-4 text-muted" />
@ -315,17 +331,12 @@ export function AdminProvidersPanel() {
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<ListboxOptions <ListboxOptions anchor="bottom start" className={listboxOptionsClass}>
anchor="bottom start"
className="z-50 w-[var(--button-width)] max-h-60 overflow-y-auto overscroll-contain rounded-md bg-background py-1 shadow-lg ring-1 ring-offbase focus:outline-none [--anchor-gap:0.25rem]"
>
{PROVIDER_TYPE_OPTIONS.map((opt) => ( {PROVIDER_TYPE_OPTIONS.map((opt) => (
<ListboxOption <ListboxOption
key={opt.value} key={opt.value}
value={opt} value={opt}
className={({ active }) => className={({ active }) => listboxOptionClass(active)}
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'}`
}
> >
{({ selected }) => ( {({ selected }) => (
<> <>
@ -367,10 +378,10 @@ export function AdminProvidersPanel() {
setCustomModelInput(''); setCustomModelInput('');
}} }}
> >
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-base border border-offbase py-1.5 pl-3 pr-10 text-left text-sm text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent hover:bg-offbase hover:text-accent transition-colors"> <ListboxButton className={listboxButtonClass}>
<span className="block truncate"> <span className="block truncate">
{selectedModelDefinition?.name ?? 'Select model'} {selectedModelDefinition?.name ?? 'Select model'}
</span> </span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-4 w-4 text-muted" /> <ChevronUpDownIcon className="h-4 w-4 text-muted" />
</span> </span>
@ -381,17 +392,12 @@ export function AdminProvidersPanel() {
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<ListboxOptions <ListboxOptions anchor="bottom start" className={listboxOptionsClass}>
anchor="bottom start"
className="z-50 w-[var(--button-width)] max-h-60 overflow-y-auto overscroll-contain rounded-md bg-background py-1 shadow-lg ring-1 ring-offbase focus:outline-none [--anchor-gap:0.25rem]"
>
{modelDefinitions.map((model) => ( {modelDefinitions.map((model) => (
<ListboxOption <ListboxOption
key={model.id} key={model.id}
value={model.id} value={model.id}
className={({ active }) => className={({ active }) => listboxOptionClass(active)}
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'}`
}
> >
{({ selected }) => ( {({ selected }) => (
<> <>
@ -479,6 +485,7 @@ export function AdminProvidersPanel() {
description="When off, this provider is hidden from users without being deleted." description="When off, this provider is hidden from users without being deleted."
checked={form.enabled} checked={form.enabled}
onChange={(checked) => setForm({ ...form, enabled: checked })} onChange={(checked) => setForm({ ...form, enabled: checked })}
variant="flat"
/> />
<div className="pt-1 flex justify-end gap-2"> <div className="pt-1 flex justify-end gap-2">
@ -493,19 +500,17 @@ export function AdminProvidersPanel() {
{saveMutation.isPending ? 'Saving…' : isEditingExisting ? 'Save changes' : 'Create'} {saveMutation.isPending ? 'Saving…' : isEditingExisting ? 'Save changes' : 'Create'}
</Button> </Button>
</div> </div>
</Card> </div>
)} )}
<div className="space-y-2"> <div className="space-y-0">
{isLoading ? ( {isLoading ? (
<p className="text-xs text-muted">Loading</p> <p className="text-xs text-muted">Loading</p>
) : providers.length === 0 ? ( ) : providers.length === 0 ? (
<Card> <p className="text-xs text-muted py-2">No shared providers configured yet.</p>
<p className="text-xs text-muted">No shared providers configured yet.</p>
</Card>
) : ( ) : (
providers.map((p) => ( providers.map((p) => (
<Card key={p.id}> <div key={p.id} className="py-1.5 border-b border-offbase last:border-b-0">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="flex-1 min-w-0 space-y-0.5"> <div className="flex-1 min-w-0 space-y-0.5">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
@ -538,7 +543,7 @@ export function AdminProvidersPanel() {
</Button> </Button>
</div> </div>
</div> </div>
</Card> </div>
)) ))
)} )}
</div> </div>

View file

@ -1,141 +1,18 @@
'use client'; export {
Badge,
import type { ReactNode } from 'react'; Card,
Field,
/** Section,
* Shared admin panel UI primitives that mirror the DocumentSettings / ToggleRow,
* SettingsModal design language (section cards, toggle rows, standard btnBase,
* button shapes). Keep this small and inline nothing here is meant btnDanger,
* to be reused outside the admin panel. btnOutline,
*/ btnPrimary,
btnSecondary,
export const btnBase = inputClass,
'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 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100'; listboxButtonClass,
export const btnPrimary = `${btnBase} bg-accent text-background hover:bg-secondary-accent hover:scale-[1.04]`; listboxOptionClass,
export const btnSecondary = `${btnBase} bg-background text-foreground hover:bg-offbase hover:text-accent hover:scale-[1.04]`; listboxOptionsClass,
export const btnOutline = `${btnBase} bg-background border border-offbase text-foreground hover:bg-offbase hover:text-accent hover:scale-[1.02]`; segmentedButtonClass,
export const btnDanger = `${btnBase} bg-red-600 text-white border border-red-700 hover:bg-red-700 hover:scale-[1.02]`; segmentedGroupClass,
} from '@/components/formPrimitives';
// Inputs use `bg-base` so they remain visible regardless of whether the
// surrounding container is `bg-background` (Card) or `bg-base` (Section).
// Using the same `bg-background` as the Card would make the input blend in.
// (Note: never use Tailwind alpha modifiers on these theme variables — they
// resolve to CSS custom properties that don't accept opacity suffixes.)
export const inputClass =
'w-full rounded-lg bg-base border border-offbase py-1.5 px-3 text-sm text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent';
export function Section({
title,
subtitle,
children,
action,
}: {
title: string;
subtitle?: string;
children: ReactNode;
action?: ReactNode;
}) {
return (
<section className="rounded-2xl border border-offbase bg-base px-3 py-2.5 space-y-2">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
{subtitle ? <p className="text-xs text-muted mt-0.5">{subtitle}</p> : null}
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
{children}
</section>
);
}
export function Card({
children,
className = '',
}: {
children: ReactNode;
className?: string;
}) {
return (
<div className={`rounded-xl border border-offbase bg-background px-3 py-2 shadow-sm ${className}`}>
{children}
</div>
);
}
export function ToggleRow({
label,
description,
checked,
onChange,
disabled = false,
right,
}: {
label: string;
description: string;
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
right?: ReactNode;
}) {
return (
<Card>
<div className="flex items-start gap-2">
<label className="flex items-start gap-2 flex-1 min-w-0">
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(event) => onChange(event.target.checked)}
className="mt-0.5 form-checkbox h-4 w-4 text-accent rounded border-muted disabled:opacity-50 disabled:cursor-not-allowed"
/>
<span className="space-y-0.5 min-w-0">
<span className="block text-sm font-medium text-foreground">{label}</span>
<span className="block text-xs text-muted">{description}</span>
</span>
</label>
{right ? <div className="shrink-0 self-start">{right}</div> : null}
</div>
</Card>
);
}
export function Field({
label,
hint,
className = '',
children,
}: {
label?: string;
hint?: string;
className?: string;
children: ReactNode;
}) {
return (
<div className={`space-y-1 ${className}`}>
{label ? <label className="block text-xs font-medium text-muted">{label}</label> : null}
{children}
{hint ? <p className="text-[11px] text-muted">{hint}</p> : null}
</div>
);
}
export function Badge({
tone = 'muted',
children,
}: {
tone?: 'muted' | 'accent' | 'foreground';
children: ReactNode;
}) {
const toneClass =
tone === 'accent'
? 'text-accent'
: tone === 'foreground'
? 'text-foreground bg-offbase'
: 'text-muted bg-offbase';
return (
<span className={`inline-flex items-center text-[10px] uppercase tracking-wide font-semibold rounded px-1.5 py-0.5 ${toneClass}`}>
{children}
</span>
);
}

View file

@ -12,13 +12,9 @@ interface DocumentSelectionModalProps {
confirmLabel: string; confirmLabel: string;
isProcessing: boolean; isProcessing: boolean;
defaultSelected?: boolean; defaultSelected?: boolean;
/** files: BaseDocument[];
* Data source: isLoading?: boolean;
* 1. `initialFiles`: Pass local files directly (synchronous). errorMessage?: string | null;
* 2. `fetcher`: Pass an async function to load files (e.g. from server).
*/
initialFiles?: BaseDocument[];
fetcher?: () => Promise<BaseDocument[]>;
} }
export function DocumentSelectionModal({ export function DocumentSelectionModal({
@ -29,44 +25,22 @@ export function DocumentSelectionModal({
confirmLabel, confirmLabel,
isProcessing, isProcessing,
defaultSelected = false, defaultSelected = false,
initialFiles, files,
fetcher, isLoading = false,
errorMessage = null,
}: DocumentSelectionModalProps) { }: DocumentSelectionModalProps) {
const [files, setFiles] = useState<BaseDocument[]>([]);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [isLoading, setIsLoading] = useState(false);
const [lastSelectedId, setLastSelectedId] = useState<string | null>(null); const [lastSelectedId, setLastSelectedId] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (isOpen) { if (!isOpen) return;
if (initialFiles) { if (defaultSelected) {
setFiles(initialFiles); setSelectedIds(new Set(files.map((f) => f.id)));
if (defaultSelected) { } else {
setSelectedIds(new Set(initialFiles.map((f) => f.id))); setSelectedIds(new Set());
} else {
setSelectedIds(new Set());
}
setLastSelectedId(null);
} else if (fetcher) {
setIsLoading(true);
fetcher()
.then((data) => {
setFiles(data);
if (defaultSelected) {
setSelectedIds(new Set(data.map((f) => f.id)));
} else {
setSelectedIds(new Set());
}
setLastSelectedId(null);
})
.catch((err) => console.error('Failed to load documents:', err))
.finally(() => setIsLoading(false));
} else {
setFiles([]);
setSelectedIds(new Set());
}
} }
}, [isOpen, initialFiles, fetcher, defaultSelected]); setLastSelectedId(null);
}, [isOpen, files, defaultSelected]);
const toggleSelection = (id: string, multiSelect: boolean, rangeSelect: boolean) => { const toggleSelection = (id: string, multiSelect: boolean, rangeSelect: boolean) => {
const newSelected = new Set(multiSelect ? selectedIds : []); const newSelected = new Set(multiSelect ? selectedIds : []);
@ -191,6 +165,8 @@ export function DocumentSelectionModal({
<div className="flex-1 overflow-auto border border-offbase rounded-lg bg-background p-2 min-h-0"> <div className="flex-1 overflow-auto border border-offbase rounded-lg bg-background p-2 min-h-0">
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center h-full text-muted">Loading documents...</div> <div className="flex items-center justify-center h-full text-muted">Loading documents...</div>
) : errorMessage ? (
<div className="flex items-center justify-center h-full text-red-500">{errorMessage}</div>
) : files.length === 0 ? ( ) : files.length === 0 ? (
<div className="flex items-center justify-center h-full text-muted">No documents found.</div> <div className="flex items-center justify-center h-full text-muted">No documents found.</div>
) : ( ) : (
@ -240,7 +216,7 @@ export function DocumentSelectionModal({
type="button" type="button"
className="inline-flex justify-center rounded-lg bg-accent px-4 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 disabled:opacity-50 disabled:cursor-not-allowed" className="inline-flex justify-center rounded-lg bg-accent px-4 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 disabled:opacity-50 disabled:cursor-not-allowed"
onClick={handleConfirmClick} onClick={handleConfirmClick}
disabled={selectedCount === 0 || isProcessing} disabled={isLoading || selectedCount === 0 || isProcessing}
> >
{isProcessing ? 'Processing...' : `${confirmLabel} ${selectedCount > 0 ? `(${selectedCount})` : ''}`} {isProcessing ? 'Processing...' : `${confirmLabel} ${selectedCount > 0 ? `(${selectedCount})` : ''}`}
</button> </button>

View file

@ -16,6 +16,7 @@ import {
clampTtsSegmentMaxBlockLength, clampTtsSegmentMaxBlockLength,
} from '@/types/config'; } from '@/types/config';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import { Section, ToggleRow, segmentedButtonClass, segmentedGroupClass } from '@/components/formPrimitives';
const viewTypeTextMapping = [ const viewTypeTextMapping = [
{ id: 'single', name: 'Single Page' }, { id: 'single', name: 'Single Page' },
@ -23,7 +24,7 @@ const viewTypeTextMapping = [
{ id: 'scroll', name: 'Continuous Scroll' }, { id: 'scroll', name: 'Continuous Scroll' },
]; ];
const rangeInputClassName = 'w-full bg-offbase rounded-lg appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-lg [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-4 [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-lg [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent'; const rangeInputClassName = 'w-full bg-offbase rounded-md appearance-none cursor-pointer accent-accent [&::-webkit-slider-runnable-track]:bg-offbase [&::-webkit-slider-runnable-track]:rounded-md [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-3.5 [&::-webkit-slider-thumb]:w-3.5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-accent [&::-moz-range-track]:bg-offbase [&::-moz-range-track]:rounded-md [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:h-3.5 [&::-moz-range-thumb]:w-3.5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-accent';
type MarginKey = 'header' | 'footer' | 'left' | 'right'; type MarginKey = 'header' | 'footer' | 'left' | 'right';
@ -52,7 +53,7 @@ function RangeSetting({
}: RangeSettingProps) { }: RangeSettingProps) {
return ( return (
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="block text-sm font-medium text-foreground">{label}</label> <label className="block text-[11px] font-semibold uppercase tracking-wide text-muted">{label}</label>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<input <input
type="range" type="range"
@ -70,34 +71,6 @@ function RangeSetting({
); );
} }
type ToggleRowProps = {
label: string;
description: string;
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
};
function ToggleRow({ label, description, checked, onChange, disabled = false }: ToggleRowProps) {
return (
<div className="rounded-xl border border-offbase bg-background px-3 py-2.5 shadow-sm">
<label className="flex items-start gap-2">
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(event) => onChange(event.target.checked)}
className="mt-0.5 form-checkbox h-4 w-4 text-accent rounded border-muted disabled:opacity-50 disabled:cursor-not-allowed"
/>
<span className="space-y-0.5">
<span className="block text-sm font-medium text-foreground">{label}</span>
<span className="block text-xs text-muted">{description}</span>
</span>
</label>
</div>
);
}
export function DocumentSettings({ isOpen, setIsOpen, epub, html }: { export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
isOpen: boolean, isOpen: boolean,
setIsOpen: (isOpen: boolean) => void, setIsOpen: (isOpen: boolean) => void,
@ -183,40 +156,41 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
onClose={() => setIsOpen(false)} onClose={() => setIsOpen(false)}
ariaLabel="Document settings" ariaLabel="Document settings"
title="Reader Settings" title="Reader Settings"
subtitle="Configure layout, preloading, and playback behavior for this document." subtitle="Tune layout, preloading, and playback."
bodyClassName="flex-1 overflow-y-auto px-4 py-4 bg-[radial-gradient(circle_at_top_right,rgba(239,68,68,0.08),transparent_35%)]" bodyClassName="flex-1 overflow-y-auto px-4 py-4 bg-[radial-gradient(circle_at_top_right,rgba(239,68,68,0.08),transparent_35%)]"
panelClassName="w-full sm:w-[30rem]" panelClassName="w-full sm:w-[30rem]"
> >
<div className="space-y-4"> <div className="space-y-4">
{!html && ( {!html && (
<section className="rounded-2xl border border-offbase bg-base px-4 py-3 space-y-3"> <Section
<div> title="Playback Flow"
<h3 className="text-sm font-semibold text-foreground">Playback Flow</h3> subtitle="Segment and queue behavior."
<p className="text-xs text-muted mt-0.5">Control segment generation and lookahead while audio is active.</p> variant="flat"
</div> >
<ToggleRow <ToggleRow
label="Skip blank pages" label="Skip blank pages"
description="Automatically skip pages with no readable text." description="Skip pages with no readable text."
checked={skipBlank} checked={skipBlank}
onChange={(checked) => updateConfigKey('skipBlank', checked)} onChange={(checked) => updateConfigKey('skipBlank', checked)}
variant="flat"
/> />
<ToggleRow <ToggleRow
label="Smart sentence splitting" label="Smart sentence splitting"
description="Merge sentence fragments across page or section boundaries." description="Merge fragments across pages/sections."
checked={smartSentenceSplitting} checked={smartSentenceSplitting}
onChange={(checked) => updateConfigKey('smartSentenceSplitting', checked)} onChange={(checked) => updateConfigKey('smartSentenceSplitting', checked)}
variant="flat"
/> />
<div className="rounded-xl border border-offbase bg-background px-3 py-3 space-y-3 shadow-sm"> <div className="space-y-3 pt-1">
<RangeSetting <RangeSetting
label="Segment preload depth" label="Segment preload depth"
value={localPreloadDepth} value={localPreloadDepth}
min={SEGMENT_PRELOAD_DEPTH_MIN} min={SEGMENT_PRELOAD_DEPTH_MIN}
max={SEGMENT_PRELOAD_DEPTH_MAX} max={SEGMENT_PRELOAD_DEPTH_MAX}
step={1} step={1}
description="How many upcoming pages or locations to queue in the background." description="Upcoming pages/locations to queue."
formatter={(value) => String(value)} formatter={(value) => String(value)}
onChange={(value) => { onChange={(value) => {
const next = clampSegmentPreloadDepth(value); const next = clampSegmentPreloadDepth(value);
@ -231,7 +205,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
min={SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MIN} min={SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MIN}
max={SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MAX} max={SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MAX}
step={1} step={1}
description="How many segments to ensure from each queued page or section." description="Segments to prepare per queued page/section."
formatter={(value) => String(value)} formatter={(value) => String(value)}
onChange={(value) => { onChange={(value) => {
const next = clampSegmentPreloadSentenceLookahead(value); const next = clampSegmentPreloadSentenceLookahead(value);
@ -246,7 +220,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
min={TTS_SEGMENT_MAX_BLOCK_LENGTH_MIN} min={TTS_SEGMENT_MAX_BLOCK_LENGTH_MIN}
max={TTS_SEGMENT_MAX_BLOCK_LENGTH_MAX} max={TTS_SEGMENT_MAX_BLOCK_LENGTH_MAX}
step={TTS_SEGMENT_MAX_BLOCK_LENGTH_STEP} step={TTS_SEGMENT_MAX_BLOCK_LENGTH_STEP}
description="Maximum character count used when chunking text into segment blocks." description="Max characters per TTS segment block."
valueWidth="w-14" valueWidth="w-14"
formatter={(value) => String(value)} formatter={(value) => String(value)}
onChange={(value) => { onChange={(value) => {
@ -256,22 +230,21 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
}} }}
/> />
</div> </div>
</section> </Section>
)} )}
{!epub && !html && ( {!epub && !html && (
<section className="rounded-2xl border border-offbase bg-base px-4 py-3 space-y-3"> <Section
<div> title="PDF Layout & Extraction"
<h3 className="text-sm font-semibold text-foreground">PDF Layout & Extraction</h3> subtitle="Page mode and extraction bounds."
<p className="text-xs text-muted mt-0.5">Set viewer mode and trim page edges before extraction.</p> variant="flat"
</div> >
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="block text-sm font-medium text-foreground">Page mode</label> <label className="block text-[11px] font-semibold uppercase tracking-wide text-muted">Page mode</label>
<div <div
role="radiogroup" role="radiogroup"
aria-label="Page mode" aria-label="Page mode"
className="grid grid-cols-3 gap-1 rounded-full border border-offbase bg-background p-1" className={`${segmentedGroupClass} grid-cols-3`}
> >
{viewTypeTextMapping.map((view) => { {viewTypeTextMapping.map((view) => {
const active = selectedView.id === view.id; const active = selectedView.id === view.id;
@ -282,11 +255,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
role="radio" role="radio"
aria-checked={active} aria-checked={active}
onClick={() => updateConfigKey('viewType', view.id as ViewType)} onClick={() => updateConfigKey('viewType', view.id as ViewType)}
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent ${ className={segmentedButtonClass(active)}
active
? 'bg-accent text-background shadow-sm'
: 'text-muted hover:bg-base hover:text-foreground'
}`}
> >
{view.name} {view.name}
</button> </button>
@ -294,14 +263,14 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
})} })}
</div> </div>
{selectedView.id === 'scroll' ? ( {selectedView.id === 'scroll' ? (
<p className="text-xs text-warning">Continuous scroll may perform poorly for very large PDFs.</p> <p className="text-xs text-warning">Scroll mode may be slower on large PDFs.</p>
) : null} ) : null}
</div> </div>
<div className="rounded-xl border border-offbase bg-background px-3 py-3 shadow-sm"> <div className="space-y-1.5 pt-1">
<p className="text-xs font-medium text-foreground">Text extraction margins</p> <p className="text-[11px] font-semibold uppercase tracking-wide text-muted">Text extraction margins</p>
<p className="text-xs text-muted mt-0.5"> <p className="text-xs text-muted mt-0.5">
Exclude content near edges before sentence extraction. Ignore edge content before extraction.
</p> </p>
<div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-3">
{(['header', 'footer', 'left', 'right'] as MarginKey[]).map((margin) => ( {(['header', 'footer', 'left', 'right'] as MarginKey[]).map((margin) => (
@ -326,57 +295,62 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
))} ))}
</div> </div>
</div> </div>
</section> </Section>
)} )}
{!epub && !html && ( {!epub && !html && (
<section className="rounded-2xl border border-offbase bg-base px-4 py-3 space-y-2"> <Section
<div> title="PDF Highlighting"
<h3 className="text-sm font-semibold text-foreground">PDF Highlighting</h3> subtitle="Playback highlighting in PDF mode."
<p className="text-xs text-muted mt-0.5">Control playback highlighting behavior in PDF mode.</p> variant="flat"
</div> >
<ToggleRow <ToggleRow
label="Highlight text during playback" label="Highlight text during playback"
description="Visual sentence-level playback highlighting in the PDF viewer." description="Highlight the current sentence in PDF."
checked={pdfHighlightEnabled} checked={pdfHighlightEnabled}
onChange={(checked) => updateConfigKey('pdfHighlightEnabled', checked)} onChange={(checked) => updateConfigKey('pdfHighlightEnabled', checked)}
variant="flat"
/> />
<ToggleRow <ToggleRow
label="Word-by-word highlighting" label="Word-by-word highlighting"
description={`Use whisper.cpp timing data to highlight words as speech progresses${!canWordHighlight ? ' (disabled by configuration)' : ''}.`} description={`Highlight words using timing data${!canWordHighlight ? ' (disabled by config)' : ''}.`}
checked={pdfWordHighlightEnabled && pdfHighlightEnabled} checked={pdfWordHighlightEnabled && pdfHighlightEnabled}
disabled={!pdfHighlightEnabled || !canWordHighlight} disabled={!pdfHighlightEnabled || !canWordHighlight}
onChange={(checked) => updateConfigKey('pdfWordHighlightEnabled', checked)} onChange={(checked) => updateConfigKey('pdfWordHighlightEnabled', checked)}
variant="flat"
/> />
</section> </Section>
)} )}
{epub && ( {epub && (
<section className="rounded-2xl border border-offbase bg-base px-4 py-3 space-y-2"> <Section
<div> title="EPUB Appearance"
<h3 className="text-sm font-semibold text-foreground">EPUB Appearance</h3> subtitle="Theme and highlighting in EPUB mode."
<p className="text-xs text-muted mt-0.5">Apply app styling and playback highlighting in EPUB mode.</p> variant="flat"
</div> >
<ToggleRow <ToggleRow
label="Apply app theme" label="Apply app theme"
description="Use selected theme on EPUB documents. May require refresh." description="Apply the app theme to EPUB (refresh may be needed)."
checked={epubTheme} checked={epubTheme}
onChange={(checked) => updateConfigKey('epubTheme', checked)} onChange={(checked) => updateConfigKey('epubTheme', checked)}
variant="flat"
/> />
<ToggleRow <ToggleRow
label="Highlight text during playback" label="Highlight text during playback"
description="Visual sentence-level playback highlighting in the EPUB viewer." description="Highlight the current sentence in EPUB."
checked={epubHighlightEnabled} checked={epubHighlightEnabled}
onChange={(checked) => updateConfigKey('epubHighlightEnabled', checked)} onChange={(checked) => updateConfigKey('epubHighlightEnabled', checked)}
variant="flat"
/> />
<ToggleRow <ToggleRow
label="Word-by-word highlighting" label="Word-by-word highlighting"
description={`Use whisper.cpp timing data to highlight words as speech progresses${!canWordHighlight ? ' (disabled by configuration)' : ''}.`} description={`Highlight words using timing data${!canWordHighlight ? ' (disabled by config)' : ''}.`}
checked={epubWordHighlightEnabled && epubHighlightEnabled} checked={epubWordHighlightEnabled && epubHighlightEnabled}
disabled={!epubHighlightEnabled || !canWordHighlight} disabled={!epubHighlightEnabled || !canWordHighlight}
onChange={(checked) => updateConfigKey('epubWordHighlightEnabled', checked)} onChange={(checked) => updateConfigKey('epubWordHighlightEnabled', checked)}
variant="flat"
/> />
</section> </Section>
)} )}
</div> </div>
</ReaderSidebarShell> </ReaderSidebarShell>

View file

@ -0,0 +1,182 @@
'use client';
import type { ReactNode } from 'react';
/**
* Shared compact form primitives used by settings-like surfaces across
* the app (settings modal, document settings, and admin panels).
*/
export const btnBase =
'inline-flex items-center justify-center rounded-md text-sm font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 transition-colors transition-transform duration-200 ease-out disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100';
export const btnPrimary = `${btnBase} bg-accent text-background hover:bg-secondary-accent hover:scale-[1.03]`;
export const btnSecondary = `${btnBase} bg-base text-foreground border border-offbase hover:bg-offbase hover:scale-[1.03]`;
export const btnOutline = `${btnBase} bg-background border border-offbase text-foreground hover:bg-base hover:text-accent hover:scale-[1.02]`;
export const btnDanger = `${btnBase} bg-red-600 text-white border border-red-700 hover:bg-red-700 hover:scale-[1.02]`;
// Inputs use `bg-base` so they remain visible regardless of whether the
// surrounding container is `bg-background` (Card) or `bg-base` (Section).
// Using the same `bg-background` as the Card would make the input blend in.
// (Note: never use Tailwind alpha modifiers on these theme variables — they
// resolve to CSS custom properties that don't accept opacity suffixes.)
export const inputClass =
'w-full rounded-md bg-background border border-offbase px-2.5 py-1.5 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-accent';
export const listboxButtonClass =
'relative w-full cursor-pointer rounded-md bg-background border border-offbase py-1.5 pl-2.5 pr-9 text-left text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-accent hover:bg-base transition-transform duration-200 ease-out hover:scale-[1.01]';
export const listboxOptionsClass =
'z-50 w-[var(--button-width)] max-h-60 overflow-y-auto overscroll-contain rounded-md bg-background p-1 shadow-lg ring-1 ring-offbase focus:outline-none [--anchor-gap:0.25rem]';
export const listboxOptionClass = (active: boolean) =>
`relative cursor-pointer select-none rounded-sm py-1.5 pl-9 pr-3 text-sm ${active ? 'bg-offbase text-foreground' : 'text-foreground'}`;
export const segmentedGroupClass =
'grid gap-1 rounded-full border border-offbase bg-background p-1';
export const segmentedButtonClass = (active: boolean) =>
`rounded-full px-2.5 py-1.5 text-xs font-medium transition-colors transition-transform duration-200 ease-out focus:outline-none focus-visible:ring-2 focus-visible:ring-accent ${
active
? 'bg-accent text-background hover:scale-[1.01]'
: 'text-muted hover:bg-base hover:text-foreground hover:scale-[1.02]'
}`;
export function Section({
title,
subtitle,
children,
action,
variant = 'panel',
}: {
title: string;
subtitle?: string;
children: ReactNode;
action?: ReactNode;
variant?: 'panel' | 'flat';
}) {
if (variant === 'flat') {
return (
<section className="space-y-2 pb-3 border-b border-offbase last:border-b-0">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
{subtitle ? <p className="text-xs text-muted mt-0.5">{subtitle}</p> : null}
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
{children}
</section>
);
}
return (
<section className="rounded-xl border border-offbase bg-base overflow-hidden">
<div className="px-3 py-2 bg-background border-b border-offbase">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
{subtitle ? <p className="text-xs text-muted mt-0.5">{subtitle}</p> : null}
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
</div>
<div className="px-3 py-2 space-y-2">
{children}
</div>
</section>
);
}
export function Card({
children,
className = '',
}: {
children: ReactNode;
className?: string;
}) {
return (
<div className={`rounded-lg border border-offbase bg-background px-3 py-2 transition-transform duration-200 ease-out hover:scale-[1.005] ${className}`}>
{children}
</div>
);
}
export function ToggleRow({
label,
description,
checked,
onChange,
disabled = false,
right,
variant = 'card',
}: {
label: string;
description: string;
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
right?: ReactNode;
variant?: 'card' | 'flat';
}) {
const rowClass =
variant === 'flat'
? 'px-0.5 pt-1 pb-2 border-b border-offbase last:border-b-0 transition-transform duration-200 ease-out hover:scale-[1.003]'
: 'rounded-md border border-offbase bg-background px-2.5 py-1.5 transition-transform duration-200 ease-out hover:scale-[1.005]';
return (
<div className={rowClass}>
<div className="flex items-start gap-2.5">
<label className="flex items-start gap-2.5 flex-1 min-w-0">
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(event) => onChange(event.target.checked)}
className="shrink-0 form-checkbox h-4 w-4 text-accent rounded border-muted disabled:opacity-50 disabled:cursor-not-allowed"
/>
<span className="space-y-0.5 min-w-0">
<span className="block text-sm font-medium leading-5 text-foreground">{label}</span>
<span className="block text-xs leading-4 text-muted">{description}</span>
</span>
</label>
{right ? <div className="shrink-0 self-start pl-1.5">{right}</div> : null}
</div>
</div>
);
}
export function Field({
label,
hint,
className = '',
children,
}: {
label?: string;
hint?: string;
className?: string;
children: ReactNode;
}) {
return (
<div className={`space-y-1 ${className}`}>
{label ? <label className="block text-[11px] font-semibold uppercase tracking-wide text-muted">{label}</label> : null}
{children}
{hint ? <p className="text-[11px] text-muted">{hint}</p> : null}
</div>
);
}
export function Badge({
tone = 'muted',
children,
}: {
tone?: 'muted' | 'accent' | 'foreground';
children: ReactNode;
}) {
const toneClass =
tone === 'accent'
? 'text-accent'
: tone === 'foreground'
? 'text-foreground bg-offbase'
: 'text-muted bg-offbase';
return (
<span className={`inline-flex items-center text-[10px] uppercase tracking-wide font-semibold rounded px-1.5 py-0.5 ${toneClass}`}>
{children}
</span>
);
}

View file

@ -0,0 +1,50 @@
'use client';
import { useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import type { BaseDocument } from '@/types/documents';
export const LIBRARY_DOCUMENTS_QUERY_KEY = ['documents-library', 10000] as const;
async function fetchLibraryDocuments(): Promise<BaseDocument[]> {
const res = await fetch('/api/documents/library?limit=10000');
if (!res.ok) throw new Error('Failed to list library documents');
const data = (await res.json()) as { documents?: BaseDocument[] };
return data.documents || [];
}
export function useLibraryDocumentsQuery(enabled: boolean): {
documents: BaseDocument[];
isLoading: boolean;
errorMessage: string | null;
refetch: () => Promise<void>;
prefetch: () => Promise<void>;
} {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: LIBRARY_DOCUMENTS_QUERY_KEY,
queryFn: fetchLibraryDocuments,
enabled,
staleTime: 60 * 1000,
});
const refetch = useCallback(async () => {
await query.refetch();
}, [query]);
const prefetch = useCallback(async () => {
await queryClient.prefetchQuery({
queryKey: LIBRARY_DOCUMENTS_QUERY_KEY,
queryFn: fetchLibraryDocuments,
staleTime: 60 * 1000,
});
}, [queryClient]);
return {
documents: query.data ?? [],
isLoading: query.isPending && !query.data,
errorMessage: query.error instanceof Error ? query.error.message : query.error ? 'Failed to list library documents' : null,
refetch,
prefetch,
};
}