mirror of
https://github.com/soconnor0919/hristudio.git
synced 2025-12-11 14:44:44 -05:00
Implement production-ready block designer and schema
- Add EnhancedBlockDesigner with Scratch-like block interface - Remove all legacy designer implementations (React Flow, FreeForm, etc.) - Add block registry and plugin schema to database - Update experiments table with visual_design, execution_graph, plugin_dependencies columns and GIN index - Update drizzle config to use hs_* table filter - Add block shape/category enums to schema - Update experiment designer route to use EnhancedBlockDesigner - Add comprehensive documentation for block designer and implementation tracking
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { ExperimentDesignerClient } from "~/components/experiments/designer/ExperimentDesignerClient";
|
||||
import { EnhancedBlockDesigner } from "~/components/experiments/designer/EnhancedBlockDesigner";
|
||||
import { api } from "~/trpc/server";
|
||||
|
||||
interface ExperimentDesignerPageProps {
|
||||
@@ -20,14 +20,17 @@ export default async function ExperimentDesignerPage({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50">
|
||||
<ExperimentDesignerClient
|
||||
experiment={{
|
||||
...experiment,
|
||||
description: experiment.description ?? "",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<EnhancedBlockDesigner
|
||||
experimentId={experiment.id}
|
||||
initialDesign={{
|
||||
id: experiment.id,
|
||||
name: experiment.name,
|
||||
description: experiment.description ?? "",
|
||||
blocks: [],
|
||||
version: 1,
|
||||
lastSaved: new Date(),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error loading experiment:", error);
|
||||
|
||||
1245
src/components/experiments/designer/EnhancedBlockDesigner.tsx
Normal file
1245
src/components/experiments/designer/EnhancedBlockDesigner.tsx
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,205 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "~/trpc/react";
|
||||
import { toast } from "sonner";
|
||||
import { X, ArrowLeft } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import {
|
||||
FlowDesigner,
|
||||
type FlowDesign,
|
||||
type FlowStep,
|
||||
type StepType,
|
||||
} from "./FlowDesigner";
|
||||
|
||||
interface ExperimentDesignerClientProps {
|
||||
experiment: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
studyId: string;
|
||||
study?: {
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function ExperimentDesignerClient({
|
||||
experiment,
|
||||
}: ExperimentDesignerClientProps) {
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
// Set breadcrumbs for the designer
|
||||
useBreadcrumbsEffect([
|
||||
{ label: "Studies", href: "/studies" },
|
||||
{
|
||||
label: experiment.study?.name ?? "Study",
|
||||
href: `/studies/${experiment.studyId}`,
|
||||
},
|
||||
{
|
||||
label: "Experiments",
|
||||
href: `/studies/${experiment.studyId}`,
|
||||
},
|
||||
{
|
||||
label: experiment.name,
|
||||
href: `/experiments/${experiment.id}`,
|
||||
},
|
||||
{
|
||||
label: "Designer",
|
||||
href: `/experiments/${experiment.id}/designer`,
|
||||
},
|
||||
]);
|
||||
|
||||
// Fetch the experiment's design data
|
||||
const { data: experimentSteps, isLoading } =
|
||||
api.experiments.getSteps.useQuery({
|
||||
experimentId: experiment.id,
|
||||
});
|
||||
|
||||
const saveDesignMutation = api.experiments.saveDesign.useMutation({
|
||||
onSuccess: () => {
|
||||
setSaveError(null);
|
||||
toast.success("Experiment design saved successfully");
|
||||
},
|
||||
onError: (error) => {
|
||||
setSaveError(error.message);
|
||||
toast.error(`Failed to save design: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = async (design: FlowDesign) => {
|
||||
try {
|
||||
await saveDesignMutation.mutateAsync({
|
||||
experimentId: experiment.id,
|
||||
steps: design.steps
|
||||
.filter((step) => step.type !== "start" && step.type !== "end") // Filter out start/end nodes
|
||||
.map((step) => ({
|
||||
id: step.id,
|
||||
type: step.type as "wizard" | "robot" | "parallel" | "conditional",
|
||||
name: step.name,
|
||||
order: Math.floor(step.position.x / 250) + 1, // Calculate order from position
|
||||
parameters: step.parameters,
|
||||
description: step.description,
|
||||
duration: step.duration,
|
||||
actions: step.actions,
|
||||
expanded: false,
|
||||
children: [],
|
||||
parentId: undefined,
|
||||
})),
|
||||
version: design.version,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to save design:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[600px] items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="border-primary mx-auto mb-4 h-8 w-8 animate-spin rounded-full border-b-2"></div>
|
||||
<p className="text-muted-foreground">
|
||||
Loading experiment designer...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Convert backend steps to flow format
|
||||
const convertToFlowSteps = (steps: any[]): FlowStep[] => {
|
||||
return steps.map((step, index) => ({
|
||||
id: step.id,
|
||||
type: step.type as StepType,
|
||||
name: step.name,
|
||||
description: step.description ?? undefined,
|
||||
duration: step.duration ?? undefined,
|
||||
actions: [], // Actions will be loaded separately if needed
|
||||
parameters: step.parameters ?? {},
|
||||
position: {
|
||||
x: index * 250 + 100,
|
||||
y: 100,
|
||||
},
|
||||
}));
|
||||
};
|
||||
|
||||
const initialDesign: FlowDesign = {
|
||||
id: experiment.id,
|
||||
name: experiment.name,
|
||||
description: experiment.description,
|
||||
steps: experimentSteps ? convertToFlowSteps(experimentSteps) : [],
|
||||
version: 1,
|
||||
lastSaved: new Date(),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-background flex h-screen flex-col">
|
||||
{/* Header */}
|
||||
<div className="bg-background/95 supports-[backdrop-filter]:bg-background/60 relative border-b backdrop-blur">
|
||||
<div className="from-primary/5 to-accent/5 absolute inset-0 bg-gradient-to-r" />
|
||||
<div className="relative flex items-center justify-between p-6">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/experiments/${experiment.id}`)}
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Back to Experiment
|
||||
</Button>
|
||||
<div className="bg-border h-6 w-px" />
|
||||
<div className="bg-primary flex h-12 w-12 items-center justify-center rounded-xl shadow-lg">
|
||||
<span className="text-primary-foreground text-xl font-bold">
|
||||
F
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold">{experiment.name}</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{experiment.description || "Visual Flow Designer"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="bg-muted rounded-lg px-3 py-1 text-sm">
|
||||
{experiment.study?.name ?? "Unknown Study"}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.push(`/experiments/${experiment.id}`)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{saveError && (
|
||||
<div className="border-destructive/50 bg-destructive/10 mx-6 mt-4 rounded-lg border p-4">
|
||||
<div className="flex items-start">
|
||||
<div className="flex-1">
|
||||
<h4 className="text-destructive font-medium">Save Error</h4>
|
||||
<p className="text-destructive/90 mt-1 text-sm">{saveError}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Flow Designer */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<FlowDesigner
|
||||
experimentId={experiment.id}
|
||||
initialDesign={initialDesign}
|
||||
onSave={handleSave}
|
||||
isSaving={saveDesignMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,906 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
addEdge,
|
||||
type Node,
|
||||
type Edge,
|
||||
type Connection,
|
||||
type NodeTypes,
|
||||
MarkerType,
|
||||
Panel,
|
||||
Handle,
|
||||
Position,
|
||||
} from "@xyflow/react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
import "./flow-theme.css";
|
||||
import {
|
||||
Bot,
|
||||
Users,
|
||||
Shuffle,
|
||||
GitBranch,
|
||||
Play,
|
||||
Zap,
|
||||
Eye,
|
||||
Clock,
|
||||
Plus,
|
||||
Save,
|
||||
Undo,
|
||||
Redo,
|
||||
Download,
|
||||
Upload,
|
||||
Settings,
|
||||
Trash2,
|
||||
Copy,
|
||||
Edit3,
|
||||
} from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuSeparator,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "~/components/ui/sheet";
|
||||
import { toast } from "sonner";
|
||||
|
||||
// Types
|
||||
type StepType =
|
||||
| "wizard"
|
||||
| "robot"
|
||||
| "parallel"
|
||||
| "conditional"
|
||||
| "start"
|
||||
| "end";
|
||||
type ActionType =
|
||||
| "speak"
|
||||
| "move"
|
||||
| "gesture"
|
||||
| "look_at"
|
||||
| "wait"
|
||||
| "instruction"
|
||||
| "question"
|
||||
| "observe";
|
||||
|
||||
interface FlowAction {
|
||||
id: string;
|
||||
type: ActionType;
|
||||
name: string;
|
||||
parameters: Record<string, unknown>;
|
||||
order: number;
|
||||
}
|
||||
|
||||
interface FlowStep {
|
||||
id: string;
|
||||
type: StepType;
|
||||
name: string;
|
||||
description?: string;
|
||||
duration?: number;
|
||||
actions: FlowAction[];
|
||||
parameters: Record<string, unknown>;
|
||||
position: { x: number; y: number };
|
||||
}
|
||||
|
||||
interface FlowDesign {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
steps: FlowStep[];
|
||||
version: number;
|
||||
lastSaved: Date;
|
||||
}
|
||||
|
||||
// Step type configurations
|
||||
const stepTypeConfig = {
|
||||
start: {
|
||||
label: "Start",
|
||||
icon: Play,
|
||||
color: "#10b981",
|
||||
bgColor: "bg-green-500",
|
||||
lightColor: "bg-green-50 border-green-200",
|
||||
description: "Experiment starting point",
|
||||
},
|
||||
wizard: {
|
||||
label: "Wizard Action",
|
||||
icon: Users,
|
||||
color: "#3b82f6",
|
||||
bgColor: "bg-blue-500",
|
||||
lightColor: "bg-blue-50 border-blue-200",
|
||||
description: "Actions performed by human wizard",
|
||||
},
|
||||
robot: {
|
||||
label: "Robot Action",
|
||||
icon: Bot,
|
||||
color: "#8b5cf6",
|
||||
bgColor: "bg-purple-500",
|
||||
lightColor: "bg-purple-50 border-purple-200",
|
||||
description: "Actions performed by robot",
|
||||
},
|
||||
parallel: {
|
||||
label: "Parallel Steps",
|
||||
icon: Shuffle,
|
||||
color: "#f59e0b",
|
||||
bgColor: "bg-amber-500",
|
||||
lightColor: "bg-amber-50 border-amber-200",
|
||||
description: "Execute multiple steps simultaneously",
|
||||
},
|
||||
conditional: {
|
||||
label: "Conditional Branch",
|
||||
icon: GitBranch,
|
||||
color: "#ef4444",
|
||||
bgColor: "bg-red-500",
|
||||
lightColor: "bg-red-50 border-red-200",
|
||||
description: "Branching logic based on conditions",
|
||||
},
|
||||
end: {
|
||||
label: "End",
|
||||
icon: Play,
|
||||
color: "#6b7280",
|
||||
bgColor: "bg-gray-500",
|
||||
lightColor: "bg-gray-50 border-gray-200",
|
||||
description: "Experiment end point",
|
||||
},
|
||||
};
|
||||
|
||||
const actionTypeConfig = {
|
||||
speak: {
|
||||
label: "Speak",
|
||||
icon: Play,
|
||||
description: "Text-to-speech output",
|
||||
defaultParams: { text: "Hello, I'm ready to help!" },
|
||||
},
|
||||
move: {
|
||||
label: "Move",
|
||||
icon: Play,
|
||||
description: "Move to location or position",
|
||||
defaultParams: { x: 0, y: 0, speed: 1 },
|
||||
},
|
||||
gesture: {
|
||||
label: "Gesture",
|
||||
icon: Zap,
|
||||
description: "Physical gesture or animation",
|
||||
defaultParams: { gesture: "wave", duration: 2 },
|
||||
},
|
||||
look_at: {
|
||||
label: "Look At",
|
||||
icon: Eye,
|
||||
description: "Orient gaze or camera",
|
||||
defaultParams: { target: "participant" },
|
||||
},
|
||||
wait: {
|
||||
label: "Wait",
|
||||
icon: Clock,
|
||||
description: "Pause for specified duration",
|
||||
defaultParams: { duration: 3 },
|
||||
},
|
||||
instruction: {
|
||||
label: "Instruction",
|
||||
icon: Settings,
|
||||
description: "Display instruction for wizard",
|
||||
defaultParams: { text: "Follow the protocol", allowSkip: true },
|
||||
},
|
||||
question: {
|
||||
label: "Question",
|
||||
icon: Plus,
|
||||
description: "Ask participant a question",
|
||||
defaultParams: { question: "How do you feel?", recordResponse: true },
|
||||
},
|
||||
observe: {
|
||||
label: "Observe",
|
||||
icon: Eye,
|
||||
description: "Observe and record behavior",
|
||||
defaultParams: { target: "participant", duration: 5, notes: "" },
|
||||
},
|
||||
};
|
||||
|
||||
// Custom Node Components
|
||||
interface StepNodeProps {
|
||||
data: {
|
||||
step: FlowStep;
|
||||
onEdit: (step: FlowStep) => void;
|
||||
onDelete: (stepId: string) => void;
|
||||
onDuplicate: (step: FlowStep) => void;
|
||||
isSelected: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function StepNode({ data }: StepNodeProps) {
|
||||
const { step, onEdit, onDelete, onDuplicate, isSelected } = data;
|
||||
const config = stepTypeConfig[step.type];
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Connection Handles */}
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Left}
|
||||
className="!bg-primary !border-background !h-3 !w-3 !border-2"
|
||||
id="input"
|
||||
/>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Right}
|
||||
className="!bg-primary !border-background !h-3 !w-3 !border-2"
|
||||
id="output"
|
||||
/>
|
||||
|
||||
<Card
|
||||
className={`min-w-[200px] border transition-all duration-200 ${
|
||||
isSelected ? "ring-primary shadow-2xl ring-2" : "hover:shadow-lg"
|
||||
}`}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`${config.bgColor} flex h-8 w-8 shrink-0 items-center justify-center rounded shadow-lg`}
|
||||
>
|
||||
<config.icon className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-sm font-medium">
|
||||
{step.name}
|
||||
</CardTitle>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{config.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onEdit(step)}>
|
||||
<Edit3 className="mr-2 h-4 w-4" />
|
||||
Edit Step
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => onDuplicate(step)}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Duplicate
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(step.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete Step
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{step.description && (
|
||||
<CardContent className="pt-0 pb-2">
|
||||
<p className="text-muted-foreground text-xs">{step.description}</p>
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{step.actions.length > 0 && (
|
||||
<CardContent className="pt-0">
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{step.actions.length} action{step.actions.length !== 1 ? "s" : ""}
|
||||
</div>
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{step.actions.slice(0, 3).map((action) => {
|
||||
const actionConfig = actionTypeConfig[action.type];
|
||||
return (
|
||||
<Badge
|
||||
key={action.id}
|
||||
variant="secondary"
|
||||
className="text-xs"
|
||||
>
|
||||
<actionConfig.icon className="mr-1 h-3 w-3" />
|
||||
{actionConfig.label}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
{step.actions.length > 3 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
+{step.actions.length - 3} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Node types configuration
|
||||
const nodeTypes: NodeTypes = {
|
||||
stepNode: StepNode,
|
||||
};
|
||||
|
||||
// Main Flow Designer Component
|
||||
interface FlowDesignerProps {
|
||||
experimentId: string;
|
||||
initialDesign: FlowDesign;
|
||||
onSave?: (design: FlowDesign) => Promise<void>;
|
||||
isSaving?: boolean;
|
||||
}
|
||||
|
||||
export function FlowDesigner({
|
||||
experimentId,
|
||||
initialDesign,
|
||||
onSave,
|
||||
isSaving = false,
|
||||
}: FlowDesignerProps) {
|
||||
const [design, setDesign] = useState<FlowDesign>(initialDesign);
|
||||
const [selectedStepId, setSelectedStepId] = useState<string>();
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const [editingStep, setEditingStep] = useState<FlowStep | null>(null);
|
||||
|
||||
// React Flow state
|
||||
const [reactFlowInstance, setReactFlowInstance] = useState(null);
|
||||
|
||||
const selectedStep = useMemo(() => {
|
||||
return design.steps.find((step) => step.id === selectedStepId);
|
||||
}, [design.steps, selectedStepId]);
|
||||
|
||||
const createStep = useCallback(
|
||||
(type: StepType, position: { x: number; y: number }): FlowStep => {
|
||||
const config = stepTypeConfig[type];
|
||||
const stepNumber = design.steps.length + 1;
|
||||
|
||||
return {
|
||||
id: `step-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
type,
|
||||
name: `${config.label} ${stepNumber}`,
|
||||
actions: [],
|
||||
parameters: {},
|
||||
position,
|
||||
};
|
||||
},
|
||||
[design.steps.length],
|
||||
);
|
||||
|
||||
const handleStepTypeAdd = useCallback(
|
||||
(type: StepType) => {
|
||||
const newPosition = {
|
||||
x: design.steps.length * 250 + 100,
|
||||
y: 100,
|
||||
};
|
||||
const newStep = createStep(type, newPosition);
|
||||
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: [...prev.steps, newStep],
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
setSelectedStepId(newStep.id);
|
||||
toast.success(`Added ${stepTypeConfig[type].label}`);
|
||||
},
|
||||
[createStep, design.steps.length],
|
||||
);
|
||||
|
||||
const handleStepDelete = useCallback(
|
||||
(stepId: string) => {
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: prev.steps.filter((step) => step.id !== stepId),
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
|
||||
if (selectedStepId === stepId) {
|
||||
setSelectedStepId(undefined);
|
||||
}
|
||||
|
||||
toast.success("Step deleted");
|
||||
},
|
||||
[selectedStepId],
|
||||
);
|
||||
|
||||
const handleStepDuplicate = useCallback((step: FlowStep) => {
|
||||
const newStep: FlowStep = {
|
||||
...step,
|
||||
id: `step-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
name: `${step.name} (Copy)`,
|
||||
position: {
|
||||
x: step.position.x + 250,
|
||||
y: step.position.y,
|
||||
},
|
||||
actions: step.actions.map((action) => ({
|
||||
...action,
|
||||
id: `action-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
})),
|
||||
};
|
||||
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: [...prev.steps, newStep],
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
toast.success("Step duplicated");
|
||||
}, []);
|
||||
|
||||
const handleNodeClick = useCallback(
|
||||
(_event: React.MouseEvent, node: Node) => {
|
||||
setSelectedStepId(node.id);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Convert design steps to React Flow nodes
|
||||
const nodes: Node[] = useMemo(() => {
|
||||
return design.steps.map((step) => ({
|
||||
id: step.id,
|
||||
type: "stepNode",
|
||||
position: step.position,
|
||||
data: {
|
||||
step,
|
||||
onEdit: setEditingStep,
|
||||
onDelete: handleStepDelete,
|
||||
onDuplicate: handleStepDuplicate,
|
||||
isSelected: selectedStepId === step.id,
|
||||
},
|
||||
}));
|
||||
}, [design.steps, selectedStepId, handleStepDelete, handleStepDuplicate]);
|
||||
|
||||
// Auto-connect sequential steps based on position
|
||||
const edges: Edge[] = useMemo(() => {
|
||||
const sortedSteps = [...design.steps].sort(
|
||||
(a, b) => a.position.x - b.position.x,
|
||||
);
|
||||
const newEdges: Edge[] = [];
|
||||
|
||||
for (let i = 0; i < sortedSteps.length - 1; i++) {
|
||||
const sourceStep = sortedSteps[i];
|
||||
const targetStep = sortedSteps[i + 1];
|
||||
|
||||
if (sourceStep && targetStep) {
|
||||
// Only auto-connect if steps are reasonably close horizontally
|
||||
const distance = Math.abs(
|
||||
targetStep.position.x - sourceStep.position.x,
|
||||
);
|
||||
if (distance < 400) {
|
||||
newEdges.push({
|
||||
id: `${sourceStep.id}-${targetStep.id}`,
|
||||
source: sourceStep.id,
|
||||
sourceHandle: "output",
|
||||
target: targetStep.id,
|
||||
targetHandle: "input",
|
||||
type: "smoothstep",
|
||||
animated: true,
|
||||
markerEnd: {
|
||||
type: MarkerType.ArrowClosed,
|
||||
width: 20,
|
||||
height: 20,
|
||||
color: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
style: {
|
||||
strokeWidth: 2,
|
||||
stroke: "hsl(var(--muted-foreground))",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newEdges;
|
||||
}, [design.steps]);
|
||||
|
||||
const handleNodesChange = useCallback((changes: any[]) => {
|
||||
// Update step positions when nodes are moved
|
||||
const positionChanges = changes.filter(
|
||||
(change) => change.type === "position" && change.position,
|
||||
);
|
||||
if (positionChanges.length > 0) {
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: prev.steps.map((step) => {
|
||||
const positionChange = positionChanges.find(
|
||||
(change) => change.id === step.id,
|
||||
);
|
||||
if (positionChange && positionChange.position) {
|
||||
return { ...step, position: positionChange.position };
|
||||
}
|
||||
return step;
|
||||
}),
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleConnect = useCallback((params: Connection) => {
|
||||
if (!params.source || !params.target) return;
|
||||
|
||||
// Update the design to reflect the new connection order
|
||||
setDesign((prev) => {
|
||||
const sourceStep = prev.steps.find((s) => s.id === params.source);
|
||||
const targetStep = prev.steps.find((s) => s.id === params.target);
|
||||
|
||||
if (sourceStep && targetStep) {
|
||||
// Automatically adjust positions to create a logical flow
|
||||
const updatedSteps = prev.steps.map((step) => {
|
||||
if (step.id === params.target) {
|
||||
return {
|
||||
...step,
|
||||
position: {
|
||||
x: Math.max(sourceStep.position.x + 300, step.position.x),
|
||||
y: step.position.y,
|
||||
},
|
||||
};
|
||||
}
|
||||
return step;
|
||||
});
|
||||
|
||||
setHasUnsavedChanges(true);
|
||||
toast.success("Steps connected successfully");
|
||||
return { ...prev, steps: updatedSteps };
|
||||
}
|
||||
|
||||
return prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!onSave) return;
|
||||
|
||||
try {
|
||||
const updatedDesign = {
|
||||
...design,
|
||||
version: design.version + 1,
|
||||
lastSaved: new Date(),
|
||||
};
|
||||
|
||||
await onSave(updatedDesign);
|
||||
setDesign(updatedDesign);
|
||||
setHasUnsavedChanges(false);
|
||||
} catch (error) {
|
||||
console.error("Save error:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStepUpdate = useCallback((updatedStep: FlowStep) => {
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
steps: prev.steps.map((step) =>
|
||||
step.id === updatedStep.id ? updatedStep : step,
|
||||
),
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
setEditingStep(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Toolbar */}
|
||||
<div className="bg-background/95 supports-[backdrop-filter]:bg-background/60 border-b p-4 backdrop-blur">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<h2 className="font-semibold">{design.name}</h2>
|
||||
{hasUnsavedChanges && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-500 bg-amber-500/10 text-amber-600 dark:text-amber-400"
|
||||
>
|
||||
Unsaved Changes
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
<Undo className="mr-2 h-4 w-4" />
|
||||
Undo
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
<Redo className="mr-2 h-4 w-4" />
|
||||
Redo
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Import
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !hasUnsavedChanges}
|
||||
size="sm"
|
||||
>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Flow Area */}
|
||||
<div className="relative flex-1">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={handleNodesChange}
|
||||
onConnect={handleConnect}
|
||||
onNodeClick={handleNodeClick}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
fitViewOptions={{ padding: 0.2 }}
|
||||
connectionLineType={"smoothstep" as any}
|
||||
snapToGrid={true}
|
||||
snapGrid={[20, 20]}
|
||||
defaultEdgeOptions={{
|
||||
type: "smoothstep",
|
||||
animated: true,
|
||||
style: { strokeWidth: 2 },
|
||||
}}
|
||||
className="[&_.react-flow\_\_background]:bg-background [&_.react-flow\_\_controls]:bg-background [&_.react-flow\_\_controls]:border-border [&_.react-flow\_\_controls-button]:bg-background [&_.react-flow\_\_controls-button]:border-border [&_.react-flow\_\_controls-button]:text-foreground [&_.react-flow\_\_controls-button:hover]:bg-accent [&_.react-flow\_\_minimap]:bg-background [&_.react-flow\_\_minimap]:border-border [&_.react-flow\_\_edge-path]:stroke-muted-foreground [&_.react-flow\_\_controls]:shadow-sm [&_.react-flow\_\_edge-path]:stroke-2"
|
||||
>
|
||||
<Background
|
||||
variant={"dots" as any}
|
||||
gap={20}
|
||||
size={1}
|
||||
className="[&>*]:fill-muted-foreground/20"
|
||||
/>
|
||||
<Controls className="bg-background border-border rounded-lg shadow-lg" />
|
||||
<MiniMap
|
||||
nodeColor={(node) => {
|
||||
const step = design.steps.find((s) => s.id === node.id);
|
||||
return step
|
||||
? stepTypeConfig[step.type].color
|
||||
: "hsl(var(--muted))";
|
||||
}}
|
||||
className="bg-background border-border rounded-lg shadow-lg"
|
||||
/>
|
||||
|
||||
{/* Step Library Panel */}
|
||||
<Panel
|
||||
position="top-left"
|
||||
className="bg-card/95 supports-[backdrop-filter]:bg-card/80 rounded-lg border p-4 shadow-lg backdrop-blur"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">Add Step</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{Object.entries(stepTypeConfig).map(([type, config]) => (
|
||||
<Button
|
||||
key={type}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-auto justify-start p-2"
|
||||
onClick={() => handleStepTypeAdd(type as StepType)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`${config.bgColor} flex h-6 w-6 shrink-0 items-center justify-center rounded shadow-lg`}
|
||||
>
|
||||
<config.icon className="h-3 w-3 text-white" />
|
||||
</div>
|
||||
<span className="text-xs">{config.label}</span>
|
||||
</div>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* Info Panel */}
|
||||
<Panel
|
||||
position="top-right"
|
||||
className="bg-card/95 supports-[backdrop-filter]:bg-card/80 rounded-lg border p-4 shadow-lg backdrop-blur"
|
||||
>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Steps:</span>
|
||||
<span className="font-medium">{design.steps.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Actions:</span>
|
||||
<span className="font-medium">
|
||||
{design.steps.reduce(
|
||||
(sum, step) => sum + step.actions.length,
|
||||
0,
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Version:</span>
|
||||
<span className="font-medium">v{design.version}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
|
||||
{/* Properties Sheet */}
|
||||
{selectedStep && (
|
||||
<Sheet
|
||||
open={!!selectedStep}
|
||||
onOpenChange={() => setSelectedStepId(undefined)}
|
||||
>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Step Properties</SheetTitle>
|
||||
<SheetDescription>
|
||||
Configure the selected step and its actions
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="space-y-6 px-4 pb-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="step-name">Name</Label>
|
||||
<Input
|
||||
id="step-name"
|
||||
value={selectedStep.name}
|
||||
onChange={(e) => {
|
||||
const updatedStep = {
|
||||
...selectedStep,
|
||||
name: e.target.value,
|
||||
};
|
||||
handleStepUpdate(updatedStep);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="step-description">Description</Label>
|
||||
<Textarea
|
||||
id="step-description"
|
||||
value={selectedStep.description ?? ""}
|
||||
onChange={(e) => {
|
||||
const updatedStep = {
|
||||
...selectedStep,
|
||||
description: e.target.value,
|
||||
};
|
||||
handleStepUpdate(updatedStep);
|
||||
}}
|
||||
placeholder="Optional description..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Step Type</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`${stepTypeConfig[selectedStep.type].bgColor} flex h-8 w-8 shrink-0 items-center justify-center rounded shadow-lg`}
|
||||
>
|
||||
{React.createElement(
|
||||
stepTypeConfig[selectedStep.type].icon,
|
||||
{
|
||||
className: "h-4 w-4 text-white",
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm font-medium">
|
||||
{stepTypeConfig[selectedStep.type].label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Actions ({selectedStep.actions.length})</Label>
|
||||
<Button size="sm" variant="outline">
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Action
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="mt-2 h-[200px]">
|
||||
<div className="space-y-2 pr-4">
|
||||
{selectedStep.actions.map((action) => {
|
||||
const actionConfig = actionTypeConfig[action.type];
|
||||
return (
|
||||
<div
|
||||
key={action.id}
|
||||
className="bg-muted/50 flex items-center gap-2 rounded border p-2"
|
||||
>
|
||||
<actionConfig.icon className="text-muted-foreground h-4 w-4 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{action.name}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Edit3 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
|
||||
{/* Step Edit Dialog */}
|
||||
{editingStep && (
|
||||
<Sheet open={!!editingStep} onOpenChange={() => setEditingStep(null)}>
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>Edit Step</SheetTitle>
|
||||
<SheetDescription>
|
||||
Modify step properties and actions
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="space-y-6 px-4 pb-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-step-name">Name</Label>
|
||||
<Input
|
||||
id="edit-step-name"
|
||||
value={editingStep.name}
|
||||
onChange={(e) => {
|
||||
setEditingStep({ ...editingStep, name: e.target.value });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-step-description">Description</Label>
|
||||
<Textarea
|
||||
id="edit-step-description"
|
||||
value={editingStep.description ?? ""}
|
||||
onChange={(e) => {
|
||||
setEditingStep({
|
||||
...editingStep,
|
||||
description: e.target.value,
|
||||
});
|
||||
}}
|
||||
placeholder="Optional description..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-6">
|
||||
<Button
|
||||
onClick={() => {
|
||||
handleStepUpdate(editingStep);
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setEditingStep(null)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type { FlowDesign, FlowStep, FlowAction, StepType, ActionType };
|
||||
@@ -1,725 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
closestCenter, DndContext,
|
||||
DragOverlay, PointerSensor, useDraggable,
|
||||
useDroppable, useSensor,
|
||||
useSensors, type DragEndEvent, type DragStartEvent
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
Bot, Clock, Edit3, Grid, MessageSquare, Play, Redo, Save, Trash2, Undo, ZoomIn,
|
||||
ZoomOut
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from "~/components/ui/dialog";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger
|
||||
} from "~/components/ui/tooltip";
|
||||
|
||||
// Free-form element types
|
||||
export type ElementType =
|
||||
| "text"
|
||||
| "action"
|
||||
| "timer"
|
||||
| "decision"
|
||||
| "note"
|
||||
| "group";
|
||||
|
||||
export interface CanvasElement {
|
||||
id: string;
|
||||
type: ElementType;
|
||||
title: string;
|
||||
content: string;
|
||||
position: { x: number; y: number };
|
||||
size: { width: number; height: number };
|
||||
style: {
|
||||
backgroundColor: string;
|
||||
textColor: string;
|
||||
borderColor: string;
|
||||
fontSize: number;
|
||||
};
|
||||
metadata: Record<string, any>;
|
||||
connections: string[]; // IDs of connected elements
|
||||
}
|
||||
|
||||
export interface Connection {
|
||||
id: string;
|
||||
from: string;
|
||||
to: string;
|
||||
label?: string;
|
||||
style: {
|
||||
color: string;
|
||||
width: number;
|
||||
type: "solid" | "dashed" | "dotted";
|
||||
};
|
||||
}
|
||||
|
||||
export interface ExperimentDesign {
|
||||
id: string;
|
||||
name: string;
|
||||
elements: CanvasElement[];
|
||||
connections: Connection[];
|
||||
canvasSettings: {
|
||||
zoom: number;
|
||||
gridSize: number;
|
||||
showGrid: boolean;
|
||||
backgroundColor: string;
|
||||
};
|
||||
version: number;
|
||||
lastSaved: Date;
|
||||
}
|
||||
|
||||
const elementTypeConfig = {
|
||||
text: {
|
||||
label: "Text Block",
|
||||
description: "Add instructions or information",
|
||||
icon: MessageSquare,
|
||||
defaultStyle: {
|
||||
backgroundColor: "#f8fafc",
|
||||
textColor: "#1e293b",
|
||||
borderColor: "#e2e8f0",
|
||||
},
|
||||
},
|
||||
action: {
|
||||
label: "Action Step",
|
||||
description: "Define an action to be performed",
|
||||
icon: Play,
|
||||
defaultStyle: {
|
||||
backgroundColor: "#dbeafe",
|
||||
textColor: "#1e40af",
|
||||
borderColor: "#3b82f6",
|
||||
},
|
||||
},
|
||||
timer: {
|
||||
label: "Timer",
|
||||
description: "Add timing constraints",
|
||||
icon: Clock,
|
||||
defaultStyle: {
|
||||
backgroundColor: "#fef3c7",
|
||||
textColor: "#92400e",
|
||||
borderColor: "#f59e0b",
|
||||
},
|
||||
},
|
||||
decision: {
|
||||
label: "Decision Point",
|
||||
description: "Create branching logic",
|
||||
icon: Bot,
|
||||
defaultStyle: {
|
||||
backgroundColor: "#dcfce7",
|
||||
textColor: "#166534",
|
||||
borderColor: "#22c55e",
|
||||
},
|
||||
},
|
||||
note: {
|
||||
label: "Research Note",
|
||||
description: "Add researcher annotations",
|
||||
icon: Edit3,
|
||||
defaultStyle: {
|
||||
backgroundColor: "#fce7f3",
|
||||
textColor: "#be185d",
|
||||
borderColor: "#ec4899",
|
||||
},
|
||||
},
|
||||
group: {
|
||||
label: "Group Container",
|
||||
description: "Group related elements",
|
||||
icon: Grid,
|
||||
defaultStyle: {
|
||||
backgroundColor: "#f3f4f6",
|
||||
textColor: "#374151",
|
||||
borderColor: "#9ca3af",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
interface FreeFormDesignerProps {
|
||||
experiment: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
onSave?: (design: ExperimentDesign) => void;
|
||||
initialDesign?: ExperimentDesign;
|
||||
}
|
||||
|
||||
// Draggable element from toolbar
|
||||
function ToolboxElement({ type }: { type: ElementType }) {
|
||||
const config = elementTypeConfig[type];
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
||||
useDraggable({
|
||||
id: `toolbox-${type}`,
|
||||
data: { type: "toolbox", elementType: type },
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: transform
|
||||
? `translate3d(${transform.x}px, ${transform.y}px, 0)`
|
||||
: undefined,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className="flex cursor-grab flex-col items-center gap-2 rounded-lg border-2 border-dashed border-gray-300 p-3 transition-colors hover:border-gray-400 hover:bg-gray-50"
|
||||
>
|
||||
<config.icon className="h-6 w-6 text-gray-600" />
|
||||
<span className="text-xs font-medium text-gray-700">
|
||||
{config.label}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{config.description}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// Canvas element component
|
||||
function CanvasElementComponent({
|
||||
element,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
element: CanvasElement;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const config = elementTypeConfig[element.type];
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
||||
useDraggable({
|
||||
id: element.id,
|
||||
data: { type: "canvas-element", element },
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: transform
|
||||
? `translate3d(${transform.x}px, ${transform.y}px, 0)`
|
||||
: undefined,
|
||||
position: "absolute" as const,
|
||||
left: element.position.x,
|
||||
top: element.position.y,
|
||||
width: element.size.width,
|
||||
height: element.size.height,
|
||||
backgroundColor: element.style.backgroundColor,
|
||||
color: element.style.textColor,
|
||||
borderColor: element.style.borderColor,
|
||||
fontSize: element.style.fontSize,
|
||||
opacity: isDragging ? 0.7 : 1,
|
||||
zIndex: isSelected ? 10 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`cursor-pointer rounded-lg border-2 p-3 shadow-sm transition-all ${
|
||||
isSelected ? "ring-2 ring-blue-500 ring-offset-2" : ""
|
||||
}`}
|
||||
onClick={onSelect}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<config.icon className="h-4 w-4 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="truncate text-sm font-medium">{element.title}</h4>
|
||||
<p className="mt-1 line-clamp-3 text-xs opacity-75">
|
||||
{element.content}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isSelected && (
|
||||
<div className="absolute -top-2 -right-2 flex gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit();
|
||||
}}
|
||||
>
|
||||
<Edit3 className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Canvas drop zone
|
||||
function DesignCanvas({
|
||||
children,
|
||||
onDrop,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onDrop: (position: { x: number; y: number }) => void;
|
||||
}) {
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: "design-canvas",
|
||||
});
|
||||
|
||||
const handleCanvasClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
onDrop({ x, y });
|
||||
}
|
||||
},
|
||||
[onDrop],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`relative h-full w-full overflow-hidden bg-gray-50 ${
|
||||
isOver ? "bg-blue-50" : ""
|
||||
}`}
|
||||
style={{
|
||||
backgroundImage:
|
||||
"radial-gradient(circle, #d1d5db 1px, transparent 1px)",
|
||||
backgroundSize: "20px 20px",
|
||||
}}
|
||||
onClick={handleCanvasClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Element editor dialog
|
||||
function ElementEditor({
|
||||
element,
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
element: CanvasElement | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (element: CanvasElement) => void;
|
||||
}) {
|
||||
const [editingElement, setEditingElement] = useState<CanvasElement | null>(
|
||||
element,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setEditingElement(element);
|
||||
}, [element]);
|
||||
|
||||
if (!editingElement) return null;
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(editingElement);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Element</DialogTitle>
|
||||
<DialogDescription>
|
||||
Customize the properties of this element.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="title">Title</Label>
|
||||
<Input
|
||||
id="title"
|
||||
value={editingElement.title}
|
||||
onChange={(e) =>
|
||||
setEditingElement({
|
||||
...editingElement,
|
||||
title: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="content">Content</Label>
|
||||
<Textarea
|
||||
id="content"
|
||||
value={editingElement.content}
|
||||
onChange={(e) =>
|
||||
setEditingElement({
|
||||
...editingElement,
|
||||
content: e.target.value,
|
||||
})
|
||||
}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="width">Width</Label>
|
||||
<Input
|
||||
id="width"
|
||||
type="number"
|
||||
value={editingElement.size.width}
|
||||
onChange={(e) =>
|
||||
setEditingElement({
|
||||
...editingElement,
|
||||
size: {
|
||||
...editingElement.size,
|
||||
width: parseInt(e.target.value) || 200,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="height">Height</Label>
|
||||
<Input
|
||||
id="height"
|
||||
type="number"
|
||||
value={editingElement.size.height}
|
||||
onChange={(e) =>
|
||||
setEditingElement({
|
||||
...editingElement,
|
||||
size: {
|
||||
...editingElement.size,
|
||||
height: parseInt(e.target.value) || 100,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="backgroundColor">Background Color</Label>
|
||||
<Input
|
||||
id="backgroundColor"
|
||||
type="color"
|
||||
value={editingElement.style.backgroundColor}
|
||||
onChange={(e) =>
|
||||
setEditingElement({
|
||||
...editingElement,
|
||||
style: {
|
||||
...editingElement.style,
|
||||
backgroundColor: e.target.value,
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>Save Changes</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function FreeFormDesigner({
|
||||
experiment,
|
||||
onSave,
|
||||
initialDesign,
|
||||
}: FreeFormDesignerProps) {
|
||||
const [design, setDesign] = useState<ExperimentDesign>(
|
||||
initialDesign || {
|
||||
id: experiment.id,
|
||||
name: experiment.name,
|
||||
elements: [],
|
||||
connections: [],
|
||||
canvasSettings: {
|
||||
zoom: 1,
|
||||
gridSize: 20,
|
||||
showGrid: true,
|
||||
backgroundColor: "#f9fafb",
|
||||
},
|
||||
version: 1,
|
||||
lastSaved: new Date(),
|
||||
},
|
||||
);
|
||||
|
||||
const [selectedElement, setSelectedElement] = useState<string | null>(null);
|
||||
const [editingElement, setEditingElement] = useState<CanvasElement | null>(
|
||||
null,
|
||||
);
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
const [draggedElement, setDraggedElement] = useState<any>(null);
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const generateId = () =>
|
||||
`element-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setDraggedElement(event.active.data.current);
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (!over || over.id !== "design-canvas") {
|
||||
setDraggedElement(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const x =
|
||||
event.delta.x + (active.rect.current.translated?.left || 0) - rect.left;
|
||||
const y =
|
||||
event.delta.y + (active.rect.current.translated?.top || 0) - rect.top;
|
||||
|
||||
const dragData = active.data.current;
|
||||
|
||||
if (dragData?.type === "toolbox") {
|
||||
// Create new element from toolbox
|
||||
createNewElement(dragData.elementType, { x, y });
|
||||
} else if (dragData?.type === "canvas-element") {
|
||||
// Move existing element
|
||||
moveElement(dragData.element.id, { x, y });
|
||||
}
|
||||
|
||||
setDraggedElement(null);
|
||||
};
|
||||
|
||||
const createNewElement = (
|
||||
type: ElementType,
|
||||
position: { x: number; y: number },
|
||||
) => {
|
||||
const config = elementTypeConfig[type];
|
||||
const newElement: CanvasElement = {
|
||||
id: generateId(),
|
||||
type,
|
||||
title: `New ${config.label}`,
|
||||
content: "Click to edit this element",
|
||||
position,
|
||||
size: { width: 200, height: 100 },
|
||||
style: {
|
||||
...config.defaultStyle,
|
||||
fontSize: 14,
|
||||
},
|
||||
metadata: {},
|
||||
connections: [],
|
||||
};
|
||||
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
elements: [...prev.elements, newElement],
|
||||
}));
|
||||
};
|
||||
|
||||
const moveElement = (
|
||||
elementId: string,
|
||||
newPosition: { x: number; y: number },
|
||||
) => {
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
elements: prev.elements.map((el) =>
|
||||
el.id === elementId ? { ...el, position: newPosition } : el,
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const deleteElement = (elementId: string) => {
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
elements: prev.elements.filter((el) => el.id !== elementId),
|
||||
connections: prev.connections.filter(
|
||||
(conn) => conn.from !== elementId && conn.to !== elementId,
|
||||
),
|
||||
}));
|
||||
setSelectedElement(null);
|
||||
};
|
||||
|
||||
const editElement = (element: CanvasElement) => {
|
||||
setEditingElement(element);
|
||||
setIsEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const saveElement = (updatedElement: CanvasElement) => {
|
||||
setDesign((prev) => ({
|
||||
...prev,
|
||||
elements: prev.elements.map((el) =>
|
||||
el.id === updatedElement.id ? updatedElement : el,
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
const updatedDesign = {
|
||||
...design,
|
||||
lastSaved: new Date(),
|
||||
};
|
||||
setDesign(updatedDesign);
|
||||
onSave?.(updatedDesign);
|
||||
};
|
||||
|
||||
const handleCanvasDrop = (position: { x: number; y: number }) => {
|
||||
// Deselect when clicking empty space
|
||||
setSelectedElement(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-white">
|
||||
{/* Toolbar */}
|
||||
<div className="w-64 border-r bg-gray-50 p-4">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">Element Toolbox</h3>
|
||||
<p className="text-sm text-gray-500">Drag elements to the canvas</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{Object.entries(elementTypeConfig).map(([type, config]) => (
|
||||
<ToolboxElement key={type} type={type as ElementType} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button onClick={handleSave} className="w-full">
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Save Design
|
||||
</Button>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<Undo className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Redo className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium text-gray-900">Design Info</h4>
|
||||
<div className="space-y-1 text-xs text-gray-500">
|
||||
<div>Elements: {design.elements.length}</div>
|
||||
<div>Last saved: {design.lastSaved.toLocaleTimeString()}</div>
|
||||
<div>Version: {design.version}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Canvas */}
|
||||
<div className="relative flex-1">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div ref={canvasRef} className="h-full">
|
||||
<DesignCanvas onDrop={handleCanvasDrop}>
|
||||
{design.elements.map((element) => (
|
||||
<CanvasElementComponent
|
||||
key={element.id}
|
||||
element={element}
|
||||
isSelected={selectedElement === element.id}
|
||||
onSelect={() => setSelectedElement(element.id)}
|
||||
onEdit={() => editElement(element)}
|
||||
onDelete={() => deleteElement(element.id)}
|
||||
/>
|
||||
))}
|
||||
</DesignCanvas>
|
||||
</div>
|
||||
|
||||
<DragOverlay>
|
||||
{draggedElement?.type === "toolbox" && (
|
||||
<div className="rounded-lg border bg-white p-3 shadow-lg">
|
||||
{(() => {
|
||||
const IconComponent =
|
||||
elementTypeConfig[draggedElement.elementType as ElementType]
|
||||
.icon;
|
||||
return <IconComponent className="h-6 w-6" />;
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
{draggedElement?.type === "canvas-element" && (
|
||||
<div className="rounded-lg border bg-white p-3 opacity-75 shadow-lg">
|
||||
{draggedElement.element.title}
|
||||
</div>
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
|
||||
{/* Element Editor Dialog */}
|
||||
<ElementEditor
|
||||
element={editingElement}
|
||||
isOpen={isEditDialogOpen}
|
||||
onClose={() => setIsEditDialogOpen(false)}
|
||||
onSave={saveElement}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
/* React Flow Theme Integration with shadcn/ui */
|
||||
|
||||
.react-flow {
|
||||
background-color: hsl(var(--background));
|
||||
}
|
||||
|
||||
.react-flow__background {
|
||||
background-color: hsl(var(--background));
|
||||
}
|
||||
|
||||
.react-flow__controls {
|
||||
background-color: hsl(var(--background));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.react-flow__controls-button {
|
||||
background-color: hsl(var(--background));
|
||||
border: 1px solid hsl(var(--border));
|
||||
color: hsl(var(--foreground));
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.react-flow__controls-button:hover {
|
||||
background-color: hsl(var(--accent));
|
||||
color: hsl(var(--accent-foreground));
|
||||
}
|
||||
|
||||
.react-flow__controls-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.react-flow__minimap {
|
||||
background-color: hsl(var(--background));
|
||||
border: 1px solid hsl(var(--border));
|
||||
border-radius: calc(var(--radius) - 2px);
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.react-flow__minimap-mask {
|
||||
fill: hsl(var(--primary) / 0.2);
|
||||
stroke: hsl(var(--primary));
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.react-flow__minimap-node {
|
||||
fill: hsl(var(--muted));
|
||||
stroke: hsl(var(--border));
|
||||
}
|
||||
|
||||
.react-flow__edge-path {
|
||||
stroke: hsl(var(--muted-foreground));
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.react-flow__edge-text {
|
||||
fill: hsl(var(--foreground));
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.react-flow__connection-line {
|
||||
stroke: hsl(var(--primary));
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 5;
|
||||
}
|
||||
|
||||
.react-flow__node.selected {
|
||||
box-shadow: 0 0 0 2px hsl(var(--primary));
|
||||
}
|
||||
|
||||
.react-flow__handle {
|
||||
background-color: hsl(var(--primary));
|
||||
border: 2px solid hsl(var(--background));
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.react-flow__handle.connectingfrom {
|
||||
background-color: hsl(var(--primary));
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
|
||||
.react-flow__handle.connectingto {
|
||||
background-color: hsl(var(--secondary));
|
||||
}
|
||||
|
||||
.react-flow__background pattern circle {
|
||||
fill: hsl(var(--muted-foreground) / 0.3);
|
||||
}
|
||||
|
||||
.react-flow__background pattern rect {
|
||||
fill: hsl(var(--muted-foreground) / 0.1);
|
||||
}
|
||||
|
||||
/* Custom node animations */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom edge animations */
|
||||
.react-flow__edge.animated path {
|
||||
stroke-dasharray: 5;
|
||||
animation: dash 0.5s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes dash {
|
||||
to {
|
||||
stroke-dashoffset: -10;
|
||||
}
|
||||
}
|
||||
|
||||
/* Selection box */
|
||||
.react-flow__selection {
|
||||
background-color: hsl(var(--primary) / 0.1);
|
||||
border: 1px solid hsl(var(--primary));
|
||||
}
|
||||
|
||||
/* Pane (canvas area) */
|
||||
.react-flow__pane {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.react-flow__pane:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* Attribution */
|
||||
.react-flow__attribution {
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.react-flow__attribution a {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.react-flow__attribution a:hover {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
useState,
|
||||
useEffect,
|
||||
type ReactNode,
|
||||
Fragment,
|
||||
} from "react";
|
||||
import {
|
||||
Breadcrumb,
|
||||
@@ -59,7 +60,7 @@ export function BreadcrumbDisplay() {
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{breadcrumbs.map((item, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
<Fragment key={index}>
|
||||
{index > 0 && <BreadcrumbSeparator />}
|
||||
<BreadcrumbItem>
|
||||
{item.href ? (
|
||||
@@ -68,7 +69,7 @@ export function BreadcrumbDisplay() {
|
||||
<BreadcrumbPage>{item.label}</BreadcrumbPage>
|
||||
)}
|
||||
</BreadcrumbItem>
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
|
||||
@@ -87,6 +87,24 @@ export const pluginStatusEnum = pgEnum("plugin_status", [
|
||||
"disabled",
|
||||
]);
|
||||
|
||||
export const blockShapeEnum = pgEnum("block_shape", [
|
||||
"action",
|
||||
"control",
|
||||
"value",
|
||||
"boolean",
|
||||
"hat",
|
||||
"cap",
|
||||
]);
|
||||
|
||||
export const blockCategoryEnum = pgEnum("block_category", [
|
||||
"wizard",
|
||||
"robot",
|
||||
"control",
|
||||
"sensor",
|
||||
"logic",
|
||||
"event",
|
||||
]);
|
||||
|
||||
export const mediaTypeEnum = pgEnum("media_type", ["video", "audio", "image"]);
|
||||
|
||||
export const exportStatusEnum = pgEnum("export_status", [
|
||||
@@ -297,6 +315,58 @@ export const robots = createTable("robot", {
|
||||
.notNull(),
|
||||
});
|
||||
|
||||
export const robotPlugins = createTable("robot_plugin", {
|
||||
id: uuid("id").notNull().primaryKey().defaultRandom(),
|
||||
name: varchar("name", { length: 255 }).notNull(),
|
||||
version: varchar("version", { length: 50 }).notNull(),
|
||||
manufacturer: varchar("manufacturer", { length: 255 }),
|
||||
description: text("description"),
|
||||
robotId: uuid("robot_id").references(() => robots.id),
|
||||
communicationProtocol: communicationProtocolEnum("communication_protocol"),
|
||||
status: pluginStatusEnum("status").default("active").notNull(),
|
||||
configSchema: jsonb("config_schema"),
|
||||
capabilities: jsonb("capabilities").default([]),
|
||||
trustLevel: trustLevelEnum("trust_level").default("community").notNull(),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
});
|
||||
|
||||
export const blockRegistry = createTable(
|
||||
"block_registry",
|
||||
{
|
||||
id: uuid("id").notNull().primaryKey().defaultRandom(),
|
||||
blockType: varchar("block_type", { length: 100 }).notNull(),
|
||||
pluginId: uuid("plugin_id").references(() => robotPlugins.id),
|
||||
shape: blockShapeEnum("shape").notNull(),
|
||||
category: blockCategoryEnum("category").notNull(),
|
||||
displayName: varchar("display_name", { length: 255 }).notNull(),
|
||||
description: text("description"),
|
||||
icon: varchar("icon", { length: 100 }),
|
||||
color: varchar("color", { length: 50 }),
|
||||
config: jsonb("config").notNull(),
|
||||
parameterSchema: jsonb("parameter_schema").notNull(),
|
||||
executionHandler: varchar("execution_handler", { length: 100 }),
|
||||
timeout: integer("timeout"),
|
||||
retryPolicy: jsonb("retry_policy"),
|
||||
requiresConnection: boolean("requires_connection").default(false),
|
||||
previewMode: boolean("preview_mode").default(true),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
},
|
||||
(table) => ({
|
||||
blockTypeUnique: unique().on(table.blockType, table.pluginId),
|
||||
categoryIdx: index("block_registry_category_idx").on(table.category),
|
||||
}),
|
||||
);
|
||||
|
||||
export const experiments = createTable(
|
||||
"experiment",
|
||||
{
|
||||
@@ -320,6 +390,9 @@ export const experiments = createTable(
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
metadata: jsonb("metadata").default({}),
|
||||
visualDesign: jsonb("visual_design"),
|
||||
executionGraph: jsonb("execution_graph"),
|
||||
pluginDependencies: text("plugin_dependencies").array(),
|
||||
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
||||
},
|
||||
(table) => ({
|
||||
@@ -328,6 +401,10 @@ export const experiments = createTable(
|
||||
table.name,
|
||||
table.version,
|
||||
),
|
||||
visualDesignIdx: index("experiment_visual_design_idx").using(
|
||||
"gin",
|
||||
table.visualDesign,
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user