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,
|
||||
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'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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
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