diff --git a/frontend/src/components/Field.jsx b/frontend/src/components/Field.jsx new file mode 100644 index 00000000..1293bf7b --- /dev/null +++ b/frontend/src/components/Field.jsx @@ -0,0 +1,47 @@ +import { NumberInput, Select, Switch, TextInput } from '@mantine/core'; +import React from 'react'; + +export const Field = ({ field, value, onChange }) => { + const common = { label: field.label, description: field.help_text }; + const effective = value ?? field.default; + switch (field.type) { + case 'boolean': + return ( + onChange(field.id, e.currentTarget.checked)} + label={field.label} + description={field.help_text} + /> + ); + case 'number': + return ( + onChange(field.id, v)} + {...common} + /> + ); + case 'select': + return ( + ({ - value: o.value + '', - label: o.label, - }))} - onChange={(v) => onChange(field.id, v)} - {...common} - /> - ); - case 'string': - default: - return ( - onChange(field.id, e.currentTarget.value)} - {...common} - /> - ); - } -}; +const PluginsList = ({ onRequestDelete, onRequireTrust, onRequestConfirm }) => { + const plugins = usePluginStore((state) => state.plugins); + const loading = usePluginStore((state) => state.loading); + const hasFetchedRef = useRef(false); -const PluginCard = ({ - plugin, - onSaveSettings, - onRunAction, - onToggleEnabled, - onRequireTrust, - onRequestDelete, -}) => { - const [settings, setSettings] = useState(plugin.settings || {}); - const [saving, setSaving] = useState(false); - const [running, setRunning] = useState(false); - const [enabled, setEnabled] = useState(!!plugin.enabled); - const [lastResult, setLastResult] = useState(null); - const [confirmOpen, setConfirmOpen] = useState(false); - const [confirmConfig, setConfirmConfig] = useState({ - title: '', - message: '', - onConfirm: null, - }); + useEffect(() => { + if (!hasFetchedRef.current) { + hasFetchedRef.current = true; + usePluginStore.getState().fetchPlugins(); + } + }, []); - // Keep local enabled state in sync with props (e.g., after import + enable) - React.useEffect(() => { - setEnabled(!!plugin.enabled); - }, [plugin.enabled]); - // Sync settings if plugin changes identity - React.useEffect(() => { - setSettings(plugin.settings || {}); - }, [plugin.key]); + const handleTogglePluginEnabled = async (key, next) => { + const resp = await setPluginEnabled(key, next); - const updateField = (id, val) => { - setSettings((prev) => ({ ...prev, [id]: val })); - }; - - const save = async () => { - setSaving(true); - try { - await onSaveSettings(plugin.key, settings); - notifications.show({ - title: 'Saved', - message: `${plugin.name} settings updated`, - color: 'green', + if (resp?.success) { + usePluginStore.getState().updatePlugin(key, { + enabled: next, + ever_enabled: resp?.ever_enabled, }); - } finally { - setSaving(false); } }; - const missing = plugin.missing; + if (loading && plugins.length === 0) { + return ; + } + return ( - - -
- {plugin.name} - - {plugin.description} + <> + {plugins.length > 0 && + + + }> + {plugins.map((p) => ( + + ))} + + + + } + + {plugins.length === 0 && ( + + + No plugins found. Drop a plugin into /data/plugins{' '} + and reload. -
- - onRequestDelete && onRequestDelete(plugin)} - > - - - - v{plugin.version || '1.0.0'} - - { - const next = e.currentTarget.checked; - if (next && !plugin.ever_enabled && onRequireTrust) { - const ok = await onRequireTrust(plugin); - if (!ok) { - // Revert - setEnabled(false); - return; - } - } - setEnabled(next); - const resp = await onToggleEnabled(plugin.key, next); - if (next && resp?.ever_enabled) { - plugin.ever_enabled = true; - } - }} - size="xs" - onLabel="On" - offLabel="Off" - disabled={missing} - /> - -
- - {missing && ( - - Missing plugin files. Re-import or delete this entry. - + )} - - {!missing && plugin.fields && plugin.fields.length > 0 && ( - - {plugin.fields.map((f) => ( - - ))} - - - - - )} - - {!missing && plugin.actions && plugin.actions.length > 0 && ( - <> - - - {plugin.actions.map((a) => ( - -
- {a.label} - {a.description && ( - - {a.description} - - )} -
- -
- ))} - {running && ( - - Running action… please wait - - )} - {!running && lastResult?.file && ( - - Output: {lastResult.file} - - )} - {!running && lastResult?.error && ( - - Error: {String(lastResult.error)} - - )} -
- - )} - { - setConfirmOpen(false); - setConfirmConfig({ title: '', message: '', onConfirm: null }); - }} - title={confirmConfig.title} - centered - > - - {confirmConfig.message} - - - - - - -
+ ); }; export default function PluginsPage() { - const [loading, setLoading] = useState(true); - const [plugins, setPlugins] = useState([]); const [importOpen, setImportOpen] = useState(false); const [importFile, setImportFile] = useState(null); const [importing, setImporting] = useState(false); @@ -358,118 +113,172 @@ export default function PluginsPage() { const [deleteOpen, setDeleteOpen] = useState(false); const [deleteTarget, setDeleteTarget] = useState(null); const [deleting, setDeleting] = useState(false); - const [uploadNoticeId, setUploadNoticeId] = useState(null); + const [confirmOpen, setConfirmOpen] = useState(false); + const [confirmConfig, setConfirmConfig] = useState({ + title: '', + message: '', + resolve: null, + }); - const load = async () => { - setLoading(true); - try { - const list = await API.getPlugins(); - setPlugins(list); - } finally { - setLoading(false); - } + const handleReload = () => { + usePluginStore.getState().invalidatePlugins(); }; - useEffect(() => { - load(); + const handleRequestDelete = useCallback((pl) => { + setDeleteTarget(pl); + setDeleteOpen(true); }, []); - const requireTrust = (plugin) => { + const requireTrust = useCallback((plugin) => { return new Promise((resolve) => { setTrustResolve(() => resolve); setTrustOpen(true); }); + }, []); + + const showImportForm = useCallback(() => { + setImportOpen(true); + setImported(null); + setImportFile(null); + setEnableAfterImport(false); + }, []); + + const requestConfirm = useCallback((title, message) => { + return new Promise((resolve) => { + setConfirmConfig({ title, message, resolve }); + setConfirmOpen(true); + }); + }, []); + + const handleImportPlugin = () => { + return async () => { + setImporting(true); + const id = showNotification({ + title: 'Uploading plugin', + message: 'Backend may restart; please wait…', + loading: true, + autoClose: false, + withCloseButton: false, + }); + try { + const resp = await importPlugin(importFile); + if (resp?.success && resp.plugin) { + setImported(resp.plugin); + usePluginStore.getState().invalidatePlugins(); + + updateNotification({ + id, + loading: false, + color: 'green', + title: 'Imported', + message: + 'Plugin imported. If the app briefly disconnected, it should be back now.', + autoClose: 3000, + }); + } else { + updateNotification({ + id, + loading: false, + color: 'red', + title: 'Import failed', + message: resp?.error || 'Unknown error', + autoClose: 5000, + }); + } + } catch (e) { + // API.importPlugin already showed a concise error; just update the loading notice + updateNotification({ + id, + loading: false, + color: 'red', + title: 'Import failed', + message: + (e?.body && (e.body.error || e.body.detail)) || + e?.message || + 'Failed', + autoClose: 5000, + }); + } finally { + setImporting(false); + } + }; }; + const handleEnablePlugin = () => { + return async () => { + if (!imported) return; + + const proceed = imported.ever_enabled || (await requireTrust(imported)); + if (proceed) { + const resp = await setPluginEnabled(imported.key, true); + if (resp?.success) { + usePluginStore.getState().updatePlugin(imported.key, { enabled: true, ever_enabled: true }); + + showNotification({ + title: imported.name, + message: 'Plugin enabled', + color: 'green', + }); + } + setImportOpen(false); + setImported(null); + setEnableAfterImport(false); + } + }; + }; + + const handleDeletePlugin = () => { + return async () => { + if (!deleteTarget) return; + setDeleting(true); + try { + const resp = await deletePluginByKey(deleteTarget.key); + if (resp?.success) { + usePluginStore.getState().removePlugin(deleteTarget.key); + + showNotification({ + title: deleteTarget.name, + message: 'Plugin deleted', + color: 'green', + }); + } + setDeleteOpen(false); + setDeleteTarget(null); + } finally { + setDeleting(false); + } + }; + }; + + const handleConfirm = useCallback((confirmed) => { + const resolver = confirmConfig.resolve; + setConfirmOpen(false); + setConfirmConfig({ title: '', message: '', resolve: null }); + if (resolver) resolver(confirmed); + }, [confirmConfig.resolve]); + return ( - + Plugins - - { - await API.reloadPlugins(); - await load(); - }} - title="Reload" - > + - {loading ? ( - - ) : ( - <> - - {plugins.map((p) => ( - { - const resp = await API.setPluginEnabled(key, next); - if (resp?.ever_enabled !== undefined) { - setPlugins((prev) => - prev.map((pl) => - pl.key === key - ? { - ...pl, - ever_enabled: resp.ever_enabled, - enabled: resp.enabled, - } - : pl - ) - ); - } else { - setPlugins((prev) => - prev.map((pl) => - pl.key === key ? { ...pl, enabled: next } : pl - ) - ); - } - return resp; - }} - onRequireTrust={requireTrust} - onRequestDelete={(plugin) => { - setDeleteTarget(plugin); - setDeleteOpen(true); - }} - /> - ))} - - {plugins.length === 0 && ( - - - No plugins found. Drop a plugin into /data/plugins{' '} - and reload. - - - )} - - )} + + {/* Import Plugin Modal */} { - setImporting(true); - const id = notifications.show({ - title: 'Uploading plugin', - message: 'Backend may restart; please wait…', - loading: true, - autoClose: false, - withCloseButton: false, - }); - setUploadNoticeId(id); - try { - const resp = await API.importPlugin(importFile); - if (resp?.success && resp.plugin) { - setImported(resp.plugin); - setPlugins((prev) => [ - resp.plugin, - ...prev.filter((p) => p.key !== resp.plugin.key), - ]); - notifications.update({ - id, - loading: false, - color: 'green', - title: 'Imported', - message: - 'Plugin imported. If the app briefly disconnected, it should be back now.', - autoClose: 3000, - }); - } else { - notifications.update({ - id, - loading: false, - color: 'red', - title: 'Import failed', - message: resp?.error || 'Unknown error', - autoClose: 5000, - }); - } - } catch (e) { - // API.importPlugin already showed a concise error; just update the loading notice - notifications.update({ - id, - loading: false, - color: 'red', - title: 'Import failed', - message: - (e?.body && (e.body.error || e.body.detail)) || - e?.message || - 'Failed', - autoClose: 5000, - }); - } finally { - setImporting(false); - setUploadNoticeId(null); - } - }} + onClick={handleImportPlugin()} > Upload @@ -612,36 +367,7 @@ export default function PluginsPage() { @@ -727,33 +453,37 @@ export default function PluginsPage() { size="xs" color="red" loading={deleting} - onClick={async () => { - if (!deleteTarget) return; - setDeleting(true); - try { - const resp = await API.deletePlugin(deleteTarget.key); - if (resp?.success) { - setPlugins((prev) => - prev.filter((p) => p.key !== deleteTarget.key) - ); - notifications.show({ - title: deleteTarget.name, - message: 'Plugin deleted', - color: 'green', - }); - } - setDeleteOpen(false); - setDeleteTarget(null); - } finally { - setDeleting(false); - } - }} + onClick={handleDeletePlugin()} > Delete - + + {/* Confirmation modal */} + handleConfirm(false)} + title={confirmConfig.title} + centered + > + + {confirmConfig.message} + + + + + + + ); } diff --git a/frontend/src/utils/cards/PluginCardUtils.js b/frontend/src/utils/cards/PluginCardUtils.js new file mode 100644 index 00000000..8752e019 --- /dev/null +++ b/frontend/src/utils/cards/PluginCardUtils.js @@ -0,0 +1,24 @@ +export const getConfirmationDetails = (action, plugin, settings) => { + const actionConfirm = action.confirm; + const confirmField = (plugin.fields || []).find((f) => f.id === 'confirm'); + let requireConfirm = false; + let confirmTitle = `Run ${action.label}?`; + let confirmMessage = `You're about to run "${action.label}" from "${plugin.name}".`; + + if (actionConfirm) { + if (typeof actionConfirm === 'boolean') { + requireConfirm = actionConfirm; + } else if (typeof actionConfirm === 'object') { + requireConfirm = actionConfirm.required !== false; + if (actionConfirm.title) confirmTitle = actionConfirm.title; + if (actionConfirm.message) confirmMessage = actionConfirm.message; + } + } else if (confirmField) { + const settingVal = settings?.confirm; + const effectiveConfirm = + (settingVal !== undefined ? settingVal : confirmField.default) ?? false; + requireConfirm = !!effectiveConfirm; + } + + return { requireConfirm, confirmTitle, confirmMessage }; +}; diff --git a/frontend/src/utils/notificationUtils.js b/frontend/src/utils/notificationUtils.js index baf91b54..ba965343 100644 --- a/frontend/src/utils/notificationUtils.js +++ b/frontend/src/utils/notificationUtils.js @@ -1,5 +1,9 @@ import { notifications } from '@mantine/notifications'; export function showNotification(notificationObject) { - notifications.show(notificationObject); + return notifications.show(notificationObject); +} + +export function updateNotification(notificationId, notificationObject) { + return notifications.update(notificationId, notificationObject); } \ No newline at end of file diff --git a/frontend/src/utils/pages/PluginsUtils.js b/frontend/src/utils/pages/PluginsUtils.js new file mode 100644 index 00000000..bae98e93 --- /dev/null +++ b/frontend/src/utils/pages/PluginsUtils.js @@ -0,0 +1,17 @@ +import API from '../../api.js'; + +export const updatePluginSettings = async (key, settings) => { + return await API.updatePluginSettings(key, settings); +}; +export const runPluginAction = async (key, actionId) => { + return await API.runPluginAction(key, actionId); +}; +export const setPluginEnabled = async (key, next) => { + return await API.setPluginEnabled(key, next); +}; +export const importPlugin = async (importFile) => { + return await API.importPlugin(importFile); +}; +export const deletePluginByKey = (key) => { + return API.deletePlugin(key); +}; \ No newline at end of file