feat(ui): Phase 8 UI/UX optimization and system documentation update
## Sidebar Enhancement - Change tabs to icon + small label layout for better space utilization - Add Teams tab with team collaboration entry point ## Settings Page Improvements - Connect theme toggle to gatewayStore.saveQuickConfig for persistence - Remove OpenFang backend download section, simplify UI - Add time range filter to UsageStats (7d/30d/all) - Add stat cards with icons (sessions, messages, input/output tokens) - Add token usage overview bar chart - Add 8 ZCLAW system skill definitions with categories ## Bug Fixes - Fix ChannelList duplicate content with deduplication logic - Integrate CreateTriggerModal in TriggersPanel - Add independent SecurityStatusPanel with 12 default enabled layers - Change workflow view to use SchedulerPanel as unified entry ## New Components - CreateTriggerModal: Event trigger creation modal - HandApprovalModal: Hand approval workflow dialog - HandParamsForm: Enhanced Hand parameter form - SecurityLayersPanel: 16-layer security status display ## Architecture - Add TOML config parsing support (toml-utils.ts, config-parser.ts) - Add request timeout and retry mechanism (request-helper.ts) - Add secure token storage (secure-storage.ts, secure_storage.rs) ## Tests - Add unit tests for config-parser, toml-utils, request-helper - Add team-client and teamStore tests ## Documentation - Update SYSTEM_ANALYSIS.md with Phase 8 completion - UI completion: 100% (30/30 components) - API coverage: 93% (63/68 endpoints) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
812
desktop/src/components/HandParamsForm.tsx
Normal file
812
desktop/src/components/HandParamsForm.tsx
Normal file
@@ -0,0 +1,812 @@
|
||||
/**
|
||||
* HandParamsForm - Dynamic form component for Hand parameters
|
||||
*
|
||||
* Supports all parameter types:
|
||||
* - text: Text input
|
||||
* - number: Number input with min/max validation
|
||||
* - boolean: Toggle/checkbox
|
||||
* - select: Dropdown select
|
||||
* - textarea: Multi-line text
|
||||
* - array: Dynamic array with add/remove items
|
||||
* - object: JSON object editor
|
||||
* - file: File selector
|
||||
*
|
||||
* Features:
|
||||
* - Form validation (required, type, range, pattern)
|
||||
* - Parameter presets (save/load/delete)
|
||||
* - Error display below inputs
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
AlertCircle,
|
||||
Plus,
|
||||
Trash2,
|
||||
Save,
|
||||
FolderOpen,
|
||||
Trash,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
FileText,
|
||||
Info,
|
||||
} from 'lucide-react';
|
||||
import type { HandParameter } from '../types/hands';
|
||||
|
||||
// === Types ===
|
||||
|
||||
export interface HandParamsFormProps {
|
||||
parameters: HandParameter[];
|
||||
values: Record<string, unknown>;
|
||||
onChange: (values: Record<string, unknown>) => void;
|
||||
errors?: Record<string, string>;
|
||||
disabled?: boolean;
|
||||
presetKey?: string; // Key for storing/loading presets
|
||||
}
|
||||
|
||||
export interface ParameterPreset {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: string;
|
||||
values: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// === Validation ===
|
||||
|
||||
interface ValidationResult {
|
||||
isValid: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function validateParameter(param: HandParameter, value: unknown): ValidationResult {
|
||||
// Required check
|
||||
if (param.required) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return { isValid: false, error: `${param.label} is required` };
|
||||
}
|
||||
if (Array.isArray(value) && value.length === 0) {
|
||||
return { isValid: false, error: `${param.label} is required` };
|
||||
}
|
||||
if (typeof value === 'object' && !Array.isArray(value) && Object.keys(value as Record<string, unknown>).length === 0) {
|
||||
return { isValid: false, error: `${param.label} is required` };
|
||||
}
|
||||
}
|
||||
|
||||
// Skip further validation if value is empty and not required
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
// Type-specific validation
|
||||
switch (param.type) {
|
||||
case 'number':
|
||||
if (typeof value !== 'number' || isNaN(value)) {
|
||||
return { isValid: false, error: `${param.label} must be a valid number` };
|
||||
}
|
||||
if (param.min !== undefined && value < param.min) {
|
||||
return { isValid: false, error: `${param.label} must be at least ${param.min}` };
|
||||
}
|
||||
if (param.max !== undefined && value > param.max) {
|
||||
return { isValid: false, error: `${param.label} must be at most ${param.max}` };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'text':
|
||||
case 'textarea':
|
||||
if (typeof value !== 'string') {
|
||||
return { isValid: false, error: `${param.label} must be text` };
|
||||
}
|
||||
if (param.pattern) {
|
||||
try {
|
||||
const regex = new RegExp(param.pattern);
|
||||
if (!regex.test(value)) {
|
||||
return { isValid: false, error: `${param.label} format is invalid` };
|
||||
}
|
||||
} catch {
|
||||
// Invalid regex pattern, skip validation
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'array':
|
||||
if (!Array.isArray(value)) {
|
||||
return { isValid: false, error: `${param.label} must be an array` };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'object':
|
||||
if (typeof value !== 'object' || Array.isArray(value)) {
|
||||
return { isValid: false, error: `${param.label} must be an object` };
|
||||
}
|
||||
try {
|
||||
// Try to stringify to validate JSON
|
||||
JSON.stringify(value);
|
||||
} catch {
|
||||
return { isValid: false, error: `${param.label} contains invalid JSON` };
|
||||
}
|
||||
break;
|
||||
|
||||
case 'file':
|
||||
if (typeof value !== 'string') {
|
||||
return { isValid: false, error: `${param.label} must be a file path` };
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return { isValid: true };
|
||||
}
|
||||
|
||||
// === Preset Storage ===
|
||||
|
||||
const PRESET_STORAGE_PREFIX = 'zclaw-hand-preset-';
|
||||
|
||||
function getPresetStorageKey(handId: string): string {
|
||||
return `${PRESET_STORAGE_PREFIX}${handId}`;
|
||||
}
|
||||
|
||||
function loadPresets(handId: string): ParameterPreset[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(getPresetStorageKey(handId));
|
||||
if (stored) {
|
||||
return JSON.parse(stored) as ParameterPreset[];
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function savePresets(handId: string, presets: ParameterPreset[]): void {
|
||||
try {
|
||||
localStorage.setItem(getPresetStorageKey(handId), JSON.stringify(presets));
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
// === Sub-Components ===
|
||||
|
||||
interface FormFieldWrapperProps {
|
||||
param: HandParameter;
|
||||
error?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function FormFieldWrapper({ param, error, children }: FormFieldWrapperProps) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{param.label}
|
||||
{param.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
{children}
|
||||
{param.description && !error && (
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
|
||||
<Info className="w-3 h-3" />
|
||||
{param.description}
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-xs text-red-500 flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// === Parameter Input Components ===
|
||||
|
||||
interface ParamInputProps {
|
||||
param: HandParameter;
|
||||
value: unknown;
|
||||
onChange: (value: unknown) => void;
|
||||
disabled?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
function TextParamInput({ param, value, onChange, disabled, error }: ParamInputProps) {
|
||||
return (
|
||||
<input
|
||||
type="text"
|
||||
value={(value as string) ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={param.placeholder}
|
||||
disabled={disabled}
|
||||
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberParamInput({ param, value, onChange, disabled, error }: ParamInputProps) {
|
||||
return (
|
||||
<input
|
||||
type="number"
|
||||
value={(value as number) ?? ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
onChange(val === '' ? undefined : parseFloat(val));
|
||||
}}
|
||||
placeholder={param.placeholder}
|
||||
min={param.min}
|
||||
max={param.max}
|
||||
disabled={disabled}
|
||||
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BooleanParamInput({ param, value, onChange, disabled }: ParamInputProps) {
|
||||
return (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={(value as boolean) ?? false}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
disabled={disabled}
|
||||
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{param.placeholder || 'Enabled'}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectParamInput({ param, value, onChange, disabled, error }: ParamInputProps) {
|
||||
return (
|
||||
<select
|
||||
value={(value as string) ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={disabled}
|
||||
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<option value="">{param.placeholder || '-- Select --'}</option>
|
||||
{param.options?.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function TextareaParamInput({ param, value, onChange, disabled, error }: ParamInputProps) {
|
||||
return (
|
||||
<textarea
|
||||
value={(value as string) ?? ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={param.placeholder}
|
||||
disabled={disabled}
|
||||
rows={3}
|
||||
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed resize-y ${
|
||||
error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ArrayParamInput({ param, value, onChange, disabled, error }: ParamInputProps) {
|
||||
const [newItem, setNewItem] = useState('');
|
||||
const items = (Array.isArray(value) ? value : []) as string[];
|
||||
|
||||
const handleAddItem = () => {
|
||||
if (newItem.trim()) {
|
||||
onChange([...items, newItem.trim()]);
|
||||
setNewItem('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveItem = (index: number) => {
|
||||
const newItems = [...items];
|
||||
newItems.splice(index, 1);
|
||||
onChange(newItems);
|
||||
};
|
||||
|
||||
const handleUpdateItem = (index: number, newValue: string) => {
|
||||
const newItems = [...items];
|
||||
newItems[index] = newValue;
|
||||
onChange(newItems);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleAddItem();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-2 p-3 border rounded-lg ${error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}`}>
|
||||
{/* Existing Items */}
|
||||
{items.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{items.map((item, index) => (
|
||||
<div key={index} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={item}
|
||||
onChange={(e) => handleUpdateItem(index, e.target.value)}
|
||||
disabled={disabled}
|
||||
className="flex-1 px-2 py-1 text-sm border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveItem(index)}
|
||||
disabled={disabled}
|
||||
className="p-1 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add New Item */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newItem}
|
||||
onChange={(e) => setNewItem(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={param.placeholder || 'Add item...'}
|
||||
disabled={disabled}
|
||||
className="flex-1 px-2 py-1 text-sm border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddItem}
|
||||
disabled={disabled || !newItem.trim()}
|
||||
className="p-1 text-blue-500 hover:text-blue-700 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{items.length === 0 && !newItem && (
|
||||
<p className="text-xs text-gray-400 text-center">No items added yet</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ObjectParamInput({ param, value, onChange, disabled, error }: ParamInputProps) {
|
||||
const [jsonText, setJsonText] = useState('');
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
|
||||
// Sync jsonText with value
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
setJsonText(JSON.stringify(value, null, 2));
|
||||
setParseError(null);
|
||||
} else {
|
||||
setJsonText('');
|
||||
}
|
||||
} catch {
|
||||
setJsonText('');
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const handleTextChange = (text: string) => {
|
||||
setJsonText(text);
|
||||
|
||||
if (!text.trim()) {
|
||||
onChange({});
|
||||
setParseError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
onChange(parsed);
|
||||
setParseError(null);
|
||||
} else {
|
||||
setParseError('Value must be a JSON object');
|
||||
}
|
||||
} catch {
|
||||
setParseError('Invalid JSON format');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-2 ${error || parseError ? 'border-red-500' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white"
|
||||
>
|
||||
{isExpanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
{isExpanded ? 'Collapse' : 'Expand'} JSON Editor
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<textarea
|
||||
value={jsonText}
|
||||
onChange={(e) => handleTextChange(e.target.value)}
|
||||
placeholder={param.placeholder || '{\n "key": "value"\n}'}
|
||||
disabled={disabled}
|
||||
rows={6}
|
||||
className={`w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 font-mono disabled:opacity-50 disabled:cursor-not-allowed resize-y ${
|
||||
error || parseError ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{parseError && (
|
||||
<p className="text-xs text-red-500 flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
{parseError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileParamInput({ param, value, onChange, disabled, error }: ParamInputProps) {
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// For now, just store the file name. In a real implementation,
|
||||
// you might want to read the file contents or handle upload
|
||||
onChange(file.name);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-2 ${error ? 'border-red-500' : ''}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<label
|
||||
className={`flex-1 flex items-center gap-2 px-3 py-2 text-sm border rounded-lg cursor-pointer transition-colors ${
|
||||
disabled
|
||||
? 'bg-gray-100 dark:bg-gray-800 cursor-not-allowed opacity-50'
|
||||
: 'bg-white dark:bg-gray-900 hover:bg-gray-50 dark:hover:bg-gray-800'
|
||||
} ${error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'}`}
|
||||
>
|
||||
<FileText className="w-4 h-4 text-gray-400" />
|
||||
<span className="flex-1 truncate text-gray-900 dark:text-white">
|
||||
{(value as string) || param.placeholder || 'Choose file...'}
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
accept={param.accept}
|
||||
onChange={handleFileChange}
|
||||
disabled={disabled}
|
||||
className="hidden"
|
||||
/>
|
||||
</label>
|
||||
{(value as string) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('')}
|
||||
disabled={disabled}
|
||||
className="p-2 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// === Parameter Field Component ===
|
||||
|
||||
function ParameterField({ param, value, onChange, disabled, externalError }: ParamInputProps & { externalError?: string }) {
|
||||
const [internalError, setInternalError] = useState<string | undefined>(undefined);
|
||||
|
||||
const handleChange = useCallback((newValue: unknown) => {
|
||||
// Validate on change
|
||||
const result = validateParameter(param, newValue);
|
||||
setInternalError(result.error ?? undefined);
|
||||
onChange(newValue);
|
||||
}, [param, onChange]);
|
||||
|
||||
const error = externalError || internalError;
|
||||
|
||||
const inputProps: ParamInputProps = { param, value, onChange: handleChange, disabled, error };
|
||||
|
||||
const renderInput = () => {
|
||||
switch (param.type) {
|
||||
case 'text':
|
||||
return <TextParamInput {...inputProps} />;
|
||||
case 'number':
|
||||
return <NumberParamInput {...inputProps} />;
|
||||
case 'boolean':
|
||||
return <BooleanParamInput {...inputProps} />;
|
||||
case 'select':
|
||||
return <SelectParamInput {...inputProps} />;
|
||||
case 'textarea':
|
||||
return <TextareaParamInput {...inputProps} />;
|
||||
case 'array':
|
||||
return <ArrayParamInput {...inputProps} />;
|
||||
case 'object':
|
||||
return <ObjectParamInput {...inputProps} />;
|
||||
case 'file':
|
||||
return <FileParamInput {...inputProps} />;
|
||||
default:
|
||||
return <TextParamInput {...inputProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<FormFieldWrapper param={param} error={error}>
|
||||
{renderInput()}
|
||||
</FormFieldWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
// === Preset Manager Component ===
|
||||
|
||||
interface PresetManagerProps {
|
||||
presetKey?: string;
|
||||
currentValues: Record<string, unknown>;
|
||||
onLoadPreset: (values: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
function PresetManager({ presetKey, currentValues, onLoadPreset }: PresetManagerProps) {
|
||||
const [presets, setPresets] = useState<ParameterPreset[]>([]);
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||
const [presetName, setPresetName] = useState('');
|
||||
const [showPresetList, setShowPresetList] = useState(false);
|
||||
|
||||
// Load presets on mount
|
||||
useEffect(() => {
|
||||
if (presetKey) {
|
||||
setPresets(loadPresets(presetKey));
|
||||
}
|
||||
}, [presetKey]);
|
||||
|
||||
const handleSavePreset = () => {
|
||||
if (!presetKey || !presetName.trim()) return;
|
||||
|
||||
const newPreset: ParameterPreset = {
|
||||
id: `preset-${Date.now()}`,
|
||||
name: presetName.trim(),
|
||||
createdAt: new Date().toISOString(),
|
||||
values: { ...currentValues },
|
||||
};
|
||||
|
||||
const newPresets = [...presets, newPreset];
|
||||
setPresets(newPresets);
|
||||
savePresets(presetKey, newPresets);
|
||||
setPresetName('');
|
||||
setShowSaveDialog(false);
|
||||
};
|
||||
|
||||
const handleLoadPreset = (preset: ParameterPreset) => {
|
||||
onLoadPreset(preset.values);
|
||||
setShowPresetList(false);
|
||||
};
|
||||
|
||||
const handleDeletePreset = (presetId: string) => {
|
||||
if (!presetKey) return;
|
||||
const newPresets = presets.filter((p) => p.id !== presetId);
|
||||
setPresets(newPresets);
|
||||
savePresets(presetKey, newPresets);
|
||||
};
|
||||
|
||||
if (!presetKey) return null;
|
||||
|
||||
return (
|
||||
<div className="border-b border-gray-200 dark:border-gray-700 pb-4 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSaveDialog(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<Save className="w-3.5 h-3.5" />
|
||||
Save Preset
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPresetList(!showPresetList)}
|
||||
disabled={presets.length === 0}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<FolderOpen className="w-3.5 h-3.5" />
|
||||
Load Preset ({presets.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Save Dialog */}
|
||||
{showSaveDialog && (
|
||||
<div className="mt-3 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Preset Name
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={presetName}
|
||||
onChange={(e) => setPresetName(e.target.value)}
|
||||
placeholder="My preset..."
|
||||
className="flex-1 px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSavePreset();
|
||||
if (e.key === 'Escape') setShowSaveDialog(false);
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSavePreset}
|
||||
disabled={!presetName.trim()}
|
||||
className="px-3 py-1.5 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSaveDialog(false)}
|
||||
className="px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preset List */}
|
||||
{showPresetList && presets.length > 0 && (
|
||||
<div className="mt-3 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Available Presets
|
||||
</label>
|
||||
<div className="space-y-1.5 max-h-48 overflow-y-auto">
|
||||
{presets.map((preset) => (
|
||||
<div
|
||||
key={preset.id}
|
||||
className="flex items-center justify-between p-2 bg-white dark:bg-gray-800 rounded border border-gray-200 dark:border-gray-700"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{preset.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{new Date(preset.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 ml-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleLoadPreset(preset)}
|
||||
className="px-2 py-1 text-xs text-blue-600 dark:text-blue-400 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded"
|
||||
>
|
||||
Load
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDeletePreset(preset.id)}
|
||||
className="p-1 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
|
||||
>
|
||||
<Trash className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// === Main Component ===
|
||||
|
||||
export function HandParamsForm({
|
||||
parameters,
|
||||
values,
|
||||
onChange,
|
||||
errors,
|
||||
disabled,
|
||||
presetKey,
|
||||
}: HandParamsFormProps) {
|
||||
// Initialize values with defaults
|
||||
const initialValues = useMemo(() => {
|
||||
const result: Record<string, unknown> = { ...values };
|
||||
parameters.forEach((param) => {
|
||||
if (result[param.name] === undefined && param.defaultValue !== undefined) {
|
||||
result[param.name] = param.defaultValue;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}, [parameters, values]);
|
||||
|
||||
// Update parent when initialValues changes
|
||||
useEffect(() => {
|
||||
const hasMissingDefaults = parameters.some(
|
||||
(p) => values[p.name] === undefined && p.defaultValue !== undefined
|
||||
);
|
||||
if (hasMissingDefaults) {
|
||||
onChange(initialValues);
|
||||
}
|
||||
}, [initialValues, parameters, values, onChange]);
|
||||
|
||||
const handleFieldChange = useCallback(
|
||||
(paramName: string, value: unknown) => {
|
||||
onChange({
|
||||
...values,
|
||||
[paramName]: value,
|
||||
});
|
||||
},
|
||||
[values, onChange]
|
||||
);
|
||||
|
||||
const handleLoadPreset = useCallback(
|
||||
(presetValues: Record<string, unknown>) => {
|
||||
onChange({
|
||||
...values,
|
||||
...presetValues,
|
||||
});
|
||||
},
|
||||
[values, onChange]
|
||||
);
|
||||
|
||||
if (parameters.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-4 text-gray-500 dark:text-gray-400 text-sm">
|
||||
No parameters required for this Hand.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Preset Manager */}
|
||||
<PresetManager
|
||||
presetKey={presetKey}
|
||||
currentValues={values}
|
||||
onLoadPreset={handleLoadPreset}
|
||||
/>
|
||||
|
||||
{/* Parameter Fields - Grid Layout */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{parameters.map((param) => (
|
||||
<div
|
||||
key={param.name}
|
||||
className={param.type === 'textarea' || param.type === 'object' || param.type === 'array' ? 'md:col-span-2' : ''}
|
||||
>
|
||||
<ParameterField
|
||||
param={param}
|
||||
value={values[param.name]}
|
||||
onChange={(value) => handleFieldChange(param.name, value)}
|
||||
disabled={disabled}
|
||||
externalError={errors?.[param.name]}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// === Validation Export ===
|
||||
|
||||
export function validateAllParameters(
|
||||
parameters: HandParameter[],
|
||||
values: Record<string, unknown>
|
||||
): Record<string, string> {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
parameters.forEach((param) => {
|
||||
const result = validateParameter(param, values[param.name]);
|
||||
if (!result.isValid && result.error) {
|
||||
errors[param.name] = result.error;
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
export default HandParamsForm;
|
||||
Reference in New Issue
Block a user