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:
2025-02-12 10:35:57 -05:00
parent ec4d8db16e
commit 4901729bd9
27 changed files with 3878 additions and 230 deletions

View File

@@ -0,0 +1,178 @@
"use client";
import { useRouter } from "next/navigation";
import { api } from "~/trpc/react";
import { PageHeader } from "~/components/layout/page-header";
import { PageContent } from "~/components/layout/page-content";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { use } from "react";
import { ExperimentDesigner } from "~/components/experiments/experiment-designer";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Textarea } from "~/components/ui/textarea";
import { useToast } from "~/hooks/use-toast";
import { useState, useEffect } from "react";
import { type Step } from "~/lib/experiments/types";
export default function EditExperimentPage({
params,
}: {
params: Promise<{ id: string; experimentId: string }>;
}) {
const router = useRouter();
const { toast } = useToast();
const resolvedParams = use(params);
const studyId = Number(resolvedParams.id);
const experimentId = Number(resolvedParams.experimentId);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [steps, setSteps] = useState<Step[]>([]);
const { data: study } = api.study.getById.useQuery({ id: studyId });
const { data: experiment, isLoading } = api.experiment.getById.useQuery({ id: experimentId });
useEffect(() => {
if (experiment) {
setTitle(experiment.title);
setDescription(experiment.description ?? "");
setSteps(experiment.steps);
}
}, [experiment]);
const { mutate: updateExperiment, isPending: isUpdating } = api.experiment.update.useMutation({
onSuccess: () => {
toast({
title: "Success",
description: "Experiment updated successfully",
});
router.push(`/dashboard/studies/${studyId}/experiments/${experimentId}`);
router.refresh();
},
onError: (error) => {
toast({
title: "Error",
description: error.message,
variant: "destructive",
});
},
});
const canEdit = study && ["OWNER", "ADMIN", "PRINCIPAL_INVESTIGATOR"]
.map(r => r.toLowerCase())
.includes(study.role.toLowerCase());
if (isLoading) {
return (
<>
<PageHeader
title="Loading..."
description="Please wait while we load the experiment details"
/>
<PageContent>
<div className="space-y-6">
<Card className="animate-pulse">
<CardHeader>
<div className="h-6 w-1/3 bg-muted rounded" />
<div className="h-4 w-1/2 bg-muted rounded mt-2" />
</CardHeader>
<CardContent>
<div className="h-4 w-1/4 bg-muted rounded" />
</CardContent>
</Card>
</div>
</PageContent>
</>
);
}
if (!study || !experiment) {
return <div>Not found</div>;
}
if (!canEdit) {
return <div>You do not have permission to edit this experiment.</div>;
}
return (
<>
<PageHeader
title="Edit Experiment"
description={`Update experiment details for ${study.title}`}
/>
<PageContent>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Experiment Details</CardTitle>
<CardDescription>
Update the basic information for your experiment.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<label htmlFor="title" className="text-sm font-medium">
Title
</label>
<Input
id="title"
placeholder="Enter experiment title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div className="space-y-2">
<label htmlFor="description" className="text-sm font-medium">
Description
</label>
<Textarea
id="description"
placeholder="Enter experiment description"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Design Experiment</CardTitle>
<CardDescription>
Use the designer below to update your experiment flow.
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<ExperimentDesigner
defaultSteps={steps}
onChange={setSteps}
/>
</CardContent>
</Card>
<div className="flex justify-end gap-4">
<Button
variant="outline"
onClick={() => router.push(`/dashboard/studies/${studyId}/experiments/${experimentId}`)}
>
Cancel
</Button>
<Button
onClick={() => {
updateExperiment({
id: experimentId,
title,
description,
steps,
});
}}
disabled={isUpdating || !title}
>
{isUpdating ? "Saving..." : "Save Changes"}
</Button>
</div>
</div>
</PageContent>
</>
);
}

View File

