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:
catlog22
2026-02-07 21:17:18 +08:00
parent d29527ae16
commit 2094c1085b
52 changed files with 2061 additions and 602 deletions

View File

@@ -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 },
],
},

View File

@@ -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({

View File

@@ -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>

View File

@@ -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]);

View File

@@ -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);

View 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;

View File

@@ -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';