add plugin form

This commit is contained in:
Ishaan Jaffer 2026-01-19 14:57:01 -08:00
parent bf876e2838
commit 357287e6e3
4 changed files with 1293 additions and 0 deletions

View file

@ -0,0 +1,328 @@
import React, { useState } from "react";
import { Modal, Form, Input, Select, message } from "antd";
import { Button } from "@tremor/react";
import { registerClaudeCodePlugin } from "../networking";
import {
validatePluginName,
isValidSemanticVersion,
isValidEmail,
isValidUrl,
parseKeywords,
} from "./helpers";
const { TextArea } = Input;
const { Option } = Select;
interface AddPluginFormProps {
visible: boolean;
onClose: () => void;
accessToken: string | null;
onSuccess: () => void;
}
const PREDEFINED_CATEGORIES = [
"Development",
"Productivity",
"Learning",
"Security",
"Data & Analytics",
"Integration",
"Testing",
"Documentation",
];
const AddPluginForm: React.FC<AddPluginFormProps> = ({
visible,
onClose,
accessToken,
onSuccess,
}) => {
const [form] = Form.useForm();
const [isSubmitting, setIsSubmitting] = useState(false);
const [sourceType, setSourceType] = useState<"github" | "url">("github");
const handleSubmit = async (values: any) => {
if (!accessToken) {
message.error("No access token available");
return;
}
// Validate plugin name
if (!validatePluginName(values.name)) {
message.error(
"Plugin name must be kebab-case (lowercase letters, numbers, and hyphens only)"
);
return;
}
// Validate semantic version if provided
if (values.version && !isValidSemanticVersion(values.version)) {
message.error(
"Version must be in semantic versioning format (e.g., 1.0.0)"
);
return;
}
// Validate email if provided
if (values.authorEmail && !isValidEmail(values.authorEmail)) {
message.error("Invalid email format");
return;
}
// Validate homepage URL if provided
if (values.homepage && !isValidUrl(values.homepage)) {
message.error("Invalid homepage URL format");
return;
}
setIsSubmitting(true);
try {
// Build plugin data
const pluginData: any = {
name: values.name.trim(),
source:
sourceType === "github"
? {
source: "github",
repo: values.repo.trim(),
}
: {
source: "url",
url: values.url.trim(),
},
};
// Add optional fields
if (values.version) {
pluginData.version = values.version.trim();
}
if (values.description) {
pluginData.description = values.description.trim();
}
if (values.authorName || values.authorEmail) {
pluginData.author = {};
if (values.authorName) {
pluginData.author.name = values.authorName.trim();
}
if (values.authorEmail) {
pluginData.author.email = values.authorEmail.trim();
}
}
if (values.homepage) {
pluginData.homepage = values.homepage.trim();
}
if (values.category) {
pluginData.category = values.category;
}
if (values.keywords) {
pluginData.keywords = parseKeywords(values.keywords);
}
await registerClaudeCodePlugin(accessToken, pluginData);
message.success("Plugin registered successfully");
form.resetFields();
setSourceType("github");
onSuccess();
onClose();
} catch (error) {
console.error("Error registering plugin:", error);
message.error("Failed to register plugin");
} finally {
setIsSubmitting(false);
}
};
const handleCancel = () => {
form.resetFields();
setSourceType("github");
onClose();
};
const handleSourceTypeChange = (value: "github" | "url") => {
setSourceType(value);
// Clear repo/url fields when switching
form.setFieldsValue({ repo: undefined, url: undefined });
};
return (
<Modal
title="Add New Claude Code Plugin"
open={visible}
onCancel={handleCancel}
footer={null}
width={700}
className="top-8"
>
<Form
form={form}
layout="vertical"
onFinish={handleSubmit}
className="mt-4"
>
{/* Plugin Name */}
<Form.Item
label="Plugin Name"
name="name"
rules={[
{ required: true, message: "Please enter plugin name" },
{
pattern: /^[a-z0-9-]+$/,
message:
"Name must be kebab-case (lowercase, numbers, hyphens only)",
},
]}
tooltip="Unique identifier in kebab-case format (e.g., my-awesome-plugin)"
>
<Input placeholder="my-awesome-plugin" className="rounded-lg" />
</Form.Item>
{/* Source Type */}
<Form.Item
label="Source Type"
name="sourceType"
initialValue="github"
rules={[{ required: true, message: "Please select source type" }]}
>
<Select onChange={handleSourceTypeChange} className="rounded-lg">
<Option value="github">GitHub</Option>
<Option value="url">URL</Option>
</Select>
</Form.Item>
{/* GitHub Repository */}
{sourceType === "github" && (
<Form.Item
label="GitHub Repository"
name="repo"
rules={[
{ required: true, message: "Please enter repository" },
{
pattern: /^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_-]+$/,
message: "Repository must be in format: org/repo",
},
]}
tooltip="Format: organization/repository (e.g., anthropics/claude-code)"
>
<Input placeholder="anthropics/claude-code" className="rounded-lg" />
</Form.Item>
)}
{/* Git URL */}
{sourceType === "url" && (
<Form.Item
label="Git URL"
name="url"
rules={[{ required: true, message: "Please enter git URL" }]}
tooltip="Full git URL to the repository"
>
<Input
type="url"
placeholder="https://github.com/org/repo.git"
className="rounded-lg"
/>
</Form.Item>
)}
{/* Version */}
<Form.Item
label="Version (Optional)"
name="version"
tooltip="Semantic version (e.g., 1.0.0)"
>
<Input placeholder="1.0.0" className="rounded-lg" />
</Form.Item>
{/* Description */}
<Form.Item
label="Description (Optional)"
name="description"
tooltip="Brief description of what the plugin does"
>
<TextArea
rows={3}
placeholder="A plugin that helps with..."
maxLength={500}
className="rounded-lg"
/>
</Form.Item>
{/* Category */}
<Form.Item
label="Category (Optional)"
name="category"
tooltip="Select a category or enter a custom one"
>
<Select
placeholder="Select or type a category"
allowClear
showSearch
optionFilterProp="children"
className="rounded-lg"
>
{PREDEFINED_CATEGORIES.map((cat) => (
<Option key={cat} value={cat}>
{cat}
</Option>
))}
</Select>
</Form.Item>
{/* Keywords */}
<Form.Item
label="Keywords (Optional)"
name="keywords"
tooltip="Comma-separated list of keywords for search"
>
<Input placeholder="search, web, api" className="rounded-lg" />
</Form.Item>
{/* Author Name */}
<Form.Item
label="Author Name (Optional)"
name="authorName"
tooltip="Name of the plugin author or organization"
>
<Input placeholder="Your Name or Organization" className="rounded-lg" />
</Form.Item>
{/* Author Email */}
<Form.Item
label="Author Email (Optional)"
name="authorEmail"
rules={[{ type: "email", message: "Please enter a valid email" }]}
tooltip="Contact email for the plugin author"
>
<Input type="email" placeholder="author@example.com" className="rounded-lg" />
</Form.Item>
{/* Homepage */}
<Form.Item
label="Homepage (Optional)"
name="homepage"
rules={[{ type: "url", message: "Please enter a valid URL" }]}
tooltip="URL to the plugin's homepage or documentation"
>
<Input type="url" placeholder="https://example.com" className="rounded-lg" />
</Form.Item>
{/* Submit Buttons */}
<Form.Item className="mb-0 mt-6">
<div className="flex justify-end gap-2">
<Button
variant="secondary"
onClick={handleCancel}
disabled={isSubmitting}
>
Cancel
</Button>
<Button type="submit" loading={isSubmitting}>
{isSubmitting ? "Registering..." : "Register Plugin"}
</Button>
</div>
</Form.Item>
</Form>
</Modal>
);
};
export default AddPluginForm;