@@ -0,0 +1,149 @@
"use client";
import { useRouter } from "next/navigation";
import { api } from "~/trpc/react";
import { PageHeader } from "~/components/layout/page-header";
import { PageContent } from "~/components/layout/page-content";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { Pencil as PencilIcon, Play, Archive } from "lucide-react";
import { use } from "react";
import { ExperimentDesigner } from "~/components/experiments/experiment-designer";
export default function ExperimentDetailsPage({
params,
}: {
params: Promise<{ id: string; experimentId: string }>;
}) {
const router = useRouter();
const resolvedParams = use(params);
const studyId = Number(resolvedParams.id);
const experimentId = Number(resolvedParams.experimentId);
const { data: study } = api.study.getById.useQuery({ id: studyId });
const { data: experiment, isLoading } = api.experiment.getById.useQuery({ id: experimentId });
const { mutate: updateExperiment } = api.experiment.update.useMutation({
onSuccess: () => {
router.refresh();
},
});
const canEdit = study && ["OWNER", "ADMIN", "PRINCIPAL_INVESTIGATOR"]
.map(r => r.toLowerCase())
.includes(study.role.toLowerCase());
if (isLoading) {
return (
<>
<PageHeader
title="Loading..."
description="Please wait while we load the experiment details"
/>
<PageContent>
<div className="space-y-6">
<Card className="animate-pulse">
<CardHeader>
<div className="h-6 w-1/3 bg-muted rounded" />
<div className="h-4 w-1/2 bg-muted rounded mt-2" />
</CardHeader>
<CardContent>
<div className="h-4 w-1/4 bg-muted rounded" />
</CardContent>
</Card>
</div>
</PageContent>
</>
);
}
if (!study || !experiment) {
return <div>Not found</div>;
}
return (
<>
<PageHeader
title={experiment.title}
description={experiment.description ?? "No description provided"}
>
<div className="flex items-center gap-2">
<Badge variant={
experiment.status === "active" ? "default" :
experiment.status === "archived" ? "secondary" :
"outline"
}>
{experiment.status}
</Badge>
{canEdit && (
<>
<Button
variant="outline"
size="sm"
onClick={() => router.push(`/dashboard/studies/${studyId}/experiments/${experimentId}/edit`)}
>
<PencilIcon className="h-4 w-4 mr-2" />
Edit
</Button>
{experiment.status === "draft" ? (
<Button
size="sm"
onClick={() => updateExperiment({
id: experimentId,
title: experiment.title,
description: experiment.description,
status: "active",
})}
>
<Play className="h-4 w-4 mr-2" />
Activate
</Button>
) : experiment.status === "active" ? (
<Button
variant="secondary"
size="sm"
onClick={() => updateExperiment({
id: experimentId,
title: experiment.title,
description: experiment.description,
status: "archived",
})}
>
<Archive className="h-4 w-4 mr-2" />
Archive
</Button>
) : null}
</>
)}
</div>
</PageHeader>
<PageContent>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Experiment Flow</CardTitle>
<CardDescription>
View the steps and actions in this experiment.
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<ExperimentDesigner
defaultSteps={experiment.steps}
onChange={canEdit ? (steps) => {
updateExperiment({
id: experimentId,
title: experiment.title,
description: experiment.description,
steps,
});
} : undefined}
readOnly={!canEdit}
/>
</CardContent>
</Card>
</div>
</PageContent>
</>
);
}

View File

@@ -0,0 +1,139 @@
"use client";
import { useRouter } from "next/navigation";
import { api } from "~/trpc/react";
import { PageHeader } from "~/components/layout/page-header";
import { PageContent } from "~/components/layout/page-content";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { use } from "react";
import { ExperimentDesigner } from "~/components/experiments/experiment-designer";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Textarea } from "~/components/ui/textarea";
import { useToast } from "~/hooks/use-toast";
import { useState } from "react";
import { type Step } from "~/lib/experiments/types";
export default function NewExperimentPage({ params }: { params: Promise<{ id: string }> }) {
const router = useRouter();
const { toast } = useToast();
const resolvedParams = use(params);
const studyId = Number(resolvedParams.id);
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [steps, setSteps] = useState<Step[]>([]);
const { data: study } = api.study.getById.useQuery({ id: studyId });
const { mutate: createExperiment, isPending: isCreating } = api.experiment.create.useMutation({
onSuccess: (data) => {
toast({
title: "Success",
description: "Experiment created successfully",
});
router.push(`/dashboard/studies/${studyId}/experiments/${data.id}`);
router.refresh();
},
onError: (error) => {
toast({
title: "Error",
description: error.message,
variant: "destructive",
});
},
});
const canCreateExperiments = study && ["OWNER", "ADMIN", "PRINCIPAL_INVESTIGATOR"]
.map(r => r.toLowerCase())
.includes(study.role.toLowerCase());
if (!study) {
return <div>Study not found</div>;
}
if (!canCreateExperiments) {
return <div>You do not have permission to create experiments in this study.</div>;
}
return (
<>
<PageHeader
title="Create Experiment"
description={`Design a new experiment for ${study.title}`}
/>
<PageContent>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>Experiment Details</CardTitle>
<CardDescription>
Enter the basic information for your experiment.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<label htmlFor="title" className="text-sm font-medium">
Title
</label>
<Input
id="title"
placeholder="Enter experiment title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div className="space-y-2">
<label htmlFor="description" className="text-sm font-medium">
Description
</label>
<Textarea
id="description"
placeholder="Enter experiment description"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Design Experiment</CardTitle>
<CardDescription>
Use the designer below to create your experiment flow.
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<ExperimentDesigner
onChange={setSteps}
/>
</CardContent>
</Card>
<div className="flex justify-end gap-4">
<Button
variant="outline"
onClick={() => router.push(`/dashboard/studies/${studyId}/experiments`)}
>
Cancel
</Button>
<Button
onClick={() => {
createExperiment({
studyId,
title,
description,
steps,
});
}}
disabled={isCreating || !title}
>
{isCreating ? "Creating..." : "Create Experiment"}
</Button>
</div>
</div>
</PageContent>
</>
);
}

