mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-02-05 07:56:30 -05:00
Studies, basic experiment designer
This commit is contained in:
361
src/components/experiments/ExperimentsGrid.tsx
Normal file
361
src/components/experiments/ExperimentsGrid.tsx
Normal file
@@ -0,0 +1,361 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Plus, FlaskConical, Settings, Calendar, Users } from "lucide-react";
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import Link from "next/link";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { Separator } from "~/components/ui/separator";
|
||||
import { api } from "~/trpc/react";
|
||||
|
||||
type ExperimentWithRelations = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
status: "draft" | "active" | "completed" | "archived";
|
||||
estimatedDuration: number | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
studyId: string;
|
||||
createdById: string;
|
||||
study: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
createdBy: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string;
|
||||
};
|
||||
_count?: {
|
||||
steps: number;
|
||||
trials: number;
|
||||
};
|
||||
};
|
||||
|
||||
const statusConfig = {
|
||||
draft: {
|
||||
label: "Draft",
|
||||
className: "bg-gray-100 text-gray-800 hover:bg-gray-200",
|
||||
icon: "📝",
|
||||
},
|
||||
active: {
|
||||
label: "Active",
|
||||
className: "bg-green-100 text-green-800 hover:bg-green-200",
|
||||
icon: "🟢",
|
||||
},
|
||||
completed: {
|
||||
label: "Completed",
|
||||
className: "bg-blue-100 text-blue-800 hover:bg-blue-200",
|
||||
icon: "✅",
|
||||
},
|
||||
archived: {
|
||||
label: "Archived",
|
||||
className: "bg-orange-100 text-orange-800 hover:bg-orange-200",
|
||||
icon: "📦",
|
||||
},
|
||||
};
|
||||
|
||||
interface ExperimentCardProps {
|
||||
experiment: ExperimentWithRelations;
|
||||
}
|
||||
|
||||
function ExperimentCard({ experiment }: ExperimentCardProps) {
|
||||
const statusInfo = statusConfig[experiment.status];
|
||||
|
||||
return (
|
||||
<Card className="group transition-all duration-200 hover:border-slate-300 hover:shadow-md">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="truncate text-lg font-semibold text-slate-900 transition-colors group-hover:text-blue-600">
|
||||
<Link
|
||||
href={`/experiments/${experiment.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{experiment.name}
|
||||
</Link>
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1 line-clamp-2 text-sm text-slate-600">
|
||||
{experiment.description}
|
||||
</CardDescription>
|
||||
<div className="mt-2 flex items-center text-xs text-slate-500">
|
||||
<span>Study: </span>
|
||||
<Link
|
||||
href={`/studies/${experiment.study.id}`}
|
||||
className="ml-1 font-medium text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
{experiment.study.name}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<Badge className={statusInfo.className} variant="secondary">
|
||||
<span className="mr-1">{statusInfo.icon}</span>
|
||||
{statusInfo.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
{/* Experiment Metadata */}
|
||||
<div className="space-y-1">
|
||||
{experiment.estimatedDuration && (
|
||||
<div className="flex items-center text-sm text-slate-600">
|
||||
<Calendar className="mr-2 h-4 w-4" />
|
||||
Estimated duration: {experiment.estimatedDuration} minutes
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center text-sm text-slate-600">
|
||||
<Users className="mr-2 h-4 w-4" />
|
||||
Created by {experiment.createdBy.name ?? experiment.createdBy.email}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistics */}
|
||||
{experiment._count && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Steps:</span>
|
||||
<span className="font-medium">{experiment._count.steps}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-600">Trials:</span>
|
||||
<span className="font-medium">{experiment._count.trials}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
<Separator />
|
||||
<div className="space-y-1 text-xs text-slate-500">
|
||||
<div className="flex justify-between">
|
||||
<span>Created:</span>
|
||||
<span>
|
||||
{formatDistanceToNow(experiment.createdAt, { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
{experiment.updatedAt !== experiment.createdAt && (
|
||||
<div className="flex justify-between">
|
||||
<span>Updated:</span>
|
||||
<span>
|
||||
{formatDistanceToNow(experiment.updatedAt, { addSuffix: true })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button asChild size="sm" className="flex-1">
|
||||
<Link href={`/experiments/${experiment.id}`}>View Details</Link>
|
||||
</Button>
|
||||
<Button asChild size="sm" variant="outline" className="flex-1">
|
||||
<Link href={`/experiments/${experiment.id}/designer`}>
|
||||
<Settings className="mr-1 h-3 w-3" />
|
||||
Design
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExperimentsGrid() {
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
const {
|
||||
data: experimentsData,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = api.experiments.getUserExperiments.useQuery(
|
||||
{ page: 1, limit: 50 },
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
);
|
||||
|
||||
const experiments = experimentsData?.experiments ?? [];
|
||||
|
||||
const handleExperimentCreated = () => {
|
||||
setRefreshKey((prev) => prev + 1);
|
||||
void refetch();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{/* Create Experiment Card */}
|
||||
<Card className="border-2 border-dashed border-slate-300">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-lg bg-blue-100">
|
||||
<Plus className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
<CardTitle>Create New Experiment</CardTitle>
|
||||
<CardDescription>
|
||||
Design a new experimental protocol
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild className="w-full">
|
||||
<Link href="/experiments/new">Create Experiment</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Loading Skeletons */}
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Card key={i} className="animate-pulse">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-5 w-3/4 rounded bg-slate-200"></div>
|
||||
<div className="h-4 w-full rounded bg-slate-200"></div>
|
||||
<div className="h-4 w-2/3 rounded bg-slate-200"></div>
|
||||
<div className="h-3 w-1/2 rounded bg-slate-200"></div>
|
||||
</div>
|
||||
<div className="h-6 w-16 rounded bg-slate-200"></div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-3/4 rounded bg-slate-200"></div>
|
||||
<div className="h-4 w-1/2 rounded bg-slate-200"></div>
|
||||
</div>
|
||||
<div className="h-px bg-slate-200"></div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="h-3 rounded bg-slate-200"></div>
|
||||
<div className="h-3 rounded bg-slate-200"></div>
|
||||
</div>
|
||||
<div className="h-px bg-slate-200"></div>
|
||||
<div className="flex gap-2">
|
||||
<div className="h-8 flex-1 rounded bg-slate-200"></div>
|
||||
<div className="h-8 flex-1 rounded bg-slate-200"></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{/* Create Experiment Card */}
|
||||
<Card className="border-2 border-dashed border-slate-300 transition-colors hover:border-slate-400">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-lg bg-blue-100">
|
||||
<Plus className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
<CardTitle>Create New Experiment</CardTitle>
|
||||
<CardDescription>
|
||||
Design a new experimental protocol
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild className="w-full">
|
||||
<Link href="/experiments/new">Create Experiment</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Error State */}
|
||||
<Card className="md:col-span-2">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-lg bg-red-100">
|
||||
<svg
|
||||
className="h-8 w-8 text-red-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold text-slate-900">
|
||||
Failed to Load Experiments
|
||||
</h3>
|
||||
<p className="mb-4 text-slate-600">
|
||||
{error.message ||
|
||||
"An error occurred while loading your experiments."}
|
||||
</p>
|
||||
<Button onClick={() => refetch()} variant="outline">
|
||||
Try Again
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{/* Create New Experiment Card */}
|
||||
<Card className="border-2 border-dashed border-slate-300 transition-colors hover:border-slate-400">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-lg bg-blue-100">
|
||||
<Plus className="h-8 w-8 text-blue-600" />
|
||||
</div>
|
||||
<CardTitle>Create New Experiment</CardTitle>
|
||||
<CardDescription>Design a new experimental protocol</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Button asChild className="w-full">
|
||||
<Link href="/experiments/new">Create Experiment</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Experiments */}
|
||||
{experiments.map((experiment) => (
|
||||
<ExperimentCard key={experiment.id} experiment={experiment} />
|
||||
))}
|
||||
|
||||
{/* Empty State */}
|
||||
{experiments.length === 0 && (
|
||||
<Card className="md:col-span-2 lg:col-span-2">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-24 w-24 items-center justify-center rounded-lg bg-slate-100">
|
||||
<FlaskConical className="h-12 w-12 text-slate-400" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold text-slate-900">
|
||||
No Experiments Yet
|
||||
</h3>
|
||||
<p className="mb-4 text-slate-600">
|
||||
Create your first experiment to start designing HRI protocols.
|
||||
Experiments define the structure and flow of your research
|
||||
trials.
|
||||
</p>
|
||||
<Button asChild>
|
||||
<Link href="/experiments/new">
|
||||
Create Your First Experiment
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
589
src/components/experiments/designer/ExperimentDesigner.tsx
Normal file
589
src/components/experiments/designer/ExperimentDesigner.tsx
Normal file
@@ -0,0 +1,589 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import {
|
||||
DndContext,
|
||||
DragOverlay,
|
||||
useDraggable,
|
||||
useDroppable,
|
||||
DragStartEvent,
|
||||
DragEndEvent,
|
||||
closestCenter,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
Play,
|
||||
Bot,
|
||||
GitBranch,
|
||||
Shuffle,
|
||||
Settings,
|
||||
Plus,
|
||||
Save,
|
||||
Undo,
|
||||
Redo,
|
||||
Eye,
|
||||
Trash2,
|
||||
} 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 { Separator } from "~/components/ui/separator";
|
||||
import { ScrollArea } from "~/components/ui/scroll-area";
|
||||
|
||||
// Step type definitions
|
||||
export type StepType = "wizard" | "robot" | "parallel" | "conditional";
|
||||
|
||||
export interface ExperimentStep {
|
||||
id: string;
|
||||
type: StepType;
|
||||
name: string;
|
||||
description?: string;
|
||||
order: number;
|
||||
parameters: Record<string, any>;
|
||||
duration?: number;
|
||||
parentId?: string;
|
||||
children?: string[];
|
||||
}
|
||||
|
||||
export interface ExperimentDesign {
|
||||
id: string;
|
||||
name: string;
|
||||
steps: ExperimentStep[];
|
||||
version: number;
|
||||
lastSaved: Date;
|
||||
}
|
||||
|
||||
const stepTypeConfig = {
|
||||
wizard: {
|
||||
label: "Wizard Action",
|
||||
description: "Manual control by wizard operator",
|
||||
icon: Play,
|
||||
color: "bg-blue-100 text-blue-700 border-blue-200",
|
||||
defaultParams: {
|
||||
instruction: "",
|
||||
allowSkip: true,
|
||||
timeout: null,
|
||||
},
|
||||
},
|
||||
robot: {
|
||||
label: "Robot Action",
|
||||
description: "Automated robot behavior",
|
||||
icon: Bot,
|
||||
color: "bg-green-100 text-green-700 border-green-200",
|
||||
defaultParams: {
|
||||
action: "",
|
||||
parameters: {},
|
||||
waitForCompletion: true,
|
||||
},
|
||||
},
|
||||
parallel: {
|
||||
label: "Parallel Steps",
|
||||
description: "Execute multiple steps simultaneously",
|
||||
icon: Shuffle,
|
||||
color: "bg-purple-100 text-purple-700 border-purple-200",
|
||||
defaultParams: {
|
||||
waitForAll: true,
|
||||
maxDuration: null,
|
||||
},
|
||||
},
|
||||
conditional: {
|
||||
label: "Conditional Branch",
|
||||
description: "Execute steps based on conditions",
|
||||
icon: GitBranch,
|
||||
color: "bg-orange-100 text-orange-700 border-orange-200",
|
||||
defaultParams: {
|
||||
condition: "",
|
||||
trueSteps: [],
|
||||
falseSteps: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
interface StepLibraryItemProps {
|
||||
type: StepType;
|
||||
onDragStart?: () => void;
|
||||
}
|
||||
|
||||
function StepLibraryItem({ type, onDragStart }: StepLibraryItemProps) {
|
||||
const config = stepTypeConfig[type];
|
||||
const Icon = config.icon;
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
isDragging,
|
||||
} = useDraggable({
|
||||
id: `library-${type}`,
|
||||
data: { type: "library-item", stepType: type },
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Translate.toString(transform),
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className={`
|
||||
cursor-grab active:cursor-grabbing
|
||||
rounded-lg border-2 border-dashed p-3 transition-all
|
||||
hover:border-solid hover:shadow-sm
|
||||
${config.color}
|
||||
`}
|
||||
onMouseDown={onDragStart}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Icon className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">{config.label}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs opacity-80">{config.description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExperimentStepCardProps {
|
||||
step: ExperimentStep;
|
||||
onEdit: (step: ExperimentStep) => void;
|
||||
onDelete: (stepId: string) => void;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
function ExperimentStepCard({
|
||||
step,
|
||||
onEdit,
|
||||
onDelete,
|
||||
isSelected,
|
||||
onClick
|
||||
}: ExperimentStepCardProps) {
|
||||
const config = stepTypeConfig[step.type];
|
||||
const Icon = config.icon;
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: step.id,
|
||||
data: { type: "step", step },
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`
|
||||
cursor-pointer transition-all
|
||||
${isSelected ? "ring-2 ring-blue-500 shadow-md" : "hover:shadow-sm"}
|
||||
`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className={`p-1 rounded ${config.color}`}>
|
||||
<Icon className="h-3 w-3" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-sm">{step.name}</CardTitle>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{config.label}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="drag-handle cursor-grab active:cursor-grabbing p-1"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-0.5">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div key={i} className="h-0.5 w-0.5 bg-slate-400 rounded-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
{step.description && (
|
||||
<p className="text-xs text-slate-600 mb-2">{step.description}</p>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2 text-xs text-slate-500">
|
||||
<span>Step {step.order}</span>
|
||||
{step.duration && <span>• {step.duration}s</span>}
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(step);
|
||||
}}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Settings className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(step.id);
|
||||
}}
|
||||
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExperimentCanvasProps {
|
||||
steps: ExperimentStep[];
|
||||
selectedStepId?: string;
|
||||
onStepSelect: (stepId: string) => void;
|
||||
onStepEdit: (step: ExperimentStep) => void;
|
||||
onStepDelete: (stepId: string) => void;
|
||||
onStepsReorder: (steps: ExperimentStep[]) => void;
|
||||
}
|
||||
|
||||
function ExperimentCanvas({
|
||||
steps,
|
||||
selectedStepId,
|
||||
onStepSelect,
|
||||
onStepEdit,
|
||||
onStepDelete,
|
||||
onStepsReorder,
|
||||
}: ExperimentCanvasProps) {
|
||||
const { setNodeRef } = useDroppable({
|
||||
id: "experiment-canvas",
|
||||
});
|
||||
|
||||
const stepIds = steps.map((step) => step.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className="flex-1 bg-slate-50 rounded-lg border-2 border-dashed border-slate-300 p-4 min-h-[500px]"
|
||||
>
|
||||
{steps.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full text-center">
|
||||
<div>
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-lg bg-slate-200">
|
||||
<Play className="h-8 w-8 text-slate-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-slate-700 mb-2">
|
||||
Start Designing Your Experiment
|
||||
</h3>
|
||||
<p className="text-slate-500 max-w-sm">
|
||||
Drag step types from the library on the left to begin creating your experimental protocol.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<SortableContext items={stepIds} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-3">
|
||||
{steps
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((step) => (
|
||||
<ExperimentStepCard
|
||||
key={step.id}
|
||||
step={step}
|
||||
onEdit={onStepEdit}
|
||||
onDelete={onStepDelete}
|
||||
isSelected={selectedStepId === step.id}
|
||||
onClick={() => onStepSelect(step.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExperimentDesignerProps {
|
||||
experimentId: string;
|
||||
initialDesign?: ExperimentDesign;
|
||||
onSave?: (design: ExperimentDesign) => Promise<void>;
|
||||
}
|
||||
|
||||
export function ExperimentDesigner({
|
||||
experimentId,
|
||||
initialDesign,
|
||||
onSave,
|
||||
}: ExperimentDesignerProps) {
|
||||
const [design, setDesign] = useState<ExperimentDesign>(
|
||||
initialDesign || {
|
||||
id: experimentId,
|
||||
name: "New Experiment",
|
||||
steps: [],
|
||||
version: 1,
|
||||
lastSaved: new Date(),
|
||||
}
|
||||
);
|
||||
const [selectedStepId, setSelectedStepId] = useState<string>();
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const createStep = useCallback((type: StepType): ExperimentStep => {
|
||||
const config = stepTypeConfig[type];
|
||||
const newOrder = Math.max(...design.steps.map(s => s.order), 0) + 1;
|
||||
|
||||
return {
|
||||
id: `step-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
type,
|
||||
name: `${config.label} ${newOrder}`,
|
||||
order: newOrder,
|
||||
parameters: { ...config.defaultParams },
|
||||
};
|
||||
}, [design.steps]);
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string);
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setActiveId(null);
|
||||
|
||||
if (!over) return;
|
||||
|
||||
// Handle dropping library item onto canvas
|
||||
if (active.data.current?.type === "library-item" && over.id === "experiment-canvas") {
|
||||
const stepType = active.data.current.stepType as StepType;
|
||||
const newStep = createStep(stepType);
|
||||
|
||||
setDesign(prev => ({
|
||||
...prev,
|
||||
steps: [...prev.steps, newStep],
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle reordering steps
|
||||
if (active.data.current?.type === "step" && over.data.current?.type === "step") {
|
||||
const activeStep = design.steps.find(s => s.id === active.id);
|
||||
const overStep = design.steps.find(s => s.id === over.id);
|
||||
|
||||
if (!activeStep || !overStep) return;
|
||||
|
||||
const newSteps = [...design.steps];
|
||||
const activeIndex = newSteps.findIndex(s => s.id === active.id);
|
||||
const overIndex = newSteps.findIndex(s => s.id === over.id);
|
||||
|
||||
// Swap positions
|
||||
[newSteps[activeIndex], newSteps[overIndex]] = [newSteps[overIndex], newSteps[activeIndex]];
|
||||
|
||||
// Update order numbers
|
||||
newSteps.forEach((step, index) => {
|
||||
step.order = index + 1;
|
||||
});
|
||||
|
||||
setDesign(prev => ({
|
||||
...prev,
|
||||
steps: newSteps,
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStepEdit = (step: ExperimentStep) => {
|
||||
// TODO: Open step configuration modal
|
||||
console.log("Edit step:", step);
|
||||
};
|
||||
|
||||
const handleStepDelete = (stepId: string) => {
|
||||
setDesign(prev => ({
|
||||
...prev,
|
||||
steps: prev.steps.filter(s => s.id !== stepId),
|
||||
}));
|
||||
setHasUnsavedChanges(true);
|
||||
|
||||
if (selectedStepId === stepId) {
|
||||
setSelectedStepId(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!onSave || !hasUnsavedChanges) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const updatedDesign = {
|
||||
...design,
|
||||
lastSaved: new Date(),
|
||||
version: design.version + 1,
|
||||
};
|
||||
|
||||
await onSave(updatedDesign);
|
||||
setDesign(updatedDesign);
|
||||
setHasUnsavedChanges(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to save experiment:", error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between p-4 border-b bg-white">
|
||||
<div className="flex items-center space-x-4">
|
||||
<h2 className="text-lg font-semibold">{design.name}</h2>
|
||||
{hasUnsavedChanges && (
|
||||
<Badge variant="outline" className="text-orange-600 border-orange-600">
|
||||
Unsaved Changes
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
<Undo className="h-4 w-4 mr-1" />
|
||||
Undo
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
<Redo className="h-4 w-4 mr-1" />
|
||||
Redo
|
||||
</Button>
|
||||
<Separator orientation="vertical" className="h-6" />
|
||||
<Button variant="outline" size="sm">
|
||||
<Eye className="h-4 w-4 mr-1" />
|
||||
Preview
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={!hasUnsavedChanges || isSaving}
|
||||
size="sm"
|
||||
>
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
{isSaving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{/* Step Library Sidebar */}
|
||||
<div className="w-64 bg-white border-r p-4">
|
||||
<div className="mb-4">
|
||||
<h3 className="font-semibold text-slate-900 mb-2">Step Library</h3>
|
||||
<p className="text-sm text-slate-600">
|
||||
Drag steps onto the canvas to build your experiment
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{(Object.keys(stepTypeConfig) as StepType[]).map((type) => (
|
||||
<StepLibraryItem key={type} type={type} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium text-slate-900 mb-2">Experiment Stats</h4>
|
||||
<div className="space-y-1 text-sm text-slate-600">
|
||||
<div className="flex justify-between">
|
||||
<span>Total Steps:</span>
|
||||
<span className="font-medium">{design.steps.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Estimated Duration:</span>
|
||||
<span className="font-medium">
|
||||
{design.steps.reduce((acc, step) => acc + (step.duration || 0), 0)}s
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Version:</span>
|
||||
<span className="font-medium">v{design.version}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Canvas */}
|
||||
<div className="flex-1 p-4">
|
||||
<ExperimentCanvas
|
||||
steps={design.steps}
|
||||
selectedStepId={selectedStepId}
|
||||
onStepSelect={setSelectedStepId}
|
||||
onStepEdit={handleStepEdit}
|
||||
onStepDelete={handleStepDelete}
|
||||
onStepsReorder={(newSteps) => {
|
||||
setDesign(prev => ({ ...prev, steps: newSteps }));
|
||||
setHasUnsavedChanges(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DragOverlay>
|
||||
{activeId ? (
|
||||
<div className="opacity-80">
|
||||
{activeId.startsWith("library-") ? (
|
||||
<div className="p-3 bg-white border rounded-lg shadow-lg">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="text-sm font-medium">New Step</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Card className="w-64 shadow-lg">
|
||||
<CardContent className="p-3">
|
||||
<div className="font-medium text-sm">Moving step...</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
src/components/experiments/designer/ExperimentDesignerClient.tsx
Normal file
127
src/components/experiments/designer/ExperimentDesignerClient.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { ExperimentDesigner, ExperimentDesign } from "./ExperimentDesigner";
|
||||
|
||||
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);
|
||||
|
||||
// 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);
|
||||
},
|
||||
onError: (error) => {
|
||||
setSaveError(error.message);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSave = async (design: ExperimentDesign) => {
|
||||
try {
|
||||
await saveDesignMutation.mutateAsync({
|
||||
experimentId: experiment.id,
|
||||
steps: design.steps,
|
||||
version: design.version,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to save design:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<p className="text-slate-600">Loading experiment designer...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const initialDesign: ExperimentDesign = {
|
||||
id: experiment.id,
|
||||
name: experiment.name,
|
||||
steps: experimentSteps || [],
|
||||
version: 1,
|
||||
lastSaved: new Date(),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b bg-white">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Link
|
||||
href={`/experiments/${experiment.id}`}
|
||||
className="flex items-center text-sm text-slate-600 hover:text-slate-900"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-1" />
|
||||
Back to Experiment
|
||||
</Link>
|
||||
<div className="h-4 w-px bg-slate-300" />
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-slate-900">
|
||||
{experiment.name}
|
||||
</h1>
|
||||
<p className="text-sm text-slate-600">
|
||||
Visual Protocol Designer
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 text-sm text-slate-500">
|
||||
<span>Study: </span>
|
||||
<Link
|
||||
href={`/studies/${experiment.studyId}`}
|
||||
className="font-medium text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
{experiment.study?.name || "Unknown Study"}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Display */}
|
||||
{saveError && (
|
||||
<div className="bg-red-50 border-l-4 border-red-400 p-4">
|
||||
<div className="flex">
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">
|
||||
Failed to save experiment: {saveError}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Designer */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ExperimentDesigner
|
||||
experimentId={experiment.id}
|
||||
initialDesign={initialDesign}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user