View file

@ -0,0 +1,264 @@
/**
* Helper utilities for Claude Code Marketplace
*/
import { PluginSource, MarketplacePluginEntry } from "./types";
/**
* Generate install command for Claude Code CLI
* Format: /plugin marketplace add org/repo OR /plugin marketplace add url
*/
export const formatInstallCommand = (plugin: {
name: string;
source: PluginSource;
}): string => {
if (plugin.source.source === "github" && plugin.source.repo) {
return `/plugin marketplace add ${plugin.source.repo}`;
} else if (plugin.source.source === "url" && plugin.source.url) {
return `/plugin marketplace add ${plugin.source.url}`;
}
// Fallback to plugin name
return `/plugin marketplace add ${plugin.name}`;
};
/**
* Extract unique categories from plugins list
* Returns array with "All" first, then sorted categories, then "Other"
*/
export const extractCategories = (
plugins: Array<{ category?: string }>
): string[] => {
const categories = new Set<string>();
plugins.forEach((p) => {
if (p.category && p.category.trim() !== "") {
categories.add(p.category);
}
});
const sortedCategories = Array.from(categories).sort();
// Return: All, sorted categories, Other
return ["All", ...sortedCategories, "Other"];
};
/**
* Validate plugin name format (kebab-case)
* Must be lowercase letters, numbers, and hyphens only
*/
export const validatePluginName = (name: string): boolean => {
if (!name || name.trim() === "") {
return false;
}
// Regex: lowercase letters, numbers, hyphens
return /^[a-z0-9-]+$/.test(name);
};
/**
* Get human-readable source display text
*/
export const getSourceDisplayText = (source: PluginSource): string => {
if (source.source === "github" && source.repo) {
return `GitHub: ${source.repo}`;
} else if (source.source === "url" && source.url) {
return source.url;
}
return "Unknown source";
};
/**
* Get clickable link for plugin source
*/
export const getSourceLink = (source: PluginSource): string | null => {
if (source.source === "github" && source.repo) {
return `https://github.com/${source.repo}`;
} else if (source.source === "url" && source.url) {
return source.url;
}
return null;
};
/**
* Get badge color based on category
*/
export const getCategoryBadgeColor = (
category?: string
): "blue" | "green" | "purple" | "red" | "orange" | "yellow" | "gray" => {
if (!category) {
return "gray";
}
const categoryLower = category.toLowerCase();
if (categoryLower.includes("development") || categoryLower.includes("dev")) {
return "blue";
} else if (
categoryLower.includes("productivity") ||
categoryLower.includes("workflow")
) {
return "green";
} else if (
categoryLower.includes("learning") ||
categoryLower.includes("education")
) {
return "purple";
} else if (
categoryLower.includes("security") ||
categoryLower.includes("safety")
) {
return "red";
} else if (
categoryLower.includes("data") ||
categoryLower.includes("analytics")
) {
return "orange";
} else if (
categoryLower.includes("integration") ||
categoryLower.includes("api")
) {
return "yellow";
}
return "gray";
};
/**
* Format date to readable string
*/
export const formatDateString = (dateString?: string): string => {
if (!dateString) {
return "N/A";
}
try {
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
} catch (error) {
return "Invalid date";
}
};
/**
* Truncate text with ellipsis
*/
export const truncateText = (text: string, maxLength: number): string => {
if (!text || text.length <= maxLength) {
return text;
}
return text.substring(0, maxLength) + "...";
};
/**
* Filter plugins by search term
* Searches in: name, description, keywords
*/
export const filterPluginsBySearch = (
plugins: MarketplacePluginEntry[],
searchTerm: string
): MarketplacePluginEntry[] => {
if (!searchTerm || searchTerm.trim() === "") {
return plugins;
}
const term = searchTerm.toLowerCase().trim();
return plugins.filter((plugin) => {
const nameMatch = plugin.name.toLowerCase().includes(term);
const descriptionMatch =
plugin.description?.toLowerCase().includes(term) || false;
const keywordsMatch =
plugin.keywords?.some((keyword) =>
keyword.toLowerCase().includes(term)
) || false;
return nameMatch || descriptionMatch || keywordsMatch;
});
};
/**
* Filter plugins by category
*/
export const filterPluginsByCategory = (
plugins: MarketplacePluginEntry[],
category: string
): MarketplacePluginEntry[] => {
if (category === "All") {
return plugins;
}
if (category === "Other") {
return plugins.filter((p) => !p.category || p.category.trim() === "");
}
return plugins.filter((p) => p.category === category);
};
/**
* Validate semantic version format (basic check)
*/
export const isValidSemanticVersion = (version?: string): boolean => {
if (!version) {
return true; // Version is optional
}
// Basic semver check: X.Y.Z
const semverRegex = /^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/;
return semverRegex.test(version);
};
/**
* Validate email format
*/
export const isValidEmail = (email?: string): boolean => {
if (!email) {
return true; // Email is optional
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
};
/**
* Validate URL format
*/
export const isValidUrl = (url?: string): boolean => {
if (!url) {
return true; // URL is optional
}
try {
new URL(url);
return true;
} catch {
return false;
}
};
/**
* Parse keywords from comma-separated string
*/
export const parseKeywords = (keywordsString: string): string[] => {
if (!keywordsString || keywordsString.trim() === "") {
return [];
}
return keywordsString
.split(",")
.map((kw) => kw.trim())
.filter((kw) => kw !== "");
};
/**
* Format keywords array to comma-separated string
*/
export const formatKeywords = (keywords?: string[]): string => {
if (!keywords || keywords.length === 0) {
return "";
}
return keywords.join(", ");
};

View file

@ -0,0 +1,350 @@
import React, { useState, useEffect } from "react";
import {
Card,
Title,
Text,
Button,
Badge,
Grid,
} from "@tremor/react";
import { Spin, Switch, Tooltip, Descriptions } from "antd";
import { ArrowLeftIcon, ExternalLinkIcon } from "@heroicons/react/outline";
import { CopyOutlined } from "@ant-design/icons";
import {
getClaudeCodePluginDetails,
enableClaudeCodePlugin,
disableClaudeCodePlugin,
} from "../networking";
import NotificationsManager from "../molecules/notifications_manager";
import { Plugin } from "./types";
import {
formatInstallCommand,
getSourceDisplayText,
getSourceLink,
getCategoryBadgeColor,
formatDateString,
formatKeywords,
} from "./helpers";
interface PluginInfoViewProps {
pluginId: string;
onClose: () => void;
accessToken: string | null;
isAdmin: boolean;
onPluginUpdated: () => void;
}
const PluginInfoView: React.FC<PluginInfoViewProps> = ({
pluginId,
onClose,
accessToken,
isAdmin,
onPluginUpdated,
}) => {
const [plugin, setPlugin] = useState<Plugin | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isToggling, setIsToggling] = useState(false);
useEffect(() => {
fetchPluginInfo();
}, [pluginId, accessToken]);
const fetchPluginInfo = async () => {
if (!accessToken) return;
setIsLoading(true);
try {
// The backend expects plugin name, not ID
// We'll need to find the plugin by ID from the list
// For now, assume pluginId is actually the plugin name
const data = await getClaudeCodePluginDetails(
accessToken,
pluginId as string
);
setPlugin(data.plugin);
} catch (error) {
console.error("Error fetching plugin info:", error);
NotificationsManager.error("Failed to load plugin information");
} finally {
setIsLoading(false);
}
};
const handleToggleEnabled = async () => {
if (!accessToken || !plugin) return;
setIsToggling(true);
try {
if (plugin.enabled) {
await disableClaudeCodePlugin(accessToken, plugin.name);
NotificationsManager.success(`Plugin "${plugin.name}" disabled`);
} else {
await enableClaudeCodePlugin(accessToken, plugin.name);
NotificationsManager.success(`Plugin "${plugin.name}" enabled`);
}
onPluginUpdated();
fetchPluginInfo();
} catch (error) {
NotificationsManager.error("Failed to toggle plugin status");
} finally {
setIsToggling(false);
}
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
NotificationsManager.success("Copied to clipboard!");
};
if (isLoading) {
return (
<div className="flex items-center justify-center p-8">
<Spin size="large" />
</div>
);
}
if (!plugin) {
return (
<div className="p-8 text-center text-gray-500">
<p>Plugin not found</p>
<Button className="mt-4" onClick={onClose}>
Go Back
</Button>
</div>
);
}
const installCommand = formatInstallCommand(plugin);
const sourceLink = getSourceLink(plugin.source);
const categoryBadgeColor = getCategoryBadgeColor(plugin.category);
return (
<div className="space-y-4">
{/* Header with Back Button */}
<div className="flex items-center gap-3 mb-6">
<ArrowLeftIcon
className="h-5 w-5 cursor-pointer text-gray-500 hover:text-gray-700"
onClick={onClose}
/>
<h2 className="text-2xl font-bold">{plugin.name}</h2>
{plugin.version && (
<Badge color="blue" size="xs">
v{plugin.version}
</Badge>
)}
{plugin.category && (
<Badge color={categoryBadgeColor} size="xs">
{plugin.category}
</Badge>
)}
<Badge color={plugin.enabled ? "green" : "gray"} size="xs">
{plugin.enabled ? "Enabled" : "Disabled"}
</Badge>
</div>
{/* Install Command */}
<Card>
<div className="flex items-center justify-between">
<div className="flex-1">
<Text className="text-gray-600 text-xs mb-2">Install Command</Text>
<div className="font-mono bg-gray-100 px-3 py-2 rounded text-sm">
{installCommand}
</div>
</div>
<Tooltip title="Copy install command">
<Button
size="xs"
variant="secondary"
icon={CopyOutlined}
onClick={() => copyToClipboard(installCommand)}
className="ml-4"
>
Copy
</Button>
</Tooltip>
</div>
</Card>
{/* Plugin Details */}
<Card>
<Title>Plugin Details</Title>
<Grid numColsSm={2} numColsLg={3} className="gap-6 mt-4">
{/* Plugin ID */}
<div>
<Text className="text-gray-600 text-xs">Plugin ID</Text>
<div className="flex items-center gap-2 mt-1">
<Text className="font-mono text-xs">{plugin.id}</Text>
<CopyOutlined
className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs"
onClick={() => copyToClipboard(plugin.id)}
/>
</div>
</div>
{/* Name */}
<div>
<Text className="text-gray-600 text-xs">Name</Text>
<Text className="font-semibold mt-1">{plugin.name}</Text>
</div>
{/* Version */}
<div>
<Text className="text-gray-600 text-xs">Version</Text>
<Text className="font-semibold mt-1">
{plugin.version || "N/A"}
</Text>
</div>
{/* Source */}
<div className="col-span-2">
<Text className="text-gray-600 text-xs">Source</Text>
<div className="flex items-center gap-2 mt-1">
<Text className="font-semibold">
{getSourceDisplayText(plugin.source)}
</Text>
{sourceLink && (
<a
href={sourceLink}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-700"
>
<ExternalLinkIcon className="h-4 w-4" />
</a>
)}
</div>
</div>
{/* Category */}
<div>
<Text className="text-gray-600 text-xs">Category</Text>
<div className="mt-1">
{plugin.category ? (
<Badge color={categoryBadgeColor} size="xs">
{plugin.category}
</Badge>
) : (
<Text className="text-gray-400">Uncategorized</Text>
)}
</div>
</div>
{/* Enabled Status */}
{isAdmin && (
<div className="col-span-3">
<Text className="text-gray-600 text-xs">Status</Text>
<div className="flex items-center gap-3 mt-2">
<Switch
checked={plugin.enabled}
loading={isToggling}
onChange={handleToggleEnabled}
/>
<Text className="text-sm">
{plugin.enabled
? "Plugin is enabled and visible in marketplace"
: "Plugin is disabled and hidden from marketplace"}
</Text>
</div>
</div>
)}
</Grid>
</Card>
{/* Description */}
{plugin.description && (
<Card>
<Title>Description</Title>
<Text className="mt-2">{plugin.description}</Text>
</Card>
)}
{/* Keywords */}
{plugin.keywords && plugin.keywords.length > 0 && (
<Card>
<Title>Keywords</Title>
<div className="flex flex-wrap gap-2 mt-2">
{plugin.keywords.map((keyword, index) => (
<Badge key={index} color="gray" size="xs">
{keyword}
</Badge>
))}
</div>
</Card>
)}
{/* Author Information */}
{plugin.author && (
<Card>
<Title>Author Information</Title>
<Grid numColsSm={2} className="gap-4 mt-4">
{plugin.author.name && (
<div>
<Text className="text-gray-600 text-xs">Name</Text>
<Text className="font-semibold mt-1">
{plugin.author.name}
</Text>
</div>
)}
{plugin.author.email && (
<div>
<Text className="text-gray-600 text-xs">Email</Text>
<Text className="font-semibold mt-1">
<a
href={`mailto:${plugin.author.email}`}
className="text-blue-500 hover:text-blue-700"
>
{plugin.author.email}
</a>
</Text>
</div>
)}
</Grid>
</Card>
)}
{/* Additional Links */}
{plugin.homepage && (
<Card>
<Title>Homepage</Title>
<a
href={plugin.homepage}
target="_blank"
rel="noopener noreferrer"
className="text-blue-500 hover:text-blue-700 flex items-center gap-2 mt-2"
>
{plugin.homepage}
<ExternalLinkIcon className="h-4 w-4" />
</a>
</Card>
)}
{/* Timestamps */}
<Card>
<Title>Metadata</Title>
<Grid numColsSm={2} className="gap-4 mt-4">
<div>
<Text className="text-gray-600 text-xs">Created At</Text>
<Text className="font-semibold mt-1">
{formatDateString(plugin.created_at)}
</Text>
</div>
<div>
<Text className="text-gray-600 text-xs">Updated At</Text>
<Text className="font-semibold mt-1">
{formatDateString(plugin.updated_at)}
</Text>
</div>
{plugin.created_by && (
<div className="col-span-2">
<Text className="text-gray-600 text-xs">Created By</Text>
<Text className="font-semibold mt-1">{plugin.created_by}</Text>
</div>
)}
</Grid>
</Card>
</div>
);
};
export default PluginInfoView;