View File

@@ -0,0 +1,105 @@
"use client";
import { useRouter } from "next/navigation";
import { api } from "~/trpc/react";
import { PageHeader } from "~/components/layout/page-header";
import { PageContent } from "~/components/layout/page-content";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Plus as PlusIcon, FlaskConical } from "lucide-react";
import { Badge } from "~/components/ui/badge";
import { use } from "react";
export default function ExperimentsPage({ params }: { params: Promise<{ id: string }> }) {
const router = useRouter();
const resolvedParams = use(params);
const studyId = Number(resolvedParams.id);
const { data: study } = api.study.getById.useQuery({ id: studyId });
const { data: experiments, isLoading } = api.experiment.getByStudyId.useQuery({ studyId });
const canCreateExperiments = study && ["OWNER", "ADMIN", "PRINCIPAL_INVESTIGATOR"]
.map(r => r.toLowerCase())
.includes(study.role.toLowerCase());
return (
<>
<PageHeader
title="Experiments"
description={study ? `Manage experiments for ${study.title}` : "Loading..."}
>
{canCreateExperiments && (
<Button
onClick={() => router.push(`/dashboard/studies/${studyId}/experiments/new`)}
size="sm"
>
<PlusIcon className="h-4 w-4 mr-2" />
New Experiment
</Button>
)}
</PageHeader>
<PageContent>
{isLoading ? (
<div className="grid gap-6">
{[...Array(3)].map((_, i) => (
<Card key={i} className="animate-pulse">
<CardHeader>
<div className="h-6 w-1/3 bg-muted rounded" />
<div className="h-4 w-1/2 bg-muted rounded mt-2" />
</CardHeader>
<CardContent>
<div className="h-4 w-1/4 bg-muted rounded" />
</CardContent>
</Card>
))}
</div>
) : !experiments || experiments.length === 0 ? (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FlaskConical className="h-5 w-5" />
No Experiments
</CardTitle>
<CardDescription>
{canCreateExperiments
? "Get started by creating your first experiment."
: "No experiments have been created for this study yet."}
</CardDescription>
</CardHeader>
</Card>
) : (
<div className="grid gap-6">
{experiments.map((experiment) => (
<Card
key={experiment.id}
className="hover:bg-muted/50 cursor-pointer transition-colors"
onClick={() => router.push(`/dashboard/studies/${studyId}/experiments/${experiment.id}`)}
>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>{experiment.title}</CardTitle>
<Badge variant={
experiment.status === "active" ? "default" :
experiment.status === "archived" ? "secondary" :
"outline"
}>
{experiment.status}
</Badge>
</div>
<CardDescription>
{experiment.description || "No description provided"}
</CardDescription>
</CardHeader>
<CardContent>
<div className="text-sm text-muted-foreground">
Version {experiment.version} {experiment.steps.length} steps
</div>
</CardContent>
</Card>
))}
</div>
)}
</PageContent>
</>
);
}

View File

