diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index 54d3441..1f7ac0c 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -18,6 +18,7 @@ import { IconSidebarDashboard, IconSidebarLogs, IconSidebarOauth, + IconSidebarPlugins, IconSidebarProviders, IconSidebarQuota, IconSidebarSystem, @@ -41,6 +42,7 @@ const sidebarIcons: Record = { authFiles: , oauth: , quota: , + plugins: , config: , logs: , system: , @@ -235,7 +237,7 @@ export function MainLayout() { const isLogsPage = location.pathname.startsWith('/logs'); const showSidebarLabels = !sidebarCollapsed || sidebarOpen; - // 将顶部悬浮控制区高度写入 CSS 变量,供移动端粘性元素和浮层避让。 + // Keep floating header height available to sticky mobile elements and overlays. useLayoutEffect(() => { const updateHeaderHeight = () => { const height = headerRef.current?.offsetHeight; @@ -264,7 +266,7 @@ export function MainLayout() { }; }, []); - // 将主内容区的中心点写入 CSS 变量,供底部浮层(配置面板操作栏、提供商导航)对齐到内容区 + // Keep the content center available to bottom overlays that align with the main area. useLayoutEffect(() => { const updateContentCenter = () => { const el = contentRef.current; @@ -379,7 +381,7 @@ export function MainLayout() { useEffect(() => { fetchConfig().catch(() => { - // ignore initial failure; login flow会提示 + // Ignore the initial failure; the login flow shows the user-facing prompt. }); }, [fetchConfig]); @@ -448,6 +450,12 @@ export function MainLayout() { metaKey: 'nav_meta.config_management', icon: sidebarIcons.config, }, + { + path: '/plugins', + labelKey: 'nav.plugins', + metaKey: 'nav_meta.plugins', + icon: sidebarIcons.plugins, + }, { path: '/system', labelKey: 'nav.system_info', diff --git a/src/components/ui/icons.tsx b/src/components/ui/icons.tsx index 6b3756e..f54d453 100644 --- a/src/components/ui/icons.tsx +++ b/src/components/ui/icons.tsx @@ -130,6 +130,17 @@ export function IconSettings({ size = 20, ...props }: IconProps) { ); } +export function IconPlug({ size = 20, ...props }: IconProps) { + return ( + + + + + + + ); +} + export function IconScrollText({ size = 20, ...props }: IconProps) { return ( @@ -463,6 +474,17 @@ export function IconSidebarConfig({ size = 20, ...props }: IconProps) { ); } +export function IconSidebarPlugins({ size = 20, ...props }: IconProps) { + return ( + + + + + + + ); +} + export function IconSidebarProviders({ size = 20, ...props }: IconProps) { return ( diff --git a/src/features/plugins/PluginsPage.module.scss b/src/features/plugins/PluginsPage.module.scss new file mode 100644 index 0000000..b3c63db --- /dev/null +++ b/src/features/plugins/PluginsPage.module.scss @@ -0,0 +1,530 @@ +@use '../../styles/variables' as *; +@use '../../styles/mixins' as *; + +.page { + display: flex; + flex-direction: column; + gap: $spacing-lg; + width: 100%; +} + +.header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: $spacing-md; + + :global(.btn > span) { + display: inline-flex; + align-items: center; + gap: 8px; + } + + @include mobile { + flex-direction: column; + align-items: stretch; + } +} + +.title { + margin: 0; + color: var(--text-primary); + font-size: 28px; + font-weight: 700; + line-height: 1.2; +} + +.description { + margin: $spacing-sm 0 0; + color: var(--text-secondary); + font-size: 14px; + line-height: 1.5; +} + +.errorBox, +.warningBox { + padding: $spacing-md; + border-radius: $radius-md; + font-size: 14px; + line-height: 1.5; +} + +.errorBox { + border: 1px solid var(--danger-color); + background: rgba($error-color, 0.1); + color: var(--danger-color); +} + +.warningBox { + border: 1px solid color-mix(in srgb, var(--warning-color, #c65746) 42%, var(--border-color)); + background: color-mix(in srgb, var(--warning-color, #c65746) 9%, var(--bg-secondary)); + color: var(--text-primary); +} + +.statsGrid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: $spacing-md; + + @media (max-width: 1100px) { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + @include mobile { + grid-template-columns: 1fr; + } +} + +.statTile { + display: flex; + min-width: 0; + min-height: 76px; + flex-direction: column; + justify-content: center; + gap: 6px; + padding: $spacing-md; + border: 1px solid var(--border-color); + border-radius: $radius-md; + background: color-mix(in srgb, var(--bg-secondary) 84%, transparent); + + span { + color: var(--text-secondary); + font-size: 12px; + font-weight: 600; + } + + strong { + min-width: 0; + color: var(--text-primary); + font-size: 20px; + line-height: 1.2; + overflow-wrap: anywhere; + } +} + +.toolbar { + display: flex; + max-width: 520px; + + :global(.form-group) { + width: 100%; + margin: 0; + } + + :global(.input) { + padding-right: 36px; + } + + @include mobile { + max-width: none; + } +} + +.pluginGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: $spacing-md; + + @include mobile { + grid-template-columns: 1fr; + } +} + +.pluginCard { + display: flex; + min-width: 0; + min-height: 260px; + flex-direction: column; + gap: $spacing-md; + padding: $spacing-lg; + border: 1px solid var(--border-color); + border-radius: $radius-md; + background: var(--bg-primary); + box-shadow: 0 12px 30px rgba(0, 0, 0, 0.04); +} + +.skeletonCard { + min-height: 260px; + border: 1px solid var(--border-color); + border-radius: $radius-md; + background: + linear-gradient( + 90deg, + color-mix(in srgb, var(--bg-secondary) 86%, transparent) 25%, + color-mix(in srgb, var(--bg-hover) 70%, transparent) 37%, + color-mix(in srgb, var(--bg-secondary) 86%, transparent) 63% + ); + background-size: 400% 100%; + animation: skeletonPulse 1.35s ease-in-out infinite; +} + +@keyframes skeletonPulse { + 0% { + background-position: 100% 0; + } + + 100% { + background-position: 0 0; + } +} + +.cardHeader { + display: grid; + grid-template-columns: 48px minmax(0, 1fr) auto; + align-items: center; + gap: $spacing-md; +} + +.logoBox { + display: inline-flex; + width: 48px; + height: 48px; + align-items: center; + justify-content: center; + overflow: hidden; + border: 1px solid var(--border-color); + border-radius: $radius-md; + background: color-mix(in srgb, var(--bg-secondary) 90%, transparent); + color: var(--text-secondary); + + img { + width: 100%; + height: 100%; + object-fit: cover; + } +} + +.pluginIdentity { + min-width: 0; + + h2 { + margin: 0; + color: var(--text-primary); + font-size: 17px; + font-weight: 700; + line-height: 1.25; + overflow-wrap: anywhere; + } + + span { + display: block; + margin-top: 4px; + color: var(--text-tertiary); + font-family: $font-mono; + font-size: 12px; + overflow-wrap: anywhere; + } +} + +.badgeRow { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.badge, +.badgeSuccess, +.badgeWarning, +.badgeMuted { + display: inline-flex; + min-height: 24px; + align-items: center; + border-radius: $radius-sm; + padding: 3px 8px; + font-size: 12px; + font-weight: 600; + line-height: 1.25; +} + +.badge { + background: color-mix(in srgb, var(--bg-secondary) 82%, transparent); + color: var(--text-secondary); +} + +.badgeSuccess { + background: rgba($success-color, 0.12); + color: var(--success-color); +} + +.badgeWarning { + background: rgba($warning-color, 0.12); + color: var(--warning-color); +} + +.badgeMuted { + background: color-mix(in srgb, var(--bg-secondary) 82%, transparent); + color: var(--text-tertiary); +} + +.metaList { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 6px $spacing-sm; + margin: 0; + color: var(--text-secondary); + font-size: 13px; + + dt { + color: var(--text-tertiary); + font-weight: 600; + } + + dd { + min-width: 0; + margin: 0; + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} + +.cardActions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: $spacing-sm; + margin-top: auto; + + :global(.btn > span) { + display: inline-flex; + align-items: center; + gap: 8px; + } +} + +.linkButton, +.iconLink { + display: inline-flex; + min-height: 34px; + align-items: center; + justify-content: center; + border: 1px solid var(--border-color); + border-radius: $radius-md; + background: var(--bg-primary); + color: var(--text-primary); + font-size: 13px; + font-weight: 600; + text-decoration: none; + transition: + border-color $transition-fast, + background-color $transition-fast, + color $transition-fast; + + &:hover { + border-color: var(--primary-color); + background: var(--bg-hover); + color: var(--primary-color); + } +} + +.linkButton { + gap: 8px; + max-width: 100%; + padding: 0 12px; + overflow-wrap: anywhere; +} + +.iconLink { + width: 34px; + flex: 0 0 auto; +} + +.sheetFooter { + display: flex; + justify-content: flex-end; + gap: $spacing-sm; +} + +.configForm { + display: flex; + flex-direction: column; + gap: $spacing-lg; +} + +.formSection { + display: flex; + flex-direction: column; + gap: $spacing-md; + + h3 { + margin: 0; + color: var(--text-primary); + font-size: 16px; + font-weight: 700; + } + + :global(.form-group) { + margin: 0; + } +} + +.fieldRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: $spacing-md; + min-height: 52px; + padding: $spacing-md; + border: 1px solid var(--border-color); + border-radius: $radius-md; + background: color-mix(in srgb, var(--bg-secondary) 72%, transparent); +} + +.fieldText { + min-width: 0; +} + +.fieldLabel { + color: var(--text-primary); + font-size: 14px; + font-weight: 650; + overflow-wrap: anywhere; +} + +.fieldDescription, +.fieldHint { + margin-top: 4px; + color: var(--text-tertiary); + font-size: 12px; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.formField { + display: flex; + flex-direction: column; + gap: 6px; + + label { + color: var(--text-primary); + font-size: 14px; + font-weight: 600; + overflow-wrap: anywhere; + } +} + +.textarea { + min-height: 148px; + resize: vertical; + border: 1px solid var(--border-color); + border-radius: $radius-md; + padding: 10px 12px; + background: var(--bg-primary); + color: var(--text-primary); + font-family: $font-mono; + font-size: 13px; + line-height: 1.5; + + &:focus { + border-color: var(--primary-color); + outline: none; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 18%, transparent); + } +} + +.arrayEditor { + display: flex; + flex-direction: column; + gap: $spacing-sm; +} + +.arrayItemRow { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: $spacing-sm; +} + +.arrayInput { + width: 100%; + min-width: 0; + height: 38px; + border: 1px solid var(--border-color); + border-radius: $radius-md; + padding: 0 12px; + background: var(--bg-primary); + color: var(--text-primary); + font-size: 14px; + line-height: 38px; + box-sizing: border-box; + + &:focus { + border-color: var(--primary-color); + outline: none; + box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 18%, transparent); + } +} + +.arrayActions { + display: inline-flex; + flex: 0 0 auto; + gap: 4px; +} + +.iconButton:global(.btn.btn-sm) { + width: 34px; + height: 34px; + min-width: 34px; + padding: 0; + + > span { + display: inline-flex; + align-items: center; + justify-content: center; + } +} + +.fieldError { + padding: 8px 10px; + border-radius: $radius-sm; + background: rgba($error-color, 0.1); + color: var(--danger-color); + font-size: 12px; + line-height: 1.4; +} + +.emptyConfig { + padding: $spacing-md; + border: 1px dashed var(--border-color); + border-radius: $radius-md; + background: color-mix(in srgb, var(--bg-secondary) 70%, transparent); + color: var(--text-secondary); + font-size: 14px; +} + +@include mobile { + .page { + gap: $spacing-md; + } + + .pluginCard { + padding: $spacing-md; + } + + .cardHeader { + grid-template-columns: 44px minmax(0, 1fr) auto; + gap: $spacing-sm; + } + + .logoBox { + width: 44px; + height: 44px; + } + + .metaList { + grid-template-columns: 1fr; + } + + .sheetFooter { + flex-direction: column-reverse; + + :global(.btn) { + width: 100%; + } + } + + .arrayItemRow { + grid-template-columns: minmax(0, 1fr) auto; + } +} diff --git a/src/features/plugins/PluginsPage.tsx b/src/features/plugins/PluginsPage.tsx new file mode 100644 index 0000000..f152b3b --- /dev/null +++ b/src/features/plugins/PluginsPage.tsx @@ -0,0 +1,841 @@ +import { useCallback, useEffect, useMemo, useState, type ChangeEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +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 { + IconExternalLink, + IconGithub, + IconPlug, + IconPlus, + IconRefreshCw, + IconSearch, + IconSettings, + IconTrash2, +} from '@/components/ui/icons'; +import { useHeaderRefresh } from '@/hooks/useHeaderRefresh'; +import { pluginsApi } from '@/services/api'; +import { useAuthStore, useConfigStore, useNotificationStore } from '@/stores'; +import type { PluginConfigField, PluginListEntry, PluginListResponse } from '@/types'; +import { normalizeApiBase } from '@/utils/connection'; +import styles from './PluginsPage.module.scss'; + +type PluginDraftValue = string | boolean | string[]; + +interface PluginConfigDraft { + enabled: boolean; + priority: string; + values: Record; + errors: Record; +} + +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); + +const cloneRecord = (value: unknown): Record => + isRecord(value) ? { ...value } : {}; + +const getErrorMessage = (error: unknown, fallback: string) => + error instanceof Error ? error.message : typeof error === 'string' ? error : fallback; + +const hasStatus = (error: unknown, status: number) => + isRecord(error) && error.status === status; + +const normalizeFieldType = (field: PluginConfigField) => field.type.trim().toLowerCase(); + +const getPluginsConfigMap = (rawConfig: Record): Record => { + const plugins = rawConfig.plugins; + if (!isRecord(plugins)) return {}; + const configs = plugins.configs; + return isRecord(configs) ? configs : {}; +}; + +const getPluginRawConfig = ( + rawConfig: Record, + pluginID: string +): Record => { + const configs = getPluginsConfigMap(rawConfig); + return cloneRecord(configs[pluginID]); +}; + +const getPluginTitle = (plugin: PluginListEntry) => + plugin.metadata?.name.trim() || plugin.id; + +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: Record +): 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, + errors: Record +) => { + 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: Record, + t: (key: string, options?: Record) => string +) => { + const errors: Record = {}; + const nextConfig: Record = { ...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 connectionStatus = useAuthStore((state) => state.connectionStatus); + const apiBase = useAuthStore((state) => state.apiBase); + const fetchConfig = useConfigStore((state) => state.fetchConfig); + const clearConfigCache = useConfigStore((state) => state.clearCache); + const showNotification = useNotificationStore((state) => state.showNotification); + + const [data, setData] = useState(null); + const [rawConfig, setRawConfig] = useState>({}); + const [filter, setFilter] = useState(''); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + const [editingPlugin, setEditingPlugin] = useState(null); + const [draft, setDraft] = useState(null); + const [mutatingID, setMutatingID] = useState(''); + + const connected = connectionStatus === 'connected'; + + const loadPlugins = useCallback(async () => { + if (!connected) { + setLoading(false); + setError(t('notification.connection_required')); + return; + } + + setLoading(true); + setError(''); + try { + const [plugins, config] = await Promise.all([ + pluginsApi.list(), + fetchConfig(undefined, true).catch(() => null), + ]); + setData(plugins); + setRawConfig(config?.raw ?? {}); + } catch (err: unknown) { + setError( + hasStatus(err, 404) + ? t('plugin_management.unsupported_backend') + : getErrorMessage(err, t('plugin_management.load_failed')) + ); + } finally { + setLoading(false); + } + }, [connected, fetchConfig, t]); + + 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 resolvePluginAssetURL = useCallback( + (value: string) => { + const trimmed = value.trim(); + if (!trimmed) return ''; + if (/^(https?:|data:|blob:)/i.test(trimmed)) return trimmed; + if (!trimmed.startsWith('/')) return trimmed; + const base = normalizeApiBase(apiBase); + return base ? `${base}${trimmed}` : trimmed; + }, + [apiBase] + ); + + const openConfigSheet = (plugin: PluginListEntry) => { + const currentConfig = getPluginRawConfig(rawConfig, plugin.id); + setEditingPlugin(plugin); + setDraft(buildDraft(plugin, currentConfig)); + }; + + const closeConfigSheet = () => { + if (mutatingID) return; + setEditingPlugin(null); + 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 loadPlugins(); + 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) return; + const currentConfig = getPluginRawConfig(rawConfig, editingPlugin.id); + const { nextConfig, errors } = buildConfigPayload( + draft, + editingPlugin.configFields, + currentConfig, + 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 loadPlugins(); + setEditingPlugin(null); + 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) => { + 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) => { + 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 ( +
+
+
{field.name}
+ {field.description ? ( +
{field.description}
+ ) : null} +
+ handleFieldBooleanChange(field.name, nextValue)} + ariaLabel={field.name} + /> +
+ ); + } + + if (fieldType === 'enum' && field.enumValues.length > 0) { + return ( +
+ + + updateArrayField(field.name, (currentItems) => + currentItems.map((currentItem, currentIndex) => + currentIndex === index ? event.target.value : currentItem + ) + ) + } + placeholder={t('plugin_management.array_item_placeholder')} + /> +
+ + +
+
+ ))} + + {field.description ? ( +
{field.description}
+ ) : null} + {errorText ?
{errorText}
: null} + + ); + } + + if (fieldType === 'object') { + return ( +
+ +