mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
add plugin form
This commit is contained in:
parent
bf876e2838
commit
357287e6e3
4 changed files with 1293 additions and 0 deletions
|
|
@ -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;
|
||||
|
|
@ -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(", ");
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
Loading…
Add table
Reference in a new issue