@@ -1,16 +1,22 @@
import { getServerAuthSession } from "~/server/auth";
import { Button } from "~/components/ui/button";
import Link from "next/link";
import { BotIcon } from "lucide-react";
import { BotIcon, ArrowRight, Sparkles, Brain, Microscope } from "lucide-react";
import { Logo } from "~/components/logo";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
export default async function Home() {
const session = await getServerAuthSession();
return (
<div className="min-h-screen bg-background">
<div className="min-h-screen bg-background relative">
{/* Background Gradients */}
<div className="pointer-events-none fixed inset-0 flex items-center justify-center opacity-40">
<div className="h-[800px] w-[800px] rounded-full bg-gradient-to-r from-primary/20 via-secondary/20 to-background blur-3xl" />
</div>
{/* Navigation Bar */}
<nav className="border-b bg-card/50 backdrop-blur supports-[backdrop-filter]:bg-card/50">
<nav className="sticky top-0 z-50 border-b bg-background/50 backdrop-blur supports-[backdrop-filter]:bg-background/50">
<div className="container mx-auto px-4 h-16 flex items-center justify-between">
<div className="flex items-center space-x-2">
<Logo />
@@ -36,61 +42,129 @@ export default async function Home() {
</nav>
{/* Hero Section */}
<section className="container mx-auto px-4 py-24 grid lg:grid-cols-2 gap-12 items-center">
<div>
<h1 className="text-4xl font-bold tracking-tight lg:text-6xl">
Streamline Your HRI Research
</h1>
<p className="mt-6 text-xl text-muted-foreground">
A comprehensive platform for designing, executing, and analyzing Wizard-of-Oz experiments in human-robot interaction studies.
</p>
<div className="mt-8 flex flex-col sm:flex-row gap-4">
{!session ? (
<Button size="lg" className="w-full sm:w-auto" asChild>
<Link href="/auth/signup">Get Started</Link>
<section className="container mx-auto px-4 py-24">
<div className="grid lg:grid-cols-2 gap-12 items-center">
<div className="space-y-6">
<div className="inline-flex rounded-lg bg-gradient-to-br from-primary/20 via-secondary/20 to-background p-1 mb-8">
<span className="rounded-md bg-background/95 px-3 py-1 text-sm backdrop-blur">
Now with Visual Experiment Designer
</span>
</div>
<h1 className="text-4xl font-bold tracking-tight lg:text-6xl bg-gradient-to-br from-foreground via-foreground/90 to-foreground/70 bg-clip-text text-transparent">
Streamline Your HRI Research
</h1>
<p className="text-xl text-muted-foreground">
A comprehensive platform for designing, executing, and analyzing Wizard-of-Oz experiments in human-robot interaction studies.
</p>
<div className="flex flex-col sm:flex-row gap-4 pt-4">
{!session ? (
<Button size="lg" className="w-full sm:w-auto group bg-gradient-to-r from-primary to-primary hover:from-primary/90 hover:to-primary" asChild>
<Link href="/auth/signup">
Get Started
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
</Link>
</Button>
) : (
<Button size="lg" className="w-full sm:w-auto group bg-gradient-to-r from-primary to-primary hover:from-primary/90 hover:to-primary" asChild>
<Link href="/dashboard">
Go to Dashboard
<ArrowRight className="ml-2 h-4 w-4 transition-transform group-hover:translate-x-1" />
</Link>
</Button>
)}
<Button size="lg" variant="outline" className="w-full sm:w-auto" asChild>
<Link href="https://github.com/soconnor0919/hristudio" target="_blank">
View on GitHub
</Link>
</Button>
) : (
<Button size="lg" className="w-full sm:w-auto" asChild>
<Link href="/dashboard">Go to Dashboard</Link>
</Button>
)}
<Button size="lg" variant="outline" className="w-full sm:w-auto" asChild>
<Link href="https://github.com/soconnor0919/hristudio" target="_blank">
View on GitHub
</Link>
</Button>
</div>
</div>
</div>
<div className="relative aspect-video">
<div className="absolute inset-0 bg-gradient-to-br from-primary/20 to-secondary/20 rounded-lg" />
<div className="absolute inset-0 flex items-center justify-center">
<BotIcon className="h-32 w-32 text-primary/40" />
<div className="relative aspect-square lg:aspect-video">
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-secondary/20 to-background rounded-lg border shadow-xl" />
<div className="absolute inset-0 flex items-center justify-center">
<BotIcon className="h-32 w-32 text-primary/40" />
</div>
</div>
</div>
</section>
{/* Features Section */}
<section className="container mx-auto px-4 py-24">
<div className="grid md:grid-cols-3 gap-8">
<div className="space-y-4">
<h3 className="text-xl font-semibold">Visual Experiment Design</h3>
<p className="text-muted-foreground">
Create and configure experiments using an intuitive drag-and-drop interface without extensive coding.
</p>
</div>
<div className="space-y-4">
<h3 className="text-xl font-semibold">Real-time Control</h3>
<p className="text-muted-foreground">
Execute experiments with synchronized views for wizards and observers, enabling seamless collaboration.
</p>
</div>
<div className="space-y-4">
<h3 className="text-xl font-semibold">Comprehensive Analysis</h3>
<p className="text-muted-foreground">
Record, playback, and analyze experimental data with built-in annotation and export tools.
</p>
</div>
<section className="container mx-auto px-4 py-24 space-y-12">
<div className="text-center space-y-4">
<h2 className="text-3xl font-bold tracking-tight bg-gradient-to-br from-foreground to-foreground/70 bg-clip-text text-transparent inline-block">
Powerful Features for HRI Research
</h2>
<p className="text-muted-foreground max-w-[600px] mx-auto">
Everything you need to design, execute, and analyze your human-robot interaction experiments.
</p>
</div>
<div className="grid md:grid-cols-3 gap-8">
<Card className="group relative overflow-hidden border bg-background/60 backdrop-blur supports-[backdrop-filter]:bg-background/60 hover:shadow-lg transition-all">
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary/10 via-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<CardHeader>
<div className="size-12 rounded-lg bg-gradient-to-br from-primary/20 to-primary/10 flex items-center justify-center mb-4">
<Sparkles className="size-6 text-primary" />
</div>
<CardTitle>Visual Experiment Design</CardTitle>
<CardDescription>
Create and configure experiments using an intuitive drag-and-drop interface without extensive coding.
</CardDescription>
</CardHeader>
</Card>
<Card className="group relative overflow-hidden border bg-background/60 backdrop-blur supports-[backdrop-filter]:bg-background/60 hover:shadow-lg transition-all">
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary/10 via-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<CardHeader>
<div className="size-12 rounded-lg bg-gradient-to-br from-primary/20 to-primary/10 flex items-center justify-center mb-4">
<Brain className="size-6 text-primary" />
</div>
<CardTitle>Real-time Control</CardTitle>
<CardDescription>
Execute experiments with synchronized views for wizards and observers, enabling seamless collaboration.
</CardDescription>
</CardHeader>
</Card>
<Card className="group relative overflow-hidden border bg-background/60 backdrop-blur supports-[backdrop-filter]:bg-background/60 hover:shadow-lg transition-all">
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary/10 via-primary/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<CardHeader>
<div className="size-12 rounded-lg bg-gradient-to-br from-primary/20 to-primary/10 flex items-center justify-center mb-4">
<Microscope className="size-6 text-primary" />
</div>
<CardTitle>Comprehensive Analysis</CardTitle>
<CardDescription>
Record, playback, and analyze experimental data with built-in annotation and export tools.
</CardDescription>
</CardHeader>
</Card>
</div>
</section>
{/* CTA Section */}
<section className="container mx-auto px-4 py-24">
<Card className="relative overflow-hidden">
<div className="pointer-events-none absolute inset-0 bg-gradient-to-br from-primary via-primary to-secondary" />
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(circle_at_50%_120%,rgba(0,0,0,0)_30%,rgba(0,0,0,0.15)_100%)]" />
<CardContent className="relative p-12 flex flex-col items-center text-center space-y-6 text-primary-foreground">
<BotIcon className="size-12 mb-4" />
<h2 className="text-3xl font-bold tracking-tight">
Ready to Transform Your Research?
</h2>
<p className="text-primary-foreground/90 max-w-[600px]">
Join the growing community of researchers using HRIStudio to advance human-robot interaction studies.
</p>
{!session ? (
<Button size="lg" variant="secondary" asChild className="mt-4 bg-background/20 hover:bg-background/30">
<Link href="/auth/signup">Start Your Journey</Link>
</Button>
) : (
<Button size="lg" variant="secondary" asChild className="mt-4 bg-background/20 hover:bg-background/30">
<Link href="/dashboard">Go to Dashboard</Link>
</Button>
)}
</CardContent>
</Card>
</section>
</div>
);

View 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>
);
}

View 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>
);
}

View 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";
}
}}
/>
</>
);
}

View 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>
);
}

View 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 ?? (() => {})}
/>
</>
);
});

View File

@@ -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()),
},
],
},
]
: []

View File

@@ -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>

View 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 };

View File

@@ -0,0 +1,110 @@
"use client";
import {
Move,
MessageSquare,
Clock,
KeyboardIcon,
Pointer,
Video,
GitBranch,
Repeat
} from "lucide-react";
import { type ActionType } from "./types";
interface ActionConfig {
type: ActionType;
title: string;
description: string;
icon: React.ReactNode;
defaultParameters: Record<string, any>;
}
export const AVAILABLE_ACTIONS: ActionConfig[] = [
{
type: "move",
title: "Move Robot",
description: "Move the robot to a specific position",
icon: <Move className="h-4 w-4" />,
defaultParameters: {
position: { x: 0, y: 0, z: 0 },
speed: 1,
easing: "linear",
},
},
{
type: "speak",
title: "Robot Speech",
description: "Make the robot say something",
icon: <MessageSquare className="h-4 w-4" />,
defaultParameters: {
text: "",
speed: 1,
pitch: 1,
volume: 1,
},
},
{
type: "wait",
title: "Wait",
description: "Pause for a specified duration",
icon: <Clock className="h-4 w-4" />,
defaultParameters: {
duration: 1000,
showCountdown: true,
},
},
{
type: "input",
title: "User Input",
description: "Wait for participant response",
icon: <KeyboardIcon className="h-4 w-4" />,
defaultParameters: {
type: "button",
prompt: "Please respond",
timeout: null,
},
},
{
type: "gesture",
title: "Gesture",
description: "Perform a predefined gesture",
icon: <Pointer className="h-4 w-4" />,
defaultParameters: {
name: "",
speed: 1,
intensity: 1,
},
},
{
type: "record",
title: "Record",
description: "Start or stop recording",
icon: <Video className="h-4 w-4" />,
defaultParameters: {
type: "start",
streams: ["video"],
},
},
{
type: "condition",
title: "Condition",
description: "Branch based on a condition",
icon: <GitBranch className="h-4 w-4" />,
defaultParameters: {
condition: "",
trueActions: [],
falseActions: [],
},
},
{
type: "loop",
title: "Loop",
description: "Repeat a sequence of actions",
icon: <Repeat className="h-4 w-4" />,
defaultParameters: {
count: 1,
actions: [],
},
},
];

View File

@@ -0,0 +1,85 @@
export type ActionType =
| "move" // Robot movement
| "speak" // Robot speech
| "wait" // Wait for a duration
| "input" // Wait for user input
| "gesture" // Robot gesture
| "record" // Start/stop recording
| "condition" // Conditional branching
| "loop"; // Repeat actions
export interface Action {
id: string;
type: ActionType;
parameters: Record<string, any>;
order: number;
}
export interface Step {
id: string;
title: string;
description?: string;
actions: Action[];
order: number;
}
export interface Experiment {
id: number;
studyId: number;
title: string;
description?: string;
version: number;
status: "draft" | "active" | "archived";
steps: Step[];
createdAt: Date;
updatedAt: Date;
}
// Action Parameters by Type
export interface MoveParameters {
position: { x: number; y: number; z: number };
speed?: number;
easing?: "linear" | "easeIn" | "easeOut" | "easeInOut";
}
export interface SpeakParameters {
text: string;
voice?: string;
speed?: number;
pitch?: number;
volume?: number;
}
export interface WaitParameters {
duration: number; // in milliseconds
showCountdown?: boolean;
}
export interface InputParameters {
prompt?: string;
type: "button" | "text" | "number" | "choice";
options?: string[]; // for choice type
timeout?: number; // optional timeout in milliseconds
}
export interface GestureParameters {
name: string;
speed?: number;
intensity?: number;
}
export interface RecordParameters {
type: "start" | "stop";
streams: ("video" | "audio" | "sensors")[];
}
export interface ConditionParameters {
condition: string; // JavaScript expression
trueActions: Action[];
falseActions?: Action[];
}
export interface LoopParameters {
count: number;
actions: Action[];
}

View File

