From 052e25dddb00587d90c91934dcc412016b6b3cfb Mon Sep 17 00:00:00 2001 From: catlog22 Date: Thu, 26 Feb 2026 23:20:18 +0800 Subject: [PATCH] feat: add SpecContentDialog component for viewing and editing spec content --- .../components/specs/SpecContentDialog.tsx | 287 ++++++++++++++++++ ccw/frontend/src/components/specs/index.ts | 8 + ccw/frontend/src/pages/SpecsSettingsPage.tsx | 210 ++----------- 3 files changed, 325 insertions(+), 180 deletions(-) create mode 100644 ccw/frontend/src/components/specs/SpecContentDialog.tsx diff --git a/ccw/frontend/src/components/specs/SpecContentDialog.tsx b/ccw/frontend/src/components/specs/SpecContentDialog.tsx new file mode 100644 index 00000000..8866005d --- /dev/null +++ b/ccw/frontend/src/components/specs/SpecContentDialog.tsx @@ -0,0 +1,287 @@ +// ======================================== +// SpecContentDialog Component +// ======================================== +// Dialog for viewing and editing spec markdown content + +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 { Textarea } from '@/components/ui/Textarea'; +import { Badge } from '@/components/ui/Badge'; +import { Eye, Edit2, Save, FileText, Loader2 } from 'lucide-react'; +import type { Spec } from './SpecCard'; + +// ========== Types ========== + +/** + * SpecContentDialog component props + */ +export interface SpecContentDialogProps { + /** Whether dialog is open */ + open: boolean; + /** Called when dialog open state changes */ + onOpenChange: (open: boolean) => void; + /** Spec being viewed/edited */ + spec: Spec | null; + /** Initial content of the spec */ + content?: string; + /** Called when content is saved */ + onSave?: (specId: string, content: string) => Promise | void; + /** Whether in read-only mode */ + readOnly?: boolean; + /** Optional loading state */ + isLoading?: boolean; +} + +// ========== Helper Functions ========== + +/** + * Extract frontmatter from markdown content + */ +function parseFrontmatter(content: string): { + frontmatter: Record; + body: string; +} { + const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/; + const match = content.match(frontmatterRegex); + + if (!match) { + return { frontmatter: {}, body: content }; + } + + const frontmatterLines = match[1].split('\n'); + const frontmatter: Record = {}; + + for (const line of frontmatterLines) { + const colonIndex = line.indexOf(':'); + if (colonIndex > 0) { + const key = line.slice(0, colonIndex).trim(); + const value = line.slice(colonIndex + 1).trim(); + + // Parse arrays (keywords: [a, b, c]) + if (value.startsWith('[') && value.endsWith(']')) { + frontmatter[key] = value + .slice(1, -1) + .split(',') + .map(s => s.trim().replace(/^['"]|['"]$/g, '')) + .filter(Boolean); + } else { + frontmatter[key] = value.replace(/^['"]|['"]$/g, ''); + } + } + } + + return { frontmatter, body: match[2] }; +} + +// ========== Component ========== + +/** + * SpecContentDialog component for viewing and editing spec content + */ +export function SpecContentDialog({ + open, + onOpenChange, + spec, + content: initialContent, + onSave, + readOnly = false, + isLoading = false, +}: SpecContentDialogProps) { + const { formatMessage } = useIntl(); + const [mode, setMode] = React.useState<'view' | 'edit'>('view'); + const [content, setContent] = React.useState(initialContent || ''); + const [editedContent, setEditedContent] = React.useState(''); + + // Parse frontmatter for display + const { body } = React.useMemo(() => { + return parseFrontmatter(content); + }, [content]); + + // Reset when spec changes + React.useEffect(() => { + if (spec && open) { + // Fetch spec content + const fetchContent = async () => { + try { + const response = await fetch(`/api/specs/detail?file=${encodeURIComponent(spec.file)}`); + if (response.ok) { + const data = await response.json(); + setContent(data.content || ''); + setEditedContent(data.content || ''); + } + } catch (error) { + console.error('Failed to fetch spec content:', error); + } + }; + fetchContent(); + setMode('view'); + } + }, [spec, open]); + + // Handle save + const handleSave = async () => { + if (!spec || !onSave) return; + await onSave(spec.id, editedContent); + setContent(editedContent); + setMode('view'); + }; + + // Handle cancel edit + const handleCancelEdit = () => { + setEditedContent(content); + setMode('view'); + }; + + if (!spec) return null; + + return ( + + + +
+
+ + + {spec.title} + + {!readOnly && ( + + )} +
+
+ + {formatMessage({ id: `specs.readMode.${spec.readMode}` })} + + + {formatMessage({ id: `specs.priority.${spec.priority}` })} + +
+
+ + {spec.file} + +
+ +
+ {mode === 'view' ? ( +
+ {/* Frontmatter Info */} +
+
+ {formatMessage({ id: 'specs.content.metadata', defaultMessage: 'Metadata' })} +
+
+
+ + {formatMessage({ id: 'specs.form.readMode', defaultMessage: 'Read Mode' })}: + {' '} + + {formatMessage({ id: `specs.readMode.${spec.readMode}` })} + +
+
+ + {formatMessage({ id: 'specs.form.priority', defaultMessage: 'Priority' })}: + {' '} + + {formatMessage({ id: `specs.priority.${spec.priority}` })} + +
+
+ {spec.keywords.length > 0 && ( +
+ + {formatMessage({ id: 'specs.form.keywords', defaultMessage: 'Keywords' })}: + {' '} + {spec.keywords.map(k => ( + {k} + ))} +
+ )} +
+ + {/* Markdown Content */} +
+
+ {formatMessage({ id: 'specs.content.markdownContent', defaultMessage: 'Markdown Content' })} +
+
+                  {body || formatMessage({ id: 'specs.content.noContent', defaultMessage: 'No content available' })}
+                
+
+
+ ) : ( +
+
+ {formatMessage({ id: 'specs.content.editHint', defaultMessage: 'Edit the full markdown content including frontmatter. Changes to frontmatter will be reflected in the spec metadata.' })} +
+