Studies, basic experiment designer

This commit is contained in:
2025-07-18 21:15:08 -04:00
parent 1121e5c6ff
commit 0cc5c8ae89
18 changed files with 3176 additions and 152 deletions

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

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

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

View File

@@ -0,0 +1,282 @@
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Card, CardContent } from "~/components/ui/card";
import { Textarea } from "~/components/ui/textarea";
import { api } from "~/trpc/react";
const createStudySchema = z.object({
name: z.string().min(1, "Study name is required").max(100, "Name too long"),
description: z
.string()
.min(10, "Description must be at least 10 characters")
.max(1000, "Description too long"),
irbProtocolNumber: z.string().optional(),
institution: z
.string()
.min(1, "Institution is required")
.max(100, "Institution name too long"),
status: z.enum(["draft", "active", "completed", "archived"]),
});
type CreateStudyFormData = z.infer<typeof createStudySchema>;
interface CreateStudyDialogProps {
children: React.ReactNode;
onSuccess?: () => void;
}
export function CreateStudyDialog({
children,
onSuccess,
}: CreateStudyDialogProps) {
const [open, setOpen] = useState(false);
const {
register,
handleSubmit,
reset,
setValue,
watch,
formState: { errors, isSubmitting },
} = useForm<CreateStudyFormData>({
resolver: zodResolver(createStudySchema),
defaultValues: {
status: "draft" as const,
},
});
const createStudyMutation = api.studies.create.useMutation({
onSuccess: () => {
setOpen(false);
reset();
onSuccess?.();
},
onError: (err) => {
console.error("Failed to create study:", err);
},
});
const onSubmit = async (data: CreateStudyFormData) => {
try {
await createStudyMutation.mutateAsync(data);
} catch (error) {
// Error handling is done in the mutation's onError callback
}
};
const watchedStatus = watch("status");
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Create New Study</DialogTitle>
<DialogDescription>
Start a new Human-Robot Interaction research study. You&apos;ll be
assigned as the study owner.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Study Name */}
<div className="space-y-2">
<Label htmlFor="name">Study Name *</Label>
<Input
id="name"
{...register("name")}
placeholder="Enter study name..."
className={errors.name ? "border-red-500" : ""}
/>
{errors.name && (
<p className="text-sm text-red-600">{errors.name.message}</p>
)}
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="description">Description *</Label>
<Textarea
id="description"
{...register("description")}
placeholder="Describe your research study, objectives, and methodology..."
rows={4}
className={errors.description ? "border-red-500" : ""}
/>
{errors.description && (
<p className="text-sm text-red-600">
{errors.description.message}
</p>
)}
</div>
{/* Institution */}
<div className="space-y-2">
<Label htmlFor="institution">Institution *</Label>
<Input
id="institution"
{...register("institution")}
placeholder="University or research institution..."
className={errors.institution ? "border-red-500" : ""}
/>
{errors.institution && (
<p className="text-sm text-red-600">
{errors.institution.message}
</p>
)}
</div>
{/* IRB Protocol Number */}
<div className="space-y-2">
<Label htmlFor="irbProtocolNumber">IRB Protocol Number</Label>
<Input
id="irbProtocolNumber"
{...register("irbProtocolNumber")}
placeholder="Optional IRB protocol number..."
/>
<p className="text-muted-foreground text-xs">
If your study has been approved by an Institutional Review Board
</p>
</div>
{/* Status */}
<div className="space-y-2">
<Label htmlFor="status">Initial Status</Label>
<Select
value={watchedStatus}
onValueChange={(value) =>
setValue(
"status",
value as "draft" | "active" | "completed" | "archived",
)
}
>
<SelectTrigger>
<SelectValue placeholder="Select status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="draft">Draft - Planning stage</SelectItem>
<SelectItem value="active">
Active - Recruiting participants
</SelectItem>
<SelectItem value="completed">
Completed - Data collection finished
</SelectItem>
<SelectItem value="archived">
Archived - Study concluded
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Info Card */}
<Card>
<CardContent className="pt-4">
<div className="flex items-start space-x-3">
<div className="mt-0.5 flex h-5 w-5 items-center justify-center rounded-full bg-blue-100">
<svg
className="h-3 w-3 text-blue-600"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
fillRule="evenodd"
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
clipRule="evenodd"
/>
</svg>
</div>
<div className="text-muted-foreground text-sm">
<p className="text-foreground font-medium">
What happens next?
</p>
<ul className="mt-1 space-y-1 text-xs">
<li> You&apos;ll be assigned as the study owner</li>
<li> You can invite team members and assign roles</li>
<li> Start designing experiments and protocols</li>
<li> Schedule trials and manage participants</li>
</ul>
</div>
</div>
</CardContent>
</Card>
{/* Error Message */}
{createStudyMutation.error && (
<div className="rounded-md bg-red-50 p-3">
<p className="text-sm text-red-800">
Failed to create study: {createStudyMutation.error.message}
</p>
</div>
)}
{/* Form Actions */}
<div className="flex justify-end space-x-3">
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
disabled={isSubmitting}
>
Cancel
</Button>
<Button
type="submit"
disabled={isSubmitting}
className="min-w-[100px]"
>
{isSubmitting ? (
<div className="flex items-center space-x-2">
<svg
className="h-4 w-4 animate-spin"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
<span>Creating...</span>
</div>
) : (
"Create Study"
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,310 @@
"use client";
import { useState } from "react";
import { Plus } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { CreateStudyDialog } from "./CreateStudyDialog";
import { StudyCard } from "./StudyCard";
import { api } from "~/trpc/react";
type StudyWithRelations = {
id: string;
name: string;
description: string;
status: "draft" | "active" | "completed" | "archived";
institution: string;
irbProtocolNumber: string | null;
createdAt: Date;
updatedAt: Date;
ownerId: string;
createdBy: {
id: string;
name: string | null;
email: string;
};
members: Array<{
role: "owner" | "researcher" | "wizard" | "observer";
user: {
id: string;
name: string | null;
email: string;
};
}>;
experiments?: Array<{ id: string }>;
participants?: Array<{ id: string }>;
};
type ProcessedStudy = {
id: string;
name: string;
description: string;
status: "draft" | "active" | "completed" | "archived";
institution: string;
irbProtocolNumber?: string;
createdAt: Date;
updatedAt: Date;
ownerId: string;
owner: {
name: string | null;
email: string;
};
userRole?: "owner" | "researcher" | "wizard" | "observer";
isOwner?: boolean;
_count?: {
experiments: number;
trials: number;
studyMembers: number;
participants: number;
};
};
export function StudiesGrid() {
const [refreshKey, setRefreshKey] = useState(0);
const { data: session } = api.auth.me.useQuery();
const {
data: studiesData,
isLoading,
error,
refetch,
} = api.studies.list.useQuery(
{ memberOnly: true },
{
refetchOnWindowFocus: false,
},
);
const processStudies = (
rawStudies: StudyWithRelations[],
): ProcessedStudy[] => {
const currentUserId = session?.id;
return rawStudies.map((study) => {
// Find current user's membership
const userMembership = study.members?.find(
(member) => member.user.id === currentUserId,
);
return {
id: study.id,
name: study.name,
description: study.description,
status: study.status,
institution: study.institution,
irbProtocolNumber: study.irbProtocolNumber ?? undefined,
createdAt: study.createdAt,
updatedAt: study.updatedAt,
ownerId: study.ownerId,
owner: {
name: study.createdBy.name,
email: study.createdBy.email,
},
userRole: userMembership?.role,
isOwner: study.ownerId === currentUserId,
_count: {
experiments: study.experiments?.length ?? 0,
trials: 0, // Will be populated when trials relation is added
studyMembers: study.members?.length ?? 0,
participants: study.participants?.length ?? 0,
},
};
});
};
const studies = studiesData?.studies
? processStudies(studiesData.studies)
: [];
const handleStudyCreated = () => {
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 Study Card Skeleton */}
<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 Study</CardTitle>
<CardDescription>Start a new HRI research study</CardDescription>
</CardHeader>
<CardContent>
<CreateStudyDialog onSuccess={handleStudyCreated}>
<Button className="w-full">Create Study</Button>
</CreateStudyDialog>
</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>
<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="space-y-2">
<div className="h-3 rounded bg-slate-200"></div>
<div className="h-3 rounded bg-slate-200"></div>
</div>
<div className="space-y-2">
<div className="h-3 rounded bg-slate-200"></div>
<div className="h-3 rounded bg-slate-200"></div>
</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 Study 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 Study</CardTitle>
<CardDescription>Start a new HRI research study</CardDescription>
</CardHeader>
<CardContent>
<CreateStudyDialog onSuccess={handleStudyCreated}>
<Button className="w-full">Create Study</Button>
</CreateStudyDialog>
</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 Studies
</h3>
<p className="mb-4 text-slate-600">
{error.message ||
"An error occurred while loading your studies."}
</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 Study 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 Study</CardTitle>
<CardDescription>Start a new HRI research study</CardDescription>
</CardHeader>
<CardContent>
<CreateStudyDialog onSuccess={handleStudyCreated}>
<Button className="w-full">Create Study</Button>
</CreateStudyDialog>
</CardContent>
</Card>
{/* Studies */}
{studies.map((study) => (
<StudyCard
key={study.id}
study={study}
userRole={study.userRole}
isOwner={study.isOwner}
/>
))}
{/* Empty State */}
{studies.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">
<svg
className="h-12 w-12 text-slate-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<h3 className="mb-2 text-lg font-semibold text-slate-900">
No Studies Yet
</h3>
<p className="mb-4 text-slate-600">
Get started by creating your first Human-Robot Interaction
research study. Studies help you organize experiments, manage
participants, and collaborate with your team.
</p>
<CreateStudyDialog onSuccess={handleStudyCreated}>
<Button>Create Your First Study</Button>
</CreateStudyDialog>
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,215 @@
"use client";
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";
interface Study {
id: string;
name: string;
description: string;
status: "draft" | "active" | "completed" | "archived";
institution: string;
irbProtocolNumber?: string;
createdAt: Date;
updatedAt: Date;
ownerId: string;
_count?: {
experiments: number;
trials: number;
studyMembers: number;
participants: number;
};
owner: {
name: string | null;
email: string;
};
}
interface StudyCardProps {
study: Study;
userRole?: "owner" | "researcher" | "wizard" | "observer";
isOwner?: boolean;
}
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: "📦",
},
};
export function StudyCard({ study, userRole, isOwner }: StudyCardProps) {
const statusInfo = statusConfig[study.status];
const canEdit =
isOwner ?? (userRole === "owner" || userRole === "researcher");
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={`/studies/${study.id}`} className="hover:underline">
{study.name}
</Link>
</CardTitle>
<CardDescription className="mt-1 line-clamp-2 text-sm text-slate-600">
{study.description}
</CardDescription>
</div>
<Badge className={statusInfo.className} variant="secondary">
<span className="mr-1">{statusInfo.icon}</span>
{statusInfo.label}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Institution and IRB */}
<div className="space-y-1">
<div className="flex items-center text-sm text-slate-600">
<svg
className="mr-2 h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-4m-5 0H3m2 0h4M9 7h6m-6 4h6m-6 4h6"
/>
</svg>
{study.institution}
</div>
{study.irbProtocolNumber && (
<div className="flex items-center text-sm text-slate-500">
<svg
className="mr-2 h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
IRB: {study.irbProtocolNumber}
</div>
)}
</div>
{/* Statistics */}
{study._count && (
<>
<Separator />
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="space-y-1">
<div className="flex justify-between">
<span className="text-slate-600">Experiments:</span>
<span className="font-medium">
{study._count.experiments}
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Trials:</span>
<span className="font-medium">{study._count.trials}</span>
</div>
</div>
<div className="space-y-1">
<div className="flex justify-between">
<span className="text-slate-600">Team:</span>
<span className="font-medium">
{study._count.studyMembers}
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-600">Participants:</span>
<span className="font-medium">
{study._count.participants}
</span>
</div>
</div>
</div>
</>
)}
{/* Metadata */}
<Separator />
<div className="space-y-1 text-xs text-slate-500">
<div className="flex justify-between">
<span>Created:</span>
<span>
{formatDistanceToNow(study.createdAt, { addSuffix: true })}
</span>
</div>
<div className="flex justify-between">
<span>Owner:</span>
<span className="ml-2 truncate">
{study.owner.name ?? study.owner.email}
</span>
</div>
{study.updatedAt !== study.createdAt && (
<div className="flex justify-between">
<span>Updated:</span>
<span>
{formatDistanceToNow(study.updatedAt, { addSuffix: true })}
</span>
</div>
)}
</div>
{/* Actions */}
<div className="flex gap-2 pt-2">
<Button asChild size="sm" className="flex-1">
<Link href={`/studies/${study.id}`}>View Details</Link>
</Button>
{canEdit && (
<Button asChild size="sm" variant="outline" className="flex-1">
<Link href={`/studies/${study.id}/edit`}>Edit</Link>
</Button>
)}
</div>
{/* Role indicator */}
{userRole && (
<div className="flex items-center justify-center pt-1">
<span className="text-xs text-slate-500 capitalize">
Your role: <span className="font-medium">{userRole}</span>
</span>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "~/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }

View File

@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "~/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }