mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-02 15:23:19 +08:00
feat: add SpecDialog component for editing spec frontmatter
- Implement SpecDialog for managing spec details including title, read mode, priority, and keywords. - Add validation and keyword management functionality. - Integrate SpecDialog into SpecsSettingsPage for editing specs. feat: create index file for specs components - Export SpecCard, SpecDialog, and related types from a new index file for better organization. feat: implement SpecsSettingsPage for managing specs and hooks - Create main settings page with tabs for Project Specs, Personal Specs, Hooks, Injection, and Settings. - Integrate SpecDialog and HookDialog for editing specs and hooks. - Add search functionality and mock data for specs and hooks. feat: add spec management API routes - Implement API endpoints for listing specs, getting spec details, updating frontmatter, rebuilding indices, and initializing the spec system. - Handle errors and responses appropriately for each endpoint.
This commit is contained in:
318
ccw/frontend/src/components/specs/HookCard.tsx
Normal file
318
ccw/frontend/src/components/specs/HookCard.tsx
Normal file
@@ -0,0 +1,318 @@
|
||||
// ========================================
|
||||
// HookCard Component
|
||||
// ========================================
|
||||
// Hook card with event badge, scope badge and action menu
|
||||
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Switch } from '@/components/ui/Switch';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
} from '@/components/ui/Dropdown';
|
||||
import {
|
||||
Zap,
|
||||
MoreVertical,
|
||||
Edit,
|
||||
Trash2,
|
||||
Globe,
|
||||
Folder,
|
||||
Play,
|
||||
Clock,
|
||||
Terminal,
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Hook event types
|
||||
*/
|
||||
export type HookEvent = 'SessionStart' | 'UserPromptSubmit' | 'SessionEnd';
|
||||
|
||||
/**
|
||||
* Hook scope types
|
||||
*/
|
||||
export type HookScope = 'global' | 'project';
|
||||
|
||||
/**
|
||||
* Fail mode for hooks
|
||||
*/
|
||||
export type HookFailMode = 'continue' | 'block' | 'warn';
|
||||
|
||||
/**
|
||||
* Hook configuration interface
|
||||
*/
|
||||
export interface HookConfig {
|
||||
/** Unique hook identifier */
|
||||
id: string;
|
||||
/** Hook name */
|
||||
name: string;
|
||||
/** Trigger event */
|
||||
event: HookEvent;
|
||||
/** Command to execute */
|
||||
command: string;
|
||||
/** Description */
|
||||
description?: string;
|
||||
/** Scope (global or project) */
|
||||
scope: HookScope;
|
||||
/** Whether hook is enabled */
|
||||
enabled: boolean;
|
||||
/** Whether hook is installed */
|
||||
installed?: boolean;
|
||||
/** Whether this is a recommended hook */
|
||||
isRecommended?: boolean;
|
||||
/** Timeout in milliseconds */
|
||||
timeout?: number;
|
||||
/** Fail mode */
|
||||
failMode?: HookFailMode;
|
||||
}
|
||||
|
||||
export interface HookCardProps {
|
||||
/** Hook data */
|
||||
hook: HookConfig;
|
||||
/** Called when edit action is triggered */
|
||||
onEdit?: (hook: HookConfig) => void;
|
||||
/** Called when uninstall action is triggered */
|
||||
onUninstall?: (hookId: string) => void;
|
||||
/** Called when toggle enabled is triggered */
|
||||
onToggle?: (hookId: string, enabled: boolean) => void;
|
||||
/** Optional className */
|
||||
className?: string;
|
||||
/** Show actions dropdown */
|
||||
showActions?: boolean;
|
||||
/** Disabled state for actions */
|
||||
actionsDisabled?: boolean;
|
||||
/** Whether this is a recommended hook card */
|
||||
isRecommendedCard?: boolean;
|
||||
/** Called when install action is triggered (recommended hooks only) */
|
||||
onInstall?: (hookId: string) => void;
|
||||
}
|
||||
|
||||
// Event type configuration
|
||||
const eventConfig: Record<
|
||||
HookEvent,
|
||||
{ variant: 'default' | 'secondary' | 'destructive' | 'success' | 'warning' | 'info'; icon: React.ReactNode }
|
||||
> = {
|
||||
SessionStart: { variant: 'success' as const, icon: <Play className="h-3 w-3" /> },
|
||||
UserPromptSubmit: { variant: 'info' as const, icon: <Terminal className="h-3 w-3" /> },
|
||||
SessionEnd: { variant: 'secondary' as const, icon: <Clock className="h-3 w-3" /> },
|
||||
};
|
||||
|
||||
// Event label keys for i18n
|
||||
const eventLabelKeys: Record<HookEvent, string> = {
|
||||
SessionStart: 'hooks.events.sessionStart',
|
||||
UserPromptSubmit: 'hooks.events.userPromptSubmit',
|
||||
SessionEnd: 'hooks.events.sessionEnd',
|
||||
};
|
||||
|
||||
// Scope label keys for i18n
|
||||
const scopeLabelKeys: Record<HookScope, string> = {
|
||||
global: 'hooks.scope.global',
|
||||
project: 'hooks.scope.project',
|
||||
};
|
||||
|
||||
/**
|
||||
* HookCard component for displaying hook information
|
||||
*/
|
||||
export function HookCard({
|
||||
hook,
|
||||
onEdit,
|
||||
onUninstall,
|
||||
onToggle,
|
||||
className,
|
||||
showActions = true,
|
||||
actionsDisabled = false,
|
||||
isRecommendedCard = false,
|
||||
onInstall,
|
||||
}: HookCardProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
const { variant: eventVariant, icon: eventIcon } = eventConfig[hook.event] || {
|
||||
variant: 'default' as const,
|
||||
icon: <Zap className="h-3 w-3" />,
|
||||
};
|
||||
const eventLabel = formatMessage({ id: eventLabelKeys[hook.event] || 'hooks.events.unknown' });
|
||||
|
||||
const scopeIcon = hook.scope === 'global' ? <Globe className="h-3 w-3" /> : <Folder className="h-3 w-3" />;
|
||||
const scopeLabel = formatMessage({ id: scopeLabelKeys[hook.scope] });
|
||||
|
||||
const handleToggle = (enabled: boolean) => {
|
||||
onToggle?.(hook.id, enabled);
|
||||
};
|
||||
|
||||
const handleAction = (e: React.MouseEvent, action: 'edit' | 'uninstall' | 'install') => {
|
||||
e.stopPropagation();
|
||||
switch (action) {
|
||||
case 'edit':
|
||||
onEdit?.(hook);
|
||||
break;
|
||||
case 'uninstall':
|
||||
onUninstall?.(hook.id);
|
||||
break;
|
||||
case 'install':
|
||||
onInstall?.(hook.id);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// For recommended hooks that are not installed
|
||||
if (isRecommendedCard && !hook.installed) {
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'group transition-all duration-200 hover:shadow-md hover:border-primary/30 hover-glow',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-card-foreground truncate">
|
||||
{hook.name}
|
||||
</h3>
|
||||
<Badge variant="outline" className="gap-1">
|
||||
{scopeIcon}
|
||||
{scopeLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
{hook.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1 line-clamp-1">
|
||||
{hook.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => handleAction(e, 'install')}
|
||||
disabled={actionsDisabled}
|
||||
className="ml-4"
|
||||
>
|
||||
{formatMessage({ id: 'hooks.actions.install' })}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={cn(
|
||||
'group transition-all duration-200 hover:shadow-md hover:border-primary/30 hover-glow',
|
||||
!hook.enabled && 'opacity-60',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-card-foreground truncate">
|
||||
{hook.name}
|
||||
</h3>
|
||||
<Badge variant="outline" className="gap-1" title={scopeLabel}>
|
||||
{scopeIcon}
|
||||
</Badge>
|
||||
</div>
|
||||
{hook.description && (
|
||||
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-1">
|
||||
{hook.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<Badge variant={eventVariant} className="gap-1">
|
||||
{eventIcon}
|
||||
{eventLabel}
|
||||
</Badge>
|
||||
<Switch
|
||||
checked={hook.enabled}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={actionsDisabled}
|
||||
className="data-[state=checked]:bg-primary"
|
||||
/>
|
||||
{showActions && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={actionsDisabled}
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
<span className="sr-only">{formatMessage({ id: 'common.aria.actions' })}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={(e) => handleAction(e, 'edit')}>
|
||||
<Edit className="mr-2 h-4 w-4" />
|
||||
{formatMessage({ id: 'hooks.actions.edit' })}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => handleAction(e, 'uninstall')}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{formatMessage({ id: 'hooks.actions.uninstall' })}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Command info */}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1 font-mono">
|
||||
<Terminal className="h-3.5 w-3.5" />
|
||||
{hook.command}
|
||||
</span>
|
||||
{hook.timeout && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{hook.timeout}ms
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton loader for HookCard
|
||||
*/
|
||||
export function HookCardSkeleton({ className }: { className?: string }) {
|
||||
return (
|
||||
<Card className={cn('animate-pulse', className)}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="h-5 w-32 rounded bg-muted" />
|
||||
<div className="mt-1 h-3 w-48 rounded bg-muted" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-5 w-20 rounded bg-muted" />
|
||||
<div className="h-5 w-8 rounded-full bg-muted" />
|
||||
<div className="h-8 w-8 rounded bg-muted" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex gap-4">
|
||||
<div className="h-3 w-32 rounded bg-muted" />
|
||||
<div className="h-3 w-16 rounded bg-muted" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user