mirror of
https://github.com/soconnor0919/hristudio.git
synced 2025-12-12 07:04:44 -05:00
feat: Implement visual experiment designer and enhance landing page
- Add drag-and-drop experiment design capabilities using @dnd-kit libraries - Introduce new experiment-related database schema and API routes - Enhance landing page with modern design, gradients, and improved call-to-action sections - Update app sidebar to include experiments navigation - Add new dependencies for experiment design and visualization (reactflow, react-zoom-pan-pinch) - Modify study and experiment schemas to support more flexible experiment configuration - Implement initial experiment creation and management infrastructure
This commit is contained in:
401
src/components/experiments/action-config-dialog.tsx
Normal file
401
src/components/experiments/action-config-dialog.tsx
Normal file
@@ -0,0 +1,401 @@
|
||||
"use client";
|
||||
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "~/components/ui/form";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Textarea } from "~/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/ui/select";
|
||||
import { Switch } from "~/components/ui/switch";
|
||||
import { AVAILABLE_ACTIONS } from "~/lib/experiments/actions";
|
||||
import { type ActionType } from "~/lib/experiments/types";
|
||||
|
||||
// Define parameter schemas for each action type
|
||||
const parameterSchemas = {
|
||||
move: z.object({
|
||||
position: z.object({
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
z: z.number(),
|
||||
}),
|
||||
speed: z.number().min(0).max(1),
|
||||
easing: z.enum(["linear", "easeIn", "easeOut", "easeInOut"]),
|
||||
}),
|
||||
speak: z.object({
|
||||
text: z.string().min(1),
|
||||
speed: z.number().min(0.5).max(2),
|
||||
pitch: z.number().min(0.5).max(2),
|
||||
volume: z.number().min(0).max(1),
|
||||
}),
|
||||
wait: z.object({
|
||||
duration: z.number().min(0),
|
||||
showCountdown: z.boolean(),
|
||||
}),
|
||||
input: z.object({
|
||||
type: z.enum(["button", "text", "number", "choice"]),
|
||||
prompt: z.string().optional(),
|
||||
options: z.array(z.string()).optional(),
|
||||
timeout: z.number().nullable(),
|
||||
}),
|
||||
gesture: z.object({
|
||||
name: z.string().min(1),
|
||||
speed: z.number().min(0).max(1),
|
||||
intensity: z.number().min(0).max(1),
|
||||
}),
|
||||
record: z.object({
|
||||
type: z.enum(["start", "stop"]),
|
||||
streams: z.array(z.enum(["video", "audio", "sensors"])),
|
||||
}),
|
||||
condition: z.object({
|
||||
condition: z.string().min(1),
|
||||
trueActions: z.array(z.any()),
|
||||
falseActions: z.array(z.any()).optional(),
|
||||
}),
|
||||
loop: z.object({
|
||||
count: z.number().min(1),
|
||||
actions: z.array(z.any()),
|
||||
}),
|
||||
} satisfies Record<ActionType, z.ZodType<any>>;
|
||||
|
||||
interface ActionConfigDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
type: ActionType;
|
||||
parameters: Record<string, any>;
|
||||
onSubmit: (parameters: Record<string, any>) => void;
|
||||
}
|
||||
|
||||
export function ActionConfigDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
type,
|
||||
parameters,
|
||||
onSubmit,
|
||||
}: ActionConfigDialogProps) {
|
||||
const actionConfig = AVAILABLE_ACTIONS.find((a) => a.type === type);
|
||||
if (!actionConfig) return null;
|
||||
|
||||
const schema = parameterSchemas[type];
|
||||
const form = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: parameters,
|
||||
});
|
||||
|
||||
function handleSubmit(data: Record<string, any>) {
|
||||
onSubmit(data);
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Configure {actionConfig.title}</DialogTitle>
|
||||
<DialogDescription>{actionConfig.description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
|
||||
{type === "move" && (
|
||||
<>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="position.x"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>X Position</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseFloat(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="position.y"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Y Position</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseFloat(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="position.z"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Z Position</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseFloat(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="speed"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Speed</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="1"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseFloat(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Movement speed (0-1)
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="easing"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Easing</FormLabel>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select easing type" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="linear">Linear</SelectItem>
|
||||
<SelectItem value="easeIn">Ease In</SelectItem>
|
||||
<SelectItem value="easeOut">Ease Out</SelectItem>
|
||||
<SelectItem value="easeInOut">Ease In Out</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
Movement easing function
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === "speak" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="text"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Text</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Enter text to speak"
|
||||
className="resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="speed"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Speed</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0.5"
|
||||
max="2"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseFloat(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="pitch"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Pitch</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0.5"
|
||||
max="2"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseFloat(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="volume"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Volume</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
max="1"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseFloat(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === "wait" && (
|
||||
<>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="duration"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Duration (ms)</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
step="100"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(parseFloat(e.target.value))
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Wait duration in milliseconds
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="showCountdown"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">
|
||||
Show Countdown
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
Display a countdown timer during the wait
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Add more action type configurations here */}
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">Save Changes</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
45
src/components/experiments/action-item.tsx
Normal file
45
src/components/experiments/action-item.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { type ActionType } from "~/lib/experiments/types";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
interface ActionItemProps {
|
||||
type: ActionType;
|
||||
title: string;
|
||||
description?: string;
|
||||
icon: React.ReactNode;
|
||||
draggable?: boolean;
|
||||
onDragStart?: (event: React.DragEvent) => void;
|
||||
}
|
||||
|
||||
export function ActionItem({
|
||||
type,
|
||||
title,
|
||||
description,
|
||||
icon,
|
||||
draggable,
|
||||
onDragStart,
|
||||
}: ActionItemProps) {
|
||||
return (
|
||||
<div
|
||||
draggable={draggable}
|
||||
onDragStart={onDragStart}
|
||||
className={cn(
|
||||
"flex cursor-grab items-center gap-3 rounded-lg border bg-card p-3 text-left",
|
||||
"hover:bg-accent hover:text-accent-foreground",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
"active:cursor-grabbing"
|
||||
)}
|
||||
>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-md border bg-background">
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex-1 space-y-1">
|
||||
<p className="text-sm font-medium leading-none">{title}</p>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
77
src/components/experiments/edges/flow-edge.tsx
Normal file
77
src/components/experiments/edges/flow-edge.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { BaseEdge, EdgeProps, getBezierPath } from "reactflow";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
export function FlowEdge({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
style = {},
|
||||
markerEnd,
|
||||
}: EdgeProps) {
|
||||
const [edgePath] = getBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge path={edgePath} markerEnd={markerEnd} style={style} />
|
||||
<motion.path
|
||||
id={id}
|
||||
style={{
|
||||
...style,
|
||||
strokeWidth: 3,
|
||||
fill: "none",
|
||||
stroke: "hsl(var(--primary))",
|
||||
strokeDasharray: "5,5",
|
||||
opacity: 0.5,
|
||||
}}
|
||||
d={edgePath}
|
||||
className="react-flow__edge-path"
|
||||
animate={{
|
||||
strokeDashoffset: [0, -10],
|
||||
}}
|
||||
transition={{
|
||||
duration: 1,
|
||||
repeat: Infinity,
|
||||
ease: "linear",
|
||||
}}
|
||||
/>
|
||||
<motion.path
|
||||
style={{
|
||||
strokeWidth: 15,
|
||||
fill: "none",
|
||||
stroke: "hsl(var(--primary))",
|
||||
opacity: 0,
|
||||
cursor: "pointer",
|
||||
}}
|
||||
d={edgePath}
|
||||
className="react-flow__edge-interaction"
|
||||
onMouseEnter={(event) => {
|
||||
const path = event.currentTarget.previousSibling as SVGPathElement;
|
||||
if (path) {
|
||||
path.style.opacity = "1";
|
||||
path.style.strokeWidth = "4";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(event) => {
|
||||
const path = event.currentTarget.previousSibling as SVGPathElement;
|
||||
if (path) {
|
||||
path.style.opacity = "0.5";
|
||||
path.style.strokeWidth = "3";
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
453
src/components/experiments/experiment-designer.tsx
Normal file
453
src/components/experiments/experiment-designer.tsx
Normal file
@@ -0,0 +1,453 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
type Node,
|
||||
type Edge,
|
||||
type Connection,
|
||||
type NodeChange,
|
||||
type EdgeChange,
|
||||
applyNodeChanges,
|
||||
applyEdgeChanges,
|
||||
ReactFlowProvider,
|
||||
Panel,
|
||||
} from "reactflow";
|
||||
import { motion, AnimatePresence } from "framer-motion";
|
||||
import { type Step } from "~/lib/experiments/types";
|
||||
import { AVAILABLE_ACTIONS } from "~/lib/experiments/actions";
|
||||
import { Card } from "~/components/ui/card";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
|
||||
import { ActionNode } from "./nodes/action-node";
|
||||
import { FlowEdge } from "./edges/flow-edge";
|
||||
import { ActionItem } from "./action-item";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { ChevronLeft, ChevronRight, Undo, Redo, ZoomIn, ZoomOut } from "lucide-react";
|
||||
import "reactflow/dist/style.css";
|
||||
|
||||
const nodeTypes = {
|
||||
action: ActionNode,
|
||||
};
|
||||
|
||||
const edgeTypes = {
|
||||
default: FlowEdge,
|
||||
};
|
||||
|
||||
interface ExperimentDesignerProps {
|
||||
className?: string;
|
||||
defaultSteps?: Step[];
|
||||
onChange?: (steps: Step[]) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function ExperimentDesigner({
|
||||
className,
|
||||
defaultSteps = [],
|
||||
onChange,
|
||||
readOnly = false,
|
||||
}: ExperimentDesignerProps) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const reactFlowWrapper = useRef<HTMLDivElement>(null);
|
||||
const [reactFlowInstance, setReactFlowInstance] = useState<any>(null);
|
||||
const [selectedNode, setSelectedNode] = useState<Node | null>(null);
|
||||
|
||||
// History management for undo/redo
|
||||
const [history, setHistory] = useState<Step[][]>([defaultSteps]);
|
||||
const [historyIndex, setHistoryIndex] = useState(0);
|
||||
|
||||
const addToHistory = useCallback((newSteps: Step[]) => {
|
||||
setHistory((h) => {
|
||||
const newHistory = h.slice(0, historyIndex + 1);
|
||||
return [...newHistory, newSteps];
|
||||
});
|
||||
setHistoryIndex((i) => i + 1);
|
||||
}, [historyIndex]);
|
||||
|
||||
const undo = useCallback(() => {
|
||||
if (historyIndex > 0) {
|
||||
setHistoryIndex((i) => i - 1);
|
||||
setSteps(history[historyIndex - 1]!);
|
||||
onChange?.(history[historyIndex - 1]!);
|
||||
}
|
||||
}, [history, historyIndex, onChange]);
|
||||
|
||||
const redo = useCallback(() => {
|
||||
if (historyIndex < history.length - 1) {
|
||||
setHistoryIndex((i) => i + 1);
|
||||
setSteps(history[historyIndex + 1]!);
|
||||
onChange?.(history[historyIndex + 1]!);
|
||||
}
|
||||
}, [history, historyIndex, onChange]);
|
||||
|
||||
// Convert steps to nodes and edges
|
||||
const initialNodes: Node[] = defaultSteps.flatMap((step, stepIndex) =>
|
||||
step.actions.map((action, actionIndex) => ({
|
||||
id: action.id,
|
||||
type: "action",
|
||||
position: { x: stepIndex * 250, y: actionIndex * 150 },
|
||||
data: {
|
||||
type: action.type,
|
||||
parameters: action.parameters,
|
||||
onChange: (parameters: Record<string, any>) => {
|
||||
const newSteps = [...steps];
|
||||
const stepIndex = newSteps.findIndex(s =>
|
||||
s.actions.some(a => a.id === action.id)
|
||||
);
|
||||
const actionIndex = stepIndex !== -1
|
||||
? newSteps[stepIndex]!.actions.findIndex(
|
||||
a => a.id === action.id
|
||||
)
|
||||
: -1;
|
||||
|
||||
if (
|
||||
stepIndex !== -1 &&
|
||||
actionIndex !== -1 &&
|
||||
newSteps[stepIndex]?.actions[actionIndex]
|
||||
) {
|
||||
const step = newSteps[stepIndex]!;
|
||||
const updatedAction = { ...step.actions[actionIndex]!, parameters };
|
||||
step.actions[actionIndex] = updatedAction;
|
||||
setSteps(newSteps);
|
||||
addToHistory(newSteps);
|
||||
onChange?.(newSteps);
|
||||
}
|
||||
},
|
||||
},
|
||||
}))
|
||||
);
|
||||
|
||||
const initialEdges: Edge[] = defaultSteps.flatMap((step, stepIndex) =>
|
||||
step.actions.slice(0, -1).map((action, actionIndex) => ({
|
||||
id: `${action.id}-${step.actions[actionIndex + 1]?.id}`,
|
||||
source: action.id,
|
||||
target: step.actions[actionIndex + 1]?.id ?? "",
|
||||
type: "default",
|
||||
animated: true,
|
||||
}))
|
||||
);
|
||||
|
||||
const [nodes, setNodes] = useState<Node[]>(initialNodes);
|
||||
const [edges, setEdges] = useState<Edge[]>(initialEdges);
|
||||
const [steps, setSteps] = useState<Step[]>(defaultSteps);
|
||||
|
||||
const onNodesChange = useCallback(
|
||||
(changes: NodeChange[]) => {
|
||||
setNodes((nds) => {
|
||||
const newNodes = applyNodeChanges(changes, nds);
|
||||
// Update selected node
|
||||
const selectedChange = changes.find((c) => c.type === "select");
|
||||
if (selectedChange) {
|
||||
const selected = newNodes.find((n) => n.id === selectedChange.id);
|
||||
setSelectedNode(selected ?? null);
|
||||
}
|
||||
return newNodes;
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const onEdgesChange = useCallback(
|
||||
(changes: EdgeChange[]) => {
|
||||
setEdges((eds) => applyEdgeChanges(changes, eds));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const onConnect = useCallback(
|
||||
(connection: Connection) => {
|
||||
const newEdge: Edge = {
|
||||
id: `${connection.source}-${connection.target}`,
|
||||
source: connection.source ?? "",
|
||||
target: connection.target ?? "",
|
||||
type: "default",
|
||||
animated: true,
|
||||
};
|
||||
setEdges((eds) => [...eds, newEdge]);
|
||||
|
||||
const sourceNode = nodes.find((n) => n.id === connection.source);
|
||||
const targetNode = nodes.find((n) => n.id === connection.target);
|
||||
if (sourceNode && targetNode) {
|
||||
const newSteps = [...steps];
|
||||
const sourceStep = newSteps.find((s) =>
|
||||
s.actions.some((a) => a.id === sourceNode.id)
|
||||
);
|
||||
const targetStep = newSteps.find((s) =>
|
||||
s.actions.some((a) => a.id === targetNode.id)
|
||||
);
|
||||
|
||||
if (sourceStep && targetStep) {
|
||||
const sourceAction = sourceStep.actions.find(
|
||||
(a) => a.id === sourceNode.id
|
||||
);
|
||||
const targetAction = targetStep.actions.find(
|
||||
(a) => a.id === targetNode.id
|
||||
);
|
||||
if (sourceAction && targetAction) {
|
||||
const targetStepIndex = newSteps.indexOf(targetStep);
|
||||
newSteps[targetStepIndex]!.actions = targetStep.actions.filter(
|
||||
(a) => a.id !== targetAction.id
|
||||
);
|
||||
const sourceStepIndex = newSteps.indexOf(sourceStep);
|
||||
const sourceActionIndex = sourceStep.actions.indexOf(sourceAction);
|
||||
newSteps[sourceStepIndex]!.actions.splice(
|
||||
sourceActionIndex + 1,
|
||||
0,
|
||||
targetAction
|
||||
);
|
||||
}
|
||||
}
|
||||
setSteps(newSteps);
|
||||
addToHistory(newSteps);
|
||||
onChange?.(newSteps);
|
||||
}
|
||||
},
|
||||
[nodes, steps, onChange, addToHistory]
|
||||
);
|
||||
|
||||
const onDragOver = useCallback((event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
}, []);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(event: React.DragEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (!reactFlowWrapper.current || !reactFlowInstance) return;
|
||||
|
||||
const type = event.dataTransfer.getData("application/reactflow");
|
||||
if (!type) return;
|
||||
|
||||
const position = reactFlowInstance.screenToFlowPosition({
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
});
|
||||
|
||||
const actionConfig = AVAILABLE_ACTIONS.find((a) => a.type === type);
|
||||
if (!actionConfig) return;
|
||||
|
||||
const newAction = {
|
||||
id: crypto.randomUUID(),
|
||||
type: actionConfig.type,
|
||||
parameters: { ...actionConfig.defaultParameters },
|
||||
order: 0,
|
||||
};
|
||||
|
||||
const newNode: Node = {
|
||||
id: newAction.id,
|
||||
type: "action",
|
||||
position,
|
||||
data: {
|
||||
type: actionConfig.type,
|
||||
parameters: newAction.parameters,
|
||||
onChange: (parameters: Record<string, any>) => {
|
||||
const newSteps = [...steps];
|
||||
const stepIndex = newSteps.findIndex((s) =>
|
||||
s.actions.some((a) => a.id === newAction.id)
|
||||
);
|
||||
const actionIndex = stepIndex !== -1
|
||||
? newSteps[stepIndex]!.actions.findIndex(
|
||||
a => a.id === newAction.id
|
||||
)
|
||||
: -1;
|
||||
|
||||
if (
|
||||
stepIndex !== -1 &&
|
||||
actionIndex !== -1 &&
|
||||
newSteps[stepIndex]?.actions[actionIndex]
|
||||
) {
|
||||
const step = newSteps[stepIndex]!;
|
||||
const updatedAction = { ...step.actions[actionIndex]!, parameters };
|
||||
step.actions[actionIndex] = updatedAction;
|
||||
setSteps(newSteps);
|
||||
addToHistory(newSteps);
|
||||
onChange?.(newSteps);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
setNodes((nds) => [...nds, newNode]);
|
||||
|
||||
const newStep: Step = {
|
||||
id: crypto.randomUUID(),
|
||||
title: `Step ${steps.length + 1}`,
|
||||
actions: [newAction],
|
||||
order: steps.length,
|
||||
};
|
||||
|
||||
setSteps((s) => [...s, newStep]);
|
||||
addToHistory([...steps, newStep]);
|
||||
onChange?.([...steps, newStep]);
|
||||
},
|
||||
[steps, onChange, reactFlowInstance, addToHistory]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn("relative flex h-[calc(100vh-16rem)]", className)}>
|
||||
<AnimatePresence>
|
||||
{sidebarOpen && (
|
||||
<motion.div
|
||||
initial={{ x: -320, opacity: 0 }}
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
exit={{ x: -320, opacity: 0 }}
|
||||
transition={{ type: "spring", damping: 20, stiffness: 300 }}
|
||||
className="absolute inset-y-0 left-0 z-30 w-80 overflow-hidden"
|
||||
>
|
||||
<Card className="flex h-full flex-col rounded-r-none border-r-0 shadow-2xl">
|
||||
<Tabs defaultValue="actions" className="flex h-full flex-col">
|
||||
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="actions">Actions</TabsTrigger>
|
||||
<TabsTrigger value="properties">Properties</TabsTrigger>
|
||||
</TabsList>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<TabsContent value="actions" className="flex-1 p-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-2 p-4">
|
||||
{AVAILABLE_ACTIONS.map((action) => (
|
||||
<ActionItem
|
||||
key={action.type}
|
||||
type={action.type}
|
||||
title={action.title}
|
||||
description={action.description}
|
||||
icon={action.icon}
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
event.dataTransfer.setData(
|
||||
"application/reactflow",
|
||||
action.type
|
||||
);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
<TabsContent value="properties" className="flex-1 p-0">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4">
|
||||
{selectedNode ? (
|
||||
<div className="space-y-4">
|
||||
<h3 className="font-medium">
|
||||
{AVAILABLE_ACTIONS.find((a) => a.type === selectedNode.data.type)?.title}
|
||||
</h3>
|
||||
<pre className="rounded-lg bg-muted p-4 text-xs">
|
||||
{JSON.stringify(selectedNode.data.parameters, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Select a node to view its properties
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{!sidebarOpen && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="absolute left-4 top-4 z-20"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={reactFlowWrapper}
|
||||
className={cn(
|
||||
"relative h-full flex-1 transition-[margin] duration-200 ease-in-out",
|
||||
sidebarOpen && "ml-80"
|
||||
)}
|
||||
>
|
||||
<ReactFlowProvider>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
onInit={setReactFlowInstance}
|
||||
nodeTypes={nodeTypes}
|
||||
edgeTypes={edgeTypes}
|
||||
fitView
|
||||
className="react-flow-wrapper"
|
||||
>
|
||||
<Background />
|
||||
<Controls />
|
||||
<MiniMap
|
||||
nodeColor={(node) => {
|
||||
const action = AVAILABLE_ACTIONS.find(
|
||||
(a) => a.type === node.data.type
|
||||
);
|
||||
return action ? "hsl(var(--primary) / 0.5)" : "hsl(var(--muted))"
|
||||
}}
|
||||
maskColor="hsl(var(--background))"
|
||||
className="!bg-card/80 !border !border-border rounded-lg backdrop-blur"
|
||||
style={{
|
||||
backgroundColor: "hsl(var(--card))",
|
||||
borderRadius: "var(--radius)",
|
||||
}}
|
||||
/>
|
||||
<Panel position="top-center" className="flex gap-2 rounded-lg bg-background/95 px-4 py-2 shadow-md backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={undo}
|
||||
disabled={historyIndex === 0}
|
||||
>
|
||||
<Undo className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={redo}
|
||||
disabled={historyIndex === history.length - 1}
|
||||
>
|
||||
<Redo className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="mx-2 w-px bg-border" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => reactFlowInstance?.zoomIn()}
|
||||
>
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => reactFlowInstance?.zoomOut()}
|
||||
>
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
</ReactFlowProvider>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
src/components/experiments/nodes/action-node.tsx
Normal file
127
src/components/experiments/nodes/action-node.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useState } from "react";
|
||||
import { Handle, Position, type NodeProps } from "reactflow";
|
||||
import { motion } from "framer-motion";
|
||||
import { AVAILABLE_ACTIONS } from "~/lib/experiments/actions";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Settings, ArrowDown, ArrowUp } from "lucide-react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { ActionConfigDialog } from "../action-config-dialog";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "~/components/ui/tooltip";
|
||||
|
||||
interface ActionNodeData {
|
||||
type: string;
|
||||
parameters: Record<string, any>;
|
||||
onChange?: (parameters: Record<string, any>) => void;
|
||||
}
|
||||
|
||||
export const ActionNode = memo(({ data, selected }: NodeProps<ActionNodeData>) => {
|
||||
const [configOpen, setConfigOpen] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const actionConfig = AVAILABLE_ACTIONS.find((a) => a.type === data.type);
|
||||
if (!actionConfig) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
}}
|
||||
transition={{ duration: 0.2 }}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className={cn(
|
||||
"relative",
|
||||
"before:absolute before:inset-[-2px] before:rounded-xl before:bg-gradient-to-br before:from-border before:to-border/50 before:opacity-100",
|
||||
"after:absolute after:inset-[-1px] after:rounded-xl after:bg-gradient-to-br after:from-background after:to-background",
|
||||
selected && "before:from-primary/50 before:to-primary/20",
|
||||
isHovered && "before:from-border/80 before:to-border/30",
|
||||
)}
|
||||
>
|
||||
<Card className="relative z-10 w-[250px] bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 border-none">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-gradient-to-br from-primary/20 to-primary/10 text-primary">
|
||||
{actionConfig.icon}
|
||||
</div>
|
||||
<CardTitle className="text-sm font-medium leading-none">
|
||||
{actionConfig.title}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setConfigOpen(true)}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<CardDescription className="text-xs">
|
||||
{actionConfig.description}
|
||||
</CardDescription>
|
||||
</CardContent>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Handle
|
||||
type="target"
|
||||
position={Position.Top}
|
||||
className={cn(
|
||||
"!h-3 !w-3 !border-2 !bg-background",
|
||||
"!border-border transition-colors duration-200",
|
||||
"data-[connecting=true]:!border-primary data-[connecting=true]:!bg-primary",
|
||||
"before:absolute before:inset-[-4px] before:rounded-full before:border-2 before:border-background",
|
||||
"after:absolute after:inset-[-8px] after:rounded-full after:border-2 after:border-border/50"
|
||||
)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="flex items-center gap-2">
|
||||
<ArrowDown className="h-3 w-3" />
|
||||
Input Connection
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Handle
|
||||
type="source"
|
||||
position={Position.Bottom}
|
||||
className={cn(
|
||||
"!h-3 !w-3 !border-2 !bg-background",
|
||||
"!border-border transition-colors duration-200",
|
||||
"data-[connecting=true]:!border-primary data-[connecting=true]:!bg-primary",
|
||||
"before:absolute before:inset-[-4px] before:rounded-full before:border-2 before:border-background",
|
||||
"after:absolute after:inset-[-8px] after:rounded-full after:border-2 after:border-border/50"
|
||||
)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="flex items-center gap-2">
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
Output Connection
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</Card>
|
||||
</motion.div>
|
||||
|
||||
<ActionConfigDialog
|
||||
open={configOpen}
|
||||
onOpenChange={setConfigOpen}
|
||||
type={data.type as any}
|
||||
parameters={data.parameters}
|
||||
onSubmit={data.onChange ?? (() => {})}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
User,
|
||||
Microscope,
|
||||
Users,
|
||||
Plus
|
||||
Plus,
|
||||
FlaskConical
|
||||
} from "lucide-react"
|
||||
import * as React from "react"
|
||||
import { useSession } from "next-auth/react"
|
||||
@@ -74,6 +75,22 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Experiments",
|
||||
url: `/dashboard/studies/${activeStudy.id}/experiments`,
|
||||
icon: FlaskConical,
|
||||
items: [
|
||||
{
|
||||
title: "All Experiments",
|
||||
url: `/dashboard/studies/${activeStudy.id}/experiments`,
|
||||
},
|
||||
{
|
||||
title: "Create Experiment",
|
||||
url: `/dashboard/studies/${activeStudy.id}/experiments/new`,
|
||||
hidden: !["OWNER", "ADMIN", "PRINCIPAL_INVESTIGATOR"].map(r => r.toLowerCase()).includes(activeStudy.role.toLowerCase()),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
: []
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ export function CreateStudyForm() {
|
||||
},
|
||||
});
|
||||
|
||||
const createStudy = api.study.create.useMutation({
|
||||
const { mutate, isPending } = api.study.create.useMutation({
|
||||
onSuccess: (study) => {
|
||||
toast({
|
||||
title: "Study created",
|
||||
@@ -66,7 +66,7 @@ export function CreateStudyForm() {
|
||||
});
|
||||
return;
|
||||
}
|
||||
createStudy.mutate(data);
|
||||
mutate(data);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -120,9 +120,9 @@ export function CreateStudyForm() {
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createStudy.isLoading || status !== "authenticated"}
|
||||
disabled={isPending || status !== "authenticated"}
|
||||
>
|
||||
{createStudy.isLoading ? "Creating..." : "Create Study"}
|
||||
{isPending ? "Creating..." : "Create Study"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
48
src/components/ui/scroll-area.tsx
Normal file
48
src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
));
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
));
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
Reference in New Issue
Block a user