'use client'; import { Fragment, useEffect, useMemo, useState } from 'react'; import { Button, Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from '@headlessui/react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import toast from 'react-hot-toast'; import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons'; import { Badge, Section, ToggleRow, buttonClass, inputClass, listboxButtonClass, listboxOptionClass, listboxOptionsClass, } from '@/components/formPrimitives'; import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog'; import { useSharedProviders, type SharedProviderEntry } from '@/hooks/useSharedProviders'; type RuntimeConfigSource = 'json-seed' | 'env-seed' | 'admin' | 'default'; interface SettingsResponse { values: Record; sources: Record; } interface ProviderOption { id: string; name: string; providerType: TtsProviderId; } const ADMIN_SETTINGS_QUERY_KEY = ['admin-settings'] as const; async function fetchAdminSettings(): Promise { const res = await fetch('/api/admin/settings'); if (!res.ok) throw new Error(`HTTP ${res.status}`); return (await res.json()) as SettingsResponse; } async function patchAdminSettings(payload: { updates?: Record; reset?: string[] }): Promise { const res = await fetch('/api/admin/settings', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!res.ok && res.status !== 207) throw new Error(`HTTP ${res.status}`); } export function AdminFeaturesPanel() { const queryClient = useQueryClient(); const { data, error } = useQuery({ queryKey: ADMIN_SETTINGS_QUERY_KEY, queryFn: fetchAdminSettings, }); const [draft, setDraft] = useState>({}); const [dirty, setDirty] = useState>(new Set()); const { providers: sharedProviders } = useSharedProviders(); useEffect(() => { if (!data) return; setDraft({ ...data.values }); setDirty(new Set()); }, [data]); useEffect(() => { if (!error) return; console.error('[AdminFeaturesPanel] load failed:', error); toast.error('Failed to load site settings'); }, [error]); const resetMutation = useMutation({ mutationFn: async (key: string) => { await patchAdminSettings({ reset: [key] }); }, onSuccess: async () => { toast.success('Reset to env default'); await queryClient.invalidateQueries({ queryKey: ADMIN_SETTINGS_QUERY_KEY }); }, onError: (mutationError) => { console.error(mutationError); toast.error('Reset failed'); }, }); const saveMutation = useMutation({ mutationFn: async (updates: Record) => { await patchAdminSettings({ updates }); }, onSuccess: async () => { toast.success('Settings saved'); await queryClient.invalidateQueries({ queryKey: ADMIN_SETTINGS_QUERY_KEY }); }, onError: (mutationError) => { console.error(mutationError); toast.error('Save failed'); }, }); const saving = resetMutation.isPending || saveMutation.isPending; const updateDraft = (key: string, value: unknown) => { setDraft((d) => ({ ...d, [key]: value })); setDirty((s) => { const next = new Set(s); const baselineValue = data?.values?.[key]; if (Object.is(value, baselineValue)) next.delete(key); else next.add(key); return next; }); }; const updatePositiveIntDraft = (key: string, raw: string) => { const parsed = Number(raw); if (!Number.isFinite(parsed)) return; updateDraft(key, Math.max(1, Math.floor(parsed))); }; const resetField = (key: string) => { if (saving) return; resetMutation.mutate(key); }; const saveAll = () => { if (saving || dirty.size === 0) return; const updates: Record = {}; for (const key of dirty) updates[key] = draft[key]; saveMutation.mutate(updates); }; const discardAll = () => { if (!data) return; setDraft({ ...data.values }); setDirty(new Set()); }; const providerOptions = useMemo(() => { return sharedProviders.map((entry) => ({ id: entry.slug, name: `${entry.displayName} (shared)`, providerType: entry.providerType, })); }, [sharedProviders]); const currentProviderId = typeof draft.defaultTtsProvider === 'string' ? draft.defaultTtsProvider : ''; const currentSharedEntry: SharedProviderEntry | undefined = sharedProviders.find( (p) => p.slug === currentProviderId, ); const fallbackShared = providerOptions[0]; const effectiveSelectedProvider = currentSharedEntry ? { id: currentSharedEntry.slug, name: `${currentSharedEntry.displayName} (shared)`, providerType: currentSharedEntry.providerType, } as ProviderOption : fallbackShared; const selectedProviderOption = effectiveSelectedProvider; const shouldRenderRateLimitInputs = draft.disableTtsRateLimit === false; const shouldRenderComputeRateLimitInputs = draft.disableComputeRateLimit === false; const handleProviderChange = (opt: ProviderOption) => { updateDraft('defaultTtsProvider', opt.id); }; const renderSource = (key: string) => { const source = data?.sources?.[key] ?? 'default'; const isDirty = dirty.has(key); return ( resetField(key)} saving={saving} /> ); }; if (!data) { return ( ); } return (
Defaults} >

