feat(plugins): implement plugin loading and management system
- Added PluginLoader component to dynamically load and initialize plugins. - Created Plugins component for managing installed and marketplace plugins. - Introduced PluginAPI for plugin interaction and settings management. - Defined types for plugin metadata, installed plugins, and plugin index. - Implemented settings rendering for plugins based on their schema. - Integrated marketplace plugin fetching and installation logic. - Added support for enabling/disabling and uninstalling plugins.
This commit is contained in:
795
src/app/features/settings/plugins/Plugins.tsx
Normal file
795
src/app/features/settings/plugins/Plugins.tsx
Normal file
@@ -0,0 +1,795 @@
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
color,
|
||||
config,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Input,
|
||||
Menu,
|
||||
MenuItem,
|
||||
PopOut,
|
||||
RectCords,
|
||||
Scroll,
|
||||
Spinner,
|
||||
Switch,
|
||||
Text,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { HexColorPicker } from 'react-colorful';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { PluginTab, PluginMetadata, PluginIndex, InstalledPlugin } from './types';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { pluginRegistry, SettingsSchema, SettingDefinition } from './PluginAPI';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
|
||||
const PLUGIN_INDEX_URL =
|
||||
'https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/index.json';
|
||||
const PLUGIN_BASE_URL =
|
||||
'https://raw.githubusercontent.com/Paarrot/Plugin-Directory/refs/heads/main/plugins/';
|
||||
|
||||
// Check if running in Electron
|
||||
const isElectron = typeof window !== 'undefined' && (window as any).electron?.plugins;
|
||||
|
||||
/**
|
||||
* Renders settings UI for a plugin based on its settings schema
|
||||
*/
|
||||
function PluginSettingsRenderer({ pluginId }: { pluginId: string }) {
|
||||
const schema = pluginRegistry.getPluginSettingsSchema(pluginId);
|
||||
const [, forceUpdate] = useState({});
|
||||
|
||||
if (!schema || Object.keys(schema).length === 0) {
|
||||
return (
|
||||
<Box padding="300">
|
||||
<Text size="T300" priority="300">No settings available for this plugin.</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const handleChange = (key: string, value: any) => {
|
||||
pluginRegistry.setPluginSetting(pluginId, key, value);
|
||||
forceUpdate({});
|
||||
};
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="300" style={{ padding: config.space.S400 }}>
|
||||
{Object.entries(schema).map(([key, def]) => (
|
||||
<SettingRenderer
|
||||
key={key}
|
||||
settingKey={key}
|
||||
definition={def}
|
||||
value={pluginRegistry.getPluginSetting(pluginId, key)}
|
||||
onChange={(value) => handleChange(key, value)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a single setting based on its type
|
||||
*/
|
||||
function SettingRenderer({
|
||||
settingKey,
|
||||
definition,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
settingKey: string;
|
||||
definition: SettingDefinition;
|
||||
value: any;
|
||||
onChange: (value: any) => void;
|
||||
}) {
|
||||
const [colorMenuCords, setColorMenuCords] = useState<RectCords>();
|
||||
const [selectMenuCords, setSelectMenuCords] = useState<RectCords>();
|
||||
|
||||
const label = definition.label || settingKey;
|
||||
const description = definition.description;
|
||||
|
||||
// Boolean - Switch
|
||||
if (definition.type === 'boolean') {
|
||||
return (
|
||||
<SettingTile
|
||||
title={label}
|
||||
description={description}
|
||||
after={<Switch value={value ?? definition.default ?? false} onChange={onChange} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// String - Input
|
||||
if (definition.type === 'string') {
|
||||
return (
|
||||
<SettingTile title={label} description={description}>
|
||||
<Input
|
||||
size="400"
|
||||
variant="Background"
|
||||
value={value ?? definition.default ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={definition.default}
|
||||
/>
|
||||
</SettingTile>
|
||||
);
|
||||
}
|
||||
|
||||
// Number - Input
|
||||
if (definition.type === 'number') {
|
||||
return (
|
||||
<SettingTile title={label} description={description}>
|
||||
<Input
|
||||
size="400"
|
||||
variant="Background"
|
||||
type="number"
|
||||
value={value ?? definition.default ?? 0}
|
||||
onChange={(e) => onChange(parseFloat(e.target.value) || 0)}
|
||||
/>
|
||||
</SettingTile>
|
||||
);
|
||||
}
|
||||
|
||||
// Select - Dropdown
|
||||
if (definition.type === 'select' && definition.options) {
|
||||
const currentValue = value ?? definition.default ?? definition.options[0]?.value;
|
||||
const selectedOption = definition.options.find(opt => opt.value === currentValue);
|
||||
|
||||
return (
|
||||
<SettingTile title={label} description={description}>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Primary"
|
||||
outlined
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
after={<Icon size="300" src={Icons.ChevronBottom} />}
|
||||
onClick={(evt) => setSelectMenuCords(evt.currentTarget.getBoundingClientRect())}
|
||||
>
|
||||
<Text size="T300">{selectedOption?.label || currentValue}</Text>
|
||||
</Button>
|
||||
<PopOut
|
||||
anchor={selectMenuCords}
|
||||
offset={5}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setSelectMenuCords(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu>
|
||||
{definition.options.map((option) => (
|
||||
<MenuItem
|
||||
key={option.value}
|
||||
size="300"
|
||||
after={currentValue === option.value ? <Icon size="100" src={Icons.Check} /> : undefined}
|
||||
radii="300"
|
||||
onClick={() => {
|
||||
onChange(option.value);
|
||||
setSelectMenuCords(undefined);
|
||||
}}
|
||||
>
|
||||
<Text size="T300">{option.label}</Text>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
</SettingTile>
|
||||
);
|
||||
}
|
||||
|
||||
// Color - Color Picker
|
||||
if (definition.type === 'color') {
|
||||
const currentColor = value ?? definition.default ?? '#000000';
|
||||
|
||||
return (
|
||||
<SettingTile title={label} description={description}>
|
||||
<Button
|
||||
size="400"
|
||||
variant="Primary"
|
||||
outlined
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
before={
|
||||
<Box
|
||||
style={{
|
||||
width: toRem(20),
|
||||
height: toRem(20),
|
||||
borderRadius: config.radii.R300,
|
||||
backgroundColor: currentColor,
|
||||
border: `1px solid ${color.Surface.ContainerLine}`,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
onClick={(evt) => setColorMenuCords(evt.currentTarget.getBoundingClientRect())}
|
||||
>
|
||||
<Text size="T300">{currentColor}</Text>
|
||||
</Button>
|
||||
<PopOut
|
||||
anchor={colorMenuCords}
|
||||
offset={5}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setColorMenuCords(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Box style={{ padding: config.space.S300 }}>
|
||||
<HexColorPicker color={currentColor} onChange={onChange} />
|
||||
</Box>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
</SettingTile>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
type PluginsProps = {
|
||||
requestClose: () => void;
|
||||
};
|
||||
|
||||
export function Plugins({ requestClose }: PluginsProps) {
|
||||
const [activeTab, setActiveTab] = useState<PluginTab>(PluginTab.Marketplace);
|
||||
const [marketplacePlugins, setMarketplacePlugins] = useState<PluginMetadata[]>([]);
|
||||
const [installedPlugins, setInstalledPlugins] = useState<InstalledPlugin[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [expandedSettings, setExpandedSettings] = useState<string | null>(null);
|
||||
|
||||
const fetchMarketplacePlugins = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const indexResponse = await fetch(PLUGIN_INDEX_URL);
|
||||
if (!indexResponse.ok) {
|
||||
throw new Error('Failed to fetch plugin index');
|
||||
}
|
||||
const index: PluginIndex = await indexResponse.json();
|
||||
|
||||
const pluginPromises = index.plugins.map(async (pluginId) => {
|
||||
const pluginResponse = await fetch(`${PLUGIN_BASE_URL}${pluginId}.json`);
|
||||
if (!pluginResponse.ok) {
|
||||
console.error(`Failed to fetch plugin ${pluginId}`);
|
||||
return null;
|
||||
}
|
||||
return pluginResponse.json();
|
||||
});
|
||||
|
||||
const plugins = await Promise.all(pluginPromises);
|
||||
setMarketplacePlugins(plugins.filter((p): p is PluginMetadata => p !== null));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch plugins');
|
||||
console.error('Error fetching marketplace plugins:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === PluginTab.Marketplace) {
|
||||
fetchMarketplacePlugins();
|
||||
}
|
||||
}, [activeTab, fetchMarketplacePlugins]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadInstalledPlugins = async () => {
|
||||
if (isElectron) {
|
||||
// Load from filesystem via Electron
|
||||
try {
|
||||
const result = await (window as any).electron.plugins.list();
|
||||
if (result.success && result.data) {
|
||||
// Merge filesystem data with metadata from marketplace
|
||||
const fsPlugins = result.data.map((fsPlugin: any) => ({
|
||||
...fsPlugin,
|
||||
enabled: true, // Default to enabled
|
||||
}));
|
||||
setInstalledPlugins(fsPlugins);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to list plugins from filesystem:', err);
|
||||
}
|
||||
} else {
|
||||
// Fallback to localStorage for web
|
||||
const stored = localStorage.getItem('installedPlugins');
|
||||
if (stored) {
|
||||
try {
|
||||
setInstalledPlugins(JSON.parse(stored));
|
||||
} catch (err) {
|
||||
console.error('Failed to parse installed plugins:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadInstalledPlugins();
|
||||
}, []);
|
||||
|
||||
const handleInstall = useCallback(
|
||||
async (plugin: PluginMetadata) => {
|
||||
if (isElectron) {
|
||||
// Download and install to filesystem via Electron
|
||||
try {
|
||||
setLoading(true);
|
||||
const result = await (window as any).electron.plugins.download(
|
||||
plugin.id,
|
||||
plugin.downloadUrl,
|
||||
plugin.name
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
const newPlugin: InstalledPlugin = {
|
||||
...plugin,
|
||||
installedDate: new Date().toISOString(),
|
||||
enabled: true,
|
||||
};
|
||||
setInstalledPlugins([...installedPlugins, newPlugin]);
|
||||
} else {
|
||||
setError(result.error || 'Failed to install plugin');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to install plugin:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to install plugin');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} else {
|
||||
// Fallback to localStorage for web
|
||||
const newPlugin: InstalledPlugin = {
|
||||
...plugin,
|
||||
installedDate: new Date().toISOString(),
|
||||
enabled: true,
|
||||
};
|
||||
const updated = [...installedPlugins, newPlugin];
|
||||
setInstalledPlugins(updated);
|
||||
localStorage.setItem('installedPlugins', JSON.stringify(updated));
|
||||
}
|
||||
},
|
||||
[installedPlugins]
|
||||
);
|
||||
|
||||
const handleUninstall = useCallback(
|
||||
async (pluginId: string) => {
|
||||
// First, unregister the plugin to trigger onUnload
|
||||
try {
|
||||
await pluginRegistry.unregisterPlugin(pluginId);
|
||||
} catch (err) {
|
||||
console.error(`Failed to unregister plugin ${pluginId}:`, err);
|
||||
}
|
||||
|
||||
if (isElectron) {
|
||||
// Uninstall from filesystem via Electron
|
||||
try {
|
||||
const result = await (window as any).electron.plugins.uninstall(pluginId);
|
||||
if (result.success) {
|
||||
const updated = installedPlugins.filter((p) => p.id !== pluginId);
|
||||
setInstalledPlugins(updated);
|
||||
} else {
|
||||
setError(result.error || 'Failed to uninstall plugin');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to uninstall plugin:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to uninstall plugin');
|
||||
}
|
||||
} else {
|
||||
// Fallback to localStorage for web
|
||||
const updated = installedPlugins.filter((p) => p.id !== pluginId);
|
||||
setInstalledPlugins(updated);
|
||||
localStorage.setItem('installedPlugins', JSON.stringify(updated));
|
||||
}
|
||||
},
|
||||
[installedPlugins]
|
||||
);
|
||||
|
||||
const handleToggleEnabled = useCallback(
|
||||
async (pluginId: string) => {
|
||||
const plugin = installedPlugins.find((p) => p.id === pluginId);
|
||||
if (!plugin) return;
|
||||
|
||||
const newEnabledState = !plugin.enabled;
|
||||
|
||||
if (plugin.enabled) {
|
||||
// Disabling: unregister the plugin to trigger onUnload
|
||||
try {
|
||||
await pluginRegistry.unregisterPlugin(pluginId);
|
||||
console.log(`[Plugins] Plugin ${pluginId} disabled and unloaded`);
|
||||
} catch (err) {
|
||||
console.error(`Failed to unregister plugin ${pluginId}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Update installedPlugins list
|
||||
const updated = installedPlugins.map((p) =>
|
||||
p.id === pluginId ? { ...p, enabled: newEnabledState } : p
|
||||
);
|
||||
setInstalledPlugins(updated);
|
||||
localStorage.setItem('installedPlugins', JSON.stringify(updated));
|
||||
|
||||
// Also update enabledPlugins for PluginLoader
|
||||
const enabledPluginsStr = localStorage.getItem('enabledPlugins');
|
||||
const enabledPlugins = enabledPluginsStr ? JSON.parse(enabledPluginsStr) : {};
|
||||
enabledPlugins[pluginId] = newEnabledState;
|
||||
localStorage.setItem('enabledPlugins', JSON.stringify(enabledPlugins));
|
||||
|
||||
// Note: Enabling a plugin requires app restart to load it
|
||||
// PluginLoader only loads plugins on mount
|
||||
if (newEnabledState) {
|
||||
console.log(`[Plugins] Plugin ${pluginId} enabled. Restart app to load it.`);
|
||||
}
|
||||
},
|
||||
[installedPlugins]
|
||||
);
|
||||
|
||||
const isPluginInstalled = useCallback(
|
||||
(pluginId: string) => installedPlugins.some((p) => p.id === pluginId),
|
||||
[installedPlugins]
|
||||
);
|
||||
|
||||
const fuzzySearch = (query: string, text: string): boolean => {
|
||||
if (!query) return true;
|
||||
const searchLower = query.toLowerCase();
|
||||
const textLower = text.toLowerCase();
|
||||
return textLower.includes(searchLower);
|
||||
};
|
||||
|
||||
const PluginAvatar = ({ thumbnail, name }: { thumbnail: string; name: string }) => {
|
||||
const [imageError, setImageError] = useState(false);
|
||||
|
||||
return (
|
||||
<Avatar size="500" radii="400">
|
||||
{!imageError ? (
|
||||
<img
|
||||
src={thumbnail}
|
||||
alt={name}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
borderRadius: 'inherit',
|
||||
}}
|
||||
onError={() => setImageError(true)}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
justifyContent="Center"
|
||||
alignItems="Center"
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: `linear-gradient(135deg, ${color.Secondary.Container} 0%, ${color.Primary.Container} 100%)`,
|
||||
borderRadius: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Text size="H3" priority="300" style={{ fontWeight: 600 }}>
|
||||
{name.charAt(0).toUpperCase()}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Avatar>
|
||||
);
|
||||
};
|
||||
|
||||
const filteredMarketplacePlugins = useMemo(() => {
|
||||
if (!searchQuery) return marketplacePlugins;
|
||||
return marketplacePlugins.filter((plugin) =>
|
||||
fuzzySearch(searchQuery, plugin.name) ||
|
||||
fuzzySearch(searchQuery, plugin.description) ||
|
||||
fuzzySearch(searchQuery, plugin.author) ||
|
||||
plugin.tags.some((tag) => fuzzySearch(searchQuery, tag))
|
||||
);
|
||||
}, [marketplacePlugins, searchQuery]);
|
||||
|
||||
const filteredInstalledPlugins = useMemo(() => {
|
||||
if (!searchQuery) return installedPlugins;
|
||||
return installedPlugins.filter((plugin) =>
|
||||
fuzzySearch(searchQuery, plugin.name) ||
|
||||
fuzzySearch(searchQuery, plugin.description) ||
|
||||
fuzzySearch(searchQuery, plugin.author)
|
||||
);
|
||||
}, [installedPlugins, searchQuery]);
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader>
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Header size="H3" className="truncate">
|
||||
Plugins
|
||||
</Header>
|
||||
</Box>
|
||||
<Box shrink="No" gap="200">
|
||||
<Button variant="Background" size="300" onClick={requestClose}>
|
||||
<Text size="B300">Close</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</PageHeader>
|
||||
<Box shrink="No" style={{ padding: `0 ${config.space.S400}` }} direction="Column" gap="300">
|
||||
<Box gap="200">
|
||||
<Badge
|
||||
style={{ cursor: 'pointer' }}
|
||||
as="button"
|
||||
variant="Secondary"
|
||||
fill={activeTab === PluginTab.Marketplace ? 'Solid' : 'None'}
|
||||
size="500"
|
||||
onClick={() => setActiveTab(PluginTab.Marketplace)}
|
||||
>
|
||||
<Text as="span" size="L400">
|
||||
Marketplace
|
||||
</Text>
|
||||
</Badge>
|
||||
<Badge
|
||||
style={{ cursor: 'pointer' }}
|
||||
as="button"
|
||||
variant="Secondary"
|
||||
fill={activeTab === PluginTab.Installed ? 'Solid' : 'None'}
|
||||
size="500"
|
||||
onClick={() => setActiveTab(PluginTab.Installed)}
|
||||
>
|
||||
<Text as="span" size="L400">
|
||||
Installed
|
||||
</Text>
|
||||
</Badge>
|
||||
</Box>
|
||||
<Input
|
||||
variant="Background"
|
||||
size="400"
|
||||
placeholder="Search plugins..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
before={<Icon size="100" src={Icons.Search} />}
|
||||
after={
|
||||
searchQuery && (
|
||||
<IconButton
|
||||
size="300"
|
||||
variant="SurfaceLow"
|
||||
radii="300"
|
||||
onClick={() => setSearchQuery('')}
|
||||
>
|
||||
<Icon size="50" src={Icons.Cross} filled />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Scroll variant="Background" visibility="Hover" hideTrack size="300">
|
||||
<PageContent>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
{activeTab === PluginTab.Marketplace && (
|
||||
<Box direction="Column" gap="400">
|
||||
{loading && (
|
||||
<Box justifyContent="Center" alignItems="Center" style={{ padding: toRem(32) }}>
|
||||
<Spinner variant="Secondary" size="400" />
|
||||
</Box>
|
||||
)}
|
||||
{error && (
|
||||
<Box direction="Column" gap="200" style={{ padding: toRem(16) }}>
|
||||
<Text size="T400" priority="300">
|
||||
Error: {error}
|
||||
</Text>
|
||||
<Button onClick={fetchMarketplacePlugins} size="300" variant="Primary">
|
||||
<Text size="B300">Retry</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
{!loading && !error && filteredMarketplacePlugins.length === 0 && (
|
||||
<Box justifyContent="Center" style={{ padding: toRem(32) }}>
|
||||
<Text size="T400" priority="300">
|
||||
{searchQuery ? 'No plugins found matching your search' : 'No plugins available'}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{!loading &&
|
||||
!error &&
|
||||
filteredMarketplacePlugins.map((plugin) => (
|
||||
<Box
|
||||
key={plugin.id}
|
||||
style={{
|
||||
padding: config.space.S500,
|
||||
background: color.Surface.Container,
|
||||
borderRadius: config.radii.R400,
|
||||
border: `1px solid ${color.Surface.ContainerLine}`,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.1)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<Box gap="500" alignItems="Start">
|
||||
<Box shrink="No">
|
||||
<PluginAvatar thumbnail={plugin.thumbnail} name={plugin.name} />
|
||||
</Box>
|
||||
<Box grow="Yes" direction="Column" gap="200">
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="H5">{plugin.name}</Text>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200" priority="400">
|
||||
by {plugin.author}
|
||||
</Text>
|
||||
<Text size="T200" priority="400">•</Text>
|
||||
<Text size="T200" priority="400">
|
||||
v{plugin.version}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Text size="T300" priority="300">
|
||||
{plugin.description}
|
||||
</Text>
|
||||
{plugin.tags.length > 0 && (
|
||||
<Box gap="100" wrap="Wrap">
|
||||
{plugin.tags.map((tag) => (
|
||||
<Badge key={tag} size="300" variant="Secondary" fill="Soft">
|
||||
<Text size="L400">{tag}</Text>
|
||||
</Badge>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box shrink="No">
|
||||
<Button
|
||||
variant={isPluginInstalled(plugin.id) ? 'Success' : 'Primary'}
|
||||
size="400"
|
||||
onClick={() => handleInstall(plugin)}
|
||||
disabled={isPluginInstalled(plugin.id)}
|
||||
>
|
||||
<Text size="B400">
|
||||
{isPluginInstalled(plugin.id) ? 'Installed' : 'Install'}
|
||||
</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
{activeTab === PluginTab.Installed && (
|
||||
<Box direction="Column" gap="400">
|
||||
{filteredInstalledPlugins.length === 0 && (
|
||||
<Box justifyContent="Center" style={{ padding: toRem(32) }}>
|
||||
<Text size="T400" priority="300">
|
||||
{searchQuery ? 'No installed plugins found matching your search' : 'No plugins installed'}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{filteredInstalledPlugins.map((plugin) => {
|
||||
const hasSettings = pluginRegistry.getPluginSettingsSchema(plugin.id) &&
|
||||
Object.keys(pluginRegistry.getPluginSettingsSchema(plugin.id)!).length > 0;
|
||||
const settingsExpanded = expandedSettings === plugin.id;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={plugin.id}
|
||||
direction="Column"
|
||||
style={{
|
||||
background: color.Surface.Container,
|
||||
borderRadius: config.radii.R400,
|
||||
border: `1px solid ${color.Surface.ContainerLine}`,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
style={{
|
||||
padding: config.space.S500,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.parentElement!.style.transform = 'translateY(-2px)';
|
||||
e.currentTarget.parentElement!.style.boxShadow = '0 4px 12px rgba(0,0,0,0.1)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.parentElement!.style.transform = 'translateY(0)';
|
||||
e.currentTarget.parentElement!.style.boxShadow = 'none';
|
||||
}}
|
||||
>
|
||||
<Box gap="500" alignItems="Start">
|
||||
<Box shrink="No">
|
||||
<PluginAvatar thumbnail={plugin.thumbnail} name={plugin.name} />
|
||||
</Box>
|
||||
<Box grow="Yes" direction="Column" gap="200">
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="H5">{plugin.name}</Text>
|
||||
<Box gap="200" alignItems="Center">
|
||||
<Text size="T200" priority="400">
|
||||
by {plugin.author}
|
||||
</Text>
|
||||
<Text size="T200" priority="400">•</Text>
|
||||
<Text size="T200" priority="400">
|
||||
v{plugin.version}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Text size="T300" priority="300">
|
||||
{plugin.description}
|
||||
</Text>
|
||||
<Box gap="100" wrap="Wrap">
|
||||
<Badge
|
||||
size="300"
|
||||
variant={plugin.enabled ? 'Success' : 'Secondary'}
|
||||
fill="Soft"
|
||||
>
|
||||
<Text size="L400">{plugin.enabled ? 'Enabled' : 'Disabled'}</Text>
|
||||
</Badge>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box shrink="No" direction="Column" gap="200">
|
||||
{hasSettings && (
|
||||
<Button
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
size="400"
|
||||
before={<Icon size="200" src={Icons.Setting} />}
|
||||
after={<Icon size="200" src={settingsExpanded ? Icons.ChevronTop : Icons.ChevronBottom} />}
|
||||
onClick={() => setExpandedSettings(settingsExpanded ? null : plugin.id)}
|
||||
>
|
||||
<Text size="B400">Settings</Text>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="Secondary"
|
||||
size="400"
|
||||
onClick={() => handleToggleEnabled(plugin.id)}
|
||||
>
|
||||
<Text size="B400">{plugin.enabled ? 'Disable' : 'Enable'}</Text>
|
||||
</Button>
|
||||
<Button
|
||||
variant="Critical"
|
||||
fill="None"
|
||||
size="400"
|
||||
onClick={() => handleUninstall(plugin.id)}
|
||||
>
|
||||
<Text size="B400">Uninstall</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
{hasSettings && settingsExpanded && (
|
||||
<Box
|
||||
direction="Column"
|
||||
style={{
|
||||
borderTop: `1px solid ${color.Surface.ContainerLine}`,
|
||||
background: color.Surface.ContainerLowest,
|
||||
}}
|
||||
>
|
||||
<PluginSettingsRenderer pluginId={plugin.id} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</SequenceCard>
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user