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:
parent
a296d6fb13
commit
e27d20419d
8 changed files with 477 additions and 404 deletions
|
|
@ -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's default model.
|
This instance restricts model selection to each provider'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}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
182
src/components/formPrimitives.tsx
Normal file
182
src/components/formPrimitives.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
50
src/hooks/useLibraryDocumentsQuery.ts
Normal file
50
src/hooks/useLibraryDocumentsQuery.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue