import { closestCenter, DndContext, type DragEndEvent, KeyboardSensor, PointerSensor, useSensor, useSensors, } from '@dnd-kit/core'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { useQuery } from '@tanstack/react-query'; import { ChevronDown, ChevronRight, GripVertical, Plus, Power, Trash2 } from 'lucide-react'; import { useCallback, useMemo, useRef, useState } from 'react'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Slider } from '@/components/ui/slider'; import { apiClient } from '@/lib/api/client'; import type { AvailableEffect, EffectConfig, EffectPresetResponse } from '@/lib/api/types'; import { cn } from '@/lib/utils/cn'; // Each effect in the chain gets a stable ID for dnd-kit interface EffectWithId extends EffectConfig { _id: string; } let nextId = 0; function makeId() { return `fx-${++nextId}`; } interface EffectsChainEditorProps { value: EffectConfig[]; onChange: (chain: EffectConfig[]) => void; compact?: boolean; showPresets?: boolean; } export function EffectsChainEditor({ value, onChange, compact = false, showPresets = true, }: EffectsChainEditorProps) { const [expandedId, setExpandedId] = useState(null); // Maintain stable IDs for each effect across renders. // We use a ref to map value items to IDs, rebuilding when length changes. const idsRef = useRef([]); const items: EffectWithId[] = useMemo(() => { // Grow ID array if effects were added while (idsRef.current.length < value.length) { idsRef.current.push(makeId()); } // Shrink if effects were removed if (idsRef.current.length > value.length) { idsRef.current = idsRef.current.slice(0, value.length); } return value.map((e, i) => ({ ...e, _id: idsRef.current[i] })); }, [value]); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); const { data: availableEffects } = useQuery({ queryKey: ['available-effects'], queryFn: () => apiClient.getAvailableEffects(), staleTime: Infinity, }); const { data: presets } = useQuery({ queryKey: ['effect-presets'], queryFn: () => apiClient.listEffectPresets(), staleTime: 30_000, }); const effectsMap = useMemo(() => { const m = new Map(); if (availableEffects) { for (const e of availableEffects.effects) { m.set(e.type, e); } } return m; }, [availableEffects]); function addEffect(type: string) { const def = effectsMap.get(type); if (!def) return; const params: Record = {}; for (const [key, p] of Object.entries(def.params)) { params[key] = p.default; } const newEffect: EffectConfig = { type, enabled: true, params }; const newId = makeId(); idsRef.current = [...idsRef.current, newId]; onChange([...value, newEffect]); setExpandedId(newId); } const removeEffect = useCallback( (index: number) => { const removedId = idsRef.current[index]; idsRef.current = idsRef.current.filter((_, i) => i !== index); onChange(value.filter((_, i) => i !== index)); if (expandedId === removedId) setExpandedId(null); }, [value, onChange, expandedId], ); const toggleEnabled = useCallback( (index: number) => { onChange(value.map((e, i) => (i === index ? { ...e, enabled: !e.enabled } : e))); }, [value, onChange], ); const updateParam = useCallback( (index: number, paramName: string, paramValue: number) => { onChange( value.map((e, i) => i === index ? { ...e, params: { ...e.params, [paramName]: paramValue } } : e, ), ); }, [value, onChange], ); function loadPreset(preset: EffectPresetResponse) { idsRef.current = preset.effects_chain.map(() => makeId()); onChange(preset.effects_chain); setExpandedId(null); } function clearAll() { idsRef.current = []; onChange([]); setExpandedId(null); } function handleDragEnd(event: DragEndEvent) { const { active, over } = event; if (!over || active.id === over.id) return; const oldIndex = idsRef.current.indexOf(active.id as string); const newIndex = idsRef.current.indexOf(over.id as string); if (oldIndex === -1 || newIndex === -1) return; idsRef.current = arrayMove(idsRef.current, oldIndex, newIndex); onChange(arrayMove([...value], oldIndex, newIndex)); } return (
{/* Preset selector row */} {showPresets && (
{value.length > 0 && ( )}
)} {/* Sortable effects chain */} i._id)} strategy={verticalListSortingStrategy}> {items.map((effect, index) => ( setExpandedId(expandedId === effect._id ? null : effect._id)} onRemove={() => removeEffect(index)} onToggleEnabled={() => toggleEnabled(index)} onUpdateParam={(paramName, paramValue) => updateParam(index, paramName, paramValue)} /> ))} {/* Add effect */} {availableEffects && ( )}
); } // --------------------------------------------------------------------------- // Sortable effect item // --------------------------------------------------------------------------- interface SortableEffectItemProps { id: string; effect: EffectConfig; index: number; effectDef?: AvailableEffect; isExpanded: boolean; onToggleExpand: () => void; onRemove: () => void; onToggleEnabled: () => void; onUpdateParam: (paramName: string, paramValue: number) => void; } function SortableEffectItem({ id, effect, effectDef, isExpanded, onToggleExpand, onRemove, onToggleEnabled, onUpdateParam, }: SortableEffectItemProps) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, }); const style = { transform: CSS.Transform.toString(transform), transition, zIndex: isDragging ? 10 : undefined, }; const label = effectDef?.label ?? effect.type; return (
{/* Header */}
{label}
{/* Params */} {isExpanded && effectDef && (
{Object.entries(effectDef.params).map(([paramName, paramDef]) => { const currentValue = effect.params[paramName] ?? paramDef.default; return (
{currentValue.toFixed( paramDef.step < 1 ? Math.max(1, -Math.floor(Math.log10(paramDef.step))) : 0, )}
onUpdateParam(paramName, v)} />
); })}
)}
); }