refactor(admin): remove unused EditableRow and EditIcon components

- Delete EditableRow from formPrimitives and its usage in AdminFeaturesPanel
- Remove EditIcon from icon set as it is no longer referenced
- Simplify AdminFeaturesPanel state and draft handling
- Update AdminProvidersPanel to use distinct query key for default provider
- Ensure query invalidation covers new default provider key

This streamlines the admin codebase by eliminating obsolete editing UI and related icon, and clarifies provider query logic.
This commit is contained in:
Richard R 2026-05-30 10:33:11 -06:00
parent 267e2ce411
commit b0a4bccf4c
4 changed files with 87 additions and 178 deletions

View file

@ -7,7 +7,6 @@ import toast from 'react-hot-toast';
import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons';
import {
Badge,
EditableRow,
Section,
ToggleRow,
buttonClass,
@ -57,7 +56,6 @@ export function AdminFeaturesPanel() {
});
const [draft, setDraft] = useState<Record<string, unknown>>({});
const [dirty, setDirty] = useState<Set<string>>(new Set());
const [editingChangelogUrl, setEditingChangelogUrl] = useState(false);
const { providers: sharedProviders } = useSharedProviders();
useEffect(() => {
@ -144,14 +142,10 @@ export function AdminFeaturesPanel() {
providerType: entry.providerType,
}));
}, [sharedProviders]);
const valueFor = (key: string) =>
Object.prototype.hasOwnProperty.call(draft, key)
? draft[key]
: data?.values?.[key];
const currentProviderId =
typeof valueFor('defaultTtsProvider') === 'string'
? (valueFor('defaultTtsProvider') as string)
typeof draft.defaultTtsProvider === 'string'
? draft.defaultTtsProvider
: '';
const currentSharedEntry: SharedProviderEntry | undefined = sharedProviders.find(
(p) => p.slug === currentProviderId,
@ -165,7 +159,7 @@ export function AdminFeaturesPanel() {
} as ProviderOption
: fallbackShared;
const selectedProviderOption = effectiveSelectedProvider;
const showTtsDailyLimitInputs = !Boolean(valueFor('disableTtsRateLimit'));
const shouldRenderRateLimitInputs = draft.disableTtsRateLimit === false;
const handleProviderChange = (opt: ProviderOption) => {
updateDraft('defaultTtsProvider', opt.id);
@ -256,7 +250,7 @@ export function AdminFeaturesPanel() {
<ToggleRow
label="Restrict user API keys (recommended)"
description="Only allow admin shared providers."
checked={Boolean(valueFor('restrictUserApiKeys'))}
checked={Boolean(draft.restrictUserApiKeys)}
onChange={(checked) => {
if (!checked) {
const ok = confirm(
@ -272,7 +266,7 @@ export function AdminFeaturesPanel() {
<ToggleRow
label="Show TTS provider settings tab"
description="Allow per-user provider overrides."
checked={Boolean(valueFor('enableTtsProvidersTab'))}
checked={Boolean(draft.enableTtsProvidersTab)}
onChange={(checked) => updateDraft('enableTtsProvidersTab', checked)}
right={renderSource('enableTtsProvidersTab')}
variant="flat"
@ -280,7 +274,7 @@ export function AdminFeaturesPanel() {
<ToggleRow
label="Show all provider models"
description="Allow model selection beyond defaults."
checked={Boolean(valueFor('showAllProviderModels'))}
checked={Boolean(draft.showAllProviderModels)}
onChange={(checked) => updateDraft('showAllProviderModels', checked)}
right={renderSource('showAllProviderModels')}
variant="flat"
@ -288,82 +282,71 @@ export function AdminFeaturesPanel() {
<ToggleRow
label="Disable TTS daily rate limiting"
description="When on, per-user/IP daily character quotas are not enforced."
checked={Boolean(valueFor('disableTtsRateLimit'))}
checked={Boolean(draft.disableTtsRateLimit)}
onChange={(checked) => updateDraft('disableTtsRateLimit', checked)}
right={renderSource('disableTtsRateLimit')}
variant="flat"
/>
<Transition
as={Fragment}
show={showTtsDailyLimitInputs}
enter="transition-all duration-200 ease-out"
enterFrom="opacity-0 -translate-y-1 max-h-0"
enterTo="opacity-100 translate-y-0 max-h-[420px]"
leave="transition-all duration-150 ease-in"
leaveFrom="opacity-100 translate-y-0 max-h-[420px]"
leaveTo="opacity-0 -translate-y-1 max-h-0"
>
<div className="overflow-hidden">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5 px-0.5 py-1.5">
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-foreground">Anonymous per-user daily limit</label>
{renderSource('ttsDailyLimitAnonymous')}
</div>
<input
type="number"
min={1}
step={1}
className={inputClass}
value={String(valueFor('ttsDailyLimitAnonymous') ?? '')}
onChange={(event) => updatePositiveIntDraft('ttsDailyLimitAnonymous', event.target.value)}
/>
{shouldRenderRateLimitInputs ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5 px-0.5 py-1.5">
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-foreground">Anonymous per-user daily limit</label>
{renderSource('ttsDailyLimitAnonymous')}
</div>
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-foreground">Authenticated per-user daily limit</label>
{renderSource('ttsDailyLimitAuthenticated')}
</div>
<input
type="number"
min={1}
step={1}
className={inputClass}
value={String(valueFor('ttsDailyLimitAuthenticated') ?? '')}
onChange={(event) => updatePositiveIntDraft('ttsDailyLimitAuthenticated', event.target.value)}
/>
<input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.ttsDailyLimitAnonymous ?? '')}
onChange={(event) => updatePositiveIntDraft('ttsDailyLimitAnonymous', event.target.value)}
/>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-foreground">Authenticated per-user daily limit</label>
{renderSource('ttsDailyLimitAuthenticated')}
</div>
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-foreground">Anonymous IP daily backstop</label>
{renderSource('ttsIpDailyLimitAnonymous')}
</div>
<input
type="number"
min={1}
step={1}
className={inputClass}
value={String(valueFor('ttsIpDailyLimitAnonymous') ?? '')}
onChange={(event) => updatePositiveIntDraft('ttsIpDailyLimitAnonymous', event.target.value)}
/>
<input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.ttsDailyLimitAuthenticated ?? '')}
onChange={(event) => updatePositiveIntDraft('ttsDailyLimitAuthenticated', event.target.value)}
/>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-foreground">Anonymous IP daily backstop</label>
{renderSource('ttsIpDailyLimitAnonymous')}
</div>
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-foreground">Authenticated IP daily backstop</label>
{renderSource('ttsIpDailyLimitAuthenticated')}
</div>
<input
type="number"
min={1}
step={1}
className={inputClass}
value={String(valueFor('ttsIpDailyLimitAuthenticated') ?? '')}
onChange={(event) => updatePositiveIntDraft('ttsIpDailyLimitAuthenticated', event.target.value)}
/>
<input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.ttsIpDailyLimitAnonymous ?? '')}
onChange={(event) => updatePositiveIntDraft('ttsIpDailyLimitAnonymous', event.target.value)}
/>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-foreground">Authenticated IP daily backstop</label>
{renderSource('ttsIpDailyLimitAuthenticated')}
</div>
<input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.ttsIpDailyLimitAuthenticated ?? '')}
onChange={(event) => updatePositiveIntDraft('ttsIpDailyLimitAuthenticated', event.target.value)}
/>
</div>
</div>
</Transition>
) : null}
</Section>
<Section
@ -371,27 +354,28 @@ export function AdminFeaturesPanel() {
subtitle="Feature flags for all users."
action={<Badge tone="foreground">Feature Flags</Badge>}
>
<EditableRow
label="Changelog feed URL"
description="Public URL to the changelog manifest JSON used by Settings."
value={String(valueFor('changelogFeedUrl') ?? '') || '—'}
expanded={editingChangelogUrl}
onExpandedChange={setEditingChangelogUrl}
right={renderSource('changelogFeedUrl')}
editLabel={editingChangelogUrl ? 'Close editor' : 'Edit URL'}
>
<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">Changelog feed URL</p>
<p className="text-xs text-muted mt-0.5">
Public URL to the changelog manifest JSON used by Settings.
</p>
</div>
<div className="shrink-0">{renderSource('changelogFeedUrl')}</div>
</div>
<input
type="text"
className={inputClass}
value={String(valueFor('changelogFeedUrl') ?? '')}
value={String(draft.changelogFeedUrl ?? '')}
onChange={(event) => updateDraft('changelogFeedUrl', event.target.value)}
placeholder="https://docs.openreader.richardr.dev/changelog/manifest.json"
/>
</EditableRow>
</div>
<ToggleRow
label="Allow new account sign-ups"
description="When off, new accounts cannot be created. Existing accounts can still sign in."
checked={Boolean(valueFor('enableUserSignups'))}
checked={Boolean(draft.enableUserSignups)}
onChange={(checked) => updateDraft('enableUserSignups', checked)}
right={renderSource('enableUserSignups')}
variant="flat"
@ -399,7 +383,7 @@ export function AdminFeaturesPanel() {
<ToggleRow
label="Audiobook export"
description='Show "Export audiobook" on PDF/EPUB pages.'
checked={Boolean(valueFor('enableAudiobookExport'))}
checked={Boolean(draft.enableAudiobookExport)}
onChange={(checked) => updateDraft('enableAudiobookExport', checked)}
right={renderSource('enableAudiobookExport')}
variant="flat"
@ -407,7 +391,7 @@ export function AdminFeaturesPanel() {
<ToggleRow
label="DOCX upload conversion"
description="Allow DOCX uploads (converted to PDF)."
checked={Boolean(valueFor('enableDocxConversion'))}
checked={Boolean(draft.enableDocxConversion)}
onChange={(checked) => updateDraft('enableDocxConversion', checked)}
right={renderSource('enableDocxConversion')}
variant="flat"
@ -415,7 +399,7 @@ export function AdminFeaturesPanel() {
<ToggleRow
label="Destructive delete buttons"
description='Show "Delete all data" actions (auth-off mode).'
checked={Boolean(valueFor('enableDestructiveDeleteActions'))}
checked={Boolean(draft.enableDestructiveDeleteActions)}
onChange={(checked) => updateDraft('enableDestructiveDeleteActions', checked)}
right={renderSource('enableDestructiveDeleteActions')}
variant="flat"

View file

@ -57,6 +57,7 @@ interface FormState {
const providerDefaultModel = defaultModelForProviderType;
const ADMIN_PROVIDERS_QUERY_KEY = ['admin-providers'] as const;
const ADMIN_SETTINGS_QUERY_KEY = ['admin-settings'] as const;
const ADMIN_DEFAULT_PROVIDER_QUERY_KEY = ['admin-settings', 'default-provider-slug'] as const;
async function fetchDefaultProviderSlug(): Promise<string> {
const res = await fetch('/api/admin/settings');
@ -160,7 +161,7 @@ export function AdminProvidersPanel() {
data: defaultProviderSlug = '',
error: defaultProviderError,
} = useQuery({
queryKey: ADMIN_SETTINGS_QUERY_KEY,
queryKey: ADMIN_DEFAULT_PROVIDER_QUERY_KEY,
queryFn: fetchDefaultProviderSlug,
});
@ -183,6 +184,7 @@ export function AdminProvidersPanel() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ADMIN_PROVIDERS_QUERY_KEY }),
queryClient.invalidateQueries({ queryKey: ADMIN_SETTINGS_QUERY_KEY }),
queryClient.invalidateQueries({ queryKey: ADMIN_DEFAULT_PROVIDER_QUERY_KEY }),
]);
},
onError: (mutationError) => {
@ -198,6 +200,7 @@ export function AdminProvidersPanel() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ADMIN_PROVIDERS_QUERY_KEY }),
queryClient.invalidateQueries({ queryKey: ADMIN_SETTINGS_QUERY_KEY }),
queryClient.invalidateQueries({ queryKey: ADMIN_DEFAULT_PROVIDER_QUERY_KEY }),
]);
},
onError: (mutationError) => {
@ -212,6 +215,7 @@ export function AdminProvidersPanel() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ADMIN_PROVIDERS_QUERY_KEY }),
queryClient.invalidateQueries({ queryKey: ADMIN_SETTINGS_QUERY_KEY }),
queryClient.invalidateQueries({ queryKey: ADMIN_DEFAULT_PROVIDER_QUERY_KEY }),
]);
},
onError: (mutationError) => {
@ -223,7 +227,10 @@ export function AdminProvidersPanel() {
mutationFn: patchDefaultProviderSlug,
onSuccess: async () => {
toast.success('Default provider updated');
await queryClient.invalidateQueries({ queryKey: ADMIN_SETTINGS_QUERY_KEY });
await Promise.all([
queryClient.invalidateQueries({ queryKey: ADMIN_SETTINGS_QUERY_KEY }),
queryClient.invalidateQueries({ queryKey: ADMIN_DEFAULT_PROVIDER_QUERY_KEY }),
]);
},
onError: (mutationError) => {
console.error('[AdminProvidersPanel] set default failed:', mutationError);

View file

@ -1,8 +1,6 @@
'use client';
import { Fragment, useId, type ReactNode } from 'react';
import { Transition } from '@headlessui/react';
import { EditIcon } from '@/components/icons/Icons';
import { useId, type ReactNode } from 'react';
/**
* Shared compact form primitives used by settings-like surfaces across
@ -241,65 +239,6 @@ export function ToggleRow({
);
}
export function EditableRow({
label,
description,
value,
expanded,
onExpandedChange,
right,
children,
editLabel = 'Edit',
}: {
label: string;
description: string;
value: ReactNode;
expanded: boolean;
onExpandedChange: (expanded: boolean) => void;
right?: ReactNode;
children: ReactNode;
editLabel?: string;
}) {
const rowClass =
'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]';
return (
<div className={rowClass}>
<div className="flex items-start gap-2.5">
<div className="flex-1 min-w-0 space-y-0.5">
<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 className="block text-xs leading-5 text-foreground truncate">{value}</span>
</div>
{right ? <div className="shrink-0 self-start pl-1.5">{right}</div> : null}
<button
type="button"
onClick={() => onExpandedChange(!expanded)}
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-offbase bg-base text-muted transition-colors hover:bg-offbase hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
title={editLabel}
aria-label={editLabel}
aria-expanded={expanded}
>
<EditIcon className="h-3.5 w-3.5" />
</button>
</div>
<Transition
as={Fragment}
show={expanded}
enter="transition-all duration-200 ease-out"
enterFrom="opacity-0 -translate-y-1 max-h-0"
enterTo="opacity-100 translate-y-0 max-h-48"
leave="transition-all duration-150 ease-in"
leaveFrom="opacity-100 translate-y-0 max-h-48"
leaveTo="opacity-0 -translate-y-1 max-h-0"
>
<div className="overflow-hidden pt-2">
{children}
</div>
</Transition>
</div>
);
}
export function CheckItem({
label,
checked,

View file

@ -193,27 +193,6 @@ export function PlusIcon(props: React.SVGProps<SVGSVGElement>) {
);
}
export function EditIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
width={props.width || '1.5em'}
height={props.height || '1.5em'}
{...props}
>
<path d="M12 20h9" />
<path d="M16.5 3.5a2.1 2.1 0 1 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
</svg>
);
}
export function UploadIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg