mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-01 10:23:24 +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:
319
ccw/frontend/src/components/specs/SpecDialog.tsx
Normal file
319
ccw/frontend/src/components/specs/SpecDialog.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
// ========================================
|
||||
// SpecDialog Component
|
||||
// ========================================
|
||||
// Dialog for editing spec frontmatter (title, readMode, priority, keywords)
|
||||
|
||||
import * as React from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/Dialog';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Label } from '@/components/ui/Label';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from '@/components/ui/Select';
|
||||
import { Tag, X } from 'lucide-react';
|
||||
import type { Spec, SpecReadMode, SpecPriority } from './SpecCard';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
/**
|
||||
* Spec form data for editing
|
||||
*/
|
||||
export interface SpecFormData {
|
||||
title: string;
|
||||
readMode: SpecReadMode;
|
||||
priority: SpecPriority;
|
||||
keywords: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* SpecDialog component props
|
||||
*/
|
||||
export interface SpecDialogProps {
|
||||
/** Whether dialog is open */
|
||||
open: boolean;
|
||||
/** Called when dialog open state changes */
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** Spec being edited */
|
||||
spec: Spec | null;
|
||||
/** Called when save is clicked */
|
||||
onSave: (specId: string, data: SpecFormData) => Promise<void> | void;
|
||||
/** Optional loading state */
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
// ========== Constants ==========
|
||||
|
||||
const READ_MODE_OPTIONS: { value: SpecReadMode; labelKey: string }[] = [
|
||||
{ value: 'required', labelKey: 'specs.readMode.required' },
|
||||
{ value: 'optional', labelKey: 'specs.readMode.optional' },
|
||||
];
|
||||
|
||||
const PRIORITY_OPTIONS: { value: SpecPriority; labelKey: string }[] = [
|
||||
{ value: 'critical', labelKey: 'specs.priority.critical' },
|
||||
{ value: 'high', labelKey: 'specs.priority.high' },
|
||||
{ value: 'medium', labelKey: 'specs.priority.medium' },
|
||||
{ value: 'low', labelKey: 'specs.priority.low' },
|
||||
];
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
/**
|
||||
* SpecDialog component for editing spec frontmatter
|
||||
*/
|
||||
export function SpecDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
spec,
|
||||
onSave,
|
||||
isLoading = false,
|
||||
}: SpecDialogProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const [formData, setFormData] = React.useState<SpecFormData>({
|
||||
title: '',
|
||||
readMode: 'optional',
|
||||
priority: 'medium',
|
||||
keywords: [],
|
||||
});
|
||||
const [keywordInput, setKeywordInput] = React.useState('');
|
||||
const [errors, setErrors] = React.useState<Partial<Record<keyof SpecFormData, string>>>({});
|
||||
|
||||
// Reset form when spec changes
|
||||
React.useEffect(() => {
|
||||
if (spec) {
|
||||
setFormData({
|
||||
title: spec.title,
|
||||
readMode: spec.readMode,
|
||||
priority: spec.priority,
|
||||
keywords: [...spec.keywords],
|
||||
});
|
||||
setErrors({});
|
||||
setKeywordInput('');
|
||||
}
|
||||
}, [spec]);
|
||||
|
||||
// Validate form
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Partial<Record<keyof SpecFormData, string>> = {};
|
||||
|
||||
if (!formData.title.trim()) {
|
||||
newErrors.title = formatMessage({ id: 'specs.validation.titleRequired' });
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
// Handle save
|
||||
const handleSave = async () => {
|
||||
if (!spec || !validateForm()) return;
|
||||
|
||||
await onSave(spec.id, formData);
|
||||
};
|
||||
|
||||
// Handle keyword input
|
||||
const handleKeywordKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault();
|
||||
addKeyword();
|
||||
}
|
||||
};
|
||||
|
||||
// Add keyword
|
||||
const addKeyword = () => {
|
||||
const keyword = keywordInput.trim().toLowerCase();
|
||||
if (keyword && !formData.keywords.includes(keyword)) {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
keywords: [...prev.keywords, keyword],
|
||||
}));
|
||||
}
|
||||
setKeywordInput('');
|
||||
};
|
||||
|
||||
// Remove keyword
|
||||
const removeKeyword = (keyword: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
keywords: prev.keywords.filter((k) => k !== keyword),
|
||||
}));
|
||||
};
|
||||
|
||||
if (!spec) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{formatMessage({ id: 'specs.dialog.editTitle' }, { title: spec.title })}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{formatMessage({ id: 'specs.dialog.editDescription' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Title field */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="title">
|
||||
{formatMessage({ id: 'specs.form.title' })}
|
||||
</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={formData.title}
|
||||
onChange={(e) => setFormData((prev) => ({ ...prev, title: e.target.value }))}
|
||||
placeholder={formatMessage({ id: 'specs.form.titlePlaceholder' })}
|
||||
error={!!errors.title}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{errors.title && (
|
||||
<p className="text-sm text-destructive">{errors.title}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Read mode and Priority */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{formatMessage({ id: 'specs.form.readMode' })}</Label>
|
||||
<Select
|
||||
value={formData.readMode}
|
||||
onValueChange={(value: SpecReadMode) =>
|
||||
setFormData((prev) => ({ ...prev, readMode: value }))
|
||||
}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{READ_MODE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{formatMessage({ id: option.labelKey })}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{formatMessage({ id: 'specs.form.priority' })}</Label>
|
||||
<Select
|
||||
value={formData.priority}
|
||||
onValueChange={(value: SpecPriority) =>
|
||||
setFormData((prev) => ({ ...prev, priority: value }))
|
||||
}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PRIORITY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{formatMessage({ id: option.labelKey })}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keywords */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="keywords">
|
||||
{formatMessage({ id: 'specs.form.keywords' })}
|
||||
</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
id="keywords"
|
||||
value={keywordInput}
|
||||
onChange={(e) => setKeywordInput(e.target.value)}
|
||||
onKeyDown={handleKeywordKeyDown}
|
||||
onBlur={addKeyword}
|
||||
placeholder={formatMessage({ id: 'specs.form.keywordsPlaceholder' })}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addKeyword}
|
||||
disabled={isLoading || !keywordInput.trim()}
|
||||
>
|
||||
{formatMessage({ id: 'specs.form.addKeyword' })}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'specs.form.keywordsHint' })}
|
||||
</p>
|
||||
|
||||
{/* Keywords display */}
|
||||
{formData.keywords.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||
<Tag className="h-3.5 w-3.5 text-muted-foreground mt-0.5" />
|
||||
{formData.keywords.map((keyword) => (
|
||||
<Badge
|
||||
key={keyword}
|
||||
variant="secondary"
|
||||
className="text-xs pl-2 pr-1 gap-1"
|
||||
>
|
||||
{keyword}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeKeyword(keyword)}
|
||||
className="ml-0.5 hover:text-destructive transition-colors"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File info */}
|
||||
<div className="pt-2 border-t border-border">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'specs.form.fileInfo' }, { file: spec.file })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{formatMessage({ id: 'common.cancel' })}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isLoading}>
|
||||
{isLoading
|
||||
? formatMessage({ id: 'specs.form.saving' })
|
||||
: formatMessage({ id: 'common.save' })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default SpecDialog;
|
||||
Reference in New Issue
Block a user