mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-11 02:33:51 +08:00
feat: enhance MCP server management and system settings
- Added functionality to save MCP server configurations as templates in the MCP Manager. - Implemented new hooks for managing system settings including Chinese response, Windows platform, and Codex CLI enhancements. - Updated API calls to support fetching and toggling system settings. - Introduced UI components for displaying and managing response language settings and system status. - Enhanced error handling and notifications for server deletion and template saving actions. - Updated localization files for new settings and descriptions in English and Chinese.
This commit is contained in:
@@ -56,6 +56,7 @@ interface NavGroupDef {
|
||||
icon: React.ElementType;
|
||||
badge?: number | string;
|
||||
badgeVariant?: 'default' | 'success' | 'warning' | 'info';
|
||||
end?: boolean;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -110,7 +111,7 @@ const navGroupDefinitions: NavGroupDef[] = [
|
||||
items: [
|
||||
{ path: '/settings/codexlens', labelKey: 'navigation.main.codexlens', icon: Sparkles },
|
||||
{ path: '/api-settings', labelKey: 'navigation.main.apiSettings', icon: Server },
|
||||
{ path: '/settings', labelKey: 'navigation.main.settings', icon: Settings },
|
||||
{ path: '/settings', labelKey: 'navigation.main.settings', icon: Settings, end: true },
|
||||
{ path: '/help', labelKey: 'navigation.main.help', icon: HelpCircle },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -115,6 +115,7 @@ export function CcwToolsMcpCard({
|
||||
mutationFn: installCcwMcp,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: ['ccwMcpConfig'] });
|
||||
onInstall();
|
||||
},
|
||||
});
|
||||
@@ -123,8 +124,12 @@ export function CcwToolsMcpCard({
|
||||
mutationFn: uninstallCcwMcp,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: mcpServersKeys.all });
|
||||
queryClient.invalidateQueries({ queryKey: ['ccwMcpConfig'] });
|
||||
onInstall();
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to uninstall CCW MCP:', error);
|
||||
},
|
||||
});
|
||||
|
||||
const updateConfigMutation = useMutation({
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
createMcpServer,
|
||||
updateMcpServer,
|
||||
fetchMcpServers,
|
||||
saveMcpTemplate,
|
||||
type McpServer,
|
||||
type McpProjectConfigType,
|
||||
} from '@/lib/api';
|
||||
@@ -95,6 +96,7 @@ export function McpServerDialog({
|
||||
const [argsInput, setArgsInput] = useState('');
|
||||
const [envInput, setEnvInput] = useState('');
|
||||
const [configType, setConfigType] = useState<McpConfigType>('mcp-json');
|
||||
const [saveAsTemplate, setSaveAsTemplate] = useState(false);
|
||||
const projectConfigType: McpProjectConfigType = configType === 'claude-json' ? 'claude' : 'mcp';
|
||||
|
||||
// Initialize form from server prop (edit mode)
|
||||
@@ -128,6 +130,7 @@ export function McpServerDialog({
|
||||
setEnvInput('');
|
||||
}
|
||||
setSelectedTemplate('');
|
||||
setSaveAsTemplate(false);
|
||||
setErrors({});
|
||||
}, [server, mode, open]);
|
||||
|
||||
@@ -261,6 +264,23 @@ export function McpServerDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
// Save as template if checked
|
||||
if (saveAsTemplate) {
|
||||
try {
|
||||
await saveMcpTemplate({
|
||||
name: formData.name,
|
||||
category: 'custom',
|
||||
serverConfig: {
|
||||
command: formData.command,
|
||||
args: formData.args.length > 0 ? formData.args : undefined,
|
||||
env: Object.keys(formData.env).length > 0 ? formData.env : undefined,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Template save failure should not block server creation
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'add') {
|
||||
createMutation.mutate({
|
||||
server: {
|
||||
@@ -501,6 +521,20 @@ export function McpServerDialog({
|
||||
{formatMessage({ id: 'mcp.dialog.form.enabled' })}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Save as Template */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="save-as-template"
|
||||
checked={saveAsTemplate}
|
||||
onChange={(e) => setSaveAsTemplate(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<label htmlFor="save-as-template" className="text-sm font-medium text-foreground cursor-pointer">
|
||||
{formatMessage({ id: 'mcp.templates.actions.saveAsTemplate' })}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
@@ -58,17 +58,18 @@ import type { McpTemplate } from '@/types/store';
|
||||
export interface McpTemplatesSectionProps {
|
||||
/** Callback when template is installed (opens McpServerDialog) */
|
||||
onInstallTemplate?: (template: McpTemplate) => void;
|
||||
/** Callback when current server should be saved as template */
|
||||
onSaveAsTemplate?: (serverName: string, config: { command: string; args: string[]; env?: Record<string, string> }) => void;
|
||||
/** Callback when saving a new template */
|
||||
onSaveAsTemplate?: (name: string, category: string, description: string, serverConfig: { command: string; args: string[]; env: Record<string, string> }) => void;
|
||||
}
|
||||
|
||||
interface TemplateSaveDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (name: string, category: string, description: string) => void;
|
||||
onSave: (name: string, category: string, description: string, serverConfig: { command: string; args: string[]; env: Record<string, string> }) => void;
|
||||
defaultName?: string;
|
||||
defaultCommand?: string;
|
||||
defaultArgs?: string[];
|
||||
defaultEnv?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface TemplateCardProps {
|
||||
@@ -181,18 +182,26 @@ function TemplateCard({ template, onInstall, onDelete, isInstalling, isDeleting
|
||||
/**
|
||||
* Template Save Dialog - Save current server configuration as template
|
||||
*/
|
||||
function TemplateSaveDialog({
|
||||
export function TemplateSaveDialog({
|
||||
open,
|
||||
onClose,
|
||||
onSave,
|
||||
defaultName = '',
|
||||
defaultCommand = '',
|
||||
defaultArgs = [],
|
||||
defaultEnv = {},
|
||||
}: TemplateSaveDialogProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const [name, setName] = useState(defaultName);
|
||||
const [category, setCategory] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [errors, setErrors] = useState<{ name?: string }>({});
|
||||
const [command, setCommand] = useState(defaultCommand);
|
||||
const [argsInput, setArgsInput] = useState(defaultArgs.join(', '));
|
||||
const [envInput, setEnvInput] = useState(
|
||||
Object.entries(defaultEnv).map(([k, v]) => `${k}=${v}`).join('\n')
|
||||
);
|
||||
const [errors, setErrors] = useState<{ name?: string; command?: string }>({});
|
||||
|
||||
// Reset form when dialog opens
|
||||
useEffect(() => {
|
||||
@@ -200,25 +209,52 @@ function TemplateSaveDialog({
|
||||
setName(defaultName || '');
|
||||
setCategory('');
|
||||
setDescription('');
|
||||
setCommand(defaultCommand || '');
|
||||
setArgsInput((defaultArgs || []).join(', '));
|
||||
setEnvInput(
|
||||
Object.entries(defaultEnv || {}).map(([k, v]) => `${k}=${v}`).join('\n')
|
||||
);
|
||||
setErrors({});
|
||||
}
|
||||
}, [open, defaultName]);
|
||||
}, [open, defaultName, defaultCommand, defaultArgs, defaultEnv]);
|
||||
|
||||
const handleSave = () => {
|
||||
const newErrors: { name?: string; command?: string } = {};
|
||||
if (!name.trim()) {
|
||||
setErrors({ name: formatMessage({ id: 'mcp.templates.saveDialog.validation.nameRequired' }) });
|
||||
newErrors.name = formatMessage({ id: 'mcp.templates.saveDialog.validation.nameRequired' });
|
||||
}
|
||||
if (!command.trim()) {
|
||||
newErrors.command = formatMessage({ id: 'mcp.dialog.validation.commandRequired' });
|
||||
}
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setErrors(newErrors);
|
||||
return;
|
||||
}
|
||||
onSave(name.trim(), category.trim(), description.trim());
|
||||
setName('');
|
||||
setCategory('');
|
||||
setDescription('');
|
||||
setErrors({});
|
||||
|
||||
const args = argsInput
|
||||
.split(',')
|
||||
.map((a) => a.trim())
|
||||
.filter((a) => a.length > 0);
|
||||
|
||||
const env: Record<string, string> = {};
|
||||
for (const line of envInput.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed && trimmed.includes('=')) {
|
||||
const [key, ...valParts] = trimmed.split('=');
|
||||
if (key) env[key.trim()] = valParts.join('=').trim();
|
||||
}
|
||||
}
|
||||
|
||||
onSave(name.trim(), category.trim(), description.trim(), {
|
||||
command: command.trim(),
|
||||
args,
|
||||
env,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogContent className="max-w-md max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{formatMessage({ id: 'mcp.templates.saveDialog.title' })}
|
||||
@@ -235,7 +271,7 @@ function TemplateSaveDialog({
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
if (errors.name) setErrors({});
|
||||
if (errors.name) setErrors((prev) => ({ ...prev, name: undefined }));
|
||||
}}
|
||||
placeholder={formatMessage({ id: 'mcp.templates.saveDialog.namePlaceholder' })}
|
||||
error={!!errors.name}
|
||||
@@ -245,6 +281,54 @@ function TemplateSaveDialog({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Command */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'mcp.dialog.form.command' })}
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
</label>
|
||||
<Input
|
||||
value={command}
|
||||
onChange={(e) => {
|
||||
setCommand(e.target.value);
|
||||
if (errors.command) setErrors((prev) => ({ ...prev, command: undefined }));
|
||||
}}
|
||||
placeholder={formatMessage({ id: 'mcp.dialog.form.commandPlaceholder' })}
|
||||
error={!!errors.command}
|
||||
/>
|
||||
{errors.command && (
|
||||
<p className="text-sm text-destructive">{errors.command}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Args */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'mcp.dialog.form.args' })}
|
||||
</label>
|
||||
<Input
|
||||
value={argsInput}
|
||||
onChange={(e) => setArgsInput(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'mcp.dialog.form.argsPlaceholder' })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'mcp.dialog.form.argsHint' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Environment Variables */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
{formatMessage({ id: 'mcp.dialog.form.env' })}
|
||||
</label>
|
||||
<textarea
|
||||
value={envInput}
|
||||
onChange={(e) => setEnvInput(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'mcp.dialog.form.envPlaceholder' })}
|
||||
className="flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm font-mono ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Category */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-foreground">
|
||||
@@ -273,7 +357,7 @@ function TemplateSaveDialog({
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'mcp.templates.saveDialog.descriptionPlaceholder' })}
|
||||
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
className="flex min-h-[60px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -374,8 +458,8 @@ export function McpTemplatesSection({ onInstallTemplate, onSaveAsTemplate }: Mcp
|
||||
}
|
||||
}, [templateToDelete, deleteMutation]);
|
||||
|
||||
const handleSaveTemplate = useCallback((_name: string, _category: string, _description: string) => {
|
||||
onSaveAsTemplate?.(_name, { command: '', args: [] });
|
||||
const handleSaveTemplate = useCallback((_name: string, _category: string, _description: string, _serverConfig: { command: string; args: string[]; env: Record<string, string> }) => {
|
||||
onSaveAsTemplate?.(_name, _category, _description, _serverConfig);
|
||||
setSaveDialogOpen(false);
|
||||
}, [onSaveAsTemplate]);
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface NavItem {
|
||||
icon: React.ElementType;
|
||||
badge?: number | string;
|
||||
badgeVariant?: 'default' | 'success' | 'warning' | 'info';
|
||||
/** When true, only exact path match activates this item (no prefix matching) */
|
||||
end?: boolean;
|
||||
}
|
||||
|
||||
export interface NavGroupProps {
|
||||
@@ -54,10 +56,10 @@ export function NavGroup({
|
||||
{items.map((item) => {
|
||||
const ItemIcon = item.icon;
|
||||
const [basePath] = item.path.split('?');
|
||||
// More precise matching: exact match or basePath followed by '/' to avoid parent/child conflicts
|
||||
const isActive =
|
||||
location.pathname === basePath ||
|
||||
(basePath !== '/' && location.pathname.startsWith(basePath + '/'));
|
||||
const isActive = item.end
|
||||
? location.pathname === basePath
|
||||
: location.pathname === basePath ||
|
||||
(basePath !== '/' && location.pathname.startsWith(basePath + '/'));
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
@@ -94,10 +96,10 @@ export function NavGroup({
|
||||
{items.map((item) => {
|
||||
const ItemIcon = item.icon;
|
||||
const [basePath, searchParams] = item.path.split('?');
|
||||
// More precise matching: exact match or basePath followed by '/' to avoid parent/child conflicts
|
||||
const isActive =
|
||||
location.pathname === basePath ||
|
||||
(basePath !== '/' && location.pathname.startsWith(basePath + '/'));
|
||||
const isActive = item.end
|
||||
? location.pathname === basePath
|
||||
: location.pathname === basePath ||
|
||||
(basePath !== '/' && location.pathname.startsWith(basePath + '/'));
|
||||
const isQueryParamActive =
|
||||
searchParams && location.search.includes(searchParams);
|
||||
|
||||
|
||||
406
ccw/frontend/src/components/shared/SkillCreateDialog.tsx
Normal file
406
ccw/frontend/src/components/shared/SkillCreateDialog.tsx
Normal file
@@ -0,0 +1,406 @@
|
||||
// ========================================
|
||||
// Skill Create Dialog Component
|
||||
// ========================================
|
||||
// Modal dialog for creating/importing skills with two modes:
|
||||
// - Import: import existing skill folder
|
||||
// - CLI Generate: AI-generated skill from description
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Folder,
|
||||
User,
|
||||
FolderInput,
|
||||
Sparkles,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Loader2,
|
||||
Info,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/Dialog';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Textarea } from '@/components/ui/Textarea';
|
||||
import { Label } from '@/components/ui/Label';
|
||||
import { validateSkillImport, createSkill } from '@/lib/api';
|
||||
import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface SkillCreateDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
type CreateMode = 'import' | 'cli-generate';
|
||||
type SkillLocation = 'project' | 'user';
|
||||
|
||||
interface ValidationResult {
|
||||
valid: boolean;
|
||||
errors?: string[];
|
||||
skillInfo?: { name: string; description: string; version?: string; supportingFiles?: string[] };
|
||||
}
|
||||
|
||||
export function SkillCreateDialog({ open, onOpenChange, onCreated }: SkillCreateDialogProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const projectPath = useWorkflowStore(selectProjectPath);
|
||||
|
||||
const [mode, setMode] = useState<CreateMode>('import');
|
||||
const [location, setLocation] = useState<SkillLocation>('project');
|
||||
|
||||
// Import mode state
|
||||
const [sourcePath, setSourcePath] = useState('');
|
||||
const [customName, setCustomName] = useState('');
|
||||
const [validationResult, setValidationResult] = useState<ValidationResult | null>(null);
|
||||
const [isValidating, setIsValidating] = useState(false);
|
||||
|
||||
// CLI Generate mode state
|
||||
const [skillName, setSkillName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setMode('import');
|
||||
setLocation('project');
|
||||
setSourcePath('');
|
||||
setCustomName('');
|
||||
setValidationResult(null);
|
||||
setIsValidating(false);
|
||||
setSkillName('');
|
||||
setDescription('');
|
||||
setIsCreating(false);
|
||||
}, []);
|
||||
|
||||
const handleOpenChange = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
resetState();
|
||||
}
|
||||
onOpenChange(open);
|
||||
}, [onOpenChange, resetState]);
|
||||
|
||||
const handleValidate = useCallback(async () => {
|
||||
if (!sourcePath.trim()) return;
|
||||
|
||||
setIsValidating(true);
|
||||
setValidationResult(null);
|
||||
|
||||
try {
|
||||
const result = await validateSkillImport(sourcePath.trim());
|
||||
setValidationResult(result);
|
||||
} catch (err) {
|
||||
setValidationResult({
|
||||
valid: false,
|
||||
errors: [err instanceof Error ? err.message : String(err)],
|
||||
});
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
}, [sourcePath]);
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
if (mode === 'import') {
|
||||
if (!sourcePath.trim()) return;
|
||||
if (!validationResult?.valid) return;
|
||||
} else {
|
||||
if (!skillName.trim()) return;
|
||||
if (!description.trim()) return;
|
||||
}
|
||||
|
||||
setIsCreating(true);
|
||||
|
||||
try {
|
||||
await createSkill({
|
||||
mode,
|
||||
location,
|
||||
sourcePath: mode === 'import' ? sourcePath.trim() : undefined,
|
||||
skillName: mode === 'import' ? (customName.trim() || undefined) : skillName.trim(),
|
||||
description: mode === 'cli-generate' ? description.trim() : undefined,
|
||||
generationType: mode === 'cli-generate' ? 'description' : undefined,
|
||||
projectPath,
|
||||
});
|
||||
|
||||
handleOpenChange(false);
|
||||
onCreated();
|
||||
} catch (err) {
|
||||
console.error('Failed to create skill:', err);
|
||||
if (mode === 'import') {
|
||||
setValidationResult({
|
||||
valid: false,
|
||||
errors: [err instanceof Error ? err.message : formatMessage({ id: 'skills.create.createError' })],
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [mode, location, sourcePath, customName, skillName, description, validationResult, projectPath, handleOpenChange, onCreated, formatMessage]);
|
||||
|
||||
const canCreate = mode === 'import'
|
||||
? sourcePath.trim() && validationResult?.valid && !isCreating
|
||||
: skillName.trim() && description.trim() && !isCreating;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{formatMessage({ id: 'skills.create.title' })}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{formatMessage({ id: 'skills.description' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5 py-2">
|
||||
{/* Location Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label>{formatMessage({ id: 'skills.create.location' })}</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'px-4 py-3 text-left border-2 rounded-lg transition-all',
|
||||
location === 'project'
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border hover:border-primary/50'
|
||||
)}
|
||||
onClick={() => setLocation('project')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Folder className="w-5 h-5" />
|
||||
<div>
|
||||
<div className="font-medium text-sm">{formatMessage({ id: 'skills.create.locationProject' })}</div>
|
||||
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'skills.create.locationProjectHint' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'px-4 py-3 text-left border-2 rounded-lg transition-all',
|
||||
location === 'user'
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border hover:border-primary/50'
|
||||
)}
|
||||
onClick={() => setLocation('user')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-5 h-5" />
|
||||
<div>
|
||||
<div className="font-medium text-sm">{formatMessage({ id: 'skills.create.locationUser' })}</div>
|
||||
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'skills.create.locationUserHint' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label>{formatMessage({ id: 'skills.create.mode' })}</Label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'px-4 py-3 text-left border-2 rounded-lg transition-all',
|
||||
mode === 'import'
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border hover:border-primary/50'
|
||||
)}
|
||||
onClick={() => setMode('import')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FolderInput className="w-5 h-5" />
|
||||
<div>
|
||||
<div className="font-medium text-sm">{formatMessage({ id: 'skills.create.modeImport' })}</div>
|
||||
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'skills.create.modeImportHint' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'px-4 py-3 text-left border-2 rounded-lg transition-all',
|
||||
mode === 'cli-generate'
|
||||
? 'border-primary bg-primary/10'
|
||||
: 'border-border hover:border-primary/50'
|
||||
)}
|
||||
onClick={() => setMode('cli-generate')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5" />
|
||||
<div>
|
||||
<div className="font-medium text-sm">{formatMessage({ id: 'skills.create.modeGenerate' })}</div>
|
||||
<div className="text-xs text-muted-foreground">{formatMessage({ id: 'skills.create.modeGenerateHint' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Import Mode Content */}
|
||||
{mode === 'import' && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="sourcePath">{formatMessage({ id: 'skills.create.sourcePath' })}</Label>
|
||||
<Input
|
||||
id="sourcePath"
|
||||
value={sourcePath}
|
||||
onChange={(e) => {
|
||||
setSourcePath(e.target.value);
|
||||
setValidationResult(null);
|
||||
}}
|
||||
placeholder={formatMessage({ id: 'skills.create.sourcePathPlaceholder' })}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{formatMessage({ id: 'skills.create.sourcePathHint' })}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="customName">
|
||||
{formatMessage({ id: 'skills.create.customName' })}
|
||||
<span className="text-muted-foreground ml-1">({formatMessage({ id: 'skills.create.customNameHint' })})</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="customName"
|
||||
value={customName}
|
||||
onChange={(e) => setCustomName(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'skills.create.customNamePlaceholder' })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Validation Result */}
|
||||
{isValidating && (
|
||||
<div className="flex items-center gap-2 p-3 bg-muted/50 rounded-lg">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span className="text-sm text-muted-foreground">{formatMessage({ id: 'skills.create.validating' })}</span>
|
||||
</div>
|
||||
)}
|
||||
{validationResult && !isValidating && (
|
||||
validationResult.valid ? (
|
||||
<div className="p-4 bg-green-500/10 border border-green-500/20 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-green-600 mb-2">
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
<span className="font-medium">{formatMessage({ id: 'skills.create.validSkill' })}</span>
|
||||
</div>
|
||||
{validationResult.skillInfo && (
|
||||
<div className="space-y-1 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">{formatMessage({ id: 'skills.card.description' })}: </span>
|
||||
<span>{validationResult.skillInfo.name}</span>
|
||||
</div>
|
||||
{validationResult.skillInfo.description && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">{formatMessage({ id: 'skills.card.description' })}: </span>
|
||||
<span>{validationResult.skillInfo.description}</span>
|
||||
</div>
|
||||
)}
|
||||
{validationResult.skillInfo.version && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">{formatMessage({ id: 'skills.card.version' })}: </span>
|
||||
<span>{validationResult.skillInfo.version}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-4 bg-destructive/10 border border-destructive/20 rounded-lg">
|
||||
<div className="flex items-center gap-2 text-destructive mb-2">
|
||||
<XCircle className="w-5 h-5" />
|
||||
<span className="font-medium">{formatMessage({ id: 'skills.create.invalidSkill' })}</span>
|
||||
</div>
|
||||
{validationResult.errors && (
|
||||
<ul className="space-y-1 text-sm">
|
||||
{validationResult.errors.map((error, i) => (
|
||||
<li key={i} className="text-destructive">{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CLI Generate Mode Content */}
|
||||
{mode === 'cli-generate' && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="skillName">
|
||||
{formatMessage({ id: 'skills.create.skillName' })} <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="skillName"
|
||||
value={skillName}
|
||||
onChange={(e) => setSkillName(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'skills.create.skillNamePlaceholder' })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{formatMessage({ id: 'skills.create.skillNameHint' })}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">
|
||||
{formatMessage({ id: 'skills.create.descriptionLabel' })} <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'skills.create.descriptionPlaceholder' })}
|
||||
rows={6}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{formatMessage({ id: 'skills.create.descriptionHint' })}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg">
|
||||
<div className="flex items-start gap-2">
|
||||
<Info className="w-4 h-4 text-blue-600 mt-0.5" />
|
||||
<div className="text-sm text-blue-600">
|
||||
<p className="font-medium">{formatMessage({ id: 'skills.create.generateInfo' })}</p>
|
||||
<p className="text-xs mt-1">{formatMessage({ id: 'skills.create.generateTimeHint' })}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)} disabled={isCreating}>
|
||||
{formatMessage({ id: 'skills.actions.cancel' })}
|
||||
</Button>
|
||||
{mode === 'import' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleValidate}
|
||||
disabled={!sourcePath.trim() || isValidating || isCreating}
|
||||
>
|
||||
{isValidating && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
{formatMessage({ id: 'skills.create.validate' })}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
disabled={!canCreate}
|
||||
>
|
||||
{isCreating && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
{isCreating
|
||||
? formatMessage({ id: 'skills.create.creating' })
|
||||
: mode === 'import'
|
||||
? formatMessage({ id: 'skills.create.import' })
|
||||
: formatMessage({ id: 'skills.create.generate' })
|
||||
}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default SkillCreateDialog;
|
||||
@@ -19,6 +19,9 @@ export type { SkillCardProps } from './SkillCard';
|
||||
export { SkillDetailPanel } from './SkillDetailPanel';
|
||||
export type { SkillDetailPanelProps } from './SkillDetailPanel';
|
||||
|
||||
export { SkillCreateDialog } from './SkillCreateDialog';
|
||||
export type { SkillCreateDialogProps } from './SkillCreateDialog';
|
||||
|
||||
export { StatCard, StatCardSkeleton } from './StatCard';
|
||||
export type { StatCardProps } from './StatCard';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user