mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-01 15:03:57 +08:00
feat: add Skill Hub feature for managing community skills
- Implemented Skill Hub page with tabs for remote, local, and installed skills. - Added localization support for Chinese in skill-hub.json. - Created API routes for fetching remote skills, listing local skills, and managing installed skills. - Developed functionality for installing and uninstalling skills from both remote and local sources. - Introduced caching mechanism for remote skills and handling updates for installed skills.
This commit is contained in:
328
ccw/frontend/src/components/shared/SkillHubCard.tsx
Normal file
328
ccw/frontend/src/components/shared/SkillHubCard.tsx
Normal file
@@ -0,0 +1,328 @@
|
||||
// ========================================
|
||||
// SkillHubCard Component
|
||||
// ========================================
|
||||
// Card component for displaying skills from hub with install/uninstall actions
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Download,
|
||||
RefreshCw,
|
||||
Check,
|
||||
Globe,
|
||||
Folder,
|
||||
Tag,
|
||||
User,
|
||||
Clock,
|
||||
MoreVertical,
|
||||
Info,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/Dropdown';
|
||||
import { CliModeToggle, type CliMode } from '@/components/mcp/CliModeToggle';
|
||||
import type { RemoteSkill, LocalSkill, InstalledSkill, CliType, SkillSource } from '@/hooks/useSkillHub';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface SkillHubCardProps {
|
||||
skill: RemoteSkill | LocalSkill;
|
||||
installedInfo?: InstalledSkill;
|
||||
source: SkillSource;
|
||||
onInstall?: (skill: RemoteSkill | LocalSkill, cliType: CliType) => Promise<void>;
|
||||
onUninstall?: (skill: RemoteSkill | LocalSkill, cliType: CliType) => Promise<void>;
|
||||
onViewDetails?: (skill: RemoteSkill | LocalSkill) => void;
|
||||
isInstalling?: boolean;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
// ========== Source Badge ==========
|
||||
|
||||
function SkillSourceBadge({ source }: { source: SkillSource }) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
if (source === 'remote') {
|
||||
return (
|
||||
<Badge variant="default" className="gap-1">
|
||||
<Globe className="w-3 h-3" />
|
||||
{formatMessage({ id: 'skillHub.source.remote' })}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Folder className="w-3 h-3" />
|
||||
{formatMessage({ id: 'skillHub.source.local' })}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Install Status Badge ==========
|
||||
|
||||
function InstallStatusBadge({ installedInfo }: { installedInfo?: InstalledSkill }) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
if (!installedInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (installedInfo.updatesAvailable) {
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1 text-amber-500 border-amber-500">
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
{formatMessage({ id: 'skillHub.status.updateAvailable' })}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant="outline" className="gap-1 text-success border-success">
|
||||
<Check className="w-3 h-3" />
|
||||
{formatMessage({ id: 'skillHub.status.installed' })}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main SkillHubCard Component ==========
|
||||
|
||||
export function SkillHubCard({
|
||||
skill,
|
||||
installedInfo,
|
||||
source,
|
||||
onInstall,
|
||||
onUninstall,
|
||||
onViewDetails,
|
||||
isInstalling = false,
|
||||
className,
|
||||
compact = false,
|
||||
}: SkillHubCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [cliMode, setCliMode] = useState<CliMode>('claude');
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const [localInstalling, setLocalInstalling] = useState(false);
|
||||
|
||||
const isRemote = source === 'remote';
|
||||
const isInstalled = !!installedInfo;
|
||||
const isLoading = isInstalling || localInstalling;
|
||||
|
||||
const handleInstall = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setLocalInstalling(true);
|
||||
try {
|
||||
await onInstall?.(skill, cliMode);
|
||||
toast.success(formatMessage({ id: 'skillHub.install.success' }, { name: skill.name }));
|
||||
} catch (error) {
|
||||
toast.error(formatMessage({ id: 'skillHub.install.error' }, { error: String(error) }));
|
||||
} finally {
|
||||
setLocalInstalling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUninstall = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsMenuOpen(false);
|
||||
try {
|
||||
await onUninstall?.(skill, installedInfo?.installedTo || cliMode);
|
||||
toast.success(formatMessage({ id: 'skillHub.uninstall.success' }, { name: skill.name }));
|
||||
} catch (error) {
|
||||
toast.error(formatMessage({ id: 'skillHub.uninstall.error' }, { error: String(error) }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewDetails = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setIsMenuOpen(false);
|
||||
onViewDetails?.(skill);
|
||||
};
|
||||
|
||||
// Compact view
|
||||
if (compact) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'p-3 bg-card border rounded-lg',
|
||||
'hover:shadow-md transition-all',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Download className="w-4 h-4 flex-shrink-0 text-primary" />
|
||||
<span className="text-sm font-medium text-foreground truncate">{skill.name}</span>
|
||||
{skill.version && (
|
||||
<span className="text-xs text-muted-foreground">v{skill.version}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CliModeToggle currentMode={cliMode} onModeChange={setCliMode} />
|
||||
<Button
|
||||
variant={isInstalled ? 'outline' : 'default'}
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={handleInstall}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<RefreshCw className="w-3 h-3 animate-spin" />
|
||||
) : isInstalled ? (
|
||||
<>
|
||||
<RefreshCw className="w-3 h-3 mr-1" />
|
||||
{formatMessage({ id: 'skillHub.actions.update' })}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-3 h-3 mr-1" />
|
||||
{formatMessage({ id: 'skillHub.actions.install' })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Full card view
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'p-4 hover:shadow-md transition-all hover-glow hover:border-primary/50',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-start gap-3 min-w-0">
|
||||
<div className="p-2 rounded-lg flex-shrink-0 bg-primary/10">
|
||||
<Download className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-medium text-foreground">{skill.name}</h3>
|
||||
{skill.version && (
|
||||
<span className="text-xs text-muted-foreground">v{skill.version}</span>
|
||||
)}
|
||||
</div>
|
||||
{skill.author && (
|
||||
<div className="flex items-center gap-1 mt-0.5 text-xs text-muted-foreground">
|
||||
<User className="w-3 h-3" />
|
||||
{skill.author}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DropdownMenu open={isMenuOpen} onOpenChange={setIsMenuOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={handleViewDetails}>
|
||||
<Info className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'skillHub.actions.viewDetails' })}
|
||||
</DropdownMenuItem>
|
||||
{isInstalled && (
|
||||
<DropdownMenuItem onClick={handleUninstall} className="text-destructive">
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'skillHub.actions.uninstall' })}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-muted-foreground mt-3 line-clamp-2">
|
||||
{skill.description}
|
||||
</p>
|
||||
|
||||
{/* Tags */}
|
||||
{skill.tags && skill.tags.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mb-1">
|
||||
<Tag className="w-3 h-3" />
|
||||
{formatMessage({ id: 'skillHub.card.tags' })}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{skill.tags.slice(0, 4).map((tag) => (
|
||||
<Badge key={tag} variant="outline" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{skill.tags.length > 4 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{skill.tags.length - 4}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Updated date (for remote skills) */}
|
||||
{isRemote && (skill as RemoteSkill).updatedAt && (
|
||||
<div className="flex items-center gap-1 mt-2 text-xs text-muted-foreground">
|
||||
<Clock className="w-3 h-3" />
|
||||
{formatMessage(
|
||||
{ id: 'skillHub.card.updated' },
|
||||
{ date: new Date((skill as RemoteSkill).updatedAt as string).toLocaleDateString() }
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between mt-4 pt-3 border-t border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
<SkillSourceBadge source={source} />
|
||||
{skill.category && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{skill.category}
|
||||
</Badge>
|
||||
)}
|
||||
<InstallStatusBadge installedInfo={installedInfo} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Install section */}
|
||||
<div className="flex items-center justify-between gap-2 mt-3">
|
||||
<CliModeToggle currentMode={cliMode} onModeChange={setCliMode} />
|
||||
<Button
|
||||
variant={isInstalled ? 'outline' : 'default'}
|
||||
size="sm"
|
||||
onClick={handleInstall}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-1 animate-spin" />
|
||||
{formatMessage({ id: 'skillHub.actions.installing' })}
|
||||
</>
|
||||
) : isInstalled ? (
|
||||
<>
|
||||
<RefreshCw className="w-4 h-4 mr-1" />
|
||||
{formatMessage({ id: 'skillHub.actions.update' })}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4 mr-1" />
|
||||
{formatMessage({ id: 'skillHub.actions.install' })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default SkillHubCard;
|
||||
@@ -22,6 +22,9 @@ export type { SkillDetailPanelProps } from './SkillDetailPanel';
|
||||
export { SkillCreateDialog } from './SkillCreateDialog';
|
||||
export type { SkillCreateDialogProps } from './SkillCreateDialog';
|
||||
|
||||
export { SkillHubCard } from './SkillHubCard';
|
||||
export type { SkillHubCardProps } from './SkillHubCard';
|
||||
|
||||
export { StatCard, StatCardSkeleton } from './StatCard';
|
||||
export type { StatCardProps } from './StatCard';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user