mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-01 12:13:51 +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:
341
ccw/frontend/src/components/specs/GlobalSettingsTab.tsx
Normal file
341
ccw/frontend/src/components/specs/GlobalSettingsTab.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
// ========================================
|
||||
// GlobalSettingsTab Component
|
||||
// ========================================
|
||||
// Global settings for personal spec defaults and spec statistics
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Settings, RefreshCw } from 'lucide-react';
|
||||
import {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
} from '@/components/ui/Card';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import { Label } from '@/components/ui/Label';
|
||||
import { Switch } from '@/components/ui/Switch';
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from '@/components/ui/Select';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
interface PersonalSpecDefaults {
|
||||
defaultReadMode: 'required' | 'optional';
|
||||
autoEnable: boolean;
|
||||
}
|
||||
|
||||
interface SystemSettings {
|
||||
injectionControl: {
|
||||
maxLength: number;
|
||||
warnThreshold: number;
|
||||
truncateOnExceed: boolean;
|
||||
};
|
||||
personalSpecDefaults: PersonalSpecDefaults;
|
||||
}
|
||||
|
||||
interface SpecDimensionStats {
|
||||
count: number;
|
||||
requiredCount: number;
|
||||
}
|
||||
|
||||
interface SpecStats {
|
||||
dimensions: {
|
||||
specs: SpecDimensionStats;
|
||||
roadmap: SpecDimensionStats;
|
||||
changelog: SpecDimensionStats;
|
||||
personal: SpecDimensionStats;
|
||||
};
|
||||
injectionLength?: {
|
||||
requiredOnly: number;
|
||||
withKeywords: number;
|
||||
maxLength: number;
|
||||
percentage: number;
|
||||
};
|
||||
}
|
||||
|
||||
// ========== API Functions ==========
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
async function fetchSystemSettings(): Promise<SystemSettings> {
|
||||
const response = await fetch(`${API_BASE}/system/settings`, {
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch settings: ${response.statusText}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return data;
|
||||
}
|
||||
|
||||
async function updateSystemSettings(
|
||||
settings: Partial<SystemSettings>
|
||||
): Promise<{ success: boolean; settings: SystemSettings }> {
|
||||
const response = await fetch(`${API_BASE}/system/settings`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to update settings: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function fetchSpecStats(): Promise<SpecStats> {
|
||||
const response = await fetch(`${API_BASE}/specs/stats`, {
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch spec stats: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// ========== Query Keys ==========
|
||||
|
||||
const settingsKeys = {
|
||||
all: ['system-settings'] as const,
|
||||
settings: () => [...settingsKeys.all, 'settings'] as const,
|
||||
stats: () => [...settingsKeys.all, 'stats'] as const,
|
||||
};
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function GlobalSettingsTab() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Local state for immediate UI feedback
|
||||
const [localDefaults, setLocalDefaults] = useState<PersonalSpecDefaults>({
|
||||
defaultReadMode: 'optional',
|
||||
autoEnable: true,
|
||||
});
|
||||
|
||||
// Fetch system settings
|
||||
const {
|
||||
data: settings,
|
||||
isLoading: isLoadingSettings,
|
||||
error: settingsError,
|
||||
} = useQuery({
|
||||
queryKey: settingsKeys.settings(),
|
||||
queryFn: fetchSystemSettings,
|
||||
staleTime: 60000, // 1 minute
|
||||
});
|
||||
|
||||
// Fetch spec stats
|
||||
const {
|
||||
data: stats,
|
||||
isLoading: isLoadingStats,
|
||||
error: statsError,
|
||||
refetch: refetchStats,
|
||||
} = useQuery({
|
||||
queryKey: settingsKeys.stats(),
|
||||
queryFn: fetchSpecStats,
|
||||
staleTime: 30000, // 30 seconds
|
||||
});
|
||||
|
||||
// Update settings mutation
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: updateSystemSettings,
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(settingsKeys.settings(), data.settings);
|
||||
toast.success('Settings saved successfully');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to save settings: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
// Sync local state with server state
|
||||
useEffect(() => {
|
||||
if (settings?.personalSpecDefaults) {
|
||||
setLocalDefaults(settings.personalSpecDefaults);
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
// Handlers
|
||||
const handleReadModeChange = (value: 'required' | 'optional') => {
|
||||
const newDefaults = { ...localDefaults, defaultReadMode: value };
|
||||
setLocalDefaults(newDefaults);
|
||||
updateMutation.mutate({ personalSpecDefaults: newDefaults });
|
||||
};
|
||||
|
||||
const handleAutoEnableChange = (checked: boolean) => {
|
||||
const newDefaults = { ...localDefaults, autoEnable: checked };
|
||||
setLocalDefaults(newDefaults);
|
||||
updateMutation.mutate({ personalSpecDefaults: newDefaults });
|
||||
};
|
||||
|
||||
// Calculate totals
|
||||
const dimensions = stats?.dimensions || {};
|
||||
const dimensionEntries = Object.entries(dimensions) as [
|
||||
keyof typeof dimensions,
|
||||
SpecDimensionStats
|
||||
][];
|
||||
const totalCount = dimensionEntries.reduce(
|
||||
(sum, [, data]) => sum + data.count,
|
||||
0
|
||||
);
|
||||
const totalRequired = dimensionEntries.reduce(
|
||||
(sum, [, data]) => sum + data.requiredCount,
|
||||
0
|
||||
);
|
||||
|
||||
const isLoading = isLoadingSettings || isLoadingStats;
|
||||
const hasError = settingsError || statsError;
|
||||
|
||||
// Dimension display config
|
||||
const dimensionLabels: Record<string, string> = {
|
||||
specs: 'Specs',
|
||||
roadmap: 'Roadmap',
|
||||
changelog: 'Changelog',
|
||||
personal: 'Personal',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Personal Spec Defaults Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings className="h-5 w-5 text-muted-foreground" />
|
||||
<CardTitle>Personal Spec Defaults</CardTitle>
|
||||
</div>
|
||||
<CardDescription>
|
||||
These settings will be applied when creating new personal specs
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Default Read Mode */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="default-read-mode">Default Read Mode</Label>
|
||||
<Select
|
||||
value={localDefaults.defaultReadMode}
|
||||
onValueChange={(value) =>
|
||||
handleReadModeChange(value as 'required' | 'optional')
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="default-read-mode" className="w-full">
|
||||
<SelectValue placeholder="Select read mode" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="required">
|
||||
Required (Always inject)
|
||||
</SelectItem>
|
||||
<SelectItem value="optional">
|
||||
Optional (Inject on keyword match)
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The default read mode for newly created personal specs
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Auto Enable */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="auto-enable">Auto Enable New Specs</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Automatically enable newly created personal specs
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="auto-enable"
|
||||
checked={localDefaults.autoEnable}
|
||||
onCheckedChange={handleAutoEnableChange}
|
||||
disabled={updateMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Spec Statistics Card */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Spec Statistics</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => refetchStats()}
|
||||
disabled={isLoadingStats}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn(
|
||||
'h-4 w-4',
|
||||
isLoadingStats && 'animate-spin'
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-center p-4 rounded-lg bg-muted animate-pulse"
|
||||
>
|
||||
<div className="h-8 w-12 mx-auto bg-muted-foreground/20 rounded mb-2" />
|
||||
<div className="h-4 w-16 mx-auto bg-muted-foreground/20 rounded" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : hasError ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Failed to load statistics
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{dimensionEntries.map(([dim, data]) => (
|
||||
<div
|
||||
key={dim}
|
||||
className="text-center p-4 rounded-lg bg-muted/50 hover:bg-muted transition-colors"
|
||||
>
|
||||
<div className="text-2xl font-bold text-foreground">
|
||||
{data.count}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground capitalize">
|
||||
{dimensionLabels[dim] || dim}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{data.requiredCount} required
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="mt-4 pt-4 border-t border-border">
|
||||
<div className="flex justify-between text-sm text-muted-foreground">
|
||||
<span>Total: {totalCount} spec files</span>
|
||||
<span>
|
||||
{totalRequired} required | {totalCount - totalRequired}{' '}
|
||||
optional
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GlobalSettingsTab;
|
||||
Reference in New Issue
Block a user