@@ -1,7 +1,7 @@
import { createTRPCRouter } from "~/server/api/trpc";
import { studyRouter } from "~/server/api/routers/study";
import { participantRouter } from "~/server/api/routers/participant";
import { userRouter } from "~/server/api/routers/user";
import { studyRouter } from "./routers/study";
import { participantRouter } from "./routers/participant";
import { experimentRouter } from "./routers/experiment";
/**
* This is the primary router for your server.
@@ -11,7 +11,7 @@ import { userRouter } from "~/server/api/routers/user";
export const appRouter = createTRPCRouter({
study: studyRouter,
participant: participantRouter,
user: userRouter,
experiment: experimentRouter,
});
// export type definition of API

View File

@@ -1,178 +1,144 @@
import { z } from "zod";
import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import { TRPCError } from "@trpc/server";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import { experiments, studyMembers } from "~/server/db/schema";
import { type Step } from "~/lib/experiments/types";
const createExperimentSchema = z.object({
studyId: z.string().uuid(),
robotId: z.string().uuid(),
title: z.string().min(1).max(256),
studyId: z.number(),
title: z.string().min(1, "Title is required"),
description: z.string().optional(),
estimatedDuration: z.number().int().min(0).optional(),
order: z.number().int().min(0),
steps: z.array(z.object({
id: z.string(),
title: z.string(),
description: z.string().optional(),
actions: z.array(z.object({
id: z.string(),
type: z.string(),
parameters: z.record(z.any()),
order: z.number(),
})),
order: z.number(),
})).default([]),
});
const updateExperimentSchema = z.object({
id: z.number(),
title: z.string().min(1, "Title is required"),
description: z.string().optional(),
status: z.enum(["draft", "active", "archived"]).optional(),
steps: z.array(z.object({
id: z.string(),
title: z.string(),
description: z.string().optional(),
actions: z.array(z.object({
id: z.string(),
type: z.string(),
parameters: z.record(z.any()),
order: z.number(),
})),
order: z.number(),
})).optional(),
});
export const experimentRouter = createTRPCRouter({
create: protectedProcedure
.input(createExperimentSchema)
.mutation(async ({ ctx, input }) => {
getByStudyId: protectedProcedure
.input(z.object({ studyId: z.number() }))
.query(async ({ ctx, input }) => {
// Check if user is a member of the study
const membership = await ctx.db.query.studyMembers.findFirst({
where: eq(studyMembers.studyId, input.studyId),
where: and(
eq(studyMembers.studyId, input.studyId),
eq(studyMembers.userId, ctx.session.user.id),
),
});
if (!membership) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Study not found",
});
}
if (membership.role !== "admin" && membership.role !== "principal_investigator") {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have permission to create experiments",
message: "You do not have permission to view experiments in this study",
});
}
const [experiment] = await ctx.db
.insert(experiments)
.values(input)
.returning();
return ctx.db.query.experiments.findMany({
where: eq(experiments.studyId, input.studyId),
orderBy: experiments.createdAt,
});
}),
getById: protectedProcedure
.input(z.object({ id: z.number() }))
.query(async ({ ctx, input }) => {
const experiment = await ctx.db.query.experiments.findFirst({
where: eq(experiments.id, input.id),
});
if (!experiment) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create experiment",
code: "NOT_FOUND",
message: "Experiment not found",
});
}
// Check if user has access to the study
const membership = await ctx.db.query.studyMembers.findFirst({
where: and(
eq(studyMembers.studyId, experiment.studyId),
eq(studyMembers.userId, ctx.session.user.id),
),
});
if (!membership) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You do not have permission to view this experiment",
});
}
return experiment;
}),
list: protectedProcedure
.input(z.object({ studyId: z.string().uuid() }))
.query(async ({ ctx, input }) => {
create: protectedProcedure
.input(createExperimentSchema)
.mutation(async ({ ctx, input }) => {
// Check if user has permission to create experiments
const membership = await ctx.db.query.studyMembers.findFirst({
where: eq(studyMembers.studyId, input.studyId),
where: and(
eq(studyMembers.studyId, input.studyId),
eq(studyMembers.userId, ctx.session.user.id),
),
});
if (!membership) {
if (!membership || !["owner", "admin", "principal_investigator"]
.includes(membership.role.toLowerCase())) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Study not found",
code: "FORBIDDEN",
message: "You do not have permission to create experiments in this study",
});
}
const experimentList = await ctx.db.query.experiments.findMany({
where: eq(experiments.studyId, input.studyId),
orderBy: (experiments, { asc }) => [asc(experiments.order)],
with: {
robot: true,
},
});
const [experiment] = await ctx.db
.insert(experiments)
.values({
studyId: input.studyId,
title: input.title,
description: input.description,
steps: input.steps as Step[],
version: 1,
status: "draft",
createdById: ctx.session.user.id,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning();
return experimentList;
}),
byId: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.query(async ({ ctx, input }) => {
const experiment = await ctx.db.query.experiments.findFirst({
where: eq(experiments.id, input.id),
with: {
robot: true,
study: true,
},
});
if (!experiment) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Experiment not found",
});
}
const membership = await ctx.db.query.studyMembers.findFirst({
where: eq(studyMembers.studyId, experiment.studyId),
});
if (!membership) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Study not found",
});
}
return {
...experiment,
role: membership.role,
};
return experiment;
}),
update: protectedProcedure
.input(
z.object({
id: z.string().uuid(),
title: z.string().min(1).max(256).optional(),
description: z.string().optional(),
estimatedDuration: z.number().int().min(0).optional(),
order: z.number().int().min(0).optional(),
})
)
.mutation(async ({ ctx, input }) => {
const { id, ...data } = input;
const experiment = await ctx.db.query.experiments.findFirst({
where: eq(experiments.id, id),
});
if (!experiment) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Experiment not found",
});
}
const membership = await ctx.db.query.studyMembers.findFirst({
where: eq(studyMembers.studyId, experiment.studyId),
});
if (!membership) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Study not found",
});
}
if (membership.role !== "admin" && membership.role !== "principal_investigator") {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have permission to update this experiment",
});
}
const [updated] = await ctx.db
.update(experiments)
.set(data)
.where(eq(experiments.id, id))
.returning();
if (!updated) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Experiment not found",
});
}
return {
...updated,
role: membership.role,
};
}),
delete: protectedProcedure
.input(z.object({ id: z.string().uuid() }))
.input(updateExperimentSchema)
.mutation(async ({ ctx, input }) => {
const experiment = await ctx.db.query.experiments.findFirst({
where: eq(experiments.id, input.id),
@@ -185,36 +151,72 @@ export const experimentRouter = createTRPCRouter({
});
}
// Check if user has permission to edit experiments
const membership = await ctx.db.query.studyMembers.findFirst({
where: eq(studyMembers.studyId, experiment.studyId),
where: and(
eq(studyMembers.studyId, experiment.studyId),
eq(studyMembers.userId, ctx.session.user.id),
),
});
if (!membership) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Study not found",
});
}
if (membership.role !== "admin" && membership.role !== "principal_investigator") {
if (!membership || !["owner", "admin", "principal_investigator"]
.includes(membership.role.toLowerCase())) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You don't have permission to delete this experiment",
message: "You do not have permission to edit this experiment",
});
}
const [deleted] = await ctx.db
.delete(experiments)
const [updatedExperiment] = await ctx.db
.update(experiments)
.set({
title: input.title,
description: input.description,
status: input.status,
steps: input.steps as Step[] | undefined,
version: experiment.version + 1,
updatedAt: new Date(),
})
.where(eq(experiments.id, input.id))
.returning();
if (!deleted) {
return updatedExperiment;
}),
delete: protectedProcedure
.input(z.object({ id: z.number() }))
.mutation(async ({ ctx, input }) => {
const experiment = await ctx.db.query.experiments.findFirst({
where: eq(experiments.id, input.id),
});
if (!experiment) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Experiment not found",
});
}
return deleted;
// Check if user has permission to delete experiments
const membership = await ctx.db.query.studyMembers.findFirst({
where: and(
eq(studyMembers.studyId, experiment.studyId),
eq(studyMembers.userId, ctx.session.user.id),
),
});
if (!membership || !["owner", "admin", "principal_investigator"]
.includes(membership.role.toLowerCase())) {
throw new TRPCError({
code: "FORBIDDEN",
message: "You do not have permission to delete this experiment",
});
}
await ctx.db
.delete(experiments)
.where(eq(experiments.id, input.id));
return { success: true };
}),
});

View File

@@ -184,7 +184,7 @@ export const studyRouter = createTRPCRouter({
.input(
z.object({
title: z.string().min(1, "Title is required"),
description: z.string().optional(),
description: z.string().optional(),
})
)
.mutation(async ({ input, ctx }) => {
@@ -198,10 +198,10 @@ export const studyRouter = createTRPCRouter({
const result = await db
.insert(studies)
.values({
title: input.title,
title: input.title,
description: input.description ?? "",
createdById: ctx.session.user.id,
createdAt: new Date(),
createdById: ctx.session.user.id,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning();

View File

@@ -1,8 +1,9 @@
import { relations } from "drizzle-orm";
import { integer, pgEnum, text, timestamp, varchar, serial } from "drizzle-orm/pg-core";
import { integer, pgEnum, text, timestamp, varchar, serial, jsonb } from "drizzle-orm/pg-core";
import { ROLES } from "~/lib/permissions/constants";
import { createTable } from "../utils";
import { users } from "./auth";
import { type Step } from "~/lib/experiments/types";
// Create enum from role values
export const studyRoleEnum = pgEnum("study_role", [
@@ -73,6 +74,13 @@ export const studyActivityTypeEnum = pgEnum("study_activity_type", [
"invitation_revoked",
]);
// Create enum for experiment status
export const experimentStatusEnum = pgEnum("experiment_status", [
"draft",
"active",
"archived",
]);
export const studies = createTable("study", {
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
title: varchar("title", { length: 256 }).notNull(),
@@ -136,12 +144,26 @@ export const studyInvitations = createTable("study_invitation", {
createdById: varchar("created_by", { length: 255 }).notNull().references(() => users.id),
});
export const experiments = createTable("experiment", {
id: integer("id").primaryKey().notNull().generatedAlwaysAsIdentity(),
studyId: integer("study_id").notNull().references(() => studies.id, { onDelete: "cascade" }),
title: varchar("title", { length: 256 }).notNull(),
description: text("description"),
version: integer("version").notNull().default(1),
status: experimentStatusEnum("status").notNull().default("draft"),
steps: jsonb("steps").$type<Step[]>().default([]),
createdById: varchar("created_by", { length: 255 }).notNull().references(() => users.id),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }),
});
// Relations
export const studiesRelations = relations(studies, ({ one, many }) => ({
creator: one(users, { fields: [studies.createdById], references: [users.id] }),
members: many(studyMembers),
participants: many(participants),
invitations: many(studyInvitations),
experiments: many(experiments),
}));
export const studyMembersRelations = relations(studyMembers, ({ one }) => ({
@@ -156,4 +178,9 @@ export const participantsRelations = relations(participants, ({ one }) => ({
export const studyInvitationsRelations = relations(studyInvitations, ({ one }) => ({
study: one(studies, { fields: [studyInvitations.studyId], references: [studies.id] }),
creator: one(users, { fields: [studyInvitations.createdById], references: [users.id] }),
}));
export const experimentsRelations = relations(experiments, ({ one }) => ({
study: one(studies, { fields: [experiments.studyId], references: [studies.id] }),
creator: one(users, { fields: [experiments.createdById], references: [users.id] }),
}));