/** * 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; onChange: (values: Record) => void; errors?: Record; disabled?: boolean; presetKey?: string; // Key for storing/loading presets } export interface ParameterPreset { id: string; name: string; createdAt: string; values: Record; } // === 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).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 (
{children} {param.description && !error && (

{param.description}

)} {error && (

{error}

)}
); } // === 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 ( 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 ( { 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 ( ); } function SelectParamInput({ param, value, onChange, disabled, error }: ParamInputProps) { return ( ); } function TextareaParamInput({ param, value, onChange, disabled, error }: ParamInputProps) { return (