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,
Input,
} from '@headlessui/react';
import { useQueryClient } from '@tanstack/react-query';
import Link from 'next/link';
import { useTheme } from '@/contexts/ThemeContext';
import { useConfig } from '@/contexts/ConfigContext';
@ -52,6 +51,19 @@ import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext';
import { AdminProvidersPanel } from '@/components/admin/AdminProvidersPanel';
import { AdminFeaturesPanel } from '@/components/admin/AdminFeaturesPanel';
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
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';
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 }) {
const queryClient = useQueryClient();
const runtimeConfig = useRuntimeConfig();
const enableDestructiveDelete = runtimeConfig.enableDestructiveDeleteActions;
const showAllDeepInfra = runtimeConfig.showAllDeepInfraModels;
@ -149,8 +152,6 @@ export function SettingsModal({ className = '' }: { className?: string }) {
title: string;
confirmLabel: string;
defaultSelected: boolean;
initialFiles?: BaseDocument[];
fetcher?: () => Promise<BaseDocument[]>;
}>({
title: '',
confirmLabel: '',
@ -167,13 +168,12 @@ export function SettingsModal({ className = '' }: { className?: string }) {
const { data: session } = useAuthSession();
const router = useRouter();
const isBusy = isImportingLibrary;
const fetchLibraryDocumentsCached = useCallback(async () => {
return queryClient.ensureQueryData({
queryKey: LIBRARY_DOCUMENTS_QUERY_KEY,
queryFn: fetchLibraryDocuments,
staleTime: 60 * 1000,
});
}, [queryClient]);
const {
documents: libraryDocuments,
isLoading: isLibraryDocumentsLoading,
errorMessage: libraryDocumentsErrorMessage,
prefetch: prefetchLibraryDocuments,
} = useLibraryDocumentsQuery(isSelectionModalOpen);
const { providers: sharedProviders } = useSharedProviders();
const {
@ -299,17 +299,11 @@ export function SettingsModal({ className = '' }: { className?: string }) {
const handleImportLibrary = async () => {
// Start fetching as soon as the user opens the picker so cached data is
// often ready before the modal asks for it.
void queryClient.prefetchQuery({
queryKey: LIBRARY_DOCUMENTS_QUERY_KEY,
queryFn: fetchLibraryDocuments,
staleTime: 60 * 1000,
});
void prefetchLibraryDocuments();
setSelectionModalProps({
title: 'Import from Library',
confirmLabel: 'Import',
defaultSelected: false,
initialFiles: queryClient.getQueryData(LIBRARY_DOCUMENTS_QUERY_KEY),
fetcher: fetchLibraryDocumentsCached,
});
setIsSelectionModalOpen(true);
};
@ -481,10 +475,13 @@ export function SettingsModal({ className = '' }: { className?: string }) {
setActiveSection(visibleSections[0]?.id ?? 'theme');
}, [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 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]`;
const fieldLabelClass = 'block text-[11px] font-semibold uppercase tracking-wide text-muted';
const sectionShellClass = 'space-y-2 pb-3 border-b border-offbase px-0.5';
const sectionHeadingClass = 'text-sm font-semibold text-foreground';
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({
providerRef: selectedProviderRef,
providerType: localProviderType,
@ -511,11 +508,11 @@ export function SettingsModal({ className = '' }: { className?: string }) {
<>
<Button
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"
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>
<Transition appear show={isOpen} as={Fragment}>
@ -543,7 +540,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
leaveFrom="opacity-100 scale-100"
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 */}
<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">
@ -552,7 +549,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
{authEnabled && (
<Button
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
</Button>
@ -567,11 +564,11 @@ export function SettingsModal({ className = '' }: { className?: string }) {
<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
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'
}`}
}`}
>
<Icon className="w-3.5 h-3.5" />
{section.label}
@ -580,21 +577,22 @@ export function SettingsModal({ className = '' }: { className?: string }) {
})}
</div>
<div className="flex flex-row h-[450px]">
<div className="flex flex-row h-[490px]">
{/* 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">
<nav className="hidden sm:block w-44 shrink-0 border-r border-offbase bg-background p-2">
<div className="flex flex-col gap-1">
{visibleSections.map((section) => {
const Icon = section.icon;
const active = activeSection === section.id;
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
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 ${
active
? '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" />
{section.label}
@ -605,12 +603,16 @@ export function SettingsModal({ className = '' }: { className?: string }) {
</nav>
{/* 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 */}
{activeSection === 'api' && (
<div className="space-y-4">
<div className="space-y-1.5">
<label className="block text-sm font-medium text-foreground">TTS Provider</label>
<label className={fieldLabelClass}>TTS Provider</label>
{ttsProviders.length === 0 ? (
<p className="text-xs text-amber-500">
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('');
}}
>
<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">
{selectedProviderOption?.name || 'Select Provider'}
</span>
@ -654,14 +656,12 @@ export function SettingsModal({ className = '' }: { className?: string }) {
>
<ListboxOptions
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) => (
<ListboxOption
key={provider.id}
className={({ active }) =>
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'}`
}
className={({ active }) => listboxOptionClass(active)}
value={provider}
>
{({ selected }) => (
@ -691,7 +691,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
{shouldShowBaseUrl && (
<div className="space-y-1.5">
<label className="block text-sm font-medium text-foreground">
<label className={fieldLabelClass}>
API Base URL
{localBaseUrl && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
</label>
@ -700,14 +700,14 @@ export function SettingsModal({ className = '' }: { className?: string }) {
value={localBaseUrl}
onChange={(e) => handleInputChange('baseUrl', e.target.value)}
placeholder="Using environment variable"
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
className={inputClass}
/>
</div>
)}
{shouldShowApiKey && (
<div className="space-y-1.5">
<label className="block text-sm font-medium text-foreground">
<label className={fieldLabelClass}>
API Key
{localApiKey && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
</label>
@ -716,7 +716,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
value={localApiKey}
onChange={(e) => handleInputChange('apiKey', e.target.value)}
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>
)}
@ -727,7 +727,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
)}
<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 && (
<p className="text-xs text-muted">
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 ? (
<span className="block">
<span className="block truncate">
@ -772,14 +772,12 @@ export function SettingsModal({ className = '' }: { className?: string }) {
>
<ListboxOptions
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) => (
<ListboxOption
key={model.id}
className={({ active }) =>
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'}`
}
className={({ active }) => listboxOptionClass(active)}
value={model}
>
{({ selected }) => (
@ -814,7 +812,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
setModelValue(e.target.value);
}}
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>
@ -822,12 +820,12 @@ export function SettingsModal({ className = '' }: { className?: string }) {
{providerModelPolicy.supportsInstructions && (
<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
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-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>
)}
@ -1116,8 +1114,8 @@ export function SettingsModal({ className = '' }: { className?: string }) {
{/* 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>
<div className={sectionShellClass}>
<h4 className={sectionHeadingClass}>Server Library</h4>
<Button
onClick={handleImportLibrary}
disabled={isBusy}
@ -1127,8 +1125,8 @@ export function SettingsModal({ className = '' }: { className?: string }) {
</Button>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-foreground">Cache & Data</label>
<div className={sectionShellClass}>
<h4 className={sectionHeadingClass}>Cache & Data</h4>
<div className="flex flex-wrap gap-2">
<Button
onClick={handleRefresh}
@ -1148,7 +1146,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
<Button
onClick={() => setShowDeleteDocsConfirm(true)}
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
</Button>
@ -1164,7 +1162,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
<div
role="radiogroup"
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' },
@ -1178,11 +1176,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
role="radio"
aria-checked={active}
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 ${
active
? 'bg-accent text-background shadow-sm'
: 'text-muted hover:bg-base hover:text-foreground'
}`}
className={segmentedButtonClass(active)}
>
{tab.label}
</button>
@ -1246,7 +1240,7 @@ export function SettingsModal({ className = '' }: { className?: string }) {
<>
<Button
onClick={handleSignOut}
className={`${btnOutline} px-4 py-2`}
className={`${accountBtnOutline} px-4 py-2`}
>
Disconnect account
</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>
<Button
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
</Button>
@ -1273,17 +1267,17 @@ export function SettingsModal({ className = '' }: { className?: string }) {
</p>
<div className="flex flex-wrap gap-2">
<Link href="/signin">
<Button className={`${btnOutline} px-4 py-2`}>
<Button className={`${accountBtnOutline} px-4 py-2`}>
Connect
</Button>
</Link>
<Link href="/signup">
<Button className={`${btnPrimary} px-4 py-2`}>
<Button className={`${accountBtnPrimary} px-4 py-2`}>
Create account
</Button>
</Link>
<Link href="/?redirect=false">
<Button className={`${btnOutline} px-4 py-2`}>
<Button className={`${accountBtnOutline} px-4 py-2`}>
Back to landing page
</Button>
</Link>
@ -1348,8 +1342,9 @@ export function SettingsModal({ className = '' }: { className?: string }) {
confirmLabel={selectionModalProps.confirmLabel}
isProcessing={false}
defaultSelected={selectionModalProps.defaultSelected}
initialFiles={selectionModalProps.initialFiles}
fetcher={selectionModalProps.fetcher}
files={libraryDocuments}
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 toast from 'react-hot-toast';
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 { useSharedProviders, type SharedProviderEntry } from '@/hooks/useSharedProviders';
@ -173,21 +182,22 @@ export function AdminFeaturesPanel() {
<div className="space-y-4">
<Section
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="min-w-0">
<p className="text-sm font-medium text-foreground">Default TTS provider</p>
<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>
</div>
<div className="shrink-0">{renderSource('defaultTtsProvider')}</div>
</div>
{providerOptions.length > 0 ? (
<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="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-4 w-4 text-muted" />
@ -199,17 +209,12 @@ export function AdminFeaturesPanel() {
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions
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]"
>
<ListboxOptions anchor="bottom start" className={listboxOptionsClass}>
{providerOptions.map((opt) => (
<ListboxOption
key={opt.id}
value={opt}
className={({ active }) =>
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'}`
}
className={({ active }) => listboxOptionClass(active)}
>
{({ selected }) => (
<>
@ -229,15 +234,15 @@ export function AdminFeaturesPanel() {
</Transition>
</Listbox>
) : (
<div className="rounded-lg border border-offbase bg-base px-3 py-2 text-sm text-muted">
No shared providers configured. Add one in the Shared providers tab first.
<div className="px-0.5 py-2 text-sm text-muted">
No shared providers yet. Add one first.
</div>
)}
</Card>
</div>
<ToggleRow
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)}
onChange={(checked) => {
if (!checked) {
@ -249,61 +254,70 @@ export function AdminFeaturesPanel() {
updateDraft('restrictUserApiKeys', checked);
}}
right={renderSource('restrictUserApiKeys')}
variant="flat"
/>
<ToggleRow
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)}
onChange={(checked) => updateDraft('enableTtsProvidersTab', checked)}
right={renderSource('enableTtsProvidersTab')}
variant="flat"
/>
<ToggleRow
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)}
onChange={(checked) => updateDraft('showAllDeepInfraModels', checked)}
right={renderSource('showAllDeepInfraModels')}
variant="flat"
/>
<ToggleRow
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)}
onChange={(checked) => updateDraft('showAllProviderModels', checked)}
right={renderSource('showAllProviderModels')}
variant="flat"
/>
</Section>
<Section
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
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)}
onChange={(checked) => updateDraft('enableWordHighlight', checked)}
right={renderSource('enableWordHighlight')}
variant="flat"
/>
<ToggleRow
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)}
onChange={(checked) => updateDraft('enableAudiobookExport', checked)}
right={renderSource('enableAudiobookExport')}
variant="flat"
/>
<ToggleRow
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)}
onChange={(checked) => updateDraft('enableDocxConversion', checked)}
right={renderSource('enableDocxConversion')}
variant="flat"
/>
<ToggleRow
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)}
onChange={(checked) => updateDraft('enableDestructiveDeleteActions', checked)}
right={renderSource('enableDestructiveDeleteActions')}
variant="flat"
/>
</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 { defaultBaseUrlForProviderType, defaultModelForProviderType, resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
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;
@ -246,20 +259,23 @@ export function AdminProvidersPanel() {
title="Shared TTS providers"
subtitle="Server-side providers visible to all users. API keys are encrypted at rest and never sent to the client."
action={
!editingId ? (
<Button
onClick={startCreate}
className={`${btnPrimary} h-7 w-7 p-0 inline-flex items-center justify-center`}
aria-label="Add provider"
title="Add provider"
>
<PlusIcon className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
) : null
<div className="flex items-center gap-2">
<Badge tone="foreground">Shared</Badge>
{!editingId ? (
<Button
onClick={startCreate}
className={`${btnPrimary} h-7 w-7 p-0 inline-flex items-center justify-center`}
aria-label="Add provider"
title="Add provider"
>
<PlusIcon className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
) : null}
</div>
}
>
{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">
<h4 className="text-sm font-semibold text-foreground">
{isEditingExisting ? `Edit "${editingProvider?.slug}"` : 'New provider'}
@ -303,7 +319,7 @@ export function AdminProvidersPanel() {
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="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-4 w-4 text-muted" />
@ -315,17 +331,12 @@ export function AdminProvidersPanel() {
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions
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]"
>
<ListboxOptions anchor="bottom start" className={listboxOptionsClass}>
{PROVIDER_TYPE_OPTIONS.map((opt) => (
<ListboxOption
key={opt.value}
value={opt}
className={({ active }) =>
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'}`
}
className={({ active }) => listboxOptionClass(active)}
>
{({ selected }) => (
<>
@ -367,10 +378,10 @@ export function AdminProvidersPanel() {
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">
<span className="block truncate">
{selectedModelDefinition?.name ?? 'Select model'}
</span>
<ListboxButton className={listboxButtonClass}>
<span className="block truncate">
{selectedModelDefinition?.name ?? 'Select model'}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-4 w-4 text-muted" />
</span>
@ -381,17 +392,12 @@ export function AdminProvidersPanel() {
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions
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]"
>
<ListboxOptions anchor="bottom start" className={listboxOptionsClass}>
{modelDefinitions.map((model) => (
<ListboxOption
key={model.id}
value={model.id}
className={({ active }) =>
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'}`
}
className={({ active }) => listboxOptionClass(active)}
>
{({ selected }) => (
<>
@ -479,6 +485,7 @@ export function AdminProvidersPanel() {
description="When off, this provider is hidden from users without being deleted."
checked={form.enabled}
onChange={(checked) => setForm({ ...form, enabled: checked })}
variant="flat"
/>
<div className="pt-1 flex justify-end gap-2">
@ -493,19 +500,17 @@ export function AdminProvidersPanel() {
{saveMutation.isPending ? 'Saving…' : isEditingExisting ? 'Save changes' : 'Create'}
</Button>
</div>
</Card>
</div>
)}
<div className="space-y-2">
<div className="space-y-0">
{isLoading ? (
<p className="text-xs text-muted">Loading</p>
) : providers.length === 0 ? (
<Card>
<p className="text-xs text-muted">No shared providers configured yet.</p>
</Card>
<p className="text-xs text-muted py-2">No shared providers configured yet.</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-1 min-w-0 space-y-0.5">
<div className="flex items-center gap-2 flex-wrap">
@ -538,7 +543,7 @@ export function AdminProvidersPanel() {
</Button>
</div>
</div>
</Card>
</div>
))
)}
</div>

View file

@ -1,141 +1,18 @@
'use client';
import type { ReactNode } from 'react';
/**
* Shared admin panel UI primitives that mirror the DocumentSettings /
* SettingsModal design language (section cards, toggle rows, standard
* button shapes). Keep this small and inline nothing here is meant
* to be reused outside the admin panel.
*/
export 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 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.04]`;
export const btnSecondary = `${btnBase} bg-background text-foreground hover:bg-offbase hover:text-accent hover:scale-[1.04]`;
export const btnOutline = `${btnBase} bg-background border border-offbase text-foreground hover:bg-offbase 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-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>
);
}
export {
Badge,
Card,
Field,
Section,
ToggleRow,
btnBase,
btnDanger,
btnOutline,
btnPrimary,
btnSecondary,
inputClass,
listboxButtonClass,
listboxOptionClass,
listboxOptionsClass,
segmentedButtonClass,
segmentedGroupClass,
} from '@/components/formPrimitives';

View file

@ -12,13 +12,9 @@ interface DocumentSelectionModalProps {
confirmLabel: string;
isProcessing: boolean;
defaultSelected?: boolean;
/**
* Data source:
* 1. `initialFiles`: Pass local files directly (synchronous).
* 2. `fetcher`: Pass an async function to load files (e.g. from server).
*/
initialFiles?: BaseDocument[];
fetcher?: () => Promise<BaseDocument[]>;
files: BaseDocument[];
isLoading?: boolean;
errorMessage?: string | null;
}
export function DocumentSelectionModal({
@ -29,44 +25,22 @@ export function DocumentSelectionModal({
confirmLabel,
isProcessing,
defaultSelected = false,
initialFiles,
fetcher,
files,
isLoading = false,
errorMessage = null,
}: DocumentSelectionModalProps) {
const [files, setFiles] = useState<BaseDocument[]>([]);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [isLoading, setIsLoading] = useState(false);
const [lastSelectedId, setLastSelectedId] = useState<string | null>(null);
useEffect(() => {
if (isOpen) {
if (initialFiles) {
setFiles(initialFiles);
if (defaultSelected) {
setSelectedIds(new Set(initialFiles.map((f) => f.id)));
} 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());
}
if (!isOpen) return;
if (defaultSelected) {
setSelectedIds(new Set(files.map((f) => f.id)));
} else {
setSelectedIds(new Set());
}
}, [isOpen, initialFiles, fetcher, defaultSelected]);
setLastSelectedId(null);
}, [isOpen, files, defaultSelected]);
const toggleSelection = (id: string, multiSelect: boolean, rangeSelect: boolean) => {
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">
{isLoading ? (
<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 ? (
<div className="flex items-center justify-center h-full text-muted">No documents found.</div>
) : (
@ -240,7 +216,7 @@ export function DocumentSelectionModal({
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"
onClick={handleConfirmClick}
disabled={selectedCount === 0 || isProcessing}
disabled={isLoading || selectedCount === 0 || isProcessing}
>
{isProcessing ? 'Processing...' : `${confirmLabel} ${selectedCount > 0 ? `(${selectedCount})` : ''}`}
</button>

View file

@ -16,6 +16,7 @@ import {
clampTtsSegmentMaxBlockLength,
} from '@/types/config';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import { Section, ToggleRow, segmentedButtonClass, segmentedGroupClass } from '@/components/formPrimitives';
const viewTypeTextMapping = [
{ id: 'single', name: 'Single Page' },
@ -23,7 +24,7 @@ const viewTypeTextMapping = [
{ 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';
@ -52,7 +53,7 @@ function RangeSetting({
}: RangeSettingProps) {
return (
<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">
<input
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 }: {
isOpen: boolean,
setIsOpen: (isOpen: boolean) => void,
@ -183,40 +156,41 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
onClose={() => setIsOpen(false)}
ariaLabel="Document 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%)]"
panelClassName="w-full sm:w-[30rem]"
>
<div className="space-y-4">
{!html && (
<section className="rounded-2xl border border-offbase bg-base px-4 py-3 space-y-3">
<div>
<h3 className="text-sm font-semibold text-foreground">Playback Flow</h3>
<p className="text-xs text-muted mt-0.5">Control segment generation and lookahead while audio is active.</p>
</div>
<Section
title="Playback Flow"
subtitle="Segment and queue behavior."
variant="flat"
>
<ToggleRow
label="Skip blank pages"
description="Automatically skip pages with no readable text."
description="Skip pages with no readable text."
checked={skipBlank}
onChange={(checked) => updateConfigKey('skipBlank', checked)}
variant="flat"
/>
<ToggleRow
label="Smart sentence splitting"
description="Merge sentence fragments across page or section boundaries."
description="Merge fragments across pages/sections."
checked={smartSentenceSplitting}
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
label="Segment preload depth"
value={localPreloadDepth}
min={SEGMENT_PRELOAD_DEPTH_MIN}
max={SEGMENT_PRELOAD_DEPTH_MAX}
step={1}
description="How many upcoming pages or locations to queue in the background."
description="Upcoming pages/locations to queue."
formatter={(value) => String(value)}
onChange={(value) => {
const next = clampSegmentPreloadDepth(value);
@ -231,7 +205,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
min={SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MIN}
max={SEGMENT_PRELOAD_SENTENCE_LOOKAHEAD_MAX}
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)}
onChange={(value) => {
const next = clampSegmentPreloadSentenceLookahead(value);
@ -246,7 +220,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
min={TTS_SEGMENT_MAX_BLOCK_LENGTH_MIN}
max={TTS_SEGMENT_MAX_BLOCK_LENGTH_MAX}
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"
formatter={(value) => String(value)}
onChange={(value) => {
@ -256,22 +230,21 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
}}
/>
</div>
</section>
</Section>
)}
{!epub && !html && (
<section className="rounded-2xl border border-offbase bg-base px-4 py-3 space-y-3">
<div>
<h3 className="text-sm font-semibold text-foreground">PDF Layout & Extraction</h3>
<p className="text-xs text-muted mt-0.5">Set viewer mode and trim page edges before extraction.</p>
</div>
<Section
title="PDF Layout & Extraction"
subtitle="Page mode and extraction bounds."
variant="flat"
>
<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
role="radiogroup"
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) => {
const active = selectedView.id === view.id;
@ -282,11 +255,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
role="radio"
aria-checked={active}
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 ${
active
? 'bg-accent text-background shadow-sm'
: 'text-muted hover:bg-base hover:text-foreground'
}`}
className={segmentedButtonClass(active)}
>
{view.name}
</button>
@ -294,14 +263,14 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
})}
</div>
{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}
</div>
<div className="rounded-xl border border-offbase bg-background px-3 py-3 shadow-sm">
<p className="text-xs font-medium text-foreground">Text extraction margins</p>
<div className="space-y-1.5 pt-1">
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted">Text extraction margins</p>
<p className="text-xs text-muted mt-0.5">
Exclude content near edges before sentence extraction.
Ignore edge content before extraction.
</p>
<div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-3">
{(['header', 'footer', 'left', 'right'] as MarginKey[]).map((margin) => (
@ -326,57 +295,62 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
))}
</div>
</div>
</section>
</Section>
)}
{!epub && !html && (
<section className="rounded-2xl border border-offbase bg-base px-4 py-3 space-y-2">
<div>
<h3 className="text-sm font-semibold text-foreground">PDF Highlighting</h3>
<p className="text-xs text-muted mt-0.5">Control playback highlighting behavior in PDF mode.</p>
</div>
<Section
title="PDF Highlighting"
subtitle="Playback highlighting in PDF mode."
variant="flat"
>
<ToggleRow
label="Highlight text during playback"
description="Visual sentence-level playback highlighting in the PDF viewer."
description="Highlight the current sentence in PDF."
checked={pdfHighlightEnabled}
onChange={(checked) => updateConfigKey('pdfHighlightEnabled', checked)}
variant="flat"
/>
<ToggleRow
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}
disabled={!pdfHighlightEnabled || !canWordHighlight}
onChange={(checked) => updateConfigKey('pdfWordHighlightEnabled', checked)}
variant="flat"
/>
</section>
</Section>
)}
{epub && (
<section className="rounded-2xl border border-offbase bg-base px-4 py-3 space-y-2">
<div>
<h3 className="text-sm font-semibold text-foreground">EPUB Appearance</h3>
<p className="text-xs text-muted mt-0.5">Apply app styling and playback highlighting in EPUB mode.</p>
</div>
<Section
title="EPUB Appearance"
subtitle="Theme and highlighting in EPUB mode."
variant="flat"
>
<ToggleRow
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}
onChange={(checked) => updateConfigKey('epubTheme', checked)}
variant="flat"
/>
<ToggleRow
label="Highlight text during playback"
description="Visual sentence-level playback highlighting in the EPUB viewer."
description="Highlight the current sentence in EPUB."
checked={epubHighlightEnabled}
onChange={(checked) => updateConfigKey('epubHighlightEnabled', checked)}
variant="flat"
/>
<ToggleRow
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}
disabled={!epubHighlightEnabled || !canWordHighlight}
onChange={(checked) => updateConfigKey('epubWordHighlightEnabled', checked)}
variant="flat"
/>
</section>
</Section>
)}
</div>
</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,
};
}