View file

@ -0,0 +1,351 @@
import React, { useState } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeaderCell,
TableRow,
Button,
Badge,
} from "@tremor/react";
import {
SwitchVerticalIcon,
ChevronUpIcon,
ChevronDownIcon,
TrashIcon,
} from "@heroicons/react/outline";
import { Tooltip, Switch } from "antd";
import { CopyOutlined } from "@ant-design/icons";
import { Plugin } from "./types";
import {
getCategoryBadgeColor,
formatDateString,
} from "./helpers";
import {
enableClaudeCodePlugin,
disableClaudeCodePlugin,
} from "../networking";
import NotificationsManager from "../molecules/notifications_manager";
import {
ColumnDef,
flexRender,
getCoreRowModel,
getSortedRowModel,
SortingState,
useReactTable,
} from "@tanstack/react-table";
interface PluginTableProps {
pluginsList: Plugin[];
isLoading: boolean;
onDeleteClick: (pluginName: string, displayName: string) => void;
accessToken: string | null;
onPluginUpdated: () => void;
isAdmin: boolean;
onPluginClick: (pluginId: string) => void;
}
const PluginTable: React.FC<PluginTableProps> = ({
pluginsList,
isLoading,
onDeleteClick,
accessToken,
onPluginUpdated,
isAdmin,
onPluginClick,
}) => {
const [sorting, setSorting] = useState<SortingState>([
{ id: "created_at", desc: true },
]);
const [togglingPlugin, setTogglingPlugin] = useState<string | null>(null);
const formatDate = (dateString?: string) => {
if (!dateString) return "-";
const date = new Date(dateString);
return date.toLocaleString();
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
NotificationsManager.success("Copied to clipboard!");
};
const handleToggleEnabled = async (plugin: Plugin) => {
if (!accessToken) return;
setTogglingPlugin(plugin.id);
try {
if (plugin.enabled) {
await disableClaudeCodePlugin(accessToken, plugin.name);
NotificationsManager.success(`Plugin "${plugin.name}" disabled`);
} else {
await enableClaudeCodePlugin(accessToken, plugin.name);
NotificationsManager.success(`Plugin "${plugin.name}" enabled`);
}
onPluginUpdated();
} catch (error) {
NotificationsManager.error("Failed to toggle plugin status");
} finally {
setTogglingPlugin(null);
}
};
const columns: ColumnDef<Plugin>[] = [
{
header: "Plugin Name",
accessorKey: "name",
cell: ({ row }) => {
const plugin = row.original;
const name = plugin.name || "";
return (
<div className="flex items-center gap-2">
<Tooltip title={name}>
<Button
size="xs"
variant="light"
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left overflow-hidden truncate min-w-[150px] justify-start"
onClick={() => onPluginClick(plugin.id)}
>
{name}
</Button>
</Tooltip>
<Tooltip title="Copy Plugin ID">
<CopyOutlined
onClick={(e) => {
e.stopPropagation();
copyToClipboard(plugin.id);
}}
className="cursor-pointer text-gray-500 hover:text-blue-500 text-xs"
/>
</Tooltip>
</div>
);
},
},
{
header: "Version",
accessorKey: "version",
cell: ({ row }) => {
const version = row.original.version || "N/A";
return <span className="text-xs text-gray-600">{version}</span>;
},
},
{
header: "Description",
accessorKey: "description",
cell: ({ row }) => {
const description = row.original.description || "No description";
return (
<Tooltip title={description}>
<span className="text-xs text-gray-600 block max-w-[300px] truncate">
{description}
</span>
</Tooltip>
);
},
},
{
header: "Category",
accessorKey: "category",
cell: ({ row }) => {
const category = row.original.category;
if (!category) {
return (
<Badge color="gray" className="text-xs font-normal" size="xs">
Uncategorized
</Badge>
);
}
const badgeColor = getCategoryBadgeColor(category);
return (
<Badge color={badgeColor} className="text-xs font-normal" size="xs">
{category}
</Badge>
);
},
},
{
header: "Enabled",
accessorKey: "enabled",
cell: ({ row }) => {
const plugin = row.original;
return (
<div className="flex items-center gap-2">
<Badge
color={plugin.enabled ? "green" : "gray"}
className="text-xs font-normal"
size="xs"
>
{plugin.enabled ? "Yes" : "No"}
</Badge>
{isAdmin && (
<Tooltip
title={plugin.enabled ? "Disable plugin" : "Enable plugin"}
>
<Switch
size="small"
checked={plugin.enabled}
loading={togglingPlugin === plugin.id}
onChange={() => handleToggleEnabled(plugin)}
/>
</Tooltip>
)}
</div>
);
},
},
{
header: "Created At",
accessorKey: "created_at",
cell: ({ row }) => {
const plugin = row.original;
return (
<Tooltip title={plugin.created_at}>
<span className="text-xs">{formatDate(plugin.created_at)}</span>
</Tooltip>
);
},
},
...(isAdmin
? [
{
header: "Actions",
id: "actions",
enableSorting: false,
cell: ({ row }: any) => {
const plugin = row.original;
return (
<div className="flex items-center gap-1">
<Tooltip title="Delete plugin">
<Button
size="xs"
variant="light"
color="red"
onClick={(e) => {
e.stopPropagation();
onDeleteClick(plugin.name, plugin.name);
}}
icon={TrashIcon}
className="text-red-500 hover:text-red-700 hover:bg-red-50"
/>
</Tooltip>
</div>
);
},
},
]
: []),
];
const table = useReactTable({
data: pluginsList,
columns,
state: {
sorting,
},
onSortingChange: setSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
enableSorting: true,
});
return (
<div className="rounded-lg custom-border relative">
<div className="overflow-x-auto">
<Table className="[&_td]:py-0.5 [&_th]:py-1">
<TableHead>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHeaderCell
key={header.id}
className={`py-1 h-8 ${
header.id === "actions"
? "sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]"
: ""
}`}
onClick={
header.column.getCanSort()
? header.column.getToggleSortingHandler()
: undefined
}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center">
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</div>
{header.column.getCanSort() && (
<div className="w-4">
{header.column.getIsSorted() ? (
{
asc: (
<ChevronUpIcon className="h-4 w-4 text-blue-500" />
),
desc: (
<ChevronDownIcon className="h-4 w-4 text-blue-500" />
),
}[header.column.getIsSorted() as string]
) : (
<SwitchVerticalIcon className="h-4 w-4 text-gray-400" />
)}
</div>
)}
</div>
</TableHeaderCell>
))}
</TableRow>
))}
</TableHead>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-gray-500">
<p>Loading...</p>
</div>
</TableCell>
</TableRow>
) : pluginsList && pluginsList.length > 0 ? (
table.getRowModel().rows.map((row) => (
<TableRow key={row.id} className="h-8">
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={`py-0.5 max-h-8 overflow-hidden text-ellipsis whitespace-nowrap ${
cell.column.id === "actions"
? "sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]"
: ""
}`}
>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-8 text-center">
<div className="text-center text-gray-500">
<p>No plugins found. Add one to get started.</p>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
);
};
export default PluginTable;