Default TTS provider

Starting provider for new users.

{renderSource('defaultTtsProvider')}
{providerOptions.length > 0 ? ( {selectedProviderOption?.name ?? 'Select provider'} {providerOptions.map((opt) => ( listboxOptionClass(active)} > {({ selected }) => ( <> {opt.name} {selected && ( )} )} ))} ) : (
No shared providers yet. Add one first.
)}
{ if (!checked) { const ok = confirm( 'Turning this off allows user-supplied API keys to flow through this server. Continue?', ); if (!ok) return; } updateDraft('restrictUserApiKeys', checked); }} right={renderSource('restrictUserApiKeys')} variant="flat" /> updateDraft('enableTtsProvidersTab', checked)} right={renderSource('enableTtsProvidersTab')} variant="flat" /> updateDraft('showAllProviderModels', checked)} right={renderSource('showAllProviderModels')} variant="flat" />
Limits} > updateDraft('disableTtsRateLimit', checked)} right={renderSource('disableTtsRateLimit')} variant="flat" /> {shouldRenderRateLimitInputs ? (
{renderSource('ttsDailyLimitAnonymous')}
updatePositiveIntDraft('ttsDailyLimitAnonymous', event.target.value)} />
{renderSource('ttsDailyLimitAuthenticated')}
updatePositiveIntDraft('ttsDailyLimitAuthenticated', event.target.value)} />
{renderSource('ttsIpDailyLimitAnonymous')}
updatePositiveIntDraft('ttsIpDailyLimitAnonymous', event.target.value)} />
{renderSource('ttsIpDailyLimitAuthenticated')}
updatePositiveIntDraft('ttsIpDailyLimitAuthenticated', event.target.value)} />
) : null} updateDraft('disableComputeRateLimit', checked)} right={renderSource('disableComputeRateLimit')} variant="flat" /> {shouldRenderComputeRateLimitInputs ? (
{renderSource('computeParseBurstMax')}
updatePositiveIntDraft('computeParseBurstMax', event.target.value)} />
{renderSource('computeParseBurstWindowSec')}
updatePositiveIntDraft('computeParseBurstWindowSec', event.target.value)} />
{renderSource('computeParseSustainedMax')}
updatePositiveIntDraft('computeParseSustainedMax', event.target.value)} />
{renderSource('computeParseSustainedWindowSec')}
updatePositiveIntDraft('computeParseSustainedWindowSec', event.target.value)} />
) : null}
Max upload size Largest single document upload accepted.
{renderSource('maxUploadMb')}
updatePositiveIntDraft('maxUploadMb', event.target.value)} /> MB
Feature Flags} >

Changelog feed URL

Public URL to the changelog manifest JSON used by Settings.

{renderSource('changelogFeedUrl')}
updateDraft('changelogFeedUrl', event.target.value)} placeholder="https://docs.openreader.richardr.dev/changelog/manifest.json" />
updateDraft('enableUserSignups', checked)} right={renderSource('enableUserSignups')} variant="flat" /> updateDraft('enableAudiobookExport', checked)} right={renderSource('enableAudiobookExport')} variant="flat" /> updateDraft('enableDocxConversion', checked)} right={renderSource('enableDocxConversion')} variant="flat" /> updateDraft('enableDestructiveDeleteActions', checked)} right={renderSource('enableDestructiveDeleteActions')} variant="flat" />
Upstream} >
{renderSource('ttsUpstreamMaxRetries')}
updatePositiveIntDraft('ttsUpstreamMaxRetries', event.target.value)} />
{renderSource('ttsUpstreamTimeoutMs')}
updatePositiveIntDraft('ttsUpstreamTimeoutMs', event.target.value)} />
{renderSource('ttsCacheMaxSizeBytes')}
updatePositiveIntDraft('ttsCacheMaxSizeBytes', event.target.value)} />
{renderSource('ttsCacheTtlMs')}
updatePositiveIntDraft('ttsCacheTtlMs', event.target.value)} />

{dirty.size > 0 ? `${dirty.size} unsaved change${dirty.size === 1 ? '' : 's'}` : 'No unsaved changes'}

); } function AdminFeaturesSkeleton() { return (
} >
} >
); } function SourceBadge({ source, dirty, canReset, onReset, saving, }: { source: RuntimeConfigSource; dirty: boolean; canReset: boolean; onReset: () => void; saving: boolean; }) { return (
{canReset && !dirty && ( )} {dirty ? ( Modified ) : source === 'json-seed' ? ( from seed ) : source === 'env-seed' ? ( from env ) : source === 'admin' ? ( admin ) : ( default )}
); }