mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-04 02:31:27 +00:00
feat(ui): migrate claude_code_plugins subtree to shadcn
- add_plugin_form: replace antd Modal/Form/Input/Select and Tremor Button with shadcn Dialog + react-hook-form + shadcn Input/Textarea/ Select/Button. Validation messages now rendered inline via formState.errors instead of antd Form.Item's rules. URL preview keeps the categorical blue palette and is recorded in .eslintrc allowlist. - MakeSkillPublicForm: drop antd Form wrapper + Steps. Replace with inline lucide-based Stepper; step state stays plain React useState. Dialog was already shadcn. All 60 claude_code_plugins tests pass (AddPluginForm + helpers). tsc --noEmit clean; eslint clean with categorical-palette allowlist. Co-authored-by: yuneng-jiang <yuneng-berri@users.noreply.github.com>
This commit is contained in:
parent
c2e8e4926b
commit
c100c04688
3 changed files with 341 additions and 187 deletions
|
|
@ -66,6 +66,7 @@
|
|||
"src/components/playground/chat_ui/A2AMetrics.tsx",
|
||||
"src/components/common_components/PassThroughGuardrailsSection.tsx",
|
||||
"src/components/claude_code_plugins/MakeSkillPublicForm.tsx",
|
||||
"src/components/claude_code_plugins/add_plugin_form.tsx",
|
||||
"src/components/mcp_tools/MCPPermissionManagement.tsx",
|
||||
"src/components/model_add/credentials.tsx",
|
||||
"src/components/model_dashboard/health_check_columns.tsx",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Form, Steps } from "antd";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
|
@ -9,6 +8,8 @@ import {
|
|||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Check } from "lucide-react";
|
||||
import {
|
||||
enableClaudeCodePlugin,
|
||||
disableClaudeCodePlugin,
|
||||
|
|
@ -16,8 +17,6 @@ import {
|
|||
import NotificationsManager from "../molecules/notifications_manager";
|
||||
import { Plugin } from "./types";
|
||||
|
||||
const { Step } = Steps;
|
||||
|
||||
interface MakeSkillPublicFormProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
|
|
@ -26,6 +25,49 @@ interface MakeSkillPublicFormProps {
|
|||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
function Stepper({ current, steps }: { current: number; steps: string[] }) {
|
||||
return (
|
||||
<ol className="flex items-center gap-2 mb-6">
|
||||
{steps.map((label, i) => {
|
||||
const active = i === current;
|
||||
const completed = i < current;
|
||||
return (
|
||||
<li
|
||||
key={label}
|
||||
className="flex items-center gap-2 flex-1 min-w-0"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-6 w-6 items-center justify-center rounded-full border text-xs font-medium",
|
||||
completed
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: active
|
||||
? "border-primary text-primary"
|
||||
: "border-border text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{completed ? <Check className="h-3 w-3" /> : i + 1}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm truncate",
|
||||
active || completed
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
{i < steps.length - 1 && (
|
||||
<div className="h-px flex-1 bg-border" />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
|
||||
visible,
|
||||
onClose,
|
||||
|
|
@ -36,12 +78,10 @@ const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
|
|||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [selectedSkills, setSelectedSkills] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const handleClose = () => {
|
||||
setCurrentStep(0);
|
||||
setSelectedSkills(new Set());
|
||||
form.resetFields();
|
||||
onClose();
|
||||
};
|
||||
|
||||
|
|
@ -251,40 +291,35 @@ const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
|
|||
<DialogHeader>
|
||||
<DialogTitle>Publish to Skill Hub</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Form form={form} layout="vertical">
|
||||
<Steps current={currentStep} className="mb-6">
|
||||
<Step title="Select Skills" />
|
||||
<Step title="Confirm" />
|
||||
</Steps>
|
||||
<Stepper current={currentStep} steps={["Select Skills", "Confirm"]} />
|
||||
|
||||
{currentStep === 0 ? renderStep1() : renderStep2()}
|
||||
{currentStep === 0 ? renderStep1() : renderStep2()}
|
||||
|
||||
<div className="flex justify-between mt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={
|
||||
currentStep === 0 ? handleClose : () => setCurrentStep(0)
|
||||
}
|
||||
>
|
||||
{currentStep === 0 ? "Cancel" : "Previous"}
|
||||
</Button>
|
||||
<div className="flex space-x-2">
|
||||
{currentStep === 0 && (
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={selectedSkills.size === 0}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
)}
|
||||
{currentStep === 1 && (
|
||||
<Button onClick={handleSubmit} disabled={loading}>
|
||||
{loading ? "Publishing..." : "Publish to Hub"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-between mt-6">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={
|
||||
currentStep === 0 ? handleClose : () => setCurrentStep(0)
|
||||
}
|
||||
>
|
||||
{currentStep === 0 ? "Cancel" : "Previous"}
|
||||
</Button>
|
||||
<div className="flex space-x-2">
|
||||
{currentStep === 0 && (
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={selectedSkills.size === 0}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
)}
|
||||
{currentStep === 1 && (
|
||||
<Button onClick={handleSubmit} disabled={loading}>
|
||||
{loading ? "Publishing..." : "Publish to Hub"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,23 @@
|
|||
import React, { useState } from "react";
|
||||
import { Modal, Form, Input, Select } from "antd";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import MessageManager from "@/components/molecules/message_manager";
|
||||
// eslint-disable-next-line litellm-ui/no-banned-ui-imports
|
||||
import { Button } from "@tremor/react";
|
||||
import { registerClaudeCodePlugin } from "../networking";
|
||||
import {
|
||||
validatePluginName,
|
||||
|
|
@ -12,9 +27,6 @@ import {
|
|||
parseKeywords,
|
||||
} from "./helpers";
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Option } = Select;
|
||||
|
||||
interface AddPluginFormProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
|
|
@ -22,6 +34,20 @@ interface AddPluginFormProps {
|
|||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
interface FormValues {
|
||||
skillUrl: string;
|
||||
name: string;
|
||||
domain?: string;
|
||||
namespace?: string;
|
||||
description?: string;
|
||||
category?: string;
|
||||
keywords?: string;
|
||||
version?: string;
|
||||
authorName?: string;
|
||||
authorEmail?: string;
|
||||
homepage?: string;
|
||||
}
|
||||
|
||||
const PREDEFINED_CATEGORIES = [
|
||||
"Development",
|
||||
"Productivity",
|
||||
|
|
@ -113,7 +139,21 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({
|
|||
accessToken,
|
||||
onSuccess,
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
skillUrl: "",
|
||||
name: "",
|
||||
domain: "",
|
||||
namespace: "",
|
||||
description: "",
|
||||
category: "",
|
||||
keywords: "",
|
||||
version: "",
|
||||
authorName: "",
|
||||
authorEmail: "",
|
||||
},
|
||||
});
|
||||
const { register, handleSubmit, setValue, getValues, reset, control, formState } = form;
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [urlPreview, setUrlPreview] = useState<ParsePreview | null>(null);
|
||||
|
||||
|
|
@ -122,15 +162,14 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({
|
|||
const preview = parseGitHubUrl(val);
|
||||
setUrlPreview(preview);
|
||||
if (preview) {
|
||||
// Auto-fill name only if it's currently empty
|
||||
const currentName = form.getFieldValue("name");
|
||||
const currentName = getValues("name");
|
||||
if (!currentName) {
|
||||
form.setFieldsValue({ name: preview.suggestedName });
|
||||
setValue("name", preview.suggestedName, { shouldValidate: false });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: any) => {
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
if (!accessToken) {
|
||||
MessageManager.error("No access token available");
|
||||
return;
|
||||
|
|
@ -185,7 +224,7 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({
|
|||
|
||||
await registerClaudeCodePlugin(accessToken, pluginData);
|
||||
MessageManager.success("Skill registered successfully");
|
||||
form.resetFields();
|
||||
reset();
|
||||
setUrlPreview(null);
|
||||
onSuccess();
|
||||
onClose();
|
||||
|
|
@ -198,168 +237,247 @@ const AddPluginForm: React.FC<AddPluginFormProps> = ({
|
|||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
form.resetFields();
|
||||
reset();
|
||||
setUrlPreview(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const urlRegister = register("skillUrl", {
|
||||
required: "Please enter a GitHub URL",
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Add New Skill"
|
||||
<Dialog
|
||||
open={visible}
|
||||
onCancel={handleCancel}
|
||||
footer={null}
|
||||
width={700}
|
||||
className="top-8"
|
||||
onOpenChange={(o) => (!o ? handleCancel() : undefined)}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={handleSubmit}
|
||||
className="mt-4"
|
||||
>
|
||||
{/* Smart URL Input */}
|
||||
<Form.Item
|
||||
label="GitHub URL"
|
||||
name="skillUrl"
|
||||
rules={[{ required: true, message: "Please enter a GitHub URL" }]}
|
||||
tooltip="Paste a GitHub URL — repo, folder, or file link. E.g. github.com/org/repo or github.com/org/repo/tree/main/my-skill"
|
||||
<DialogContent className="max-w-[700px] max-h-[90vh] overflow-y-auto top-8">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add New Skill</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="mt-4 space-y-4"
|
||||
>
|
||||
<Input
|
||||
placeholder="https://github.com/org/repo/tree/main/my-skill"
|
||||
className="rounded-lg"
|
||||
onChange={handleUrlChange}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{/* Parsed preview */}
|
||||
{urlPreview && (
|
||||
<div className="mb-4 px-3 py-2 bg-blue-50 border border-blue-200 rounded-lg text-sm text-blue-700">
|
||||
Detected: {urlPreview.label}
|
||||
{/* Smart URL Input */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="skillUrl">
|
||||
GitHub URL <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="skillUrl"
|
||||
placeholder="https://github.com/org/repo/tree/main/my-skill"
|
||||
className="rounded-lg"
|
||||
aria-invalid={!!formState.errors.skillUrl}
|
||||
{...urlRegister}
|
||||
onChange={(e) => {
|
||||
urlRegister.onChange(e);
|
||||
handleUrlChange(e);
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Paste a GitHub URL — repo, folder, or file link. E.g.
|
||||
github.com/org/repo or github.com/org/repo/tree/main/my-skill
|
||||
</p>
|
||||
{formState.errors.skillUrl && (
|
||||
<p className="text-sm text-destructive">
|
||||
{formState.errors.skillUrl.message as string}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skill Name */}
|
||||
<Form.Item
|
||||
label="Skill Name"
|
||||
name="name"
|
||||
rules={[
|
||||
{ required: true, message: "Please enter skill 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-skill)"
|
||||
>
|
||||
<Input placeholder="my-skill" className="rounded-lg" />
|
||||
</Form.Item>
|
||||
{/* Parsed preview */}
|
||||
{urlPreview && (
|
||||
<div className="px-3 py-2 bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-900 rounded-lg text-sm text-blue-700 dark:text-blue-300">
|
||||
Detected: {urlPreview.label}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Domain and Namespace — side by side */}
|
||||
<div className="flex gap-4">
|
||||
<Form.Item
|
||||
label="Domain (Optional)"
|
||||
name="domain"
|
||||
tooltip="Top-level grouping in the Skill Hub (e.g., Productivity)"
|
||||
className="flex-1"
|
||||
>
|
||||
<Input placeholder="Productivity" className="rounded-lg" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="Namespace (Optional)"
|
||||
name="namespace"
|
||||
tooltip="Sub-grouping within domain (e.g., workflows)"
|
||||
className="flex-1"
|
||||
>
|
||||
<Input placeholder="workflows" className="rounded-lg" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
{/* Skill Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">
|
||||
Skill Name <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="my-skill"
|
||||
className="rounded-lg"
|
||||
aria-invalid={!!formState.errors.name}
|
||||
{...register("name", {
|
||||
required: "Please enter skill name",
|
||||
pattern: {
|
||||
value: /^[a-z0-9-]+$/,
|
||||
message:
|
||||
"Name must be kebab-case (lowercase, numbers, hyphens only)",
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Unique identifier in kebab-case format (e.g., my-skill)
|
||||
</p>
|
||||
{formState.errors.name && (
|
||||
<p className="text-sm text-destructive">
|
||||
{formState.errors.name.message as string}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<Form.Item
|
||||
label="Description (Optional)"
|
||||
name="description"
|
||||
tooltip="Brief description of what the skill does"
|
||||
>
|
||||
<TextArea
|
||||
rows={3}
|
||||
placeholder="A skill that helps with..."
|
||||
maxLength={500}
|
||||
className="rounded-lg"
|
||||
/>
|
||||
</Form.Item>
|
||||
{/* Domain and Namespace — side by side */}
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 space-y-2">
|
||||
<Label htmlFor="domain">Domain (Optional)</Label>
|
||||
<Input
|
||||
id="domain"
|
||||
placeholder="Productivity"
|
||||
className="rounded-lg"
|
||||
{...register("domain")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Top-level grouping in the Skill Hub (e.g., Productivity)
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 space-y-2">
|
||||
<Label htmlFor="namespace">Namespace (Optional)</Label>
|
||||
<Input
|
||||
id="namespace"
|
||||
placeholder="workflows"
|
||||
className="rounded-lg"
|
||||
{...register("namespace")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Sub-grouping within domain (e.g., workflows)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
{/* Description */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description (Optional)</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
rows={3}
|
||||
placeholder="A skill that helps with..."
|
||||
maxLength={500}
|
||||
className="rounded-lg"
|
||||
{...register("description")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Brief description of what the skill does
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
{/* Category */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">Category (Optional)</Label>
|
||||
<Controller
|
||||
control={control}
|
||||
name="category"
|
||||
render={({ field }) => (
|
||||
<Select
|
||||
value={field.value || ""}
|
||||
onValueChange={(v) => field.onChange(v === "__none__" ? "" : v)}
|
||||
>
|
||||
<SelectTrigger id="category" className="rounded-lg">
|
||||
<SelectValue placeholder="Select a category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">
|
||||
<span className="text-muted-foreground">None</span>
|
||||
</SelectItem>
|
||||
{PREDEFINED_CATEGORIES.map((cat) => (
|
||||
<SelectItem key={cat} value={cat}>
|
||||
{cat}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
{/* Keywords */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="keywords">Keywords (Optional)</Label>
|
||||
<Input
|
||||
id="keywords"
|
||||
placeholder="search, web, api"
|
||||
className="rounded-lg"
|
||||
{...register("keywords")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Comma-separated list of keywords for search
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Author Name */}
|
||||
<Form.Item
|
||||
label="Author Name (Optional)"
|
||||
name="authorName"
|
||||
tooltip="Name of the skill author or organization"
|
||||
>
|
||||
<Input placeholder="Your Name or Organization" className="rounded-lg" />
|
||||
</Form.Item>
|
||||
{/* Version */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="version">Version (Optional)</Label>
|
||||
<Input
|
||||
id="version"
|
||||
placeholder="1.0.0"
|
||||
className="rounded-lg"
|
||||
{...register("version")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Semantic version (e.g., 1.0.0)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Author Email */}
|
||||
<Form.Item
|
||||
label="Author Email (Optional)"
|
||||
name="authorEmail"
|
||||
rules={[{ type: "email", message: "Please enter a valid email" }]}
|
||||
tooltip="Contact email for the skill author"
|
||||
>
|
||||
<Input type="email" placeholder="author@example.com" className="rounded-lg" />
|
||||
</Form.Item>
|
||||
{/* Author Name */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="authorName">Author Name (Optional)</Label>
|
||||
<Input
|
||||
id="authorName"
|
||||
placeholder="Your Name or Organization"
|
||||
className="rounded-lg"
|
||||
{...register("authorName")}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Name of the skill author or organization
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Submit Buttons */}
|
||||
<Form.Item className="mb-0 mt-6">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={handleCancel} disabled={isSubmitting}>
|
||||
{/* Author Email */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="authorEmail">Author Email (Optional)</Label>
|
||||
<Input
|
||||
id="authorEmail"
|
||||
type="email"
|
||||
placeholder="author@example.com"
|
||||
className="rounded-lg"
|
||||
aria-invalid={!!formState.errors.authorEmail}
|
||||
{...register("authorEmail", {
|
||||
validate: (v) =>
|
||||
!v || isValidEmail(v) || "Please enter a valid email",
|
||||
})}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Contact email for the skill author
|
||||
</p>
|
||||
{formState.errors.authorEmail && (
|
||||
<p className="text-sm text-destructive">
|
||||
{formState.errors.authorEmail.message as string}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Submit Buttons */}
|
||||
<div className="flex justify-end gap-2 pt-6 mt-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={isSubmitting}>
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Adding..." : "Add Skill"}
|
||||
</Button>
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue