Files
Cli-Proxy-API-Management-Ce…/src/features/plugins/PluginsPage.tsx
T

921 lines
31 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/Button';
import { EmptyState } from '@/components/ui/EmptyState';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import { Sheet } from '@/components/ui/Sheet';
import { ToggleSwitch } from '@/components/ui/ToggleSwitch';
import {
IconGithub,
IconPlug,
IconPlus,
IconRefreshCw,
IconSearch,
IconSettings,
IconSidebarStore,
IconTrash2,
} from '@/components/ui/icons';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { pluginsApi } from '@/services/api';
import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores';
import { getErrorMessage, isRecord } from '@/utils/helpers';
import type {
PluginConfigField,
PluginConfigObject,
PluginListEntry,
PluginListResponse,
} from '@/types';
import {
getPluginTitle,
notifyPluginResourcesChanged,
resolvePluginAssetURL,
} from './pluginResources';
import styles from './PluginsPage.module.scss';
type PluginDraftValue = string | boolean | string[];
interface PluginConfigDraft {
enabled: boolean;
priority: string;
values: Record<string, PluginDraftValue>;
errors: Record<string, string>;
}
const PLUGIN_ENABLE_REFRESH_DELAY_MS = 1600;
const wait = (ms: number) =>
new Promise<void>((resolve) => {
window.setTimeout(resolve, ms);
});
function PluginCardLogo({ src }: { src: string }) {
const [failed, setFailed] = useState(false);
const showImage = Boolean(src) && !failed;
return showImage ? (
<img src={src} alt="" onError={() => setFailed(true)} />
) : (
<IconPlug size={18} />
);
}
const hasStatus = (error: unknown, status: number) =>
isRecord(error) && error.status === status;
const normalizeFieldType = (field: PluginConfigField) => field.type.trim().toLowerCase();
const stringifyArrayItem = (value: unknown): string => {
if (value === undefined || value === null) return '';
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
try {
return JSON.stringify(value);
} catch {
return String(value);
}
};
const getFieldDraftValue = (field: PluginConfigField, value: unknown): PluginDraftValue => {
const type = normalizeFieldType(field);
if (type === 'boolean') return value === true;
if (type === 'array') {
if (Array.isArray(value)) {
return value.length > 0 ? value.map((item) => stringifyArrayItem(item)) : [''];
}
if (value !== undefined && value !== null) return [stringifyArrayItem(value)];
return [''];
}
if (value === undefined || value === null) return '';
if (type === 'object') {
return JSON.stringify(value, null, 2);
}
return String(value);
};
const buildDraft = (
plugin: PluginListEntry,
currentConfig: PluginConfigObject
): PluginConfigDraft => {
const enabled = typeof currentConfig.enabled === 'boolean' ? currentConfig.enabled : plugin.enabled;
const priority =
typeof currentConfig.priority === 'number' || typeof currentConfig.priority === 'string'
? String(currentConfig.priority)
: '0';
const values: PluginConfigDraft['values'] = {};
plugin.configFields.forEach((field) => {
values[field.name] = getFieldDraftValue(field, currentConfig[field.name]);
});
return {
enabled,
priority,
values,
errors: {},
};
};
const parseJSONField = (
text: string,
fieldType: string,
fieldName: string,
t: (key: string, options?: Record<string, unknown>) => string,
errors: Record<string, string>
) => {
try {
const parsed = JSON.parse(text);
if (fieldType === 'array' && !Array.isArray(parsed)) {
errors[fieldName] = t('plugin_management.expected_array');
return undefined;
}
if (fieldType === 'object' && !isRecord(parsed)) {
errors[fieldName] = t('plugin_management.expected_object');
return undefined;
}
return parsed;
} catch {
errors[fieldName] = t('plugin_management.invalid_json');
return undefined;
}
};
const buildConfigPayload = (
draft: PluginConfigDraft,
fields: PluginConfigField[],
currentConfig: PluginConfigObject,
t: (key: string, options?: Record<string, unknown>) => string
) => {
const errors: Record<string, string> = {};
const nextConfig: PluginConfigObject = { ...currentConfig };
const priorityText = draft.priority.trim();
nextConfig.enabled = draft.enabled;
if (!priorityText) {
nextConfig.priority = 0;
} else if (!/^-?\d+$/.test(priorityText)) {
errors.priority = t('plugin_management.invalid_priority');
} else {
nextConfig.priority = Number.parseInt(priorityText, 10);
}
fields.forEach((field) => {
const fieldType = normalizeFieldType(field);
const value = draft.values[field.name];
if (fieldType === 'boolean') {
nextConfig[field.name] = value === true;
return;
}
if (fieldType === 'array') {
const items = Array.isArray(value)
? value.map((item) => item.trim()).filter(Boolean)
: [];
if (items.length === 0) {
delete nextConfig[field.name];
} else {
nextConfig[field.name] = items;
}
return;
}
const text = typeof value === 'string' ? value.trim() : '';
if (!text) {
delete nextConfig[field.name];
return;
}
if (fieldType === 'enum') {
if (field.enumValues.length > 0 && !field.enumValues.includes(text)) {
errors[field.name] = t('plugin_management.invalid_enum');
return;
}
nextConfig[field.name] = text;
return;
}
if (fieldType === 'number') {
const parsed = Number(text);
if (!Number.isFinite(parsed)) {
errors[field.name] = t('plugin_management.invalid_number');
return;
}
nextConfig[field.name] = parsed;
return;
}
if (fieldType === 'integer') {
if (!/^-?\d+$/.test(text)) {
errors[field.name] = t('plugin_management.invalid_integer');
return;
}
nextConfig[field.name] = Number.parseInt(text, 10);
return;
}
if (fieldType === 'object') {
const parsed = parseJSONField(text, fieldType, field.name, t, errors);
if (errors[field.name]) return;
nextConfig[field.name] = parsed;
return;
}
nextConfig[field.name] = text;
});
return { nextConfig, errors };
};
export function PluginsPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const connectionStatus = useAuthStore((state) => state.connectionStatus);
const apiBase = useAuthStore((state) => state.apiBase);
const clearConfigCache = useConfigStore((state) => state.clearCache);
const showNotification = useNotificationStore((state) => state.showNotification);
const [data, setData] = useState<PluginListResponse | null>(null);
const [filter, setFilter] = useState('');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [editingPlugin, setEditingPlugin] = useState<PluginListEntry | null>(null);
const [editingConfig, setEditingConfig] = useState<PluginConfigObject>({});
const [draft, setDraft] = useState<PluginConfigDraft | null>(null);
const [mutatingID, setMutatingID] = useState('');
const [openingConfigID, setOpeningConfigID] = useState('');
const configRequestSeq = useRef(0);
const connected = connectionStatus === 'connected';
const loadPlugins = useCallback(async () => {
if (!connected) {
setLoading(false);
setError(t('notification.connection_required'));
return;
}
setLoading(true);
setError('');
try {
const plugins = await pluginsApi.list();
setData(plugins);
} catch (err: unknown) {
setError(
hasStatus(err, 404)
? t('plugin_management.unsupported_backend')
: getErrorMessage(err, t('plugin_management.load_failed'))
);
} finally {
setLoading(false);
}
}, [connected, t]);
const loadPluginsAfterMutation = useCallback(
async (waitForRegistration: boolean) => {
if (waitForRegistration) {
await wait(PLUGIN_ENABLE_REFRESH_DELAY_MS);
}
await loadPlugins();
},
[loadPlugins]
);
useHeaderRefresh(loadPlugins, connected);
useEffect(() => {
void loadPlugins();
}, [loadPlugins]);
const pluginStats = useMemo(() => {
const plugins = data?.plugins ?? [];
return {
discovered: plugins.length,
registered: plugins.filter((plugin) => plugin.registered).length,
configured: plugins.filter((plugin) => plugin.configured).length,
effective: plugins.filter((plugin) => plugin.effectiveEnabled).length,
};
}, [data?.plugins]);
const visiblePlugins = useMemo(() => {
const query = filter.trim().toLowerCase();
const plugins = data?.plugins ?? [];
if (!query) return plugins;
return plugins.filter((plugin) => {
const haystack = [
plugin.id,
plugin.path,
plugin.metadata?.name,
plugin.metadata?.author,
plugin.metadata?.version,
plugin.metadata?.githubRepository,
...plugin.menus.map((menu) => `${menu.menu} ${menu.path} ${menu.description}`),
]
.filter(Boolean)
.join(' ')
.toLowerCase();
return haystack.includes(query);
});
}, [data?.plugins, filter]);
const resolvePluginAsset = useCallback(
(value: string) => resolvePluginAssetURL(value, apiBase),
[apiBase]
);
const openConfigSheet = async (plugin: PluginListEntry) => {
if (openingConfigID || mutatingID) return;
const requestSeq = configRequestSeq.current + 1;
configRequestSeq.current = requestSeq;
setOpeningConfigID(plugin.id);
setEditingPlugin(plugin);
setEditingConfig({});
setDraft(null);
try {
const currentConfig = await pluginsApi.getConfig(plugin.id);
if (configRequestSeq.current !== requestSeq) return;
setEditingConfig(currentConfig);
setDraft(buildDraft(plugin, currentConfig));
} catch (err: unknown) {
if (configRequestSeq.current !== requestSeq) return;
setEditingPlugin(null);
setEditingConfig({});
setDraft(null);
showNotification(
hasStatus(err, 404)
? t('plugin_management.config_not_found')
: `${t('plugin_management.config_load_failed')}: ${getErrorMessage(
err,
t('plugin_management.config_load_failed')
)}`,
'error'
);
} finally {
if (configRequestSeq.current === requestSeq) {
setOpeningConfigID('');
}
}
};
const closeConfigSheet = () => {
if (mutatingID || openingConfigID) return;
setEditingPlugin(null);
setEditingConfig({});
setDraft(null);
};
const updateDraft = (updater: (current: PluginConfigDraft) => PluginConfigDraft) => {
setDraft((current) => (current ? updater(current) : current));
};
const handleTogglePlugin = async (plugin: PluginListEntry, enabled: boolean) => {
setMutatingID(plugin.id);
try {
await pluginsApi.updateEnabled(plugin.id, enabled);
clearConfigCache();
await loadPluginsAfterMutation(enabled);
notifyPluginResourcesChanged();
showNotification(t('plugin_management.toggle_success'), 'success');
} catch (err: unknown) {
showNotification(
`${t('plugin_management.toggle_failed')}: ${getErrorMessage(
err,
t('plugin_management.toggle_failed')
)}`,
'error'
);
} finally {
setMutatingID('');
}
};
const handleSaveConfig = async () => {
if (!editingPlugin || !draft || openingConfigID || mutatingID) return;
const { nextConfig, errors } = buildConfigPayload(
draft,
editingPlugin.configFields,
editingConfig,
t
);
if (Object.keys(errors).length > 0) {
setDraft({ ...draft, errors });
showNotification(t('plugin_management.validation_failed'), 'warning');
return;
}
setMutatingID(editingPlugin.id);
try {
await pluginsApi.putConfig(editingPlugin.id, nextConfig);
clearConfigCache();
await loadPluginsAfterMutation(
nextConfig.enabled === true && editingPlugin.enabled !== true
);
notifyPluginResourcesChanged();
setEditingPlugin(null);
setEditingConfig({});
setDraft(null);
showNotification(t('plugin_management.save_success'), 'success');
} catch (err: unknown) {
showNotification(
`${t('plugin_management.save_failed')}: ${getErrorMessage(
err,
t('plugin_management.save_failed')
)}`,
'error'
);
} finally {
setMutatingID('');
}
};
const handleFieldTextChange =
(fieldName: string) => (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const value = event.target.value;
updateDraft((current) => ({
...current,
values: { ...current.values, [fieldName]: value },
errors: { ...current.errors, [fieldName]: '' },
}));
};
const handleFieldBooleanChange = (fieldName: string, value: boolean) => {
updateDraft((current) => ({
...current,
values: { ...current.values, [fieldName]: value },
errors: { ...current.errors, [fieldName]: '' },
}));
};
const updateArrayField = (
fieldName: string,
updater: (items: string[]) => string[]
) => {
updateDraft((current) => {
const currentValue = current.values[fieldName];
const items = Array.isArray(currentValue) ? currentValue : [''];
return {
...current,
values: { ...current.values, [fieldName]: updater(items) },
errors: { ...current.errors, [fieldName]: '' },
};
});
};
const handlePriorityChange = (event: ChangeEvent<HTMLInputElement>) => {
const value = event.target.value;
updateDraft((current) => ({
...current,
priority: value,
errors: { ...current.errors, priority: '' },
}));
};
const renderFieldEditor = (field: PluginConfigField) => {
if (!draft) return null;
const fieldType = normalizeFieldType(field);
const value = draft.values[field.name];
const textValue = typeof value === 'string' ? value : '';
const errorText = draft.errors[field.name];
if (fieldType === 'boolean') {
return (
<div key={field.name} className={styles.fieldRow}>
<div className={styles.fieldText}>
<div className={styles.fieldLabel}>{field.name}</div>
{field.description ? (
<div className={styles.fieldDescription}>{field.description}</div>
) : null}
</div>
<ToggleSwitch
checked={value === true}
onChange={(nextValue) => handleFieldBooleanChange(field.name, nextValue)}
ariaLabel={field.name}
/>
</div>
);
}
if (fieldType === 'enum' && field.enumValues.length > 0) {
return (
<div key={field.name} className={styles.formField}>
<label htmlFor={`plugin-field-${field.name}`}>{field.name}</label>
<Select
id={`plugin-field-${field.name}`}
value={textValue}
options={field.enumValues.map((item) => ({ value: item, label: item }))}
onChange={(nextValue) =>
updateDraft((current) => ({
...current,
values: { ...current.values, [field.name]: nextValue },
errors: { ...current.errors, [field.name]: '' },
}))
}
placeholder={t('plugin_management.select_placeholder')}
/>
{field.description ? (
<div className={styles.fieldHint}>{field.description}</div>
) : null}
{errorText ? <div className={styles.fieldError}>{errorText}</div> : null}
</div>
);
}
if (fieldType === 'array') {
const items = Array.isArray(value) && value.length > 0 ? value : [''];
return (
<div key={field.name} className={styles.formField}>
<div className={styles.fieldLabel}>{field.name}</div>
<div className={styles.arrayEditor}>
{items.map((item, index) => (
<div key={`${field.name}-${index}`} className={styles.arrayItemRow}>
<input
className={styles.arrayInput}
aria-label={`${field.name} ${index + 1}`}
value={item}
onChange={(event) =>
updateArrayField(field.name, (currentItems) =>
currentItems.map((currentItem, currentIndex) =>
currentIndex === index ? event.target.value : currentItem
)
)
}
placeholder={t('plugin_management.array_item_placeholder')}
/>
<div className={styles.arrayActions}>
<Button
type="button"
variant="ghost"
size="sm"
className={styles.iconButton}
onClick={() =>
updateArrayField(field.name, (currentItems) => [
...currentItems.slice(0, index + 1),
'',
...currentItems.slice(index + 1),
])
}
title={t('plugin_management.add_array_item')}
aria-label={t('plugin_management.add_array_item')}
>
<IconPlus size={16} />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className={styles.iconButton}
onClick={() =>
updateArrayField(field.name, (currentItems) =>
currentItems.length <= 1
? ['']
: currentItems.filter((_, currentIndex) => currentIndex !== index)
)
}
title={t('plugin_management.remove_array_item')}
aria-label={t('plugin_management.remove_array_item')}
>
<IconTrash2 size={16} />
</Button>
</div>
</div>
))}
</div>
{field.description ? (
<div className={styles.fieldHint}>{field.description}</div>
) : null}
{errorText ? <div className={styles.fieldError}>{errorText}</div> : null}
</div>
);
}
if (fieldType === 'object') {
return (
<div key={field.name} className={styles.formField}>
<label htmlFor={`plugin-field-${field.name}`}>{field.name}</label>
<textarea
id={`plugin-field-${field.name}`}
className={styles.textarea}
value={textValue}
onChange={handleFieldTextChange(field.name)}
placeholder="{}"
spellCheck={false}
/>
{field.description ? (
<div className={styles.fieldHint}>{field.description}</div>
) : null}
{errorText ? <div className={styles.fieldError}>{errorText}</div> : null}
</div>
);
}
return (
<Input
key={field.name}
id={`plugin-field-${field.name}`}
label={field.name}
value={textValue}
onChange={handleFieldTextChange(field.name)}
inputMode={fieldType === 'integer' || fieldType === 'number' ? 'decimal' : undefined}
hint={field.description || undefined}
error={errorText || undefined}
/>
);
};
const savingConfig = Boolean(editingPlugin && mutatingID === editingPlugin.id);
return (
<div className={styles.page}>
{/* ── Page Header ── */}
<div className={styles.pageHeader}>
<h1 className={styles.title}>{t('plugin_management.title')}</h1>
<p className={styles.description}>{t('plugin_management.description')}</p>
</div>
{/* ── Alerts ── */}
{error ? <div className={styles.errorBox}>{error}</div> : null}
{data && !data.pluginsEnabled ? (
<div className={styles.warningBox}>{t('plugin_management.global_disabled_hint')}</div>
) : null}
{/* ── Status Bar ── */}
{data ? (
<div className={styles.statusBar}>
<div className={styles.statusPill}>
<span
className={`${styles.statusDot} ${
data.pluginsEnabled ? styles.statusDotOn : styles.statusDotOff
}`}
/>
<span className={styles.statusLabel}>{t('plugin_management.global_status')}</span>
<span className={styles.statusValue}>
{data.pluginsEnabled
? t('plugin_management.global_enabled')
: t('plugin_management.global_disabled')}
</span>
</div>
<span className={styles.statusDivider} />
<div className={styles.statusPill}>
<span className={styles.statusLabel}>{t('plugin_management.plugins_dir')}</span>
<span
className={`${styles.statusValue} ${styles.statusPathValue}`}
title={data.pluginsDir || 'plugins'}
>
{data.pluginsDir || 'plugins'}
</span>
</div>
<span className={styles.statusDivider} />
<div className={styles.statusPill}>
<span className={styles.statusLabel}>{t('plugin_management.discovered')}</span>
<span className={styles.statusValue}>{pluginStats.discovered}</span>
</div>
<span className={styles.statusDivider} />
<div className={styles.statusPill}>
<span className={styles.statusLabel}>{t('plugin_management.effective')}</span>
<span className={styles.statusValue}>
{pluginStats.effective}/{pluginStats.registered}
</span>
</div>
</div>
) : null}
{/* ── Toolbar ── */}
<div className={styles.toolbar}>
<Input
type="search"
value={filter}
onChange={(event) => setFilter(event.target.value)}
placeholder={t('plugin_management.search_placeholder')}
aria-label={t('plugin_management.search_label')}
rightElement={<IconSearch size={16} />}
/>
<Button
variant="secondary"
size="sm"
onClick={loadPlugins}
disabled={!connected || loading || Boolean(mutatingID)}
loading={loading}
>
<IconRefreshCw size={16} />
{t('plugin_management.refresh')}
</Button>
<Button variant="secondary" size="sm" onClick={() => navigate('/plugin-store')}>
<IconSidebarStore size={16} />
{t('plugin_store.title')}
</Button>
</div>
{/* ── Plugin List ── */}
{loading ? (
<div className={styles.pluginList}>
{Array.from({ length: 4 }, (_, index) => (
<div key={index} className={styles.skeletonRow}>
<div className={styles.skeletonAvatar} />
<div className={styles.skeletonText}>
<div className={styles.skeletonLine} />
<div className={styles.skeletonLine} />
</div>
</div>
))}
</div>
) : visiblePlugins.length === 0 ? (
<EmptyState
title={t('plugin_management.no_plugins')}
description={t('plugin_management.no_plugins_desc')}
action={
<Button variant="secondary" size="sm" onClick={loadPlugins} disabled={!connected}>
<IconRefreshCw size={16} />
{t('plugin_management.refresh')}
</Button>
}
/>
) : (
<div className={styles.pluginList}>
{visiblePlugins.map((plugin) => {
const logo = resolvePluginAsset(plugin.logo || plugin.metadata?.logo || '');
const github = plugin.metadata?.githubRepository.trim();
const openingConfig = openingConfigID === plugin.id;
const actionBusy = Boolean(mutatingID || openingConfigID);
const version = plugin.metadata?.version;
const author = plugin.metadata?.author;
return (
<article key={plugin.id} className={styles.pluginRow}>
{/* Logo */}
<div className={styles.logoBox} aria-hidden="true">
<PluginCardLogo src={logo} />
</div>
{/* Info */}
<div className={styles.pluginInfo}>
<div className={styles.pluginName}>
<h2>{getPluginTitle(plugin)}</h2>
<div className={styles.badgeRow}>
<span
className={
plugin.effectiveEnabled ? styles.badgeSuccess : styles.badgeMuted
}
>
{plugin.effectiveEnabled
? t('plugin_management.status_effective')
: t('plugin_management.status_inactive')}
</span>
<span className={plugin.registered ? styles.badge : styles.badgeWarning}>
{plugin.registered
? t('plugin_management.registered')
: t('plugin_management.not_registered')}
</span>
<span className={plugin.configured ? styles.badge : styles.badgeMuted}>
{plugin.configured
? t('plugin_management.configured')
: t('plugin_management.not_configured')}
</span>
{plugin.supportsOAuth ? (
<span className={styles.badge}>{t('plugin_management.oauth')}</span>
) : null}
</div>
</div>
<span className={styles.pluginId}>{plugin.id}</span>
{version || author || plugin.path ? (
<div className={styles.pluginMeta}>
{version ? (
<span className={styles.metaItem}>
<strong>{version}</strong>
</span>
) : null}
{version && author ? (
<span className={styles.metaDot} aria-hidden="true" />
) : null}
{author ? <span className={styles.metaItem}>{author}</span> : null}
{(version || author) && plugin.path ? (
<span className={styles.metaDot} aria-hidden="true" />
) : null}
{plugin.path ? (
<span
className={`${styles.metaItem} ${styles.metaPath}`}
title={plugin.path}
>
{plugin.path}
</span>
) : null}
</div>
) : null}
</div>
{/* Actions */}
<div className={styles.rowActions}>
<ToggleSwitch
checked={plugin.enabled}
onChange={(enabled) => handleTogglePlugin(plugin, enabled)}
disabled={!connected || actionBusy}
ariaLabel={t('plugin_management.enabled')}
/>
<Button
variant="secondary"
size="sm"
onClick={() => openConfigSheet(plugin)}
disabled={!connected || actionBusy}
loading={openingConfig}
>
<IconSettings size={14} />
{t('plugin_management.edit_config')}
</Button>
{github ? (
<a
className={styles.iconLink}
href={github}
target="_blank"
rel="noreferrer"
title={t('plugin_management.open_repository')}
aria-label={t('plugin_management.open_repository')}
>
<IconGithub size={14} />
</a>
) : null}
</div>
</article>
);
})}
</div>
)}
{/* ── Config Sheet ── */}
<Sheet
open={Boolean(editingPlugin && draft)}
onClose={closeConfigSheet}
size="lg"
title={
editingPlugin
? t('plugin_management.config_title', { name: getPluginTitle(editingPlugin) })
: t('plugin_management.edit_config')
}
description={editingPlugin?.id}
closeDisabled={savingConfig}
footer={
<div className={styles.sheetFooter}>
<Button variant="secondary" onClick={closeConfigSheet} disabled={savingConfig}>
{t('common.cancel')}
</Button>
<Button onClick={handleSaveConfig} loading={savingConfig}>
{t('common.save')}
</Button>
</div>
}
>
{draft && editingPlugin ? (
<div className={styles.configForm}>
<section className={styles.formSection}>
<h3>{t('plugin_management.base_settings')}</h3>
<div className={styles.fieldRow}>
<div className={styles.fieldText}>
<div className={styles.fieldLabel}>{t('plugin_management.enabled')}</div>
<div className={styles.fieldDescription}>
{t('plugin_management.enabled_hint')}
</div>
</div>
<ToggleSwitch
checked={draft.enabled}
onChange={(enabled) => updateDraft((current) => ({ ...current, enabled }))}
ariaLabel={t('plugin_management.enabled')}
/>
</div>
<Input
label={t('plugin_management.priority')}
value={draft.priority}
onChange={handlePriorityChange}
inputMode="numeric"
error={draft.errors.priority || undefined}
/>
</section>
<section className={styles.formSection}>
<h3>{t('plugin_management.config_fields')}</h3>
{editingPlugin.configFields.length > 0 ? (
editingPlugin.configFields.map((field) => renderFieldEditor(field))
) : (
<div className={styles.emptyConfig}>{t('plugin_management.no_config_fields')}</div>
)}
</section>
</div>
) : null}
</Sheet>
</div>
);
}