feat: Redesign Landing, Auth, and Dashboard Pages

Also fixed schema type exports and seed script errors.
This commit is contained in:
2026-02-01 22:28:19 -05:00
parent 816b2b9e31
commit dbfdd91dea
300 changed files with 17239 additions and 5952 deletions

0
src/app/(dashboard)/admin/page.tsx Normal file → Executable file
View File

0
src/app/(dashboard)/admin/repositories/page.tsx Normal file → Executable file
View File

View File

@@ -1,64 +0,0 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { AlertCircle, ArrowRight } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { useStudyContext } from "~/lib/study-context";
export default function AnalyticsRedirect() {
const router = useRouter();
const { selectedStudyId } = useStudyContext();
useEffect(() => {
// If user has a selected study, redirect to study analytics
if (selectedStudyId) {
router.replace(`/studies/${selectedStudyId}/analytics`);
}
}, [selectedStudyId, router]);
return (
<div className="flex min-h-[60vh] items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-blue-50">
<AlertCircle className="h-8 w-8 text-blue-500" />
</div>
<CardTitle className="text-2xl">Analytics Moved</CardTitle>
<CardDescription>
Analytics are now organized by study for better data insights.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-muted-foreground space-y-2 text-center text-sm">
<p>To view analytics, please:</p>
<ul className="space-y-1 text-left">
<li> Select a study from your studies list</li>
<li> Navigate to that study&apos;s analytics page</li>
<li> Get study-specific insights and data</li>
</ul>
</div>
<div className="flex flex-col gap-2 pt-4">
<Button asChild className="w-full">
<Link href="/studies">
<ArrowRight className="mr-2 h-4 w-4" />
Browse Studies
</Link>
</Button>
<Button asChild variant="outline" className="w-full">
<Link href="/dashboard">Go to Dashboard</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,513 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Textarea } from "~/components/ui/textarea";
import { Badge } from "~/components/ui/badge";
import { Separator } from "~/components/ui/separator";
import { Alert, AlertDescription } from "~/components/ui/alert";
import { PageHeader } from "~/components/ui/page-header";
import { PageLayout } from "~/components/ui/page-layout";
import { ScrollArea } from "~/components/ui/scroll-area";
import {
Wifi,
WifiOff,
AlertTriangle,
CheckCircle,
Play,
Square,
Trash2,
Copy,
} from "lucide-react";
export default function DebugPage() {
const [connectionStatus, setConnectionStatus] = useState<
"disconnected" | "connecting" | "connected" | "error"
>("disconnected");
const [rosSocket, setRosSocket] = useState<WebSocket | null>(null);
const [logs, setLogs] = useState<string[]>([]);
const [messages, setMessages] = useState<any[]>([]);
const [testMessage, setTestMessage] = useState("");
const [selectedTopic, setSelectedTopic] = useState("/speech");
const [messageType, setMessageType] = useState("std_msgs/String");
const [lastError, setLastError] = useState<string | null>(null);
const [connectionAttempts, setConnectionAttempts] = useState(0);
const logsEndRef = useRef<HTMLDivElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const ROS_BRIDGE_URL = "ws://134.82.159.25:9090";
const addLog = (message: string, type: "info" | "error" | "success" = "info") => {
const timestamp = new Date().toLocaleTimeString();
const logEntry = `[${timestamp}] [${type.toUpperCase()}] ${message}`;
setLogs((prev) => [...prev.slice(-99), logEntry]);
console.log(logEntry);
};
const addMessage = (message: any, direction: "sent" | "received") => {
const timestamp = new Date().toLocaleTimeString();
setMessages((prev) => [
...prev.slice(-49),
{
timestamp,
direction,
data: message,
},
]);
};
useEffect(() => {
logsEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [logs]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const connectToRos = () => {
if (rosSocket?.readyState === WebSocket.OPEN) return;
setConnectionStatus("connecting");
setConnectionAttempts((prev) => prev + 1);
setLastError(null);
addLog(`Attempting connection #${connectionAttempts + 1} to ${ROS_BRIDGE_URL}`);
const socket = new WebSocket(ROS_BRIDGE_URL);
// Connection timeout
const timeout = setTimeout(() => {
if (socket.readyState === WebSocket.CONNECTING) {
addLog("Connection timeout (10s) - closing socket", "error");
socket.close();
}
}, 10000);
socket.onopen = () => {
clearTimeout(timeout);
setConnectionStatus("connected");
setRosSocket(socket);
setLastError(null);
addLog("✅ WebSocket connection established successfully", "success");
// Test basic functionality by advertising
const advertiseMsg = {
op: "advertise",
topic: "/hristudio_debug",
type: "std_msgs/String",
};
socket.send(JSON.stringify(advertiseMsg));
addMessage(advertiseMsg, "sent");
};
socket.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
addMessage(data, "received");
if (data.level === "error") {
addLog(`ROS Error: ${data.msg}`, "error");
} else if (data.op === "status") {
addLog(`Status: ${data.msg} (Level: ${data.level})`);
} else {
addLog(`Received: ${data.op || "unknown"} operation`);
}
} catch (error) {
addLog(`Failed to parse message: ${error}`, "error");
addMessage({ raw: event.data, error: String(error) }, "received");
}
};
socket.onclose = (event) => {
clearTimeout(timeout);
const wasConnected = connectionStatus === "connected";
setConnectionStatus("disconnected");
setRosSocket(null);
let reason = "Unknown reason";
if (event.code === 1000) {
reason = "Normal closure";
addLog(`Connection closed normally: ${event.reason || reason}`);
} else if (event.code === 1006) {
reason = "Connection lost/refused";
setLastError("ROS Bridge server not responding - check if rosbridge_server is running");
addLog(`❌ Connection failed: ${reason} (${event.code})`, "error");
} else if (event.code === 1011) {
reason = "Server error";
setLastError("ROS Bridge server encountered an error");
addLog(`❌ Server error: ${reason} (${event.code})`, "error");
} else {
reason = `Code ${event.code}`;
setLastError(`Connection closed with code ${event.code}: ${event.reason || "No reason given"}`);
addLog(`❌ Connection closed: ${reason}`, "error");
}
if (wasConnected) {
addLog("Connection was working but lost - check network/server");
}
};
socket.onerror = (error) => {
clearTimeout(timeout);
setConnectionStatus("error");
const errorMsg = "WebSocket error occurred";
setLastError(errorMsg);
addLog(`${errorMsg}`, "error");
console.error("WebSocket error details:", error);
};
};
const disconnectFromRos = () => {
if (rosSocket) {
addLog("Manually closing connection");
rosSocket.close(1000, "Manual disconnect");
}
};
const sendTestMessage = () => {
if (!rosSocket || connectionStatus !== "connected") {
addLog("Cannot send message - not connected", "error");
return;
}
try {
let message: any;
if (selectedTopic === "/speech" && messageType === "std_msgs/String") {
message = {
op: "publish",
topic: "/speech",
type: "std_msgs/String",
msg: { data: testMessage || "Hello from debug page" },
};
} else if (selectedTopic === "/cmd_vel") {
message = {
op: "publish",
topic: "/cmd_vel",
type: "geometry_msgs/Twist",
msg: {
linear: { x: 0.1, y: 0, z: 0 },
angular: { x: 0, y: 0, z: 0 },
},
};
} else {
// Generic message
message = {
op: "publish",
topic: selectedTopic,
type: messageType,
msg: { data: testMessage || "test" },
};
}
rosSocket.send(JSON.stringify(message));
addMessage(message, "sent");
addLog(`Sent message to ${selectedTopic}`, "success");
} catch (error) {
addLog(`Failed to send message: ${error}`, "error");
}
};
const subscribeToTopic = () => {
if (!rosSocket || connectionStatus !== "connected") {
addLog("Cannot subscribe - not connected", "error");
return;
}
const subscribeMsg = {
op: "subscribe",
topic: selectedTopic,
type: messageType,
};
rosSocket.send(JSON.stringify(subscribeMsg));
addMessage(subscribeMsg, "sent");
addLog(`Subscribed to ${selectedTopic}`, "success");
};
const clearLogs = () => {
setLogs([]);
setMessages([]);
addLog("Logs cleared");
};
const copyLogs = () => {
const logText = logs.join("\n");
navigator.clipboard.writeText(logText);
addLog("Logs copied to clipboard", "success");
};
const getStatusIcon = () => {
switch (connectionStatus) {
case "connected":
return <CheckCircle className="h-4 w-4 text-green-600" />;
case "connecting":
return <Wifi className="h-4 w-4 animate-pulse text-blue-600" />;
case "error":
return <AlertTriangle className="h-4 w-4 text-red-600" />;
default:
return <WifiOff className="h-4 w-4 text-gray-400" />;
}
};
const commonTopics = [
{ topic: "/speech", type: "std_msgs/String" },
{ topic: "/cmd_vel", type: "geometry_msgs/Twist" },
{ topic: "/joint_angles", type: "naoqi_bridge_msgs/JointAnglesWithSpeed" },
{ topic: "/naoqi_driver/joint_states", type: "sensor_msgs/JointState" },
{ topic: "/naoqi_driver/bumper", type: "naoqi_bridge_msgs/Bumper" },
];
return (
<PageLayout>
<PageHeader
title="ROS Bridge WebSocket Debug"
description="Debug and test WebSocket connection to ROS Bridge server"
/>
<div className="grid gap-6 md:grid-cols-2">
{/* Connection Control */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
{getStatusIcon()}
Connection Control
</CardTitle>
<CardDescription>
Connect to ROS Bridge at {ROS_BRIDGE_URL}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-2">
<Badge
variant={
connectionStatus === "connected"
? "default"
: connectionStatus === "error"
? "destructive"
: "outline"
}
>
{connectionStatus.toUpperCase()}
</Badge>
<span className="text-sm text-muted-foreground">
Attempts: {connectionAttempts}
</span>
</div>
{lastError && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription className="text-sm">{lastError}</AlertDescription>
</Alert>
)}
<div className="flex gap-2">
{connectionStatus !== "connected" ? (
<Button
onClick={connectToRos}
disabled={connectionStatus === "connecting"}
className="flex-1"
>
<Play className="mr-2 h-4 w-4" />
{connectionStatus === "connecting" ? "Connecting..." : "Connect"}
</Button>
) : (
<Button
onClick={disconnectFromRos}
variant="outline"
className="flex-1"
>
<Square className="mr-2 h-4 w-4" />
Disconnect
</Button>
)}
</div>
<Separator />
{/* Message Testing */}
<div className="space-y-3">
<Label>Test Messages</Label>
<div className="grid grid-cols-2 gap-2">
<div>
<Label htmlFor="topic" className="text-xs">
Topic
</Label>
<Input
id="topic"
value={selectedTopic}
onChange={(e) => setSelectedTopic(e.target.value)}
placeholder="/speech"
/>
</div>
<div>
<Label htmlFor="msgType" className="text-xs">
Message Type
</Label>
<Input
id="msgType"
value={messageType}
onChange={(e) => setMessageType(e.target.value)}
placeholder="std_msgs/String"
/>
</div>
</div>
<div>
<Label htmlFor="testMsg" className="text-xs">
Test Message
</Label>
<Input
id="testMsg"
value={testMessage}
onChange={(e) => setTestMessage(e.target.value)}
placeholder="Hello from debug page"
/>
</div>
<div className="flex gap-2">
<Button
onClick={sendTestMessage}
disabled={connectionStatus !== "connected"}
size="sm"
className="flex-1"
>
Publish
</Button>
<Button
onClick={subscribeToTopic}
disabled={connectionStatus !== "connected"}
size="sm"
variant="outline"
className="flex-1"
>
Subscribe
</Button>
</div>
{/* Quick Topic Buttons */}
<div className="space-y-1">
<Label className="text-xs">Quick Topics</Label>
<div className="grid grid-cols-1 gap-1">
{commonTopics.map((item) => (
<Button
key={item.topic}
onClick={() => {
setSelectedTopic(item.topic);
setMessageType(item.type);
}}
variant="ghost"
size="sm"
className="justify-start text-xs"
>
{item.topic}
</Button>
))}
</div>
</div>
</div>
</CardContent>
</Card>
{/* Connection Logs */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
Connection Logs
<div className="flex gap-1">
<Button onClick={copyLogs} size="sm" variant="ghost">
<Copy className="h-4 w-4" />
</Button>
<Button onClick={clearLogs} size="sm" variant="ghost">
<Trash2 className="h-4 w-4" />
</Button>
</div>
</CardTitle>
<CardDescription>
Real-time connection and message logs ({logs.length}/100)
</CardDescription>
</CardHeader>
<CardContent>
<ScrollArea className="h-64 w-full rounded border p-2">
<div className="space-y-1 font-mono text-xs">
{logs.map((log, index) => (
<div
key={index}
className={`${
log.includes("ERROR")
? "text-red-600"
: log.includes("SUCCESS")
? "text-green-600"
: "text-slate-600"
}`}
>
{log}
</div>
))}
{logs.length === 0 && (
<div className="text-muted-foreground">No logs yet...</div>
)}
<div ref={logsEndRef} />
</div>
</ScrollArea>
</CardContent>
</Card>
{/* Message Inspector */}
<Card className="md:col-span-2">
<CardHeader>
<CardTitle>Message Inspector</CardTitle>
<CardDescription>
Raw WebSocket messages sent and received ({messages.length}/50)
</CardDescription>
</CardHeader>
<CardContent>
<ScrollArea className="h-64 w-full rounded border p-2">
<div className="space-y-2">
{messages.map((msg, index) => (
<div
key={index}
className={`rounded p-2 text-xs ${
msg.direction === "sent"
? "bg-blue-50 border-l-2 border-blue-400"
: "bg-green-50 border-l-2 border-green-400"
}`}
>
<div className="flex items-center justify-between mb-1">
<Badge
variant={msg.direction === "sent" ? "default" : "secondary"}
className="text-xs"
>
{msg.direction === "sent" ? "SENT" : "RECEIVED"}
</Badge>
<span className="text-muted-foreground">{msg.timestamp}</span>
</div>
<pre className="whitespace-pre-wrap text-xs">
{JSON.stringify(msg.data, null, 2)}
</pre>
</div>
))}
{messages.length === 0 && (
<div className="text-center text-muted-foreground py-8">
No messages yet. Connect and send a test message to see data here.
</div>
)}
<div ref={messagesEndRef} />
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
</PageLayout>
);
}

View File

@@ -1,15 +0,0 @@
import { ExperimentForm } from "~/components/experiments/ExperimentForm";
interface EditExperimentPageProps {
params: Promise<{
id: string;
}>;
}
export default async function EditExperimentPage({
params,
}: EditExperimentPageProps) {
const { id } = await params;
return <ExperimentForm mode="edit" experimentId={id} />;
}

View File

@@ -1,459 +0,0 @@
"use client";
import { formatDistanceToNow } from "date-fns";
import { Calendar, Clock, Edit, Play, Settings, Users } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { useEffect, useState } from "react";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import {
EntityView,
EntityViewHeader,
EntityViewSection,
EmptyState,
InfoGrid,
QuickActions,
StatsGrid,
} from "~/components/ui/entity-view";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { api } from "~/trpc/react";
import { useSession } from "next-auth/react";
interface ExperimentDetailPageProps {
params: Promise<{ id: string }>;
}
const statusConfig = {
draft: {
label: "Draft",
variant: "secondary" as const,
icon: "FileText" as const,
},
testing: {
label: "Testing",
variant: "outline" as const,
icon: "TestTube" as const,
},
ready: {
label: "Ready",
variant: "default" as const,
icon: "CheckCircle" as const,
},
deprecated: {
label: "Deprecated",
variant: "destructive" as const,
icon: "AlertTriangle" as const,
},
};
type Experiment = {
id: string;
name: string;
description: string | null;
status: string;
createdAt: Date;
updatedAt: Date;
study: { id: string; name: string };
robot: { id: string; name: string; description: string | null } | null;
protocol?: { blocks: unknown[] } | null;
visualDesign?: unknown;
studyId: string;
createdBy: string;
robotId: string | null;
version: number;
};
type Trial = {
id: string;
status: string;
createdAt: Date;
duration: number | null;
participant: {
id: string;
participantCode: string;
name?: string | null;
} | null;
experiment: { name: string } | null;
participantId: string | null;
experimentId: string;
startedAt: Date | null;
completedAt: Date | null;
notes: string | null;
updatedAt: Date;
canAccess: boolean;
userRole: string;
};
export default function ExperimentDetailPage({
params,
}: ExperimentDetailPageProps) {
const { data: session } = useSession();
const [experiment, setExperiment] = useState<Experiment | null>(null);
const [trials, setTrials] = useState<Trial[]>([]);
const [loading, setLoading] = useState(true);
const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(
null,
);
useEffect(() => {
const resolveParams = async () => {
const resolved = await params;
setResolvedParams(resolved);
};
void resolveParams();
}, [params]);
const experimentQuery = api.experiments.get.useQuery(
{ id: resolvedParams?.id ?? "" },
{ enabled: !!resolvedParams?.id },
);
const trialsQuery = api.trials.list.useQuery(
{ experimentId: resolvedParams?.id ?? "" },
{ enabled: !!resolvedParams?.id },
);
useEffect(() => {
if (experimentQuery.data) {
setExperiment(experimentQuery.data);
}
}, [experimentQuery.data]);
useEffect(() => {
if (trialsQuery.data) {
setTrials(trialsQuery.data);
}
}, [trialsQuery.data]);
useEffect(() => {
if (experimentQuery.isLoading || trialsQuery.isLoading) {
setLoading(true);
} else {
setLoading(false);
}
}, [experimentQuery.isLoading, trialsQuery.isLoading]);
// Set breadcrumbs
useBreadcrumbsEffect([
{
label: "Dashboard",
href: "/",
},
{
label: "Studies",
href: "/studies",
},
{
label: experiment?.study?.name ?? "Unknown Study",
href: `/studies/${experiment?.study?.id}`,
},
{
label: "Experiments",
href: `/studies/${experiment?.study?.id}/experiments`,
},
{
label: experiment?.name ?? "Experiment",
},
]);
if (loading) return <div>Loading...</div>;
if (experimentQuery.error) return notFound();
if (!experiment) return notFound();
const displayName = experiment.name ?? "Untitled Experiment";
const description = experiment.description;
// Check if user can edit this experiment
const userRoles = session?.user?.roles?.map((r) => r.role) ?? [];
const canEdit =
userRoles.includes("administrator") || userRoles.includes("researcher");
const statusInfo =
statusConfig[experiment.status as keyof typeof statusConfig];
return (
<EntityView>
<EntityViewHeader
title={displayName}
subtitle={description ?? undefined}
icon="TestTube"
status={{
label: statusInfo?.label ?? "Unknown",
variant: statusInfo?.variant ?? "secondary",
icon: statusInfo?.icon ?? "TestTube",
}}
actions={
canEdit ? (
<>
<Button asChild variant="outline">
<Link href={`/experiments/${experiment.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
<Button asChild variant="outline">
<Link href={`/experiments/${experiment.id}/designer`}>
<Settings className="mr-2 h-4 w-4" />
Designer
</Link>
</Button>
<Button asChild>
<Link
href={`/studies/${experiment.study.id}/trials/new?experimentId=${experiment.id}`}
>
<Play className="mr-2 h-4 w-4" />
Start Trial
</Link>
</Button>
</>
) : undefined
}
/>
<div className="grid gap-6 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-2">
{/* Basic Information */}
<EntityViewSection title="Information" icon="Info">
<InfoGrid
columns={2}
items={[
{
label: "Study",
value: experiment.study ? (
<Link
href={`/studies/${experiment.study.id}`}
className="text-primary hover:underline"
>
{experiment.study.name}
</Link>
) : (
"No study assigned"
),
},
{
label: "Status",
value: statusInfo?.label ?? "Unknown",
},
{
label: "Created",
value: formatDistanceToNow(experiment.createdAt, {
addSuffix: true,
}),
},
{
label: "Last Updated",
value: formatDistanceToNow(experiment.updatedAt, {
addSuffix: true,
}),
},
]}
/>
</EntityViewSection>
{/* Protocol Section */}
<EntityViewSection
title="Experiment Protocol"
icon="FileText"
actions={
canEdit && (
<Button asChild variant="outline" size="sm">
<Link href={`/experiments/${experiment.id}/designer`}>
<Edit className="mr-2 h-4 w-4" />
Edit Protocol
</Link>
</Button>
)
}
>
{experiment.protocol &&
typeof experiment.protocol === "object" &&
experiment.protocol !== null ? (
<div className="space-y-3">
<div className="text-muted-foreground text-sm">
Protocol contains{" "}
{Array.isArray(
(experiment.protocol as { blocks: unknown[] }).blocks,
)
? (experiment.protocol as { blocks: unknown[] }).blocks
.length
: 0}{" "}
blocks
</div>
</div>
) : (
<EmptyState
icon="FileText"
title="No protocol defined"
description="Create an experiment protocol using the visual designer"
action={
canEdit && (
<Button asChild>
<Link href={`/experiments/${experiment.id}/designer`}>
Open Designer
</Link>
</Button>
)
}
/>
)}
</EntityViewSection>
{/* Recent Trials */}
<EntityViewSection
title="Recent Trials"
icon="Play"
actions={
<Button asChild variant="outline" size="sm">
<Link href={`/studies/${experiment.study?.id}/trials`}>
View All
</Link>
</Button>
}
>
{trials.length > 0 ? (
<div className="space-y-3">
{trials.slice(0, 5).map((trial) => (
<div
key={trial.id}
className="hover:bg-muted/50 rounded-lg border p-4 transition-colors"
>
<div className="mb-2 flex items-center justify-between">
<Link
href={`/studies/${experiment.study.id}/trials/${trial.id}`}
className="font-medium hover:underline"
>
Trial #{trial.id.slice(-6)}
</Link>
<Badge
variant={
trial.status === "completed"
? "default"
: trial.status === "in_progress"
? "secondary"
: trial.status === "failed"
? "destructive"
: "outline"
}
>
{trial.status.charAt(0).toUpperCase() +
trial.status.slice(1).replace("_", " ")}
</Badge>
</div>
<div className="text-muted-foreground flex items-center gap-4 text-sm">
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{formatDistanceToNow(trial.createdAt, {
addSuffix: true,
})}
</span>
{trial.duration && (
<span className="flex items-center gap-1">
<Clock className="h-4 w-4" />
{Math.round(trial.duration / 60)} min
</span>
)}
{trial.participant && (
<span className="flex items-center gap-1">
<Users className="h-4 w-4" />
{trial.participant.name ??
trial.participant.participantCode}
</span>
)}
</div>
</div>
))}
</div>
) : (
<EmptyState
icon="Play"
title="No trials yet"
description="Start your first trial to collect data"
action={
canEdit && (
<Button asChild>
<Link
href={`/studies/${experiment.study.id}/trials/new?experimentId=${experiment.id}`}
>
Start Trial
</Link>
</Button>
)
}
/>
)}
</EntityViewSection>
</div>
<div className="space-y-6">
{/* Statistics */}
<EntityViewSection title="Statistics" icon="BarChart">
<StatsGrid
stats={[
{
label: "Total Trials",
value: trials.length,
},
{
label: "Completed",
value: trials.filter((t) => t.status === "completed").length,
},
{
label: "In Progress",
value: trials.filter((t) => t.status === "in_progress")
.length,
},
]}
/>
</EntityViewSection>
{/* Robot Information */}
{experiment.robot && (
<EntityViewSection title="Robot Platform" icon="Bot">
<InfoGrid
columns={1}
items={[
{
label: "Platform",
value: experiment.robot.name,
},
{
label: "Type",
value: experiment.robot.description ?? "Not specified",
},
]}
/>
</EntityViewSection>
)}
{/* Quick Actions */}
<EntityViewSection title="Quick Actions" icon="Zap">
<QuickActions
actions={[
{
label: "Export Data",
icon: "Download" as const,
href: `/experiments/${experiment.id}/export`,
},
...(canEdit
? [
{
label: "Edit Experiment",
icon: "Edit" as const,
href: `/experiments/${experiment.id}/edit`,
},
{
label: "Open Designer",
icon: "Palette" as const,
href: `/experiments/${experiment.id}/designer`,
},
]
: []),
]}
/>
</EntityViewSection>
</div>
</div>
</EntityView>
);
}

View File

@@ -1,65 +0,0 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { FlaskConical, ArrowRight } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { useStudyContext } from "~/lib/study-context";
export default function ExperimentsRedirect() {
const router = useRouter();
const { selectedStudyId } = useStudyContext();
useEffect(() => {
// If user has a selected study, redirect to study experiments
if (selectedStudyId) {
router.replace(`/studies/${selectedStudyId}/experiments`);
}
}, [selectedStudyId, router]);
return (
<div className="flex min-h-[60vh] items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-blue-50">
<FlaskConical className="h-8 w-8 text-blue-500" />
</div>
<CardTitle className="text-2xl">Experiments Moved</CardTitle>
<CardDescription>
Experiment management is now organized by study for better
workflow organization.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-muted-foreground space-y-2 text-center text-sm">
<p>To manage experiments:</p>
<ul className="space-y-1 text-left">
<li> Select a study from your studies list</li>
<li> Navigate to that study&apos;s experiments page</li>
<li> Create and manage experiment protocols for that specific study</li>
</ul>
</div>
<div className="flex flex-col gap-2 pt-4">
<Button asChild className="w-full">
<Link href="/studies">
<ArrowRight className="mr-2 h-4 w-4" />
Browse Studies
</Link>
</Button>
<Button asChild variant="outline" className="w-full">
<Link href="/dashboard">Go to Dashboard</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

0
src/app/(dashboard)/layout.tsx Normal file → Executable file
View File

View File

@@ -0,0 +1,607 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Textarea } from "~/components/ui/textarea";
import { Badge } from "~/components/ui/badge";
import { Separator } from "~/components/ui/separator";
import { Slider } from "~/components/ui/slider";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { Alert, AlertDescription } from "~/components/ui/alert";
import { PageHeader } from "~/components/ui/page-header";
import { PageLayout } from "~/components/ui/page-layout";
import {
Play,
Square,
Volume2,
Camera,
Zap,
ArrowUp,
ArrowDown,
ArrowLeft,
ArrowRight,
RotateCcw,
RotateCw,
Wifi,
WifiOff,
AlertTriangle,
CheckCircle,
Activity,
Battery,
Eye,
Hand,
Footprints,
} from "lucide-react";
interface RosMessage {
topic: string;
msg: any;
type: string;
}
export default function NaoTestPage() {
const [connectionStatus, setConnectionStatus] = useState<
"disconnected" | "connecting" | "connected" | "error"
>("disconnected");
const [rosSocket, setRosSocket] = useState<WebSocket | null>(null);
const [robotStatus, setRobotStatus] = useState<any>(null);
const [jointStates, setJointStates] = useState<any>(null);
const [speechText, setSpeechText] = useState("");
const [walkSpeed, setWalkSpeed] = useState([0.1]);
const [turnSpeed, setTurnSpeed] = useState([0.3]);
const [headYaw, setHeadYaw] = useState([0]);
const [headPitch, setHeadPitch] = useState([0]);
const [logs, setLogs] = useState<string[]>([]);
const [sensorData, setSensorData] = useState<any>({});
const logsEndRef = useRef<HTMLDivElement>(null);
const ROS_BRIDGE_URL =
process.env.NEXT_PUBLIC_ROS_BRIDGE_URL || "ws://localhost:9090";
const addLog = (message: string) => {
const timestamp = new Date().toLocaleTimeString();
setLogs((prev) => [...prev.slice(-49), `[${timestamp}] ${message}`]);
};
useEffect(() => {
logsEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [logs]);
const connectToRos = () => {
if (rosSocket?.readyState === WebSocket.OPEN) return;
setConnectionStatus("connecting");
addLog("Connecting to ROS bridge...");
const socket = new WebSocket(ROS_BRIDGE_URL);
socket.onopen = () => {
setConnectionStatus("connected");
setRosSocket(socket);
addLog("Connected to ROS bridge successfully");
// Subscribe to robot topics
subscribeToTopics(socket);
};
socket.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
handleRosMessage(data);
} catch (error) {
console.error("Error parsing ROS message:", error);
}
};
socket.onclose = () => {
setConnectionStatus("disconnected");
setRosSocket(null);
addLog("Disconnected from ROS bridge");
};
socket.onerror = () => {
setConnectionStatus("error");
addLog("Error connecting to ROS bridge");
};
};
const disconnectFromRos = () => {
if (rosSocket) {
rosSocket.close();
setRosSocket(null);
setConnectionStatus("disconnected");
addLog("Manually disconnected from ROS bridge");
}
};
const subscribeToTopics = (socket: WebSocket) => {
const topics = [
{ topic: "/naoqi_driver/joint_states", type: "sensor_msgs/JointState" },
{ topic: "/naoqi_driver/info", type: "naoqi_bridge_msgs/StringStamped" },
{ topic: "/naoqi_driver/bumper", type: "naoqi_bridge_msgs/Bumper" },
{
topic: "/naoqi_driver/hand_touch",
type: "naoqi_bridge_msgs/HandTouch",
},
{
topic: "/naoqi_driver/head_touch",
type: "naoqi_bridge_msgs/HeadTouch",
},
{ topic: "/naoqi_driver/sonar/left", type: "sensor_msgs/Range" },
{ topic: "/naoqi_driver/sonar/right", type: "sensor_msgs/Range" },
];
topics.forEach(({ topic, type }) => {
const subscribeMsg = {
op: "subscribe",
topic,
type,
};
socket.send(JSON.stringify(subscribeMsg));
addLog(`Subscribed to ${topic}`);
});
};
const handleRosMessage = (data: any) => {
if (data.topic === "/naoqi_driver/joint_states") {
setJointStates(data.msg);
} else if (data.topic === "/naoqi_driver/info") {
setRobotStatus(data.msg);
} else if (
data.topic?.includes("bumper") ||
data.topic?.includes("touch") ||
data.topic?.includes("sonar")
) {
setSensorData((prev) => ({
...prev,
[data.topic]: data.msg,
}));
}
};
const publishMessage = (topic: string, type: string, msg: any) => {
if (!rosSocket || rosSocket.readyState !== WebSocket.OPEN) {
addLog("Error: Not connected to ROS bridge");
return;
}
const rosMsg = {
op: "publish",
topic,
type,
msg,
};
rosSocket.send(JSON.stringify(rosMsg));
addLog(`Published to ${topic}: ${JSON.stringify(msg)}`);
};
const sayText = () => {
if (!speechText.trim()) return;
publishMessage("/speech", "std_msgs/String", {
data: speechText,
});
setSpeechText("");
};
const walkForward = () => {
publishMessage("/cmd_vel", "geometry_msgs/Twist", {
linear: { x: walkSpeed[0], y: 0, z: 0 },
angular: { x: 0, y: 0, z: 0 },
});
};
const walkBackward = () => {
publishMessage("/cmd_vel", "geometry_msgs/Twist", {
linear: { x: -walkSpeed[0], y: 0, z: 0 },
angular: { x: 0, y: 0, z: 0 },
});
};
const turnLeft = () => {
publishMessage("/cmd_vel", "geometry_msgs/Twist", {
linear: { x: 0, y: 0, z: 0 },
angular: { x: 0, y: 0, z: turnSpeed[0] },
});
};
const turnRight = () => {
publishMessage("/cmd_vel", "geometry_msgs/Twist", {
linear: { x: 0, y: 0, z: 0 },
angular: { x: 0, y: 0, z: -turnSpeed[0] },
});
};
const stopMovement = () => {
publishMessage("/cmd_vel", "geometry_msgs/Twist", {
linear: { x: 0, y: 0, z: 0 },
angular: { x: 0, y: 0, z: 0 },
});
};
const moveHead = () => {
publishMessage("/joint_angles", "naoqi_bridge_msgs/JointAnglesWithSpeed", {
joint_names: ["HeadYaw", "HeadPitch"],
joint_angles: [headYaw[0], headPitch[0]],
speed: 0.3,
});
};
const getConnectionStatusIcon = () => {
switch (connectionStatus) {
case "connected":
return <Wifi className="h-4 w-4 text-green-500" />;
case "connecting":
return <Activity className="h-4 w-4 animate-spin text-yellow-500" />;
case "error":
return <AlertTriangle className="h-4 w-4 text-red-500" />;
default:
return <WifiOff className="h-4 w-4 text-gray-500" />;
}
};
const getConnectionStatusBadge = () => {
const variants = {
connected: "default",
connecting: "secondary",
error: "destructive",
disconnected: "outline",
} as const;
return (
<Badge
variant={variants[connectionStatus]}
className="flex items-center gap-1"
>
{getConnectionStatusIcon()}
{connectionStatus.charAt(0).toUpperCase() + connectionStatus.slice(1)}
</Badge>
);
};
return (
<PageLayout>
<PageHeader
title="NAO Robot Test Console"
description="Test and control your NAO6 robot through ROS bridge"
/>
<div className="space-y-6">
{/* Connection Status */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
ROS Bridge Connection
{getConnectionStatusBadge()}
</CardTitle>
<CardDescription>
Connect to ROS bridge at {ROS_BRIDGE_URL}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex gap-2">
{connectionStatus === "connected" ? (
<Button onClick={disconnectFromRos} variant="destructive">
<WifiOff className="mr-2 h-4 w-4" />
Disconnect
</Button>
) : (
<Button
onClick={connectToRos}
disabled={connectionStatus === "connecting"}
>
<Wifi className="mr-2 h-4 w-4" />
{connectionStatus === "connecting"
? "Connecting..."
: "Connect"}
</Button>
)}
</div>
</CardContent>
</Card>
{connectionStatus === "connected" && (
<Tabs defaultValue="control" className="space-y-4">
<TabsList>
<TabsTrigger value="control">Robot Control</TabsTrigger>
<TabsTrigger value="sensors">Sensor Data</TabsTrigger>
<TabsTrigger value="status">Robot Status</TabsTrigger>
<TabsTrigger value="logs">Logs</TabsTrigger>
</TabsList>
<TabsContent value="control" className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{/* Speech Control */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Volume2 className="h-4 w-4" />
Speech
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="speech">Text to Speech</Label>
<Textarea
id="speech"
placeholder="Enter text for NAO to say..."
value={speechText}
onChange={(e) => setSpeechText(e.target.value)}
onKeyDown={(e) =>
e.key === "Enter" &&
!e.shiftKey &&
(e.preventDefault(), sayText())
}
/>
</div>
<Button
onClick={sayText}
disabled={!speechText.trim()}
className="w-full"
>
<Play className="mr-2 h-4 w-4" />
Say Text
</Button>
</CardContent>
</Card>
{/* Movement Control */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Footprints className="h-4 w-4" />
Movement
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Walk Speed: {walkSpeed[0].toFixed(2)} m/s</Label>
<Slider
value={walkSpeed}
onValueChange={setWalkSpeed}
max={0.5}
min={0.05}
step={0.05}
/>
</div>
<div className="space-y-2">
<Label>Turn Speed: {turnSpeed[0].toFixed(2)} rad/s</Label>
<Slider
value={turnSpeed}
onValueChange={setTurnSpeed}
max={1.0}
min={0.1}
step={0.1}
/>
</div>
<div className="grid grid-cols-3 gap-2">
<Button variant="outline" onClick={walkForward}>
<ArrowUp className="h-4 w-4" />
</Button>
<Button variant="destructive" onClick={stopMovement}>
<Square className="h-4 w-4" />
</Button>
<Button variant="outline" onClick={walkBackward}>
<ArrowDown className="h-4 w-4" />
</Button>
<Button variant="outline" onClick={turnLeft}>
<RotateCcw className="h-4 w-4" />
</Button>
<div></div>
<Button variant="outline" onClick={turnRight}>
<RotateCw className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
{/* Head Control */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Eye className="h-4 w-4" />
Head Control
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Head Yaw: {headYaw[0].toFixed(2)} rad</Label>
<Slider
value={headYaw}
onValueChange={setHeadYaw}
max={2.09}
min={-2.09}
step={0.1}
/>
</div>
<div className="space-y-2">
<Label>Head Pitch: {headPitch[0].toFixed(2)} rad</Label>
<Slider
value={headPitch}
onValueChange={setHeadPitch}
max={0.51}
min={-0.67}
step={0.1}
/>
</div>
<Button onClick={moveHead} className="w-full">
Move Head
</Button>
</CardContent>
</Card>
{/* Emergency Stop */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-red-600">
<AlertTriangle className="h-4 w-4" />
Emergency
</CardTitle>
</CardHeader>
<CardContent>
<Button
onClick={stopMovement}
variant="destructive"
size="lg"
className="w-full"
>
<Square className="mr-2 h-4 w-4" />
EMERGENCY STOP
</Button>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="sensors" className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{Object.entries(sensorData).map(([topic, data]) => (
<Card key={topic}>
<CardHeader>
<CardTitle className="text-sm">
{topic
.split("/")
.pop()
?.replace(/_/g, " ")
.toUpperCase()}
</CardTitle>
</CardHeader>
<CardContent>
<pre className="max-h-32 overflow-auto rounded bg-gray-100 p-2 text-xs">
{JSON.stringify(data, null, 2)}
</pre>
</CardContent>
</Card>
))}
{Object.keys(sensorData).length === 0 && (
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
No sensor data received yet. Make sure the robot is
connected and publishing data.
</AlertDescription>
</Alert>
)}
</div>
</TabsContent>
<TabsContent value="status" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Activity className="h-4 w-4" />
Robot Status
</CardTitle>
</CardHeader>
<CardContent>
{robotStatus ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Robot Info</Label>
<pre className="mt-1 rounded bg-gray-100 p-2 text-xs">
{JSON.stringify(robotStatus, null, 2)}
</pre>
</div>
{jointStates && (
<div>
<Label>Joint States</Label>
<div className="mt-1 max-h-64 overflow-auto rounded bg-gray-100 p-2 text-xs">
<div>Joints: {jointStates.name?.length || 0}</div>
<div>
Last Update: {new Date().toLocaleTimeString()}
</div>
{jointStates.name
?.slice(0, 10)
.map((name: string, i: number) => (
<div
key={name}
className="flex justify-between"
>
<span>{name}:</span>
<span>
{jointStates.position?.[i]?.toFixed(3) ||
"N/A"}
</span>
</div>
))}
{(jointStates.name?.length || 0) > 10 && (
<div className="text-gray-500">
... and {(jointStates.name?.length || 0) - 10}{" "}
more
</div>
)}
</div>
</div>
)}
</div>
</div>
) : (
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
No robot status data received. Check that the NAO robot
is connected and the naoqi_driver is running.
</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="logs" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Communication Logs</CardTitle>
<CardDescription>
Real-time log of ROS bridge communication
</CardDescription>
</CardHeader>
<CardContent>
<div className="h-64 overflow-auto rounded bg-black p-4 font-mono text-xs text-green-400">
{logs.map((log, index) => (
<div key={index}>{log}</div>
))}
<div ref={logsEndRef} />
</div>
<Button
onClick={() => setLogs([])}
variant="outline"
className="mt-2"
>
Clear Logs
</Button>
</CardContent>
</Card>
</TabsContent>
</Tabs>
)}
{connectionStatus !== "connected" && (
<Alert>
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
Connect to ROS bridge to start controlling the robot. Make sure
the NAO integration is running:
<br />
<code className="mt-2 block rounded bg-gray-100 p-2">
ros2 launch nao6_hristudio.launch.py nao_ip:=nao.local
password:=robolab
</code>
</AlertDescription>
</Alert>
)}
</div>
</PageLayout>
);
}

0
src/app/(dashboard)/not-found.tsx Normal file → Executable file
View File

View File

@@ -1,65 +0,0 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Users, ArrowRight } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { useStudyContext } from "~/lib/study-context";
export default function ParticipantsRedirect() {
const router = useRouter();
const { selectedStudyId } = useStudyContext();
useEffect(() => {
// If user has a selected study, redirect to study participants
if (selectedStudyId) {
router.replace(`/studies/${selectedStudyId}/participants`);
}
}, [selectedStudyId, router]);
return (
<div className="flex min-h-[60vh] items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-50">
<Users className="h-8 w-8 text-green-500" />
</div>
<CardTitle className="text-2xl">Participants Moved</CardTitle>
<CardDescription>
Participant management is now organized by study for better
organization.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-muted-foreground space-y-2 text-center text-sm">
<p>To manage participants:</p>
<ul className="space-y-1 text-left">
<li> Select a study from your studies list</li>
<li> Navigate to that study&apos;s participants page</li>
<li> Add and manage participants for that specific study</li>
</ul>
</div>
<div className="flex flex-col gap-2 pt-4">
<Button asChild className="w-full">
<Link href="/studies">
<ArrowRight className="mr-2 h-4 w-4" />
Browse Studies
</Link>
</Button>
<Button asChild variant="outline" className="w-full">
<Link href="/dashboard">Go to Dashboard</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,67 +0,0 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { ArrowRight, Store } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { useStudyContext } from "~/lib/study-context";
export default function PluginBrowseRedirect() {
const router = useRouter();
const { selectedStudyId } = useStudyContext();
useEffect(() => {
// If user has a selected study, redirect to study plugin browse
if (selectedStudyId) {
router.replace(`/studies/${selectedStudyId}/plugins/browse`);
}
}, [selectedStudyId, router]);
return (
<div className="flex min-h-[60vh] items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-purple-50">
<Store className="h-8 w-8 text-purple-500" />
</div>
<CardTitle className="text-2xl">Plugin Store Moved</CardTitle>
<CardDescription>
Plugin browsing is now organized by study for better robot
capability management.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-muted-foreground space-y-2 text-center text-sm">
<p>To browse and install plugins:</p>
<ul className="space-y-1 text-left">
<li> Select a study from your studies list</li>
<li> Navigate to that study&apos;s plugin store</li>
<li>
Browse and install robot capabilities for that specific study
</li>
</ul>
</div>
<div className="flex flex-col gap-2 pt-4">
<Button asChild className="w-full">
<Link href="/studies">
<ArrowRight className="mr-2 h-4 w-4" />
Browse Studies
</Link>
</Button>
<Button asChild variant="outline" className="w-full">
<Link href="/dashboard">Go to Dashboard</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -1,68 +0,0 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Puzzle, ArrowRight } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { useStudyContext } from "~/lib/study-context";
export default function PluginsRedirect() {
const router = useRouter();
const { selectedStudyId } = useStudyContext();
useEffect(() => {
// If user has a selected study, redirect to study plugins
if (selectedStudyId) {
router.replace(`/studies/${selectedStudyId}/plugins`);
}
}, [selectedStudyId, router]);
return (
<div className="flex min-h-[60vh] items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-purple-50">
<Puzzle className="h-8 w-8 text-purple-500" />
</div>
<CardTitle className="text-2xl">Plugins Moved</CardTitle>
<CardDescription>
Plugin management is now organized by study for better robot
capability management.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-muted-foreground space-y-2 text-center text-sm">
<p>To manage plugins:</p>
<ul className="space-y-1 text-left">
<li> Select a study from your studies list</li>
<li> Navigate to that study&apos;s plugins page</li>
<li>
Install and configure robot capabilities for that specific
study
</li>
</ul>
</div>
<div className="flex flex-col gap-2 pt-4">
<Button asChild className="w-full">
<Link href="/studies">
<ArrowRight className="mr-2 h-4 w-4" />
Browse Studies
</Link>
</Button>
<Button asChild variant="outline" className="w-full">
<Link href="/dashboard">Go to Dashboard</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

0
src/app/(dashboard)/profile/page.tsx Normal file → Executable file
View File

478
src/app/(dashboard)/studies/[id]/analytics/page.tsx Normal file → Executable file
View File

@@ -1,25 +1,25 @@
"use client";
import { useParams } from "next/navigation";
import { Suspense, useEffect } from "react";
import { Suspense, useEffect, useState } from "react";
import {
Activity,
BarChart3,
Calendar,
Download,
Search,
Filter,
TrendingDown,
TrendingUp,
PlayCircle,
Calendar,
Clock,
ChevronRight,
User,
LayoutGrid
} from "lucide-react";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { PageHeader } from "~/components/ui/page-header";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { useStudyContext } from "~/lib/study-context";
import { useSelectedStudyDetails } from "~/hooks/useSelectedStudyDetails";
import { api } from "~/trpc/react";
import { TrialAnalysisView } from "~/components/trials/views/TrialAnalysisView";
import {
Select,
SelectContent,
@@ -27,283 +27,180 @@ import {
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { PageHeader } from "~/components/ui/page-header";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { useStudyContext } from "~/lib/study-context";
import { useSelectedStudyDetails } from "~/hooks/useSelectedStudyDetails";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
import { Button } from "~/components/ui/button";
import { ScrollArea } from "~/components/ui/scroll-area";
import { formatDistanceToNow } from "date-fns";
// Mock chart component - replace with actual charting library
function MockChart({ title, data }: { title: string; data: number[] }) {
const maxValue = Math.max(...data);
// -- Sub-Components --
function AnalyticsContent({
selectedTrialId,
setSelectedTrialId,
trialsList,
isLoadingList
}: {
selectedTrialId: string | null;
setSelectedTrialId: (id: string | null) => void;
trialsList: any[];
isLoadingList: boolean;
}) {
// Fetch full details of selected trial
const {
data: selectedTrial,
isLoading: isLoadingTrial,
error: trialError
} = api.trials.get.useQuery(
{ id: selectedTrialId! },
{ enabled: !!selectedTrialId }
);
// Transform trial data
const trialData = selectedTrial ? {
...selectedTrial,
startedAt: selectedTrial.startedAt ? new Date(selectedTrial.startedAt) : null,
completedAt: selectedTrial.completedAt ? new Date(selectedTrial.completedAt) : null,
eventCount: (selectedTrial as any).eventCount,
mediaCount: (selectedTrial as any).mediaCount,
} : null;
return (
<div className="space-y-2">
<h4 className="text-sm font-medium">{title}</h4>
<div className="flex h-32 items-end space-x-1">
{data.map((value, index) => (
<div
key={index}
className="bg-primary min-h-[4px] flex-1 rounded-t"
style={{ height: `${(value / maxValue) * 100}%` }}
<div className="h-[calc(100vh-140px)] flex flex-col">
{selectedTrialId ? (
isLoadingTrial ? (
<div className="flex-1 flex items-center justify-center bg-background/50 rounded-lg border border-dashed">
<div className="flex flex-col items-center gap-2 animate-pulse">
<div className="h-8 w-8 rounded-full border-2 border-primary border-t-transparent animate-spin" />
<span className="text-muted-foreground text-sm">Loading trial data...</span>
</div>
</div>
) : trialError ? (
<div className="flex-1 flex items-center justify-center p-8 bg-background/50 rounded-lg border border-dashed text-destructive">
<div className="max-w-md text-center">
<h3 className="font-semibold mb-2">Error Loading Trial</h3>
<p className="text-sm opacity-80">{trialError.message}</p>
<Button variant="outline" className="mt-4" onClick={() => setSelectedTrialId(null)}>
Return to Overview
</Button>
</div>
</div>
) : trialData ? (
<TrialAnalysisView trial={trialData} />
) : null
) : (
<div className="flex-1 bg-background/50 rounded-lg border shadow-sm overflow-hidden">
<StudyOverviewPlaceholder
trials={trialsList ?? []}
onSelect={(id) => setSelectedTrialId(id)}
/>
))}
</div>
</div>
)}
</div>
);
}
function AnalyticsOverview() {
const metrics = [
{
title: "Total Trials This Month",
value: "142",
change: "+12%",
trend: "up",
description: "vs last month",
icon: Activity,
},
{
title: "Avg Trial Duration",
value: "24.5m",
change: "-3%",
trend: "down",
description: "vs last month",
icon: Calendar,
},
{
title: "Completion Rate",
value: "94.2%",
change: "+2.1%",
trend: "up",
description: "vs last month",
icon: TrendingUp,
},
{
title: "Participant Retention",
value: "87.3%",
change: "+5.4%",
trend: "up",
description: "vs last month",
icon: BarChart3,
},
];
function StudyOverviewPlaceholder({ trials, onSelect }: { trials: any[], onSelect: (id: string) => void }) {
const recentTrials = [...trials].sort((a, b) =>
new Date(b.startedAt || b.createdAt).getTime() - new Date(a.startedAt || a.createdAt).getTime()
).slice(0, 5);
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{metrics.map((metric) => (
<Card key={metric.title}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
{metric.title}
</CardTitle>
<metric.icon className="text-muted-foreground h-4 w-4" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{metric.value}</div>
<div className="text-muted-foreground flex items-center space-x-2 text-xs">
<span
className={`flex items-center ${
metric.trend === "up" ? "text-green-600" : "text-red-600"
}`}
>
{metric.trend === "up" ? (
<TrendingUp className="mr-1 h-3 w-3" />
) : (
<TrendingDown className="mr-1 h-3 w-3" />
)}
{metric.change}
</span>
<span>{metric.description}</span>
<div className="h-full p-8 grid place-items-center bg-muted/5">
<div className="max-w-3xl w-full grid gap-8 md:grid-cols-2">
{/* Left: Illustration / Prompt */}
<div className="flex flex-col justify-center space-y-4">
<div className="bg-primary/10 w-16 h-16 rounded-2xl flex items-center justify-center mb-2">
<BarChart3 className="h-8 w-8 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold tracking-tight">Analytics & Playback</h2>
<CardDescription className="text-base mt-2">
Select a session from the top right to review video recordings, event logs, and metrics.
</CardDescription>
</div>
<div className="flex gap-4 pt-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<PlayCircle className="h-4 w-4" />
Feature-rich playback
</div>
</CardContent>
</Card>
))}
</div>
);
}
function ChartsSection() {
const trialData = [12, 19, 15, 27, 32, 28, 35, 42, 38, 41, 37, 44];
const participantData = [8, 12, 10, 15, 18, 16, 20, 24, 22, 26, 23, 28];
const completionData = [85, 88, 92, 89, 94, 91, 95, 92, 96, 94, 97, 94];
return (
<div className="grid gap-4 lg:grid-cols-3">
<Card>
<CardHeader>
<CardTitle>Trial Volume</CardTitle>
<CardDescription>Monthly trial execution trends</CardDescription>
</CardHeader>
<CardContent>
<MockChart title="Trials per Month" data={trialData} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Participant Enrollment</CardTitle>
<CardDescription>New participants over time</CardDescription>
</CardHeader>
<CardContent>
<MockChart title="New Participants" data={participantData} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Completion Rates</CardTitle>
<CardDescription>Trial completion percentage</CardDescription>
</CardHeader>
<CardContent>
<MockChart title="Completion %" data={completionData} />
</CardContent>
</Card>
</div>
);
}
function RecentInsights() {
const insights = [
{
title: "Peak Performance Hours",
description:
"Participants show 23% better performance during 10-11 AM trials",
type: "trend",
severity: "info",
},
{
title: "Attention Span Decline",
description:
"Average attention span has decreased by 8% over the last month",
type: "alert",
severity: "warning",
},
{
title: "High Completion Rate",
description: "Memory retention study achieved 98% completion rate",
type: "success",
severity: "success",
},
{
title: "Equipment Utilization",
description: "Robot interaction trials are at 85% capacity utilization",
type: "info",
severity: "info",
},
];
const getSeverityColor = (severity: string) => {
switch (severity) {
case "success":
return "bg-green-50 text-green-700 border-green-200";
case "warning":
return "bg-yellow-50 text-yellow-700 border-yellow-200";
case "info":
return "bg-blue-50 text-blue-700 border-blue-200";
default:
return "bg-gray-50 text-gray-700 border-gray-200";
}
};
return (
<Card>
<CardHeader>
<CardTitle>Recent Insights</CardTitle>
<CardDescription>
AI-generated insights from your research data
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{insights.map((insight, index) => (
<div
key={index}
className={`rounded-lg border p-4 ${getSeverityColor(insight.severity)}`}
>
<h4 className="mb-1 font-medium">{insight.title}</h4>
<p className="text-sm">{insight.description}</p>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="h-4 w-4" />
Synchronized timeline
</div>
))}
</div>
</div>
</CardContent>
</Card>
);
}
function AnalyticsContent({ studyId: _studyId }: { studyId: string }) {
return (
<div className="space-y-6">
{/* Header with time range controls */}
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Select defaultValue="30d">
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="Time range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="7d">Last 7 days</SelectItem>
<SelectItem value="30d">Last 30 days</SelectItem>
<SelectItem value="90d">Last 90 days</SelectItem>
<SelectItem value="1y">Last year</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" size="sm">
<Filter className="mr-2 h-4 w-4" />
Filter
</Button>
<Button variant="outline" size="sm">
<Download className="mr-2 h-4 w-4" />
Export
</Button>
</div>
</div>
{/* Overview Metrics */}
<AnalyticsOverview />
{/* Charts */}
<ChartsSection />
{/* Insights */}
<div className="grid gap-4 lg:grid-cols-3">
<div className="lg:col-span-2">
<RecentInsights />
</div>
{/* Right: Recent Sessions */}
<Card>
<CardHeader>
<CardTitle>Quick Actions</CardTitle>
<CardDescription>Generate custom reports</CardDescription>
<CardHeader className="pb-3">
<CardTitle className="text-base">Recent Sessions</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<Button variant="outline" className="w-full justify-start">
<BarChart3 className="mr-2 h-4 w-4" />
Trial Performance Report
</Button>
<Button variant="outline" className="w-full justify-start">
<Activity className="mr-2 h-4 w-4" />
Participant Engagement
</Button>
<Button variant="outline" className="w-full justify-start">
<TrendingUp className="mr-2 h-4 w-4" />
Trend Analysis
</Button>
<Button variant="outline" className="w-full justify-start">
<Download className="mr-2 h-4 w-4" />
Custom Export
</Button>
<CardContent className="p-0">
<ScrollArea className="h-[240px]">
<div className="px-4 pb-4 space-y-1">
{recentTrials.map(trial => (
<button
key={trial.id}
onClick={() => onSelect(trial.id)}
className="w-full flex items-center gap-3 p-3 rounded-md hover:bg-accent transition-colors text-left group"
>
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-xs font-mono font-medium text-primary">
{trial.sessionNumber}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">
{trial.participant?.participantCode ?? "Unknown"}
</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded-full border capitalize ${trial.status === 'completed' ? 'bg-green-500/10 text-green-500 border-green-500/20' :
trial.status === 'in_progress' ? 'bg-blue-500/10 text-blue-500 border-blue-500/20' :
'bg-slate-500/10 text-slate-500 border-slate-500/20'
}`}>
{trial.status.replace('_', ' ')}
</span>
</div>
<div className="text-xs text-muted-foreground flex items-center gap-2 mt-0.5">
<Calendar className="h-3 w-3" />
{new Date(trial.createdAt).toLocaleDateString()}
<span className="text-muted-foreground top-[1px] relative text-[10px]"></span>
{formatDistanceToNow(new Date(trial.createdAt), { addSuffix: true })}
</div>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground/30 group-hover:text-primary transition-colors" />
</button>
))}
{recentTrials.length === 0 && (
<div className="py-8 text-center text-sm text-muted-foreground">
No sessions found.
</div>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
</div>
);
)
}
// -- Main Page --
export default function StudyAnalyticsPage() {
const params = useParams();
const studyId: string = typeof params.id === "string" ? params.id : "";
const { setSelectedStudyId, selectedStudyId } = useStudyContext();
const { study } = useSelectedStudyDetails();
// State lifted up
const [selectedTrialId, setSelectedTrialId] = useState<string | null>(null);
// Fetch list of trials for the selector
const { data: trialsList, isLoading: isLoadingList } = api.trials.list.useQuery(
{ studyId, limit: 100 },
{ enabled: !!studyId }
);
// Set breadcrumbs
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
@@ -320,16 +217,53 @@ export default function StudyAnalyticsPage() {
}, [studyId, selectedStudyId, setSelectedStudyId]);
return (
<div className="space-y-6">
<PageHeader
title="Analytics"
description="Insights and data analysis for this study"
icon={BarChart3}
/>
<div className="h-[calc(100vh-64px)] flex flex-col p-6 gap-6">
<div className="flex-none">
<PageHeader
title="Analytics"
description="Analyze trial data and replay sessions"
icon={BarChart3}
actions={
<div className="flex items-center gap-2">
{/* Session Selector in Header */}
<div className="w-[300px]">
<Select
value={selectedTrialId ?? "overview"}
onValueChange={(val) => setSelectedTrialId(val === "overview" ? null : val)}
>
<SelectTrigger className="w-full h-9 text-xs">
<LayoutGrid className="h-3.5 w-3.5 mr-2 text-muted-foreground" />
<SelectValue placeholder="Select Session" />
</SelectTrigger>
<SelectContent className="max-h-[400px]" align="end">
<SelectItem value="overview" className="border-b mb-1 pb-1 font-medium text-xs">
Show Study Overview
</SelectItem>
{trialsList?.map((trial) => (
<SelectItem key={trial.id} value={trial.id} className="text-xs">
<span className="font-mono mr-2 text-muted-foreground">#{trial.sessionNumber}</span>
{trial.participant?.participantCode ?? "Unknown"} <span className="text-muted-foreground ml-1">({new Date(trial.createdAt).toLocaleDateString()})</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
}
/>
</div>
<Suspense fallback={<div>Loading analytics...</div>}>
<AnalyticsContent studyId={studyId} />
</Suspense>
<div className="flex-1 min-h-0 bg-transparent">
<Suspense fallback={<div>Loading analytics...</div>}>
<AnalyticsContent
selectedTrialId={selectedTrialId}
setSelectedTrialId={setSelectedTrialId}
trialsList={trialsList ?? []}
isLoadingList={isLoadingList}
studyId={studyId}
/>
</Suspense>
</div>
</div>
);
}

0
src/app/(dashboard)/studies/[id]/edit/page.tsx Normal file → Executable file
View File

View File

@@ -48,7 +48,7 @@ export function DesignerPageClient({
},
{
label: experiment.name,
href: `/experiments/${experiment.id}`,
href: `/studies/${experiment.study.id}/experiments/${experiment.id}`,
},
{
label: "Designer",

View File

@@ -11,7 +11,7 @@ import { DesignerPageClient } from "./DesignerPageClient";
interface ExperimentDesignerPageProps {
params: Promise<{
id: string;
experimentId: string;
}>;
}
@@ -20,7 +20,7 @@ export default async function ExperimentDesignerPage({
}: ExperimentDesignerPageProps) {
try {
const resolvedParams = await params;
const experiment = await api.experiments.get({ id: resolvedParams.id });
const experiment = await api.experiments.get({ id: resolvedParams.experimentId });
if (!experiment) {
notFound();
@@ -36,13 +36,13 @@ export default async function ExperimentDesignerPage({
// Only pass initialDesign if there's existing visual design data
let initialDesign:
| {
id: string;
name: string;
description: string;
steps: ExperimentStep[];
version: number;
lastSaved: Date;
}
id: string;
name: string;
description: string;
steps: ExperimentStep[];
version: number;
lastSaved: Date;
}
| undefined;
if (existingDesign?.steps && existingDesign.steps.length > 0) {
@@ -258,7 +258,7 @@ export async function generateMetadata({
}> {
try {
const resolvedParams = await params;
const experiment = await api.experiments.get({ id: resolvedParams.id });
const experiment = await api.experiments.get({ id: resolvedParams.experimentId });
return {
title: `${experiment?.name} - Designer | HRIStudio`,

View File

@@ -0,0 +1,170 @@
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { Button } from "~/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import { Textarea } from "~/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { toast } from "sonner";
import { api } from "~/trpc/react";
import { useRouter } from "next/navigation";
import { type Experiment } from "~/lib/experiments/types";
const formSchema = z.object({
name: z.string().min(2, {
message: "Name must be at least 2 characters.",
}),
description: z.string().optional(),
status: z.enum([
"draft",
"ready",
"data_collection",
"analysis",
"completed",
"archived",
]),
});
interface ExperimentFormProps {
experiment: Experiment;
}
export function ExperimentForm({ experiment }: ExperimentFormProps) {
const router = useRouter();
const updateExperiment = api.experiments.update.useMutation({
onSuccess: () => {
toast.success("Experiment updated successfully");
router.refresh();
router.push(`/studies/${experiment.studyId}/experiments/${experiment.id}`);
},
onError: (error) => {
toast.error(`Error updating experiment: ${error.message}`);
},
});
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
name: experiment.name,
description: experiment.description ?? "",
status: experiment.status,
},
});
function onSubmit(values: z.infer<typeof formSchema>) {
updateExperiment.mutate({
id: experiment.id,
name: values.name,
description: values.description,
status: values.status,
});
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name</FormLabel>
<FormControl>
<Input placeholder="Experiment name" {...field} />
</FormControl>
<FormDescription>
The name of your experiment.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="Describe your experiment..."
className="resize-none"
{...field}
/>
</FormControl>
<FormDescription>
A short description of the experiment goals.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<Select onValueChange={field.onChange} defaultValue={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select a status" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="ready">Ready</SelectItem>
<SelectItem value="data_collection">Data Collection</SelectItem>
<SelectItem value="analysis">Analysis</SelectItem>
<SelectItem value="completed">Completed</SelectItem>
<SelectItem value="archived">Archived</SelectItem>
</SelectContent>
</Select>
<FormDescription>
The current status of the experiment.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-4">
<Button type="submit" disabled={updateExperiment.isPending}>
{updateExperiment.isPending ? "Saving..." : "Save Changes"}
</Button>
<Button
type="button"
variant="outline"
onClick={() =>
router.push(
`/studies/${experiment.studyId}/experiments/${experiment.id}`,
)
}
>
Cancel
</Button>
</div>
</form>
</Form>
);
}

View File

@@ -0,0 +1,63 @@
import { notFound } from "next/navigation";
import { type Experiment } from "~/lib/experiments/types";
import { api } from "~/trpc/server";
import { ExperimentForm } from "./experiment-form";
import {
EntityView,
EntityViewHeader,
EntityViewSection,
} from "~/components/ui/entity-view";
import { Button } from "~/components/ui/button";
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
interface ExperimentEditPageProps {
params: Promise<{ id: string; experimentId: string }>;
}
export default async function ExperimentEditPage({
params,
}: ExperimentEditPageProps) {
const { id: studyId, experimentId } = await params;
const experiment = await api.experiments.get({ id: experimentId });
if (!experiment) {
notFound();
}
// Ensure experiment belongs to study
if (experiment.studyId !== studyId) {
notFound();
}
// Convert to type expected by form
const experimentData: Experiment = {
...experiment,
status: experiment.status as Experiment["status"],
};
return (
<EntityView>
<EntityViewHeader
title="Edit Experiment"
subtitle={`Update settings for ${experiment.name}`}
icon="Edit"
backButton={
<Button variant="ghost" size="sm" asChild className="-ml-2 mb-2">
<Link href={`/studies/${studyId}/experiments/${experimentId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Experiment
</Link>
</Button>
}
/>
<div className="max-w-2xl">
<EntityViewSection title="Experiment Details" icon="Settings">
<ExperimentForm experiment={experimentData} />
</EntityViewSection>
</div>
</EntityView>
);
}

View File

@@ -0,0 +1,468 @@
"use client";
import { formatDistanceToNow } from "date-fns";
import { Calendar, Clock, Edit, Play, Settings, Users } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { useEffect, useState } from "react";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import {
EntityView,
EntityViewHeader,
EntityViewSection,
EmptyState,
InfoGrid,
QuickActions,
StatsGrid,
} from "~/components/ui/entity-view";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { api } from "~/trpc/react";
import { useSession } from "next-auth/react";
import { useStudyManagement } from "~/hooks/useStudyManagement";
interface ExperimentDetailPageProps {
params: Promise<{ id: string; experimentId: string }>;
}
const statusConfig = {
draft: {
label: "Draft",
variant: "secondary" as const,
icon: "FileText" as const,
},
testing: {
label: "Testing",
variant: "outline" as const,
icon: "TestTube" as const,
},
ready: {
label: "Ready",
variant: "default" as const,
icon: "CheckCircle" as const,
},
deprecated: {
label: "Deprecated",
variant: "destructive" as const,
icon: "AlertTriangle" as const,
},
};
type Experiment = {
id: string;
name: string;
description: string | null;
status: string;
createdAt: Date;
updatedAt: Date;
study: { id: string; name: string };
robot: { id: string; name: string; description: string | null } | null;
protocol?: { blocks: unknown[] } | null;
visualDesign?: unknown;
studyId: string;
createdBy: string;
robotId: string | null;
version: number;
};
type Trial = {
id: string;
status: string;
createdAt: Date;
duration: number | null;
participant: {
id: string;
participantCode: string;
name?: string | null;
} | null;
experiment: { name: string } | null;
participantId: string | null;
experimentId: string;
startedAt: Date | null;
completedAt: Date | null;
notes: string | null;
updatedAt: Date;
canAccess: boolean;
userRole: string;
};
export default function ExperimentDetailPage({
params,
}: ExperimentDetailPageProps) {
const { data: session } = useSession();
const [experiment, setExperiment] = useState<Experiment | null>(null);
const [trials, setTrials] = useState<Trial[]>([]);
const [loading, setLoading] = useState(true);
const [resolvedParams, setResolvedParams] = useState<{ id: string; experimentId: string } | null>(
null,
);
const { selectStudy } = useStudyManagement();
useEffect(() => {
const resolveParams = async () => {
const resolved = await params;
setResolvedParams(resolved);
// Ensure study context is synced
if (resolved.id) {
void selectStudy(resolved.id);
}
};
void resolveParams();
}, [params, selectStudy]);
const experimentQuery = api.experiments.get.useQuery(
{ id: resolvedParams?.experimentId ?? "" },
{ enabled: !!resolvedParams?.experimentId },
);
const trialsQuery = api.trials.list.useQuery(
{ experimentId: resolvedParams?.experimentId ?? "" },
{ enabled: !!resolvedParams?.experimentId },
);
useEffect(() => {
if (experimentQuery.data) {
setExperiment(experimentQuery.data);
}
}, [experimentQuery.data]);
useEffect(() => {
if (trialsQuery.data) {
setTrials(trialsQuery.data);
}
}, [trialsQuery.data]);
useEffect(() => {
if (experimentQuery.isLoading || trialsQuery.isLoading) {
setLoading(true);
} else {
setLoading(false);
}
}, [experimentQuery.isLoading, trialsQuery.isLoading]);
// Set breadcrumbs
useBreadcrumbsEffect([
{
label: "Dashboard",
href: "/",
},
{
label: "Studies",
href: "/studies",
},
{
label: experiment?.study?.name ?? "Study",
href: `/studies/${experiment?.study?.id}`,
},
{
label: "Experiments",
href: `/studies/${experiment?.study?.id}/experiments`,
},
{
label: experiment?.name ?? "Experiment",
},
]);
if (loading) return <div>Loading...</div>;
if (experimentQuery.error) return notFound();
if (!experiment) return notFound();
const displayName = experiment.name ?? "Untitled Experiment";
const description = experiment.description;
// Check if user can edit this experiment
const userRoles = session?.user?.roles?.map((r) => r.role) ?? [];
const canEdit =
userRoles.includes("administrator") || userRoles.includes("researcher");
const statusInfo =
statusConfig[experiment.status as keyof typeof statusConfig];
const studyId = experiment.study.id;
const experimentId = experiment.id;
return (
<EntityView>
<EntityViewHeader
title={displayName}
subtitle={description ?? undefined}
icon="TestTube"
status={{
label: statusInfo?.label ?? "Unknown",
variant: statusInfo?.variant ?? "secondary",
icon: statusInfo?.icon ?? "TestTube",
}}
actions={
canEdit ? (
<>
<Button asChild variant="outline">
<Link href={`/studies/${studyId}/experiments/${experimentId}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
<Button asChild variant="outline">
<Link href={`/studies/${studyId}/experiments/${experimentId}/designer`}>
<Settings className="mr-2 h-4 w-4" />
Designer
</Link>
</Button>
<Button asChild>
<Link
href={`/studies/${studyId}/trials/new?experimentId=${experimentId}`}
>
<Play className="mr-2 h-4 w-4" />
Start Trial
</Link>
</Button>
</>
) : undefined
}
/>
<div className="grid gap-6 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-2">
{/* Basic Information */}
<EntityViewSection title="Information" icon="Info">
<InfoGrid
columns={2}
items={[
{
label: "Study",
value: experiment.study ? (
<Link
href={`/studies/${experiment.study.id}`}
className="text-primary hover:underline"
>
{experiment.study.name}
</Link>
) : (
"No study assigned"
),
},
{
label: "Status",
value: statusInfo?.label ?? "Unknown",
},
{
label: "Created",
value: formatDistanceToNow(experiment.createdAt, {
addSuffix: true,
}),
},
{
label: "Last Updated",
value: formatDistanceToNow(experiment.updatedAt, {
addSuffix: true,
}),
},
]}
/>
</EntityViewSection>
{/* Protocol Section */}
<EntityViewSection
title="Experiment Protocol"
icon="FileText"
actions={
canEdit && (
<Button asChild variant="outline" size="sm">
<Link href={`/studies/${studyId}/experiments/${experimentId}/designer`}>
<Edit className="mr-2 h-4 w-4" />
Edit Protocol
</Link>
</Button>
)
}
>
{experiment.protocol &&
typeof experiment.protocol === "object" &&
experiment.protocol !== null ? (
<div className="space-y-3">
<div className="text-muted-foreground text-sm">
Protocol contains{" "}
{Array.isArray(
(experiment.protocol as { blocks: unknown[] }).blocks,
)
? (experiment.protocol as { blocks: unknown[] }).blocks
.length
: 0}{" "}
blocks
</div>
</div>
) : (
<EmptyState
icon="FileText"
title="No protocol defined"
description="Create an experiment protocol using the visual designer"
action={
canEdit && (
<Button asChild>
<Link href={`/studies/${studyId}/experiments/${experimentId}/designer`}>
Open Designer
</Link>
</Button>
)
}
/>
)}
</EntityViewSection>
{/* Recent Trials */}
<EntityViewSection
title="Recent Trials"
icon="Play"
actions={
<Button asChild variant="outline" size="sm">
<Link href={`/studies/${experiment.study?.id}/trials`}>
View All
</Link>
</Button>
}
>
{trials.length > 0 ? (
<div className="space-y-3">
{trials.slice(0, 5).map((trial) => (
<div
key={trial.id}
className="hover:bg-muted/50 rounded-lg border p-4 transition-colors"
>
<div className="mb-2 flex items-center justify-between">
<Link
href={`/studies/${experiment.study.id}/trials/${trial.id}`}
className="font-medium hover:underline"
>
Trial #{trial.id.slice(-6)}
</Link>
<Badge
variant={
trial.status === "completed"
? "default"
: trial.status === "in_progress"
? "secondary"
: trial.status === "failed"
? "destructive"
: "outline"
}
>
{trial.status.charAt(0).toUpperCase() +
trial.status.slice(1).replace("_", " ")}
</Badge>
</div>
<div className="text-muted-foreground flex items-center gap-4 text-sm">
<span className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
{formatDistanceToNow(trial.createdAt, {
addSuffix: true,
})}
</span>
{trial.duration && (
<span className="flex items-center gap-1">
<Clock className="h-4 w-4" />
{Math.round(trial.duration / 60)} min
</span>
)}
{trial.participant && (
<span className="flex items-center gap-1">
<Users className="h-4 w-4" />
{trial.participant.name ??
trial.participant.participantCode}
</span>
)}
</div>
</div>
))}
</div>
) : (
<EmptyState
icon="Play"
title="No trials yet"
description="Start your first trial to collect data"
action={
canEdit && (
<Button asChild>
<Link
href={`/studies/${studyId}/trials/new?experimentId=${experimentId}`}
>
Start Trial
</Link>
</Button>
)
}
/>
)}
</EntityViewSection>
</div>
<div className="space-y-6">
{/* Statistics */}
<EntityViewSection title="Statistics" icon="BarChart">
<StatsGrid
stats={[
{
label: "Total Trials",
value: trials.length,
},
{
label: "Completed",
value: trials.filter((t) => t.status === "completed").length,
},
{
label: "In Progress",
value: trials.filter((t) => t.status === "in_progress")
.length,
},
]}
/>
</EntityViewSection>
{/* Robot Information */}
{experiment.robot && (
<EntityViewSection title="Robot Platform" icon="Bot">
<InfoGrid
columns={1}
items={[
{
label: "Platform",
value: experiment.robot.name,
},
{
label: "Type",
value: experiment.robot.description ?? "Not specified",
},
]}
/>
</EntityViewSection>
)}
{/* Quick Actions */}
<EntityViewSection title="Quick Actions" icon="Zap">
<QuickActions
actions={[
{
label: "Export Data",
icon: "Download" as const,
href: `/studies/${studyId}/experiments/${experimentId}/export`,
},
...(canEdit
? [
{
label: "Edit Experiment",
icon: "Edit" as const,
href: `/studies/${studyId}/experiments/${experimentId}/edit`,
},
{
label: "Open Designer",
icon: "Palette" as const,
href: `/studies/${studyId}/experiments/${experimentId}/designer`,
},
]
: []),
]}
/>
</EntityViewSection>
</div>
</div>
</EntityView>
);
}

View File

0
src/app/(dashboard)/studies/[id]/experiments/page.tsx Normal file → Executable file
View File

17
src/app/(dashboard)/studies/[id]/page.tsx Normal file → Executable file
View File

@@ -185,7 +185,7 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
</Link>
</Button>
<Button asChild>
<Link href={`/experiments/new?studyId=${study.id}`}>
<Link href={`/studies/${study.id}/experiments/new`}>
<Plus className="mr-2 h-4 w-4" />
New Experiment
</Link>
@@ -232,7 +232,7 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
description="Design and manage experimental protocols for this study"
actions={
<Button asChild variant="outline" size="sm">
<Link href={`/experiments/new?studyId=${study.id}`}>
<Link href={`/studies/${study.id}/experiments/new`}>
<Plus className="mr-2 h-4 w-4" />
Add Experiment
</Link>
@@ -246,7 +246,7 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
description="Create your first experiment to start designing research protocols"
action={
<Button asChild>
<Link href={`/experiments/new?studyId=${study.id}`}>
<Link href={`/studies/${study.id}/experiments/new`}>
Create First Experiment
</Link>
</Button>
@@ -263,20 +263,19 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
<div className="flex items-center space-x-3">
<h4 className="font-medium">
<Link
href={`/experiments/${experiment.id}`}
href={`/studies/${study.id}/experiments/${experiment.id}`}
className="hover:underline"
>
{experiment.name}
</Link>
</h4>
<span
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
experiment.status === "draft"
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${experiment.status === "draft"
? "bg-gray-100 text-gray-800"
: experiment.status === "ready"
? "bg-green-100 text-green-800"
: "bg-blue-100 text-blue-800"
}`}
}`}
>
{experiment.status}
</span>
@@ -300,12 +299,12 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
</div>
<div className="flex items-center space-x-2">
<Button asChild variant="outline" size="sm">
<Link href={`/experiments/${experiment.id}/designer`}>
<Link href={`/studies/${study.id}/experiments/${experiment.id}/designer`}>
Design
</Link>
</Button>
<Button asChild variant="outline" size="sm">
<Link href={`/experiments/${experiment.id}`}>View</Link>
<Link href={`/studies/${study.id}/experiments/${experiment.id}`}>View</Link>
</Button>
</div>
</div>

View File

@@ -0,0 +1,31 @@
import { ParticipantForm } from "~/components/participants/ParticipantForm";
import { api } from "~/trpc/server";
import { notFound } from "next/navigation";
interface EditParticipantPageProps {
params: Promise<{
id: string;
participantId: string;
}>;
}
export default async function EditParticipantPage({
params,
}: EditParticipantPageProps) {
const { id: studyId, participantId } = await params;
const participant = await api.participants.get({ id: participantId });
if (!participant || participant.studyId !== studyId) {
notFound();
}
// Transform data to match form expectations if needed, or pass directly
return (
<ParticipantForm
mode="edit"
studyId={studyId}
participantId={participantId}
/>
);
}

View File

@@ -0,0 +1,109 @@
import { notFound } from "next/navigation";
import { api } from "~/trpc/server";
import {
EntityView,
EntityViewHeader,
EntityViewSection,
} from "~/components/ui/entity-view";
import { ParticipantDocuments } from "./participant-documents";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { Button } from "~/components/ui/button";
import { Edit } from "lucide-react";
import Link from "next/link";
interface ParticipantDetailPageProps {
params: Promise<{ id: string; participantId: string }>;
}
export default async function ParticipantDetailPage({
params,
}: ParticipantDetailPageProps) {
const { id: studyId, participantId } = await params;
const participant = await api.participants.get({ id: participantId });
if (!participant) {
notFound();
}
// Ensure participant belongs to study
if (participant.studyId !== studyId) {
notFound();
}
return (
<EntityView>
<EntityViewHeader
title={participant.participantCode}
subtitle={participant.name ?? "Unnamed Participant"}
icon="Users"
badge={
<Badge variant={participant.consentGiven ? "default" : "secondary"}>
{participant.consentGiven ? "Consent Given" : "No Consent"}
</Badge>
}
actions={
<Button asChild variant="outline" size="sm">
<Link href={`/studies/${studyId}/participants/${participantId}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit Participant
</Link>
</Button>
}
/>
<Tabs defaultValue="overview" className="w-full">
<TabsList className="mb-4">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="files">Files & Documents</TabsTrigger>
</TabsList>
<TabsContent value="overview">
<div className="grid gap-6 grid-cols-1">
<EntityViewSection title="Participant Information" icon="Info">
<div className="grid grid-cols-2 gap-4 text-sm md:grid-cols-4">
<div>
<span className="text-muted-foreground block mb-1">Code</span>
<span className="font-medium text-base">{participant.participantCode}</span>
</div>
<div>
<span className="text-muted-foreground block mb-1">Name</span>
<span className="font-medium text-base">{participant.name || "-"}</span>
</div>
<div>
<span className="text-muted-foreground block mb-1">Email</span>
<span className="font-medium text-base">{participant.email || "-"}</span>
</div>
<div>
<span className="text-muted-foreground block mb-1">Added</span>
<span className="font-medium text-base">{new Date(participant.createdAt).toLocaleDateString()}</span>
</div>
<div>
<span className="text-muted-foreground block mb-1">Age</span>
<span className="font-medium text-base">{(participant.demographics as any)?.age || "-"}</span>
</div>
<div>
<span className="text-muted-foreground block mb-1">Gender</span>
<span className="font-medium capitalize text-base">{(participant.demographics as any)?.gender?.replace("_", " ") || "-"}</span>
</div>
</div>
</EntityViewSection>
</div>
</TabsContent>
<TabsContent value="files">
<EntityViewSection title="Documents" icon="FileText">
<ParticipantDocuments participantId={participantId} />
</EntityViewSection>
</TabsContent>
</Tabs>
</EntityView>
);
}

View File

@@ -0,0 +1,187 @@
"use client";
import { useState } from "react";
import { Upload, FileText, Trash2, Download, Loader2 } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { api } from "~/trpc/react";
import { formatBytes } from "~/lib/utils";
import { toast } from "sonner";
interface ParticipantDocumentsProps {
participantId: string;
}
export function ParticipantDocuments({ participantId }: ParticipantDocumentsProps) {
const [isUploading, setIsUploading] = useState(false);
const utils = api.useUtils();
const { data: documents, isLoading } = api.files.listParticipantDocuments.useQuery({
participantId,
});
const getPresignedUrl = api.files.getPresignedUrl.useMutation();
const registerUpload = api.files.registerUpload.useMutation();
const deleteDocument = api.files.deleteDocument.useMutation({
onSuccess: () => {
toast.success("Document deleted");
utils.files.listParticipantDocuments.invalidate({ participantId });
},
onError: (err) => toast.error(`Failed to delete: ${err.message}`),
});
// Since presigned URLs are for PUT, we can use a direct fetch
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setIsUploading(true);
try {
// 1. Get presigned URL
const { url, storagePath } = await getPresignedUrl.mutateAsync({
filename: file.name,
contentType: file.type || "application/octet-stream",
participantId,
});
// 2. Upload to MinIO/S3
const uploadRes = await fetch(url, {
method: "PUT",
body: file,
headers: {
"Content-Type": file.type || "application/octet-stream",
},
});
if (!uploadRes.ok) {
throw new Error("Upload to storage failed");
}
// 3. Register in DB
await registerUpload.mutateAsync({
participantId,
name: file.name,
type: file.type,
storagePath,
fileSize: file.size,
});
toast.success("File uploaded successfully");
utils.files.listParticipantDocuments.invalidate({ participantId });
} catch (error) {
console.error(error);
toast.error("Failed to upload file");
} finally {
setIsUploading(false);
// Reset input
e.target.value = "";
}
};
const handleDownload = async (storagePath: string, filename: string) => {
// We would typically get a temporary download URL here
// For now assuming public bucket or implementing a separate download procedure
// Let's implement a quick procedure call right here via client or assume the server router has it.
// I added getDownloadUrl to the router in previous steps.
try {
const { url } = await utils.client.files.getDownloadUrl.query({ storagePath });
window.open(url, "_blank");
} catch (e) {
toast.error("Could not get download URL");
}
};
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="space-y-1">
<CardTitle>Documents</CardTitle>
<CardDescription>
Manage consent forms and other files for this participant.
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Button disabled={isUploading} asChild>
<label className="cursor-pointer">
{isUploading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
Upload PDF
<input
type="file"
className="hidden"
accept=".pdf,.doc,.docx,.txt" // User asked for PDF, but generic is fine
onChange={handleFileUpload}
disabled={isUploading}
/>
</label>
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="flex justify-center p-4">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : documents?.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
<FileText className="mb-2 h-8 w-8 opacity-50" />
<p>No documents uploaded yet.</p>
</div>
) : (
<div className="space-y-2">
{documents?.map((doc) => (
<div
key={doc.id}
className="flex items-center justify-between rounded-lg border p-3 hover:bg-muted/50"
>
<div className="flex items-center gap-3">
<div className="rounded-md bg-blue-50 p-2">
<FileText className="h-4 w-4 text-blue-600" />
</div>
<div>
<p className="font-medium">{doc.name}</p>
<p className="text-xs text-muted-foreground">
{formatBytes(doc.fileSize ?? 0)} {new Date(doc.createdAt).toLocaleDateString()}
</p>
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => handleDownload(doc.storagePath, doc.name)}
>
<Download className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => {
if (confirm("Are you sure you want to delete this file?")) {
deleteDocument.mutate({ id: doc.id });
}
}}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}

View File

0
src/app/(dashboard)/studies/[id]/participants/page.tsx Normal file → Executable file
View File

View File

0
src/app/(dashboard)/studies/[id]/plugins/page.tsx Normal file → Executable file
View File

View File

@@ -0,0 +1,132 @@
"use client";
import { useParams } from "next/navigation";
import { Suspense, useEffect } from "react";
import Link from "next/link";
import { LineChart, ArrowLeft } from "lucide-react";
import { PageHeader } from "~/components/ui/page-header";
import { Button } from "~/components/ui/button";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { useStudyContext } from "~/lib/study-context";
import { useSelectedStudyDetails } from "~/hooks/useSelectedStudyDetails";
import { TrialAnalysisView } from "~/components/trials/views/TrialAnalysisView";
import { api } from "~/trpc/react";
function AnalysisPageContent() {
const params = useParams();
const studyId: string = typeof params.id === "string" ? params.id : "";
const trialId: string =
typeof params.trialId === "string" ? params.trialId : "";
const { setSelectedStudyId, selectedStudyId } = useStudyContext();
const { study } = useSelectedStudyDetails();
// Get trial data
const {
data: trial,
isLoading,
error,
} = api.trials.get.useQuery({ id: trialId }, { enabled: !!trialId });
// Set breadcrumbs
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Studies", href: "/studies" },
{ label: study?.name ?? "Study", href: `/studies/${studyId}` },
{ label: "Trials", href: `/studies/${studyId}/trials` },
{
label: trial?.experiment.name ?? "Trial",
href: `/studies/${studyId}/trials`,
},
{ label: "Analysis" },
]);
// Sync selected study (unified study-context)
useEffect(() => {
if (studyId && selectedStudyId !== studyId) {
setSelectedStudyId(studyId);
}
}, [studyId, selectedStudyId, setSelectedStudyId]);
if (isLoading) {
return (
<div className="flex h-96 items-center justify-center">
<div className="text-muted-foreground">Loading analysis...</div>
</div>
);
}
if (error || !trial) {
return (
<div className="space-y-6">
<PageHeader
title="Trial Analysis"
description="Analyze trial results"
icon={LineChart}
actions={
<Button asChild variant="outline">
<Link href={`/studies/${studyId}/trials`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Trials
</Link>
</Button>
}
/>
<div className="flex h-96 items-center justify-center">
<div className="text-center">
<h3 className="text-destructive mb-2 text-lg font-semibold">
{error ? "Error Loading Trial" : "Trial Not Found"}
</h3>
<p className="text-muted-foreground">
{error?.message || "The requested trial could not be found."}
</p>
</div>
</div>
</div>
);
}
const trialData = {
...trial,
startedAt: trial.startedAt ? new Date(trial.startedAt) : null,
completedAt: trial.completedAt ? new Date(trial.completedAt) : null,
eventCount: (trial as any).eventCount,
mediaCount: (trial as any).mediaCount,
};
return (
<div className="flex h-full flex-col">
<PageHeader
title="Trial Analysis"
description={`Analysis for Session ${trial.sessionNumber} • Participant ${trial.participant.participantCode}`}
icon={LineChart}
actions={
<Button asChild variant="outline">
<Link href={`/studies/${studyId}/trials/${trialId}`}>
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Trial Details
</Link>
</Button>
}
/>
<div className="min-h-0 flex-1">
<TrialAnalysisView trial={trialData} />
</div>
</div>
);
}
export default function TrialAnalysisPage() {
return (
<Suspense
fallback={
<div className="flex h-96 items-center justify-center">
<div className="text-muted-foreground">Loading...</div>
</div>
}
>
<AnalysisPageContent />
</Suspense>
);
}

View File

@@ -3,7 +3,7 @@
import { useParams } from "next/navigation";
import { Suspense, useEffect } from "react";
import Link from "next/link";
import { Play, Zap, ArrowLeft, User, FlaskConical } from "lucide-react";
import { Play, Zap, ArrowLeft, User, FlaskConical, LineChart } from "lucide-react";
import { PageHeader } from "~/components/ui/page-header";
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
@@ -150,10 +150,18 @@ function TrialDetailContent() {
)}
{(trial.status === "in_progress" ||
trial.status === "scheduled") && (
<Button asChild>
<Link href={`/studies/${studyId}/trials/${trialId}/wizard`}>
<Zap className="mr-2 h-4 w-4" />
Wizard Interface
</Link>
</Button>
)}
{trial.status === "completed" && (
<Button asChild>
<Link href={`/studies/${studyId}/trials/${trialId}/wizard`}>
<Zap className="mr-2 h-4 w-4" />
Wizard Interface
<Link href={`/studies/${studyId}/trials/${trialId}/analysis`}>
<LineChart className="mr-2 h-4 w-4" />
View Analysis
</Link>
</Button>
)}

View File

0
src/app/(dashboard)/studies/[id]/trials/new/page.tsx Normal file → Executable file
View File

0
src/app/(dashboard)/studies/[id]/trials/page.tsx Normal file → Executable file
View File

0
src/app/(dashboard)/studies/new/page.tsx Normal file → Executable file
View File

0
src/app/(dashboard)/studies/page.tsx Normal file → Executable file
View File

0
src/app/api/auth/[...nextauth]/route.ts Normal file → Executable file
View File

0
src/app/api/test-trial/route.ts Normal file → Executable file
View File

0
src/app/api/trpc/[trpc]/route.ts Normal file → Executable file
View File

0
src/app/api/upload/route.ts Normal file → Executable file
View File

56
src/app/auth/signin/page.tsx Normal file → Executable file
View File

@@ -6,14 +6,15 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Logo } from "~/components/ui/logo";
export default function SignInPage() {
const [email, setEmail] = useState("");
@@ -52,30 +53,35 @@ export default function SignInPage() {
};
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 px-4">
<div className="w-full max-w-md">
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background px-4">
{/* Background Gradients */}
<div className="absolute top-0 left-1/2 -z-10 h-[500px] w-[1000px] -translate-x-1/2 rounded-full bg-primary/20 blur-3xl opacity-30 dark:opacity-20" />
<div className="absolute bottom-0 right-0 -z-10 h-[300px] w-[300px] rounded-full bg-violet-500/10 blur-3xl" />
<div className="w-full max-w-md animate-in fade-in zoom-in-95 duration-500">
{/* Header */}
<div className="mb-8 text-center">
<Link href="/" className="inline-block">
<h1 className="text-3xl font-bold text-slate-900">HRIStudio</h1>
<Link href="/" className="inline-flex items-center justify-center transition-opacity hover:opacity-80">
<Logo iconSize="lg" showText={false} />
</Link>
<p className="mt-2 text-slate-600">
<h1 className="mt-6 text-2xl font-bold tracking-tight text-foreground">Welcome back</h1>
<p className="mt-2 text-sm text-muted-foreground">
Sign in to your research account
</p>
</div>
{/* Sign In Card */}
<Card>
<Card className="border-muted/40 bg-card/50 backdrop-blur-sm shadow-xl">
<CardHeader>
<CardTitle>Welcome back</CardTitle>
<CardTitle>Sign In</CardTitle>
<CardDescription>
Enter your credentials to access your account
Enter your credentials to access the platform
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-red-50 p-3 text-sm text-red-700">
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive font-medium border border-destructive/20">
{error}
</div>
)}
@@ -85,48 +91,52 @@ export default function SignInPage() {
<Input
id="email"
type="email"
placeholder="your.email@example.com"
placeholder="name@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isLoading}
className="bg-background/50"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="flex items-center justify-between">
<Label htmlFor="password">Password</Label>
<Link href="#" className="text-xs text-primary hover:underline">Forgot password?</Link>
</div>
<Input
id="password"
type="password"
placeholder="Enter your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={isLoading}
className="bg-background/50"
/>
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
<Button type="submit" className="w-full" disabled={isLoading} size="lg">
{isLoading ? "Signing in..." : "Sign In"}
</Button>
</form>
<div className="mt-6 text-center text-sm text-slate-600">
<div className="mt-6 text-center text-sm text-muted-foreground">
Don&apos;t have an account?{" "}
<Link
href="/auth/signup"
className="font-medium text-blue-600 hover:text-blue-500"
className="font-medium text-primary hover:text-primary/80"
>
Sign up here
Sign up
</Link>
</div>
</CardContent>
</Card>
{/* Footer */}
<div className="mt-8 text-center text-xs text-slate-500">
<div className="mt-8 text-center text-xs text-muted-foreground">
<p>
© 2024 HRIStudio. A platform for Human-Robot Interaction research.
&copy; {new Date().getFullYear()} HRIStudio. All rights reserved.
</p>
</div>
</div>

0
src/app/auth/signout/page.tsx Normal file → Executable file
View File

107
src/app/auth/signup/page.tsx Normal file → Executable file
View File

@@ -5,14 +5,15 @@ import { useRouter } from "next/navigation";
import { useState } from "react";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Logo } from "~/components/ui/logo";
import { api } from "~/trpc/react";
export default function SignUpPage() {
@@ -55,30 +56,35 @@ export default function SignUpPage() {
};
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 px-4">
<div className="w-full max-w-md">
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background px-4">
{/* Background Gradients */}
<div className="absolute top-0 left-1/2 -z-10 h-[500px] w-[1000px] -translate-x-1/2 rounded-full bg-primary/20 blur-3xl opacity-30 dark:opacity-20" />
<div className="absolute bottom-0 left-0 -z-10 h-[300px] w-[300px] rounded-full bg-blue-500/10 blur-3xl" />
<div className="w-full max-w-md animate-in fade-in zoom-in-95 duration-500">
{/* Header */}
<div className="mb-8 text-center">
<Link href="/" className="inline-block">
<h1 className="text-3xl font-bold text-slate-900">HRIStudio</h1>
<Link href="/" className="inline-flex items-center justify-center transition-opacity hover:opacity-80">
<Logo iconSize="lg" showText={false} />
</Link>
<p className="mt-2 text-slate-600">
Create your research account
<h1 className="mt-6 text-2xl font-bold tracking-tight text-foreground">Create an account</h1>
<p className="mt-2 text-sm text-muted-foreground">
Start your journey in HRI research
</p>
</div>
{/* Sign Up Card */}
<Card>
<Card className="border-muted/40 bg-card/50 backdrop-blur-sm shadow-xl">
<CardHeader>
<CardTitle>Get started</CardTitle>
<CardTitle>Sign Up</CardTitle>
<CardDescription>
Create your account to begin your HRI research
Enter your details to create your research account
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="rounded-md bg-red-50 p-3 text-sm text-red-700">
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive font-medium border border-destructive/20">
{error}
</div>
)}
@@ -88,11 +94,12 @@ export default function SignUpPage() {
<Input
id="name"
type="text"
placeholder="Your full name"
placeholder="John Doe"
value={name}
onChange={(e) => setName(e.target.value)}
required
disabled={createUser.isPending}
className="bg-background/50"
/>
</div>
@@ -101,67 +108,73 @@ export default function SignUpPage() {
<Input
id="email"
type="email"
placeholder="your.email@example.com"
placeholder="name@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={createUser.isPending}
className="bg-background/50"
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="Create a password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={createUser.isPending}
minLength={6}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="******"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
disabled={createUser.isPending}
minLength={6}
className="bg-background/50"
/>
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm Password</Label>
<Input
id="confirmPassword"
type="password"
placeholder="Confirm your password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
disabled={createUser.isPending}
minLength={6}
/>
<div className="space-y-2">
<Label htmlFor="confirmPassword">Confirm</Label>
<Input
id="confirmPassword"
type="password"
placeholder="******"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
disabled={createUser.isPending}
minLength={6}
className="bg-background/50"
/>
</div>
</div>
<Button
type="submit"
className="w-full"
disabled={createUser.isPending}
size="lg"
>
{createUser.isPending ? "Creating account..." : "Create Account"}
</Button>
</form>
<div className="mt-6 text-center text-sm text-slate-600">
<div className="mt-6 text-center text-sm text-muted-foreground">
Already have an account?{" "}
<Link
href="/auth/signin"
className="font-medium text-blue-600 hover:text-blue-500"
className="font-medium text-primary hover:text-primary/80"
>
Sign in here
Sign in
</Link>
</div>
</CardContent>
</Card>
{/* Footer */}
<div className="mt-8 text-center text-xs text-slate-500">
<div className="mt-8 text-center text-xs text-muted-foreground">
<p>
© 2024 HRIStudio. A platform for Human-Robot Interaction research.
&copy; {new Date().getFullYear()} HRIStudio. All rights reserved.
</p>
</div>
</div>

0
src/app/dashboard/layout.tsx Normal file → Executable file
View File

622
src/app/dashboard/page.tsx Normal file → Executable file
View File

@@ -2,17 +2,23 @@
import * as React from "react";
import Link from "next/link";
import {
Building,
FlaskConical,
TestTube,
Users,
Calendar,
Clock,
AlertCircle,
CheckCircle2,
} from "lucide-react";
import { format } from "date-fns";
import { formatDistanceToNow } from "date-fns";
import {
Activity,
ArrowRight,
Bot,
Calendar,
CheckCircle2,
Clock,
LayoutDashboard,
MoreHorizontal,
Play,
PlayCircle,
Plus,
Settings,
Users,
} from "lucide-react";
import { Button } from "~/components/ui/button";
import {
@@ -22,7 +28,14 @@ import {
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { Progress } from "~/components/ui/progress";
import {
Select,
@@ -31,375 +44,270 @@ import {
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Badge } from "~/components/ui/badge";
import { ScrollArea } from "~/components/ui/scroll-area";
import { api } from "~/trpc/react";
// Dashboard Overview Cards
function OverviewCards({ studyFilter }: { studyFilter: string | null }) {
const { data: stats, isLoading } = api.dashboard.getStats.useQuery({
studyId: studyFilter ?? undefined,
});
const cards = [
{
title: "Active Studies",
value: stats?.totalStudies ?? 0,
description: "Research studies you have access to",
icon: Building,
color: "text-blue-600",
bg: "bg-blue-50",
},
{
title: "Experiments",
value: stats?.totalExperiments ?? 0,
description: "Experiment protocols designed",
icon: FlaskConical,
color: "text-green-600",
bg: "bg-green-50",
},
{
title: "Participants",
value: stats?.totalParticipants ?? 0,
description: "Enrolled participants",
icon: Users,
color: "text-purple-600",
bg: "bg-purple-50",
},
{
title: "Trials",
value: stats?.totalTrials ?? 0,
description: "Total trials conducted",
icon: TestTube,
color: "text-orange-600",
bg: "bg-orange-50",
},
];
if (isLoading) {
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<div className="bg-muted h-4 w-20 animate-pulse rounded" />
<div className="bg-muted h-8 w-8 animate-pulse rounded" />
</CardHeader>
<CardContent>
<div className="bg-muted h-8 w-12 animate-pulse rounded" />
<div className="bg-muted mt-2 h-3 w-24 animate-pulse rounded" />
</CardContent>
</Card>
))}
</div>
);
}
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{cards.map((card) => (
<Card key={card.title}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{card.title}</CardTitle>
<div className={`rounded-md p-2 ${card.bg}`}>
<card.icon className={`h-4 w-4 ${card.color}`} />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{card.value}</div>
<p className="text-muted-foreground text-xs">{card.description}</p>
</CardContent>
</Card>
))}
</div>
);
}
// Recent Activity Component
function RecentActivity({ studyFilter }: { studyFilter: string | null }) {
const { data: activities = [], isLoading } =
api.dashboard.getRecentActivity.useQuery({
limit: 8,
studyId: studyFilter ?? undefined,
});
const getStatusIcon = (status: string) => {
switch (status) {
case "success":
return <CheckCircle2 className="h-4 w-4 text-green-600" />;
case "pending":
return <Clock className="h-4 w-4 text-yellow-600" />;
case "error":
return <AlertCircle className="h-4 w-4 text-red-600" />;
default:
return <AlertCircle className="h-4 w-4 text-blue-600" />;
}
};
return (
<Card className="col-span-4">
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
<CardDescription>
Latest updates from your research platform
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center space-x-4">
<div className="bg-muted h-4 w-4 animate-pulse rounded-full" />
<div className="flex-1 space-y-2">
<div className="bg-muted h-4 w-3/4 animate-pulse rounded" />
<div className="bg-muted h-3 w-1/2 animate-pulse rounded" />
</div>
<div className="bg-muted h-3 w-16 animate-pulse rounded" />
</div>
))}
</div>
) : activities.length === 0 ? (
<div className="py-8 text-center">
<AlertCircle className="text-muted-foreground mx-auto h-8 w-8" />
<p className="text-muted-foreground mt-2 text-sm">
No recent activity
</p>
</div>
) : (
<div className="space-y-4">
{activities.map((activity) => (
<div key={activity.id} className="flex items-center space-x-4">
{getStatusIcon(activity.status)}
<div className="flex-1 space-y-1">
<p className="text-sm leading-none font-medium">
{activity.title}
</p>
<p className="text-muted-foreground text-sm">
{activity.description}
</p>
</div>
<div className="text-muted-foreground text-sm">
{formatDistanceToNow(activity.time, { addSuffix: true })}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
// Quick Actions Component
function QuickActions() {
const actions = [
{
title: "Create Study",
description: "Start a new research study",
href: "/studies/new",
icon: Building,
color: "bg-blue-500 hover:bg-blue-600",
},
{
title: "Browse Studies",
description: "View and manage your studies",
href: "/studies",
icon: Building,
color: "bg-green-500 hover:bg-green-600",
},
{
title: "Create Experiment",
description: "Design new experiment protocol",
href: "/experiments/new",
icon: FlaskConical,
color: "bg-purple-500 hover:bg-purple-600",
},
{
title: "Browse Experiments",
description: "View experiment templates",
href: "/experiments",
icon: FlaskConical,
color: "bg-orange-500 hover:bg-orange-600",
},
];
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{actions.map((action) => (
<Card
key={action.title}
className="group cursor-pointer transition-all hover:shadow-md"
>
<CardContent className="p-6">
<Button asChild className={`w-full ${action.color} text-white`}>
<Link href={action.href}>
<action.icon className="mr-2 h-4 w-4" />
{action.title}
</Link>
</Button>
<p className="text-muted-foreground mt-2 text-sm">
{action.description}
</p>
</CardContent>
</Card>
))}
</div>
);
}
// Study Progress Component
function StudyProgress({ studyFilter }: { studyFilter: string | null }) {
const { data: studies = [], isLoading } =
api.dashboard.getStudyProgress.useQuery({
limit: 5,
studyId: studyFilter ?? undefined,
});
return (
<Card className="col-span-3">
<CardHeader>
<CardTitle>Study Progress</CardTitle>
<CardDescription>
Current status of active research studies
</CardDescription>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-6">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="space-y-2">
<div className="flex items-center justify-between">
<div className="space-y-1">
<div className="bg-muted h-4 w-32 animate-pulse rounded" />
<div className="bg-muted h-3 w-24 animate-pulse rounded" />
</div>
<div className="bg-muted h-5 w-16 animate-pulse rounded" />
</div>
<div className="bg-muted h-2 w-full animate-pulse rounded" />
<div className="bg-muted h-3 w-16 animate-pulse rounded" />
</div>
))}
</div>
) : studies.length === 0 ? (
<div className="py-8 text-center">
<Building className="text-muted-foreground mx-auto h-8 w-8" />
<p className="text-muted-foreground mt-2 text-sm">
No active studies found
</p>
<p className="text-muted-foreground text-xs">
Create a study to get started
</p>
</div>
) : (
<div className="space-y-6">
{studies.map((study) => (
<div key={study.id} className="space-y-2">
<div className="flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm leading-none font-medium">
{study.name}
</p>
<p className="text-muted-foreground text-sm">
{study.participants}/{study.totalParticipants} completed
trials
</p>
</div>
<Badge
variant={
study.status === "active" ? "default" : "secondary"
}
>
{study.status}
</Badge>
</div>
<Progress value={study.progress} className="h-2" />
<p className="text-muted-foreground text-xs">
{study.progress}% complete
</p>
</div>
))}
</div>
)}
</CardContent>
</Card>
);
}
export default function DashboardPage() {
const [studyFilter, setStudyFilter] = React.useState<string | null>(null);
// Get user studies for filter dropdown
// --- Data Fetching ---
const { data: userStudiesData } = api.studies.list.useQuery({
memberOnly: true,
limit: 100,
});
const userStudies = userStudiesData?.studies ?? [];
const { data: stats } = api.dashboard.getStats.useQuery({
studyId: studyFilter ?? undefined,
});
const { data: scheduledTrials } = api.trials.list.useQuery({
studyId: studyFilter ?? undefined,
status: "scheduled",
limit: 5,
});
const { data: recentActivity } = api.dashboard.getRecentActivity.useQuery({
limit: 10,
studyId: studyFilter ?? undefined,
});
const { data: studyProgress } = api.dashboard.getStudyProgress.useQuery({
limit: 5,
studyId: studyFilter ?? undefined,
});
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex flex-col space-y-8 animate-in fade-in duration-500">
{/* Header Section */}
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">
Dashboard
{studyFilter && (
<Badge variant="secondary" className="ml-2">
{userStudies.find((s) => s.id === studyFilter)?.name}
</Badge>
)}
</h1>
<h1 className="text-3xl font-bold tracking-tight text-foreground">Dashboard</h1>
<p className="text-muted-foreground">
{studyFilter
? "Study-specific dashboard view"
: "Welcome to your HRI Studio research platform"}
Overview of your research activities and upcoming tasks.
</p>
</div>
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<span className="text-muted-foreground text-sm">
Filter by study:
</span>
<Select
value={studyFilter ?? "all"}
onValueChange={(value) =>
setStudyFilter(value === "all" ? null : value)
}
>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="All Studies" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Studies</SelectItem>
{userStudies.map((study) => (
<SelectItem key={study.id} value={study.id}>
{study.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Badge variant="outline" className="text-xs">
<Calendar className="mr-1 h-3 w-3" />
{new Date().toLocaleDateString()}
</Badge>
<div className="flex items-center gap-2">
<Select
value={studyFilter ?? "all"}
onValueChange={(value) =>
setStudyFilter(value === "all" ? null : value)
}
>
<SelectTrigger className="w-[200px] bg-background">
<SelectValue placeholder="All Studies" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Studies</SelectItem>
{userStudies.map((study) => (
<SelectItem key={study.id} value={study.id}>
{study.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button asChild>
<Link href="/studies/new">
<Plus className="mr-2 h-4 w-4" /> New Study
</Link>
</Button>
</div>
</div>
{/* Overview Cards */}
<OverviewCards studyFilter={studyFilter} />
{/* Stats Cards */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatsCard
title="Total Participants"
value={stats?.totalParticipants ?? 0}
icon={Users}
description="Across all studies"
trend="+2 this week"
/>
<StatsCard
title="Active Trials"
value={stats?.activeTrials ?? 0}
icon={Activity}
description="Currently in progress"
{/* Main Content Grid */}
<div className="grid gap-4 lg:grid-cols-7">
<StudyProgress studyFilter={studyFilter} />
/>
<StatsCard
title="Completed Trials"
value={stats?.completedToday ?? 0}
icon={CheckCircle2}
description="Completed today"
/>
<StatsCard
title="Scheduled"
value={stats?.scheduledTrials ?? 0}
icon={Calendar}
description="Upcoming sessions"
/>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
{/* Main Column: Scheduled Trials & Study Progress */}
<div className="col-span-4 space-y-4">
<RecentActivity studyFilter={studyFilter} />
</div>
</div>
{/* Quick Actions */}
<div className="space-y-4">
<h2 className="text-xl font-semibold">Quick Actions</h2>
<QuickActions />
{/* Scheduled Trials */}
<Card className="col-span-4 border-muted/40 shadow-sm">
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Upcoming Sessions</CardTitle>
<CardDescription>
You have {scheduledTrials?.length ?? 0} scheduled trials coming up.
</CardDescription>
</div>
<Button variant="ghost" size="sm" asChild>
<Link href="/trials?status=scheduled">View All <ArrowRight className="ml-2 h-4 w-4" /></Link>
</Button>
</div>
</CardHeader>
<CardContent>
{!scheduledTrials?.length ? (
<div className="flex h-[150px] flex-col items-center justify-center rounded-md border border-dashed text-center animate-in fade-in-50">
<Calendar className="h-8 w-8 text-muted-foreground/50" />
<p className="mt-2 text-sm text-muted-foreground">No scheduled trials found.</p>
<Button variant="link" size="sm" asChild className="mt-1">
<Link href="/trials/new">Schedule a Trial</Link>
</Button>
</div>
) : (
<div className="space-y-4">
{scheduledTrials.map((trial) => (
<div key={trial.id} className="flex items-center justify-between rounded-lg border p-3 hover:bg-muted/50 transition-colors">
<div className="flex items-center gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400">
<Calendar className="h-5 w-5" />
</div>
<div>
<p className="font-medium text-sm">
{trial.participant.participantCode}
<span className="ml-2 text-muted-foreground font-normal text-xs"> {trial.experiment.name}</span>
</p>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
{trial.scheduledAt ? format(trial.scheduledAt, "MMM d, h:mm a") : "Unscheduled"}
</div>
</div>
</div>
<Button size="sm" className="gap-2" asChild>
<Link href={`/wizard/${trial.id}`}>
<Play className="h-3.5 w-3.5" /> Start
</Link>
</Button>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Study Progress */}
<Card className="border-muted/40 shadow-sm">
<CardHeader>
<CardTitle>Study Progress</CardTitle>
<CardDescription>
Completion tracking for active studies
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{studyProgress?.map((study) => (
<div key={study.id} className="space-y-2">
<div className="flex items-center justify-between text-sm">
<div className="font-medium">{study.name}</div>
<div className="text-muted-foreground">{study.participants} / {study.totalParticipants} Participants</div>
</div>
<Progress value={study.progress} className="h-2" />
</div>
))}
{!studyProgress?.length && (
<p className="text-sm text-muted-foreground text-center py-4">No active studies to track.</p>
)}
</CardContent>
</Card>
</div>
{/* Side Column: Recent Activity & Quick Actions */}
<div className="col-span-3 space-y-4">
{/* Quick Actions */}
<div className="grid grid-cols-2 gap-4">
<Button variant="outline" className="h-24 flex-col gap-2 hover:border-primary/50 hover:bg-primary/5" asChild>
<Link href="/experiments/new">
<Bot className="h-6 w-6 mb-1" />
<span>New Experim.</span>
</Link>
</Button>
<Button variant="outline" className="h-24 flex-col gap-2 hover:border-primary/50 hover:bg-primary/5" asChild>
<Link href="/trials/new">
<PlayCircle className="h-6 w-6 mb-1" />
<span>Run Trial</span>
</Link>
</Button>
</div>
{/* Recent Activity */}
<Card className="border-muted/40 shadow-sm h-full">
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
</CardHeader>
<CardContent>
<ScrollArea className="h-[400px] pr-4">
<div className="space-y-4">
{recentActivity?.map((activity) => (
<div key={activity.id} className="relative pl-4 pb-1 border-l last:border-0 border-muted-foreground/20">
<span className="absolute left-[-5px] top-1 h-2.5 w-2.5 rounded-full bg-primary/30 ring-4 ring-background" />
<div className="mb-1 text-sm font-medium leading-none">{activity.title}</div>
<div className="text-xs text-muted-foreground mb-1">{activity.description}</div>
<div className="text-[10px] text-muted-foreground/70 uppercase">
{formatDistanceToNow(activity.time, { addSuffix: true })}
</div>
</div>
))}
{!recentActivity?.length && (
<p className="text-sm text-muted-foreground text-center py-8">No recent activity.</p>
)}
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
</div>
</div>
);
}
function StatsCard({
title,
value,
icon: Icon,
description,
trend,
}: {
title: string;
value: string | number;
icon: React.ElementType;
description: string;
trend?: string;
}) {
return (
<Card className="border-muted/40 shadow-sm">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
<Icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
<p className="text-xs text-muted-foreground">
{description}
{trend && <span className="ml-1 text-green-600 dark:text-green-400 font-medium">{trend}</span>}
</p>
</CardContent>
</Card>
);
}

8
src/app/layout.tsx Normal file → Executable file
View File

@@ -1,7 +1,7 @@
import "~/styles/globals.css";
import { type Metadata } from "next";
import { Geist } from "next/font/google";
import { Inter } from "next/font/google";
import { SessionProvider } from "next-auth/react";
import { TRPCReactProvider } from "~/trpc/react";
@@ -13,16 +13,16 @@ export const metadata: Metadata = {
icons: [{ rel: "icon", url: "/favicon.ico" }],
};
const geist = Geist({
const inter = Inter({
subsets: ["latin"],
variable: "--font-geist-sans",
variable: "--font-sans",
});
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en" className={`${geist.variable}`}>
<html lang="en" className={`${inter.variable}`}>
<body>
<SessionProvider>
<TRPCReactProvider>{children}</TRPCReactProvider>

791
src/app/page.tsx Normal file → Executable file
View File

@@ -5,561 +5,290 @@ import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { Badge } from "~/components/ui/badge";
import { Logo } from "~/components/ui/logo";
import { auth } from "~/server/auth";
import {
ArrowRight,
Beaker,
Bot,
Database,
LayoutTemplate,
Lock,
Network,
PlayCircle,
Settings2,
Share2,
} from "lucide-react";
export default async function Home() {
const session = await auth();
// Redirect authenticated users to their dashboard
if (session?.user) {
redirect("/dashboard");
}
return (
<main className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
{/* Header */}
<div className="border-b bg-white/50 backdrop-blur-sm">
<div className="container mx-auto px-4 py-4">
<div className="flex items-center justify-between">
<Logo iconSize="md" showText={true} />
<div className="flex min-h-screen flex-col bg-background text-foreground">
{/* Navbar */}
<header className="sticky top-0 z-50 w-full border-b bg-background/80 backdrop-blur-sm">
<div className="container mx-auto flex h-16 items-center justify-between px-4">
<Logo iconSize="md" showText={true} />
<nav className="flex items-center gap-4">
<Button variant="ghost" asChild className="hidden sm:inline-flex">
<Link href="#features">Features</Link>
</Button>
<Button variant="ghost" asChild className="hidden sm:inline-flex">
<Link href="#architecture">Architecture</Link>
</Button>
<div className="h-6 w-px bg-border hidden sm:block" />
<Button variant="ghost" asChild>
<Link href="/auth/signin">Sign In</Link>
</Button>
<Button asChild>
<Link href="/auth/signup">Get Started</Link>
</Button>
</nav>
</div>
</header>
<div className="flex items-center gap-4">
<Button asChild variant="outline">
<Link href="/auth/signin">Sign In</Link>
<main className="flex-1">
{/* Hero Section */}
<section className="relative overflow-hidden pt-20 pb-32 md:pt-32">
{/* Background Gradients */}
<div className="absolute top-0 left-1/2 -z-10 h-[500px] w-[1000px] -translate-x-1/2 rounded-full bg-primary/20 blur-3xl opacity-30 dark:opacity-20" />
<div className="container mx-auto flex flex-col items-center px-4 text-center">
<Badge variant="secondary" className="mb-6 rounded-full px-4 py-1.5 text-sm font-medium">
The Modern Standard for HRI Research
</Badge>
<h1 className="max-w-4xl text-5xl font-extrabold tracking-tight sm:text-6xl md:text-7xl">
Reproducible WoZ Studies <br className="hidden md:block" />
<span className="bg-gradient-to-r from-blue-600 to-violet-600 bg-clip-text text-transparent dark:from-blue-400 dark:to-violet-400">
Made Simple
</span>
</h1>
<p className="mt-6 max-w-2xl text-lg text-muted-foreground md:text-xl">
HRIStudio is the open-source platform that bridges the gap between
ease of use and scientific rigor. Design, execute, and analyze
human-robot interaction experiments with zero friction.
</p>
<div className="mt-10 flex flex-col gap-4 sm:flex-row sm:justify-center">
<Button size="lg" className="h-12 px-8 text-base" asChild>
<Link href="/auth/signup">
Start Researching
<ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
<Button asChild>
<Link href="/auth/signup">Get Started</Link>
<Button size="lg" variant="outline" className="h-12 px-8 text-base" asChild>
<Link href="https://github.com/robolab/hristudio" target="_blank">
View on GitHub
</Link>
</Button>
</div>
</div>
</div>
</div>
{/* Hero Section */}
<section className="container mx-auto px-4 py-20">
<div className="mx-auto max-w-4xl text-center">
<Badge variant="secondary" className="mb-4">
🤖 Human-Robot Interaction Research Platform
</Badge>
<h1 className="mb-6 text-5xl font-bold tracking-tight text-slate-900">
Standardize Your
<span className="bg-gradient-to-r from-blue-600 to-purple-600 bg-clip-text text-transparent">
{" "}
Wizard of Oz{" "}
</span>
Studies
</h1>
<p className="mb-8 text-xl leading-relaxed text-slate-600">
A comprehensive web-based platform that enhances the scientific
rigor of Human-Robot Interaction experiments while remaining
accessible to researchers with varying levels of technical
expertise.
</p>
<div className="flex flex-col justify-center gap-4 sm:flex-row">
<Button size="lg" asChild>
<Link href="/auth/signup">Start Your Research</Link>
</Button>
<Button size="lg" variant="outline" asChild>
<Link href="#features">Learn More</Link>
</Button>
</div>
</div>
</section>
{/* Problem Section */}
<section className="bg-white/50 py-20">
<div className="container mx-auto px-4">
<div className="mx-auto max-w-4xl">
<div className="mb-12 text-center">
<h2 className="mb-4 text-3xl font-bold text-slate-900">
The Challenge of WoZ Studies
</h2>
<p className="text-lg text-slate-600">
While Wizard of Oz is a powerful paradigm for HRI research, it
faces significant challenges
</p>
</div>
<div className="grid grid-cols-1 gap-8 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-red-600">
Reproducibility Issues
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-slate-600">
<li> Wizard behavior variability across trials</li>
<li> Inconsistent experimental conditions</li>
<li> Lack of standardized terminology</li>
<li> Insufficient documentation</li>
</ul>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-red-600">
Technical Barriers
</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-slate-600">
<li> Platform-specific robot control systems</li>
<li> Extensive custom coding requirements</li>
<li> Limited to domain experts</li>
<li> Fragmented data collection</li>
</ul>
</CardContent>
</Card>
</div>
</div>
</div>
</section>
{/* Features Section */}
<section id="features" className="py-20">
<div className="container mx-auto px-4">
<div className="mx-auto max-w-6xl">
<div className="mb-16 text-center">
<h2 className="mb-4 text-3xl font-bold text-slate-900">
Six Key Design Principles
</h2>
<p className="text-lg text-slate-600">
Our platform addresses these challenges through comprehensive
design principles
</p>
</div>
<div className="grid grid-cols-1 gap-8 md:grid-cols-2 lg:grid-cols-3">
<Card className="border-blue-200 bg-blue-50/50">
<CardHeader>
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-lg bg-blue-100">
<svg
className="h-6 w-6 text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"
/>
</svg>
</div>
<CardTitle>Integrated Environment</CardTitle>
</CardHeader>
<CardContent>
<p className="text-slate-600">
All functionalities unified in a single web-based platform
with intuitive interfaces
</p>
</CardContent>
</Card>
<Card className="border-green-200 bg-green-50/50">
<CardHeader>
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-lg bg-green-100">
<svg
className="h-6 w-6 text-green-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
/>
</svg>
</div>
<CardTitle>Visual Experiment Design</CardTitle>
</CardHeader>
<CardContent>
<p className="text-slate-600">
Minimal-to-no coding required with drag-and-drop visual
programming capabilities
</p>
</CardContent>
</Card>
<Card className="border-purple-200 bg-purple-50/50">
<CardHeader>
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-lg bg-purple-100">
<svg
className="h-6 w-6 text-purple-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
</div>
<CardTitle>Real-time Control</CardTitle>
</CardHeader>
<CardContent>
<p className="text-slate-600">
Fine-grained, real-time control of scripted experimental
runs with multiple robot platforms
</p>
</CardContent>
</Card>
<Card className="border-orange-200 bg-orange-50/50">
<CardHeader>
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-lg bg-orange-100">
<svg
className="h-6 w-6 text-orange-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"
/>
</svg>
</div>
<CardTitle>Data Management</CardTitle>
</CardHeader>
<CardContent>
<p className="text-slate-600">
Comprehensive data collection and logging with structured
storage and retrieval
</p>
</CardContent>
</Card>
<Card className="border-teal-200 bg-teal-50/50">
<CardHeader>
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-lg bg-teal-100">
<svg
className="h-6 w-6 text-teal-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.367 2.684 3 3 0 00-5.367-2.684z"
/>
</svg>
</div>
<CardTitle>Platform Agnostic</CardTitle>
</CardHeader>
<CardContent>
<p className="text-slate-600">
Support for wide range of robot hardware through RESTful
APIs, ROS, and custom plugins
</p>
</CardContent>
</Card>
<Card className="border-indigo-200 bg-indigo-50/50">
<CardHeader>
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-lg bg-indigo-100">
<svg
className="h-6 w-6 text-indigo-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"
/>
</svg>
</div>
<CardTitle>Collaboration Support</CardTitle>
</CardHeader>
<CardContent>
<p className="text-slate-600">
Role-based access control and data sharing for effective
research team collaboration
</p>
</CardContent>
</Card>
</div>
</div>
</div>
</section>
{/* Architecture Section */}
<section className="bg-white/50 py-20">
<div className="container mx-auto px-4">
<div className="mx-auto max-w-4xl">
<div className="mb-12 text-center">
<h2 className="mb-4 text-3xl font-bold text-slate-900">
Three-Layer Architecture
</h2>
<p className="text-lg text-slate-600">
Modular web application with clear separation of concerns
</p>
</div>
<div className="space-y-8">
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<div className="h-3 w-3 rounded-full bg-blue-500"></div>
<span>User Interface Layer</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
<div className="rounded-lg bg-blue-50 p-4 text-center">
<h4 className="font-semibold text-blue-900">
Experiment Designer
</h4>
<p className="mt-1 text-sm text-blue-700">
Visual programming for experimental protocols
</p>
</div>
<div className="rounded-lg bg-blue-50 p-4 text-center">
<h4 className="font-semibold text-blue-900">
Wizard Interface
</h4>
<p className="mt-1 text-sm text-blue-700">
Real-time control during trial execution
</p>
</div>
<div className="rounded-lg bg-blue-50 p-4 text-center">
<h4 className="font-semibold text-blue-900">
Playback & Analysis
</h4>
<p className="mt-1 text-sm text-blue-700">
Data exploration and visualization
</p>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<div className="h-3 w-3 rounded-full bg-green-500"></div>
<span>Data Management Layer</span>
</CardTitle>
</CardHeader>
<CardContent>
<p className="mb-4 text-slate-600">
Secure database functionality with role-based access control
(Researcher, Wizard, Observer) for organizing experiment
definitions, metadata, and media assets.
</p>
<div className="flex flex-wrap gap-2">
<Badge variant="secondary">PostgreSQL</Badge>
<Badge variant="secondary">MinIO Storage</Badge>
<Badge variant="secondary">Role-based Access</Badge>
<Badge variant="secondary">Cloud/On-premise</Badge>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<div className="h-3 w-3 rounded-full bg-purple-500"></div>
<span>Robot Integration Layer</span>
</CardTitle>
</CardHeader>
<CardContent>
<p className="mb-4 text-slate-600">
Robot-agnostic communication layer supporting multiple
integration methods for diverse hardware platforms.
</p>
<div className="flex flex-wrap gap-2">
<Badge variant="secondary">RESTful APIs</Badge>
<Badge variant="secondary">ROS Integration</Badge>
<Badge variant="secondary">Custom Plugins</Badge>
<Badge variant="secondary">Docker Deployment</Badge>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
</section>
{/* Workflow Section */}
<section className="py-20">
<div className="container mx-auto px-4">
<div className="mx-auto max-w-4xl">
<div className="mb-12 text-center">
<h2 className="mb-4 text-3xl font-bold text-slate-900">
Hierarchical Experiment Structure
</h2>
<p className="text-lg text-slate-600">
Standardized terminology and organization for reproducible
research
</p>
</div>
<div className="relative">
{/* Hierarchy visualization */}
<div className="space-y-6">
<Card className="border-l-4 border-l-blue-500">
<CardContent className="pt-6">
<div className="flex items-center space-x-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-100 text-sm font-semibold text-blue-600">
1
</div>
<div>
<h3 className="font-semibold">Study</h3>
<p className="text-sm text-slate-600">
Top-level container comprising one or more experiments
</p>
</div>
</div>
</CardContent>
</Card>
<Card className="ml-8 border-l-4 border-l-green-500">
<CardContent className="pt-6">
<div className="flex items-center space-x-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-green-100 text-sm font-semibold text-green-600">
2
</div>
<div>
<h3 className="font-semibold">Experiment</h3>
<p className="text-sm text-slate-600">
Parameterized template specifying experimental
protocol
</p>
</div>
</div>
</CardContent>
</Card>
<Card className="ml-16 border-l-4 border-l-orange-500">
<CardContent className="pt-6">
<div className="flex items-center space-x-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-orange-100 text-sm font-semibold text-orange-600">
3
</div>
<div>
<h3 className="font-semibold">Trial</h3>
<p className="text-sm text-slate-600">
Executable instance with specific participant and
conditions
</p>
</div>
</div>
</CardContent>
</Card>
<Card className="ml-24 border-l-4 border-l-purple-500">
<CardContent className="pt-6">
<div className="flex items-center space-x-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-purple-100 text-sm font-semibold text-purple-600">
4
</div>
<div>
<h3 className="font-semibold">Step</h3>
<p className="text-sm text-slate-600">
Distinct phase containing wizard or robot instructions
</p>
</div>
</div>
</CardContent>
</Card>
<Card className="ml-32 border-l-4 border-l-pink-500">
<CardContent className="pt-6">
<div className="flex items-center space-x-3">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-pink-100 text-sm font-semibold text-pink-600">
5
</div>
<div>
<h3 className="font-semibold">Action</h3>
<p className="text-sm text-slate-600">
Specific atomic task (speech, movement, input
gathering, etc.)
</p>
</div>
</div>
</CardContent>
</Card>
{/* Mockup / Visual Interest */}
<div className="relative mt-20 w-full max-w-5xl rounded-xl border bg-background/50 p-2 shadow-2xl backdrop-blur-sm lg:rounded-2xl lg:p-4">
<div className="absolute inset-x-0 -top-px mx-auto h-px w-3/4 bg-gradient-to-r from-transparent via-foreground/20 to-transparent" />
<div className="aspect-[16/9] w-full overflow-hidden rounded-lg border bg-muted/50 flex items-center justify-center relative">
{/* Placeholder for actual app screenshot */}
<div className="absolute inset-0 bg-gradient-to-tr from-blue-500/10 to-violet-500/10" />
<div className="text-center p-8">
<LayoutTemplate className="w-16 h-16 mx-auto text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground font-medium">Interactive Experiment Designer</p>
</div>
</div>
</div>
</div>
</div>
</section>
</section>
{/* CTA Section */}
<section className="bg-gradient-to-r from-blue-600 to-purple-600 py-20">
<div className="container mx-auto px-4">
<div className="mx-auto max-w-4xl text-center text-white">
<h2 className="mb-4 text-3xl font-bold">
Ready to Revolutionize Your HRI Research?
</h2>
<p className="mb-8 text-xl opacity-90">
Join researchers worldwide who are using our platform to conduct
more rigorous, reproducible Wizard of Oz studies.
</p>
<div className="flex flex-col justify-center gap-4 sm:flex-row">
<Button size="lg" variant="secondary" asChild>
<Link href="/auth/signup">Get Started Free</Link>
</Button>
<Button
size="lg"
variant="outline"
className="border-white text-white hover:bg-white hover:text-blue-600"
asChild
>
<Link href="/auth/signin">Sign In</Link>
</Button>
{/* Features Bento Grid */}
<section id="features" className="container mx-auto px-4 py-24">
<div className="mb-12 text-center">
<h2 className="text-3xl font-bold tracking-tight md:text-4xl">Everything You Need</h2>
<p className="mt-4 text-lg text-muted-foreground">Built for the specific needs of HRI researchers and wizards.</p>
</div>
<div className="grid gap-4 md:grid-cols-3 lg:grid-cols-4 lg:grid-rows-2">
{/* Visual Designer - Large Item */}
<Card className="col-span-1 md:col-span-2 lg:col-span-2 row-span-2 flex flex-col overflow-hidden bg-gradient-to-br from-blue-500/5 to-violet-500/5 dark:from-blue-900/10 dark:to-violet-900/10">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<LayoutTemplate className="h-5 w-5 text-blue-500" />
Visual Experiment Designer
</CardTitle>
</CardHeader>
<CardContent className="flex-1">
<p className="text-muted-foreground mb-6">
Construct complex branching narratives without writing a single line of code.
Our node-based editor handles logic, timing, and robot actions automatically.
</p>
<div className="rounded-lg border bg-background/50 p-4 h-full min-h-[200px] flex items-center justify-center shadow-inner">
<div className="flex gap-2 items-center text-sm text-muted-foreground">
<span className="rounded bg-accent p-2">Start</span>
<ArrowRight className="h-4 w-4" />
<span className="rounded bg-primary/10 p-2 border border-primary/20 text-primary font-medium">Robot: Greet</span>
<ArrowRight className="h-4 w-4" />
<span className="rounded bg-accent p-2">Wait: 5s</span>
</div>
</div>
</CardContent>
</Card>
{/* Robot Agnostic */}
<Card className="col-span-1 md:col-span-1 lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bot className="h-5 w-5 text-green-500" />
Robot Agnostic
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
Switch between robots instantly. Whether it's a NAO, Pepper, or a custom ROS2 bot,
your experiment logic remains strictly separated from hardware implementation.
</p>
</CardContent>
</Card>
{/* Role Based */}
<Card className="col-span-1 md:col-span-1 lg:col-span-1 bg-muted/30">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Lock className="h-4 w-4 text-orange-500" />
Role-Based Access
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Granular permissions for Principal Investigators, Wizards, and Observers.
</p>
</CardContent>
</Card>
{/* Data Logging */}
<Card className="col-span-1 md:col-span-1 lg:col-span-1 bg-muted/30">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Database className="h-4 w-4 text-rose-500" />
Full Traceability
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Every wizard action, automated response, and sensor reading is time-stamped and logged.
</p>
</CardContent>
</Card>
</div>
</section>
{/* Architecture Section */}
<section id="architecture" className="border-t bg-muted/30 py-24">
<div className="container mx-auto px-4">
<div className="grid gap-12 lg:grid-cols-2 lg:gap-8 items-center">
<div>
<h2 className="text-3xl font-bold tracking-tight">Enterprise-Grade Architecture</h2>
<p className="mt-4 text-lg text-muted-foreground">
Designed for reliability and scale. HRIStudio uses a modern stack to ensure your data is safe and your experiments run smoothly.
</p>
<div className="mt-8 space-y-4">
<div className="flex gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-background border shadow-sm">
<Network className="h-5 w-5 text-primary" />
</div>
<div>
<h3 className="font-semibold">3-Layer Design</h3>
<p className="text-muted-foreground">Clear separation between UI, Data, and Hardware layers for maximum stability.</p>
</div>
</div>
<div className="flex gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-background border shadow-sm">
<Share2 className="h-5 w-5 text-primary" />
</div>
<div>
<h3 className="font-semibold">Collaborative by Default</h3>
<p className="text-muted-foreground">Real-time state synchronization allows multiple researchers to monitor a single trial.</p>
</div>
</div>
<div className="flex gap-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-background border shadow-sm">
<Settings2 className="h-5 w-5 text-primary" />
</div>
<div>
<h3 className="font-semibold">ROS2 Integration</h3>
<p className="text-muted-foreground">Native support for ROS2 nodes, topics, and actions right out of the box.</p>
</div>
</div>
</div>
</div>
<div className="relative mx-auto w-full max-w-[500px]">
{/* Abstract representation of architecture */}
<div className="space-y-4 relative z-10">
<Card className="border-blue-500/20 bg-blue-500/5 relative left-0 hover:left-2 transition-all cursor-default">
<CardHeader className="pb-2">
<CardTitle className="text-blue-600 dark:text-blue-400 text-sm font-mono">APP LAYER</CardTitle>
</CardHeader>
<CardContent>
<p className="font-semibold">Next.js Dashboard + Experiment Designer</p>
</CardContent>
</Card>
<Card className="border-violet-500/20 bg-violet-500/5 relative left-4 hover:left-6 transition-all cursor-default">
<CardHeader className="pb-2">
<CardTitle className="text-violet-600 dark:text-violet-400 text-sm font-mono">DATA LAYER</CardTitle>
</CardHeader>
<CardContent>
<p className="font-semibold">PostgreSQL + MinIO + TRPC API</p>
</CardContent>
</Card>
<Card className="border-green-500/20 bg-green-500/5 relative left-8 hover:left-10 transition-all cursor-default">
<CardHeader className="pb-2">
<CardTitle className="text-green-600 dark:text-green-400 text-sm font-mono">HARDWARE LAYER</CardTitle>
</CardHeader>
<CardContent>
<p className="font-semibold">ROS2 Bridge + Robot Plugins</p>
</CardContent>
</Card>
</div>
{/* Decorative blobs */}
<div className="absolute top-1/2 left-1/2 -z-10 h-[300px] w-[300px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-primary/10 blur-3xl" />
</div>
</div>
</div>
</div>
</section>
</section>
{/* Footer */}
<footer className="bg-slate-900 py-12">
<div className="container mx-auto px-4">
<div className="text-center text-slate-400">
<div className="mb-4 flex items-center justify-center">
<Logo
iconSize="md"
showText={true}
className="text-white [&>div]:bg-white [&>div]:text-blue-600"
/>
</div>
<p className="mb-4">
Advancing Human-Robot Interaction research through standardized
Wizard of Oz methodologies
{/* CTA Section */}
<section className="container mx-auto px-4 py-24 text-center">
<h2 className="text-3xl font-bold tracking-tight md:text-4xl">Ready to upgrade your lab?</h2>
<p className="mx-auto mt-4 max-w-2xl text-lg text-muted-foreground">
Join the community of researchers building the future of HRI with reproducible, open-source tools.
</p>
<div className="mt-8">
<Button size="lg" className="h-12 px-8 text-base shadow-lg shadow-primary/20" asChild>
<Link href="/auth/signup">Get Started for Free</Link>
</Button>
</div>
</section>
</main>
<footer className="border-t bg-muted/40 py-12">
<div className="container mx-auto px-4 flex flex-col items-center justify-between gap-6 md:flex-row text-center md:text-left">
<div className="flex flex-col gap-2">
<Logo iconSize="sm" showText={true} />
<p className="text-sm text-muted-foreground">
&copy; {new Date().getFullYear()} HRIStudio. All rights reserved.
</p>
<div className="flex justify-center space-x-6 text-sm">
<Link href="#" className="transition-colors hover:text-white">
Documentation
</Link>
<Link href="#" className="transition-colors hover:text-white">
API Reference
</Link>
<Link href="#" className="transition-colors hover:text-white">
Research Papers
</Link>
<Link href="#" className="transition-colors hover:text-white">
Support
</Link>
</div>
</div>
<div className="flex gap-6 text-sm text-muted-foreground">
<Link href="#" className="hover:text-foreground">Privacy</Link>
<Link href="#" className="hover:text-foreground">Terms</Link>
<Link href="#" className="hover:text-foreground">GitHub</Link>
<Link href="#" className="hover:text-foreground">Documentation</Link>
</div>
</div>
</footer>
</main>
</div>
);
}

0
src/app/unauthorized/page.tsx Normal file → Executable file
View File

0
src/components/admin/AdminContent.tsx Normal file → Executable file
View File

0
src/components/admin/admin-user-table.tsx Normal file → Executable file
View File

0
src/components/admin/repositories-columns.tsx Normal file → Executable file
View File

0
src/components/admin/repositories-data-table.tsx Normal file → Executable file
View File

0
src/components/admin/role-management.tsx Normal file → Executable file
View File

0
src/components/admin/system-stats.tsx Normal file → Executable file
View File

0
src/components/dashboard/DashboardContent.tsx Normal file → Executable file
View File

22
src/components/dashboard/app-sidebar.tsx Normal file → Executable file
View File

@@ -143,9 +143,9 @@ export function AppSidebar({
// Build study work items with proper URLs when study is selected
const studyWorkItemsWithUrls = selectedStudyId
? studyWorkItems.map((item) => ({
...item,
url: `/studies/${selectedStudyId}${item.url}`,
}))
...item,
url: `/studies/${selectedStudyId}${item.url}`,
}))
: [];
const handleSignOut = async () => {
@@ -233,6 +233,22 @@ export function AppSidebar({
// Show debug info in development
const showDebug = process.env.NODE_ENV === "development";
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
if (!mounted) {
return (
<Sidebar collapsible="icon" variant="sidebar" {...props}>
<SidebarHeader />
<SidebarContent />
<SidebarFooter />
</Sidebar>
);
}
return (
<Sidebar collapsible="icon" variant="sidebar" {...props}>
<SidebarHeader>

0
src/components/dashboard/study-guard.tsx Normal file → Executable file
View File

56
src/components/experiments/ExperimentForm.tsx Normal file → Executable file
View File

@@ -87,33 +87,33 @@ export function ExperimentForm({ mode, experimentId }: ExperimentFormProps) {
{ label: "Studies", href: "/studies" },
...(selectedStudyId
? [
{
label: experiment?.study?.name ?? "Study",
href: `/studies/${selectedStudyId}`,
},
{ label: "Experiments", href: "/experiments" },
...(mode === "edit" && experiment
? [
{
label: experiment.name,
href: `/experiments/${experiment.id}`,
},
{ label: "Edit" },
]
: [{ label: "New Experiment" }]),
]
{
label: experiment?.study?.name ?? "Study",
href: `/studies/${selectedStudyId}`,
},
{ label: "Experiments", href: "/experiments" },
...(mode === "edit" && experiment
? [
{
label: experiment.name,
href: `/studies/${selectedStudyId}/experiments/${experiment.id}`,
},
{ label: "Edit" },
]
: [{ label: "New Experiment" }]),
]
: [
{ label: "Experiments", href: "/experiments" },
...(mode === "edit" && experiment
? [
{
label: experiment.name,
href: `/experiments/${experiment.id}`,
},
{ label: "Edit" },
]
: [{ label: "New Experiment" }]),
]),
{ label: "Experiments", href: "/experiments" },
...(mode === "edit" && experiment
? [
{
label: experiment.name,
href: `/studies/${experiment.studyId}/experiments/${experiment.id}`,
},
{ label: "Edit" },
]
: [{ label: "New Experiment" }]),
]),
];
useBreadcrumbsEffect(breadcrumbs);
@@ -153,14 +153,14 @@ export function ExperimentForm({ mode, experimentId }: ExperimentFormProps) {
...data,
estimatedDuration: data.estimatedDuration ?? undefined,
});
router.push(`/experiments/${newExperiment.id}/designer`);
router.push(`/studies/${data.studyId}/experiments/${newExperiment.id}/designer`);
} else {
const updatedExperiment = await updateExperimentMutation.mutateAsync({
id: experimentId!,
...data,
estimatedDuration: data.estimatedDuration ?? undefined,
});
router.push(`/experiments/${updatedExperiment.id}`);
router.push(`/studies/${experiment?.studyId ?? data.studyId}/experiments/${updatedExperiment.id}`);
}
} catch (error) {
setError(

6
src/components/experiments/ExperimentsGrid.tsx Normal file → Executable file
View File

@@ -78,7 +78,7 @@ function ExperimentCard({ experiment }: ExperimentCardProps) {
<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}`}
href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}
className="hover:underline"
>
{experiment.name}
@@ -158,10 +158,10 @@ function ExperimentCard({ experiment }: ExperimentCardProps) {
{/* Actions */}
<div className="flex gap-2 pt-2">
<Button asChild size="sm" className="flex-1">
<Link href={`/experiments/${experiment.id}`}>View Details</Link>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}>View Details</Link>
</Button>
<Button asChild size="sm" variant="outline" className="flex-1">
<Link href={`/experiments/${experiment.id}/designer`}>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
<Settings className="mr-1 h-3 w-3" />
Design
</Link>

32
src/components/experiments/ExperimentsTable.tsx Normal file → Executable file
View File

@@ -1,7 +1,7 @@
"use client";
import { type ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
import { ArrowUpDown, MoreHorizontal, Copy, Eye, Edit, LayoutTemplate, PlayCircle, Archive } from "lucide-react";
import * as React from "react";
import { formatDistanceToNow } from "date-fns";
@@ -103,7 +103,7 @@ export const columns: ColumnDef<Experiment>[] = [
<div className="max-w-[200px]">
<div className="truncate font-medium">
<Link
href={`/experiments/${row.original.id}`}
href={`/studies/${row.original.studyId}/experiments/${row.original.id}`}
className="hover:underline"
>
{String(name)}
@@ -259,20 +259,26 @@ export const columns: ColumnDef<Experiment>[] = [
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText(experiment.id)}
>
Copy experiment ID
<Copy className="mr-2 h-4 w-4" />
Copy ID
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/experiments/${experiment.id}`}>View details</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/experiments/${experiment.id}/edit`}>
Edit experiment
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}>
<Eye className="mr-2 h-4 w-4" />
Details
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/experiments/${experiment.id}/designer`}>
Open designer
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
<LayoutTemplate className="mr-2 h-4 w-4" />
Designer
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
@@ -280,12 +286,14 @@ export const columns: ColumnDef<Experiment>[] = [
<Link
href={`/studies/${experiment.studyId}/trials/new?experimentId=${experiment.id}`}
>
Create trial
<PlayCircle className="mr-2 h-4 w-4" />
Start Trial
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="text-red-600">
Archive experiment
<Archive className="mr-2 h-4 w-4" />
Archive
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>

82
src/components/experiments/designer/ActionRegistry.ts Normal file → Executable file
View File

@@ -78,6 +78,7 @@ export class ActionRegistry {
parameters?: CoreBlockParam[];
timeoutMs?: number;
retryable?: boolean;
nestable?: boolean;
}
try {
@@ -139,6 +140,7 @@ export class ActionRegistry {
parameterSchemaRaw: {
parameters: block.parameters ?? [],
},
nestable: block.nestable,
};
this.actions.set(actionDef.id, actionDef);
@@ -180,31 +182,33 @@ export class ActionRegistry {
private loadFallbackActions(): void {
const fallbackActions: ActionDefinition[] = [
{
id: "wizard_speak",
type: "wizard_speak",
id: "wizard_say",
type: "wizard_say",
name: "Wizard Says",
description: "Wizard speaks to participant",
category: "wizard",
icon: "MessageSquare",
color: "#3b82f6",
color: "#a855f7",
parameters: [
{
id: "text",
name: "Text to say",
id: "message",
name: "Message",
type: "text",
placeholder: "Hello, participant!",
required: true,
},
],
source: { kind: "core", baseActionId: "wizard_speak" },
execution: { transport: "internal", timeoutMs: 30000 },
parameterSchemaRaw: {
type: "object",
properties: {
text: { type: "string" },
{
id: "tone",
name: "Tone",
type: "select",
options: ["neutral", "friendly", "encouraging"],
value: "neutral",
},
required: ["text"],
},
],
source: { kind: "core", baseActionId: "wizard_say" },
execution: { transport: "internal", timeoutMs: 30000 },
parameterSchemaRaw: {},
nestable: false,
},
{
id: "wait",
@@ -366,34 +370,34 @@ export class ActionRegistry {
const execution = action.ros2
? {
transport: "ros2" as const,
timeoutMs: action.timeout,
retryable: action.retryable,
ros2: {
topic: action.ros2.topic,
messageType: action.ros2.messageType,
service: action.ros2.service,
action: action.ros2.action,
qos: action.ros2.qos,
payloadMapping: action.ros2.payloadMapping,
},
}
transport: "ros2" as const,
timeoutMs: action.timeout,
retryable: action.retryable,
ros2: {
topic: action.ros2.topic,
messageType: action.ros2.messageType,
service: action.ros2.service,
action: action.ros2.action,
qos: action.ros2.qos,
payloadMapping: action.ros2.payloadMapping,
},
}
: action.rest
? {
transport: "rest" as const,
timeoutMs: action.timeout,
retryable: action.retryable,
rest: {
method: action.rest.method,
path: action.rest.path,
headers: action.rest.headers,
},
}
transport: "rest" as const,
timeoutMs: action.timeout,
retryable: action.retryable,
rest: {
method: action.rest.method,
path: action.rest.path,
headers: action.rest.headers,
},
}
: {
transport: "internal" as const,
timeoutMs: action.timeout,
retryable: action.retryable,
};
transport: "internal" as const,
timeoutMs: action.timeout,
retryable: action.retryable,
};
const actionDef: ActionDefinition = {
id: `${plugin.robotId ?? plugin.id}.${action.id}`,

View File

@@ -57,6 +57,15 @@ export interface DependencyInspectorProps {
* Available action definitions from registry
*/
actionDefinitions: ActionDefinition[];
/**
* Study plugins with name and metadata
*/
studyPlugins?: Array<{
id: string;
robotId: string;
name: string;
version: string;
}>;
/**
* Called when user wants to reconcile a drifted action
*/
@@ -80,6 +89,12 @@ function extractPluginDependencies(
steps: ExperimentStep[],
actionDefinitions: ActionDefinition[],
driftedActions: Set<string>,
studyPlugins?: Array<{
id: string;
robotId: string;
name: string;
version: string;
}>,
): PluginDependency[] {
const dependencyMap = new Map<string, PluginDependency>();
@@ -134,9 +149,12 @@ function extractPluginDependencies(
dep.installedVersion = dep.version;
}
// Set plugin name from first available definition
// Set plugin name from studyPlugins if available
if (availableActions[0]) {
dep.name = availableActions[0].source.pluginId; // Could be enhanced with actual plugin name
const pluginMeta = studyPlugins?.find(
(p) => p.robotId === dep.pluginId,
);
dep.name = pluginMeta?.name ?? dep.pluginId;
}
}
});
@@ -247,7 +265,9 @@ function PluginDependencyItem({
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{dependency.pluginId}</span>
<span className="text-sm font-medium">
{dependency.name ?? dependency.pluginId}
</span>
<Badge
variant={config.badgeVariant}
className={cn("h-4 text-[10px]", config.badgeColor)}
@@ -382,6 +402,7 @@ export function DependencyInspector({
steps,
actionSignatureDrift,
actionDefinitions,
studyPlugins,
onReconcileAction,
onRefreshDependencies,
onInstallPlugin,
@@ -389,8 +410,13 @@ export function DependencyInspector({
}: DependencyInspectorProps) {
const dependencies = useMemo(
() =>
extractPluginDependencies(steps, actionDefinitions, actionSignatureDrift),
[steps, actionDefinitions, actionSignatureDrift],
extractPluginDependencies(
steps,
actionDefinitions,
actionSignatureDrift,
studyPlugins,
),
[steps, actionDefinitions, actionSignatureDrift, studyPlugins],
);
const drifts = useMemo(

606
src/components/experiments/designer/DesignerRoot.tsx Normal file → Executable file
View File

@@ -1,9 +1,16 @@
"use client";
import React, { useCallback, useEffect, useRef, useState } from "react";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { toast } from "sonner";
import { Play } from "lucide-react";
import { Play, RefreshCw } from "lucide-react";
import { cn } from "~/lib/utils";
import { PageHeader } from "~/components/ui/page-header";
import { Button } from "~/components/ui/button";
@@ -19,8 +26,10 @@ import {
MouseSensor,
TouchSensor,
KeyboardSensor,
closestCorners,
type DragEndEvent,
type DragStartEvent,
type DragOverEvent,
} from "@dnd-kit/core";
import { BottomStatusBar } from "./layout/BottomStatusBar";
import { ActionLibraryPanel } from "./panels/ActionLibraryPanel";
@@ -150,20 +159,28 @@ export function DesignerRoot({
} = api.experiments.get.useQuery({ id: experimentId });
const updateExperiment = api.experiments.update.useMutation({
onSuccess: async () => {
toast.success("Experiment saved");
await refetchExperiment();
},
onError: (err) => {
toast.error(`Save failed: ${err.message}`);
},
});
const { data: studyPlugins } = api.robots.plugins.getStudyPlugins.useQuery(
const { data: studyPluginsRaw } = api.robots.plugins.getStudyPlugins.useQuery(
{ studyId: experiment?.studyId ?? "" },
{ enabled: !!experiment?.studyId },
);
// Map studyPlugins to format expected by DependencyInspector
const studyPlugins = useMemo(
() =>
studyPluginsRaw?.map((sp) => ({
id: sp.plugin.id,
robotId: sp.plugin.robotId ?? "",
name: sp.plugin.name,
version: sp.plugin.version,
})),
[studyPluginsRaw],
);
/* ------------------------------ Store Access ----------------------------- */
const steps = useDesignerStore((s) => s.steps);
const setSteps = useDesignerStore((s) => s.setSteps);
@@ -230,6 +247,7 @@ export function DesignerRoot({
const [isSaving, setIsSaving] = useState(false);
const [isValidating, setIsValidating] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [isReady, setIsReady] = useState(false); // Track when everything is loaded
const [lastSavedAt, setLastSavedAt] = useState<Date | undefined>(undefined);
const [inspectorTab, setInspectorTab] = useState<
@@ -250,6 +268,13 @@ export function DesignerRoot({
useEffect(() => {
if (initialized) return;
if (loadingExperiment && !initialDesign) return;
console.log('[DesignerRoot] 🚀 INITIALIZING', {
hasExperiment: !!experiment,
hasInitialDesign: !!initialDesign,
loadingExperiment,
});
const adapted =
initialDesign ??
(experiment
@@ -274,8 +299,9 @@ export function DesignerRoot({
setValidatedHash(ih);
}
setInitialized(true);
// Kick initial hash
void recomputeHash();
// NOTE: We don't call recomputeHash() here because the automatic
// hash recomputation useEffect will trigger when setSteps() updates the steps array
console.log('[DesignerRoot] 🚀 Initialization complete, steps set');
}, [
initialized,
loadingExperiment,
@@ -299,26 +325,69 @@ export function DesignerRoot({
// Load plugin actions when study plugins available
useEffect(() => {
if (!experiment?.studyId) return;
if (!studyPlugins || studyPlugins.length === 0) return;
actionRegistry.loadPluginActions(
experiment.studyId,
studyPlugins.map((sp) => ({
plugin: {
id: sp.plugin.id,
robotId: sp.plugin.robotId,
version: sp.plugin.version,
actionDefinitions: Array.isArray(sp.plugin.actionDefinitions)
? sp.plugin.actionDefinitions
: undefined,
},
})),
);
}, [experiment?.studyId, studyPlugins]);
if (!studyPluginsRaw) return;
// @ts-expect-error - studyPluginsRaw type from tRPC is compatible but TypeScript can't infer it
actionRegistry.loadPluginActions(experiment.studyId, studyPluginsRaw);
}, [experiment?.studyId, studyPluginsRaw]);
/* ------------------------- Ready State Management ------------------------ */
// Mark as ready once initialized and plugins are loaded
useEffect(() => {
if (!initialized || isReady) return;
// Check if plugins are loaded by verifying the action registry has plugin actions
const debugInfo = actionRegistry.getDebugInfo();
const hasPlugins = debugInfo.pluginActionsLoaded;
if (hasPlugins) {
// Small delay to ensure all components have rendered
const timer = setTimeout(() => {
setIsReady(true);
console.log('[DesignerRoot] ✅ Designer ready (plugins loaded), fading in');
}, 150);
return () => clearTimeout(timer);
}
}, [initialized, isReady, studyPluginsRaw]);
/* ----------------------- Automatic Hash Recomputation -------------------- */
// Automatically recompute hash when steps change (debounced to avoid excessive computation)
useEffect(() => {
if (!initialized) return;
console.log('[DesignerRoot] Steps changed, scheduling hash recomputation', {
stepsCount: steps.length,
actionsCount: steps.reduce((sum, s) => sum + s.actions.length, 0),
});
const timeoutId = setTimeout(async () => {
console.log('[DesignerRoot] Executing debounced hash recomputation');
const result = await recomputeHash();
if (result) {
console.log('[DesignerRoot] Hash recomputed:', {
newHash: result.designHash.slice(0, 16),
fullHash: result.designHash,
});
}
}, 300); // Debounce 300ms
return () => clearTimeout(timeoutId);
}, [steps, initialized, recomputeHash]);
/* ----------------------------- Derived State ----------------------------- */
const hasUnsavedChanges =
!!currentDesignHash && lastPersistedHash !== currentDesignHash;
// Debug logging to track hash updates and save button state
useEffect(() => {
console.log('[DesignerRoot] Hash State:', {
currentDesignHash: currentDesignHash?.slice(0, 10),
lastPersistedHash: lastPersistedHash?.slice(0, 10),
hasUnsavedChanges,
stepsCount: steps.length,
});
}, [currentDesignHash, lastPersistedHash, hasUnsavedChanges, steps.length]);
/* ------------------------------- Step Ops -------------------------------- */
const createNewStep = useCallback(() => {
const newStep: ExperimentStep = {
@@ -386,8 +455,7 @@ export function DesignerRoot({
}
} catch (err) {
toast.error(
`Validation error: ${
err instanceof Error ? err.message : "Unknown error"
`Validation error: ${err instanceof Error ? err.message : "Unknown error"
}`,
);
} finally {
@@ -404,6 +472,14 @@ export function DesignerRoot({
/* --------------------------------- Save ---------------------------------- */
const persist = useCallback(async () => {
if (!initialized) return;
console.log('[DesignerRoot] 💾 SAVE initiated', {
stepsCount: steps.length,
actionsCount: steps.reduce((sum, s) => sum + s.actions.length, 0),
currentHash: currentDesignHash?.slice(0, 16),
lastPersistedHash: lastPersistedHash?.slice(0, 16),
});
setIsSaving(true);
try {
const visualDesign = {
@@ -411,15 +487,43 @@ export function DesignerRoot({
version: designMeta.version,
lastSaved: new Date().toISOString(),
};
updateExperiment.mutate({
console.log('[DesignerRoot] 💾 Sending to server...', {
experimentId,
stepsCount: steps.length,
version: designMeta.version,
});
// Wait for mutation to complete
await updateExperiment.mutateAsync({
id: experimentId,
visualDesign,
createSteps: true,
compileExecution: autoCompile,
});
// Optimistic hash recompute
await recomputeHash();
console.log('[DesignerRoot] 💾 Server save successful');
// NOTE: We do NOT refetch here because it would reset the local steps state
// to the server state, which would cause the hash to match the persisted hash,
// preventing the save button from re-enabling on subsequent changes.
// The local state is already the source of truth after a successful save.
// Recompute hash and update persisted hash
const hashResult = await recomputeHash();
if (hashResult?.designHash) {
console.log('[DesignerRoot] 💾 Updated persisted hash:', {
newPersistedHash: hashResult.designHash.slice(0, 16),
fullHash: hashResult.designHash,
});
setPersistedHash(hashResult.designHash);
}
setLastSavedAt(new Date());
toast.success("Experiment saved");
console.log('[DesignerRoot] 💾 SAVE complete');
onPersist?.({
id: experimentId,
name: designMeta.name,
@@ -428,16 +532,22 @@ export function DesignerRoot({
version: designMeta.version,
lastSaved: new Date(),
});
} catch (error) {
console.error('[DesignerRoot] 💾 SAVE failed:', error);
// Error already handled by mutation onError
} finally {
setIsSaving(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
initialized,
steps,
designMeta,
experimentId,
updateExperiment,
recomputeHash,
currentDesignHash,
setPersistedHash,
refetchExperiment,
onPersist,
autoCompile,
]);
@@ -479,8 +589,7 @@ export function DesignerRoot({
toast.success("Exported design bundle");
} catch (err) {
toast.error(
`Export failed: ${
err instanceof Error ? err.message : "Unknown error"
`Export failed: ${err instanceof Error ? err.message : "Unknown error"
}`,
);
} finally {
@@ -489,10 +598,11 @@ export function DesignerRoot({
}, [currentDesignHash, steps, experimentId, designMeta, experiment]);
/* ---------------------------- Incremental Hash --------------------------- */
useEffect(() => {
if (!initialized) return;
void recomputeHash();
}, [steps.length, initialized, recomputeHash]);
// Serialize steps for stable comparison
const stepsHash = useMemo(() => JSON.stringify(steps), [steps]);
// Intentionally removed redundant recomputeHash useEffect that was causing excessive refreshes
// The debounced useEffect (lines 352-372) handles this correctly.
useEffect(() => {
if (selectedStepId || selectedActionId) {
@@ -517,18 +627,10 @@ export function DesignerRoot({
) {
e.preventDefault();
void persist();
} else if (e.key === "v" && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
void validateDesign();
} else if (e.key === "e" && !e.metaKey && !e.ctrlKey) {
e.preventDefault();
void handleExport();
} else if (e.key === "n" && e.shiftKey) {
e.preventDefault();
createNewStep();
}
// 'v' (validate), 'e' (export), 'Shift+N' (new step) shortcuts removed to prevent accidents
},
[hasUnsavedChanges, persist, validateDesign, handleExport, createNewStep],
[hasUnsavedChanges, persist],
);
useEffect(() => {
@@ -576,43 +678,163 @@ export function DesignerRoot({
[toggleLibraryScrollLock],
);
const handleDragEnd = useCallback(
async (event: DragEndEvent) => {
const { active, over } = event;
console.debug("[DesignerRoot] dragEnd", {
active: active?.id,
over: over?.id ?? null,
});
// Clear overlay immediately
toggleLibraryScrollLock(false);
setDragOverlayAction(null);
if (!over) {
console.debug("[DesignerRoot] dragEnd: no drop target (ignored)");
const handleDragOver = useCallback((event: DragOverEvent) => {
const { active, over } = event;
const store = useDesignerStore.getState();
// Only handle Library -> Flow projection
if (!active.id.toString().startsWith("action-")) {
if (store.insertionProjection) {
store.setInsertionProjection(null);
}
return;
}
if (!over) {
if (store.insertionProjection) {
store.setInsertionProjection(null);
}
return;
}
const overId = over.id.toString();
const activeDef = active.data.current?.action;
if (!activeDef) return;
let stepId: string | null = null;
let parentId: string | null = null;
let index = 0;
// Detect target based on over id
if (overId.startsWith("s-act-")) {
const data = over.data.current;
if (data && data.stepId) {
stepId = data.stepId;
parentId = data.parentId ?? null; // Use parentId from the action we are hovering over
// Use sortable index (insertion point provided by dnd-kit sortable strategy)
index = data.sortable?.index ?? 0;
}
} else if (overId.startsWith("container-")) {
// Dropping into a container (e.g. Loop)
const data = over.data.current;
if (data && data.stepId) {
stepId = data.stepId;
parentId = data.parentId ?? overId.slice("container-".length);
// If dropping into container, appending is a safe default if specific index logic is missing
// But actually we can find length if we want. For now, 0 or append logic?
// If container is empty, index 0 is correct.
// If not empty, we are hitting the container *background*, so append?
// The projection logic will insert at 'index'. If index is past length, it appends.
// Let's set a large index to ensure append, or look up length.
// Lookup requires finding the action in store. Expensive?
// Let's assume index 0 for now (prepend) or implement lookup.
// Better: lookup action -> children length.
const actionId = parentId;
const step = store.steps.find(s => s.id === stepId);
// Find action recursive? Store has `findActionById` helper but it is not exported/accessible easily here?
// Actually, `store.steps` is available.
// We can implement a quick BFS/DFS or just assume 0.
// If dragging over the container *background* (empty space), append is usually expected.
// Let's try 9999?
index = 9999;
}
} else if (overId.startsWith("s-step-") || overId.startsWith("step-")) {
// Container drop (Step)
stepId = overId.startsWith("s-step-")
? overId.slice("s-step-".length)
: overId.slice("step-".length);
const step = store.steps.find((s) => s.id === stepId);
index = step ? step.actions.length : 0;
} else if (overId === "projection-placeholder") {
// Hovering over our own projection placeholder -> keep current state
return;
}
if (stepId) {
const current = store.insertionProjection;
// Optimization: avoid redundant updates if projection matches
if (
current &&
current.stepId === stepId &&
current.parentId === parentId &&
current.index === index
) {
return;
}
// Expect dragged action (library) onto a step droppable
const activeId = active.id.toString();
const overId = over.id.toString();
store.setInsertionProjection({
stepId,
parentId,
index,
action: {
id: "projection-placeholder",
type: activeDef.type,
name: activeDef.name,
category: activeDef.category,
description: "Drop here",
source: activeDef.source || { kind: "library" },
parameters: {},
execution: activeDef.execution,
} as any,
});
} else {
if (store.insertionProjection) store.setInsertionProjection(null);
}
}, []);
if (activeId.startsWith("action-") && active.data.current?.action) {
// Resolve stepId from possible over ids: step-<id>, s-step-<id>, or s-act-<actionId>
let stepId: string | null = null;
const handleDragEnd = useCallback(
async (event: DragEndEvent) => {
const { active, over } = event;
// Clear overlay immediately
toggleLibraryScrollLock(false);
setDragOverlayAction(null);
// Capture and clear projection
const store = useDesignerStore.getState();
const projection = store.insertionProjection;
store.setInsertionProjection(null);
if (!over) {
return;
}
// 1. Determine Target (Step, Parent, Index)
let stepId: string | null = null;
let parentId: string | null = null;
let index: number | undefined = undefined;
if (projection) {
stepId = projection.stepId;
parentId = projection.parentId;
index = projection.index;
} else {
// Fallback: resolution from overId (if projection failed or raced)
const overId = over.id.toString();
if (overId.startsWith("step-")) {
stepId = overId.slice("step-".length);
} else if (overId.startsWith("s-step-")) {
stepId = overId.slice("s-step-".length);
} else if (overId.startsWith("s-act-")) {
// This might fail if s-act-projection, but that should have covered by projection check above
const actionId = overId.slice("s-act-".length);
const parent = steps.find((s) =>
s.actions.some((a) => a.id === actionId),
);
stepId = parent?.id ?? null;
}
if (!stepId) return;
}
if (!stepId) return;
const targetStep = steps.find((s) => s.id === stepId);
if (!targetStep) return;
// 2. Instantiate Action
if (active.id.toString().startsWith("action-") && active.data.current?.action) {
const actionDef = active.data.current.action as {
id: string;
id: string; // type
type: string;
name: string;
category: string;
@@ -622,51 +844,82 @@ export function DesignerRoot({
parameters: Array<{ id: string; name: string }>;
};
const targetStep = steps.find((s) => s.id === stepId);
if (!targetStep) return;
const fullDef = actionRegistry.getAction(actionDef.type);
const defaultParams: Record<string, unknown> = {};
if (fullDef?.parameters) {
for (const param of fullDef.parameters) {
// @ts-expect-error - 'default' property access
if (param.default !== undefined) {
// @ts-expect-error - 'default' property access
defaultParams[param.id] = param.default;
}
}
}
const execution: ExperimentAction["execution"] =
actionDef.execution &&
(actionDef.execution.transport === "internal" ||
actionDef.execution.transport === "rest" ||
actionDef.execution.transport === "ros2")
(actionDef.execution.transport === "internal" ||
actionDef.execution.transport === "rest" ||
actionDef.execution.transport === "ros2")
? {
transport: actionDef.execution.transport,
retryable: actionDef.execution.retryable ?? false,
}
: {
transport: "internal",
retryable: false,
};
transport: actionDef.execution.transport,
retryable: actionDef.execution.retryable ?? false,
}
: undefined;
const newAction: ExperimentAction = {
id: `action-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
type: actionDef.type,
id: crypto.randomUUID(),
type: actionDef.type, // this is the 'type' key
name: actionDef.name,
category: actionDef.category as ExperimentAction["category"],
parameters: {},
source: actionDef.source as ExperimentAction["source"],
category: actionDef.category as any,
description: "",
parameters: defaultParams,
source: actionDef.source ? {
kind: actionDef.source.kind as any,
pluginId: actionDef.source.pluginId,
pluginVersion: actionDef.source.pluginVersion,
baseActionId: actionDef.id
} : { kind: "core" },
execution,
children: [],
};
upsertAction(stepId, newAction);
// Select the newly added action and open properties
selectStep(stepId);
// 3. Commit
upsertAction(stepId, newAction, parentId, index);
// Auto-select
selectAction(stepId, newAction.id);
setInspectorTab("properties");
await recomputeHash();
toast.success(`Added ${actionDef.name} to ${targetStep.name}`);
void recomputeHash();
}
},
[
steps,
upsertAction,
recomputeHash,
selectStep,
selectAction,
toggleLibraryScrollLock,
],
[steps, upsertAction, selectAction, recomputeHash, toggleLibraryScrollLock],
);
// validation status badges removed (unused)
/* ------------------------------- Panels ---------------------------------- */
const leftPanel = useMemo(
() => (
<div ref={libraryRootRef} data-library-root className="h-full">
<ActionLibraryPanel />
</div>
),
[],
);
const centerPanel = useMemo(() => <FlowWorkspace />, []);
const rightPanel = useMemo(
() => (
<div className="h-full">
<InspectorPanel
activeTab={inspectorTab}
onTabChange={setInspectorTab}
studyPlugins={studyPlugins}
/>
</div>
),
[inspectorTab, studyPlugins],
);
/* ------------------------------- Render ---------------------------------- */
if (loadingExperiment && !initialized) {
@@ -677,80 +930,105 @@ export function DesignerRoot({
);
}
const actions = (
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="default"
className="h-8 px-3 text-xs"
onClick={() => validateDesign()}
disabled={isValidating}
>
Validate
</Button>
<Button
size="sm"
variant="secondary"
className="h-8 px-3 text-xs"
onClick={() => persist()}
disabled={!hasUnsavedChanges || isSaving}
>
Save
</Button>
</div>
);
return (
<div className="space-y-4">
<div className="flex h-full w-full flex-col overflow-hidden">
<PageHeader
title={designMeta.name}
description="Compose ordered steps with provenance-aware actions."
description={designMeta.description || "No description"}
icon={Play}
actions={
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="default"
className="h-8 px-3 text-xs"
onClick={() => validateDesign()}
disabled={isValidating}
>
Validate
</Button>
<Button
size="sm"
variant="secondary"
className="h-8 px-3 text-xs"
onClick={() => persist()}
disabled={!hasUnsavedChanges || isSaving}
>
Save
</Button>
</div>
}
actions={actions}
className="pb-6"
/>
<div className="flex h-[calc(100vh-12rem)] w-full max-w-full flex-col overflow-hidden rounded-md border">
<DndContext
sensors={sensors}
collisionDetection={pointerWithin}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={() => toggleLibraryScrollLock(false)}
<div className="relative flex flex-1 flex-col overflow-hidden">
{/* Loading Overlay */}
{!isReady && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
<RefreshCw className="h-8 w-8 animate-spin text-primary" />
<p className="text-muted-foreground text-sm">Loading designer...</p>
</div>
</div>
)}
{/* Main Content - Fade in when ready */}
<div
className={cn(
"flex flex-1 flex-col overflow-hidden transition-opacity duration-500",
isReady ? "opacity-100" : "opacity-0"
)}
>
<PanelsContainer
showDividers
className="min-h-0 flex-1"
left={
<div ref={libraryRootRef} data-library-root className="h-full">
<ActionLibraryPanel />
</div>
}
center={<FlowWorkspace />}
right={
<div className="h-full">
<InspectorPanel
activeTab={inspectorTab}
onTabChange={setInspectorTab}
/>
</div>
}
/>
<DragOverlay>
{dragOverlayAction ? (
<div className="bg-background pointer-events-none rounded border px-2 py-1 text-xs shadow-lg select-none">
{dragOverlayAction.name}
</div>
) : null}
</DragOverlay>
</DndContext>
<div className="flex-shrink-0 border-t">
<BottomStatusBar
onSave={() => persist()}
onValidate={() => validateDesign()}
onExport={() => handleExport()}
lastSavedAt={lastSavedAt}
saving={isSaving}
validating={isValidating}
exporting={isExporting}
/>
<div className="flex h-[calc(100vh-12rem)] w-full max-w-full flex-col overflow-hidden rounded-md border">
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragCancel={() => toggleLibraryScrollLock(false)}
>
<PanelsContainer
showDividers
className="min-h-0 flex-1"
left={leftPanel}
center={centerPanel}
right={rightPanel}
/>
<DragOverlay>
{dragOverlayAction ? (
<div className="bg-background flex items-center gap-2 rounded border px-3 py-2 text-xs font-medium shadow-lg select-none">
<span
className={cn(
"h-2.5 w-2.5 rounded-full",
{
wizard: "bg-blue-500",
robot: "bg-emerald-600",
control: "bg-amber-500",
observation: "bg-purple-600",
}[dragOverlayAction.category] || "bg-slate-400",
)}
/>
{dragOverlayAction.name}
</div>
) : null}
</DragOverlay>
</DndContext>
<div className="flex-shrink-0 border-t">
<BottomStatusBar
onSave={() => persist()}
onValidate={() => validateDesign()}
onExport={() => handleExport()}
onRecalculateHash={() => recomputeHash()}
lastSavedAt={lastSavedAt}
saving={isSaving}
validating={isValidating}
exporting={isExporting}
/>
</div>
</div>
</div>
</div>
</div>

471
src/components/experiments/designer/PropertiesPanel.tsx Normal file → Executable file
View File

@@ -1,6 +1,6 @@
"use client";
import React from "react";
import React, { useState, useEffect, useCallback, useRef } from "react";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import {
@@ -70,7 +70,7 @@ export interface PropertiesPanelProps {
className?: string;
}
export function PropertiesPanel({
export function PropertiesPanelBase({
design,
selectedStep,
selectedAction,
@@ -80,6 +80,85 @@ export function PropertiesPanel({
}: PropertiesPanelProps) {
const registry = actionRegistry;
// Local state for controlled inputs
const [localActionName, setLocalActionName] = useState("");
const [localStepName, setLocalStepName] = useState("");
const [localStepDescription, setLocalStepDescription] = useState("");
const [localParams, setLocalParams] = useState<Record<string, unknown>>({});
// Debounce timers
const actionUpdateTimer = useRef<NodeJS.Timeout | undefined>(undefined);
const stepUpdateTimer = useRef<NodeJS.Timeout | undefined>(undefined);
const paramUpdateTimers = useRef(new Map<string, NodeJS.Timeout>());
// Sync local state when selection ID changes (not on every object recreation)
useEffect(() => {
if (selectedAction) {
setLocalActionName(selectedAction.name);
setLocalParams(selectedAction.parameters);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedAction?.id]);
useEffect(() => {
if (selectedStep) {
setLocalStepName(selectedStep.name);
setLocalStepDescription(selectedStep.description ?? "");
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedStep?.id]);
// Cleanup timers on unmount
useEffect(() => {
const timersMap = paramUpdateTimers.current;
return () => {
if (actionUpdateTimer.current) clearTimeout(actionUpdateTimer.current);
if (stepUpdateTimer.current) clearTimeout(stepUpdateTimer.current);
timersMap.forEach((timer) => clearTimeout(timer));
};
}, []);
// Debounced update handlers
const debouncedActionUpdate = useCallback(
(stepId: string, actionId: string, updates: Partial<ExperimentAction>) => {
if (actionUpdateTimer.current) clearTimeout(actionUpdateTimer.current);
actionUpdateTimer.current = setTimeout(() => {
onActionUpdate(stepId, actionId, updates);
}, 300);
},
[onActionUpdate],
);
const debouncedStepUpdate = useCallback(
(stepId: string, updates: Partial<ExperimentStep>) => {
if (stepUpdateTimer.current) clearTimeout(stepUpdateTimer.current);
stepUpdateTimer.current = setTimeout(() => {
onStepUpdate(stepId, updates);
}, 300);
},
[onStepUpdate],
);
const debouncedParamUpdate = useCallback(
(stepId: string, actionId: string, paramId: string, value: unknown) => {
const existing = paramUpdateTimers.current.get(paramId);
if (existing) clearTimeout(existing);
const timer = setTimeout(() => {
onActionUpdate(stepId, actionId, {
parameters: {
...selectedAction?.parameters,
[paramId]: value,
},
});
paramUpdateTimers.current.delete(paramId);
}, 300);
paramUpdateTimers.current.set(paramId, timer);
},
[onActionUpdate, selectedAction?.parameters],
);
// Find containing step for selected action (if any)
const containingStep =
selectedAction &&
@@ -119,8 +198,8 @@ export function PropertiesPanel({
const ResolvedIcon: React.ComponentType<{ className?: string }> =
def?.icon && iconComponents[def.icon]
? (iconComponents[def.icon] as React.ComponentType<{
className?: string;
}>)
className?: string;
}>)
: Zap;
return (
@@ -176,12 +255,21 @@ export function PropertiesPanel({
<div>
<Label className="text-xs">Display Name</Label>
<Input
value={selectedAction.name}
onChange={(e) =>
onActionUpdate(containingStep.id, selectedAction.id, {
name: e.target.value,
})
}
value={localActionName}
onChange={(e) => {
const newName = e.target.value;
setLocalActionName(newName);
debouncedActionUpdate(containingStep.id, selectedAction.id, {
name: newName,
});
}}
onBlur={() => {
if (localActionName !== selectedAction.name) {
onActionUpdate(containingStep.id, selectedAction.id, {
name: localActionName,
});
}
}}
className="mt-1 h-7 w-full text-xs"
/>
</div>
@@ -194,148 +282,22 @@ export function PropertiesPanel({
Parameters
</div>
<div className="space-y-3">
{def.parameters.map((param) => {
const rawValue = selectedAction.parameters[param.id];
const commonLabel = (
<Label className="flex items-center gap-2 text-xs">
{param.name}
<span className="text-muted-foreground font-normal">
{param.type === "number" &&
(param.min !== undefined || param.max !== undefined) &&
typeof rawValue === "number" &&
`( ${rawValue} )`}
</span>
</Label>
);
/* ---- Handlers ---- */
const updateParamValue = (value: unknown) => {
onActionUpdate(containingStep.id, selectedAction.id, {
parameters: {
...selectedAction.parameters,
[param.id]: value,
},
});
};
/* ---- Control Rendering ---- */
let control: React.ReactNode = null;
if (param.type === "text") {
control = (
<Input
value={(rawValue as string) ?? ""}
placeholder={param.placeholder}
onChange={(e) => updateParamValue(e.target.value)}
className="mt-1 h-7 w-full text-xs"
/>
);
} else if (param.type === "select") {
control = (
<Select
value={(rawValue as string) ?? ""}
onValueChange={(val) => updateParamValue(val)}
>
<SelectTrigger className="mt-1 h-7 w-full text-xs">
<SelectValue placeholder="Select…" />
</SelectTrigger>
<SelectContent>
{param.options?.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
);
} else if (param.type === "boolean") {
control = (
<div className="mt-1 flex h-7 items-center">
<Switch
checked={Boolean(rawValue)}
onCheckedChange={(val) => updateParamValue(val)}
aria-label={param.name}
/>
<span className="text-muted-foreground ml-2 text-[11px]">
{Boolean(rawValue) ? "Enabled" : "Disabled"}
</span>
</div>
);
} else if (param.type === "number") {
const numericVal =
typeof rawValue === "number"
? rawValue
: typeof param.value === "number"
? param.value
: (param.min ?? 0);
if (param.min !== undefined || param.max !== undefined) {
const min = param.min ?? 0;
const max =
param.max ??
Math.max(
min + 1,
Number.isFinite(numericVal) ? numericVal : min + 1,
);
// Step heuristic
const range = max - min;
const step =
param.step ??
(range <= 5
? 0.1
: range <= 50
? 0.5
: Math.max(1, Math.round(range / 100)));
control = (
<div className="mt-1">
<div className="flex items-center gap-2">
<Slider
min={min}
max={max}
step={step}
value={[Number(numericVal)]}
onValueChange={(vals: number[]) =>
updateParamValue(vals[0])
}
/>
<span className="text-muted-foreground min-w-[2.5rem] text-right text-[10px] tabular-nums">
{step < 1
? Number(numericVal).toFixed(2)
: Number(numericVal).toString()}
</span>
</div>
<div className="text-muted-foreground mt-1 flex justify-between text-[10px]">
<span>{min}</span>
<span>{max}</span>
</div>
</div>
);
} else {
control = (
<Input
type="number"
value={numericVal}
onChange={(e) =>
updateParamValue(parseFloat(e.target.value) || 0)
}
className="mt-1 h-7 w-full text-xs"
/>
);
}
}
return (
<div key={param.id} className="space-y-1">
{commonLabel}
{param.description && (
<div className="text-muted-foreground text-[10px]">
{param.description}
</div>
)}
{control}
</div>
);
})}
{def.parameters.map((param) => (
<ParameterEditor
key={param.id}
param={param}
value={selectedAction.parameters[param.id]}
onUpdate={(val) => {
onActionUpdate(containingStep.id, selectedAction.id, {
parameters: {
...selectedAction.parameters,
[param.id]: val,
},
});
}}
onCommit={() => { }}
/>
))}
</div>
</div>
) : (
@@ -373,23 +335,41 @@ export function PropertiesPanel({
<div>
<Label className="text-xs">Name</Label>
<Input
value={selectedStep.name}
onChange={(e) =>
onStepUpdate(selectedStep.id, { name: e.target.value })
}
value={localStepName}
onChange={(e) => {
const newName = e.target.value;
setLocalStepName(newName);
debouncedStepUpdate(selectedStep.id, { name: newName });
}}
onBlur={() => {
if (localStepName !== selectedStep.name) {
onStepUpdate(selectedStep.id, { name: localStepName });
}
}}
className="mt-1 h-7 w-full text-xs"
/>
</div>
<div>
<Label className="text-xs">Description</Label>
<Input
value={selectedStep.description ?? ""}
value={localStepDescription}
placeholder="Optional step description"
onChange={(e) =>
onStepUpdate(selectedStep.id, {
description: e.target.value,
})
}
onChange={(e) => {
const newDesc = e.target.value;
setLocalStepDescription(newDesc);
debouncedStepUpdate(selectedStep.id, {
description: newDesc,
});
}}
onBlur={() => {
if (
localStepDescription !== (selectedStep.description ?? "")
) {
onStepUpdate(selectedStep.id, {
description: localStepDescription,
});
}
}}
className="mt-1 h-7 w-full text-xs"
/>
</div>
@@ -405,9 +385,9 @@ export function PropertiesPanel({
<Label className="text-xs">Type</Label>
<Select
value={selectedStep.type}
onValueChange={(val) =>
onStepUpdate(selectedStep.id, { type: val as StepType })
}
onValueChange={(val) => {
onStepUpdate(selectedStep.id, { type: val as StepType });
}}
>
<SelectTrigger className="mt-1 h-7 w-full text-xs">
<SelectValue />
@@ -424,14 +404,14 @@ export function PropertiesPanel({
<Label className="text-xs">Trigger</Label>
<Select
value={selectedStep.trigger.type}
onValueChange={(val) =>
onValueChange={(val) => {
onStepUpdate(selectedStep.id, {
trigger: {
...selectedStep.trigger,
type: val as TriggerType,
},
})
}
});
}}
>
<SelectTrigger className="mt-1 h-7 w-full text-xs">
<SelectValue />
@@ -470,3 +450,158 @@ export function PropertiesPanel({
</div>
);
}
export const PropertiesPanel = React.memo(PropertiesPanelBase);
/* -------------------------------------------------------------------------- */
/* Isolated Parameter Editor (Optimized) */
/* -------------------------------------------------------------------------- */
interface ParameterEditorProps {
param: any;
value: unknown;
onUpdate: (value: unknown) => void;
onCommit: () => void;
}
const ParameterEditor = React.memo(function ParameterEditor({
param,
value: rawValue,
onUpdate,
onCommit
}: ParameterEditorProps) {
// Local state for immediate feedback
const [localValue, setLocalValue] = useState<unknown>(rawValue);
const debounceRef = useRef<NodeJS.Timeout | undefined>();
// Sync from prop if it changes externally
useEffect(() => {
setLocalValue(rawValue);
}, [rawValue]);
const handleUpdate = useCallback((newVal: unknown, immediate = false) => {
setLocalValue(newVal);
if (debounceRef.current) clearTimeout(debounceRef.current);
if (immediate) {
onUpdate(newVal);
} else {
debounceRef.current = setTimeout(() => {
onUpdate(newVal);
}, 300);
}
}, [onUpdate]);
const handleCommit = useCallback(() => {
if (localValue !== rawValue) {
onUpdate(localValue);
}
}, [localValue, rawValue, onUpdate]);
let control: React.ReactNode = null;
if (param.type === "text") {
control = (
<Input
value={(localValue as string) ?? ""}
placeholder={param.placeholder}
onChange={(e) => handleUpdate(e.target.value)}
onBlur={handleCommit}
className="mt-1 h-7 w-full text-xs"
/>
);
} else if (param.type === "select") {
control = (
<Select
value={(localValue as string) ?? ""}
onValueChange={(val) => handleUpdate(val, true)}
>
<SelectTrigger className="mt-1 h-7 w-full text-xs">
<SelectValue placeholder="Select…" />
</SelectTrigger>
<SelectContent>
{param.options?.map((opt: string) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
</SelectContent>
</Select>
);
} else if (param.type === "boolean") {
control = (
<div className="mt-1 flex h-7 items-center">
<Switch
checked={Boolean(localValue)}
onCheckedChange={(val) => handleUpdate(val, true)}
aria-label={param.name}
/>
<span className="text-muted-foreground ml-2 text-[11px]">
{Boolean(localValue) ? "Enabled" : "Disabled"}
</span>
</div>
);
} else if (param.type === "number") {
const numericVal = typeof localValue === "number" ? localValue : (param.min ?? 0);
if (param.min !== undefined || param.max !== undefined) {
const min = param.min ?? 0;
const max = param.max ?? Math.max(min + 1, Number.isFinite(numericVal) ? numericVal : min + 1);
const range = max - min;
const step = param.step ?? (range <= 5 ? 0.1 : range <= 50 ? 0.5 : Math.max(1, Math.round(range / 100)));
control = (
<div className="mt-1">
<div className="flex items-center gap-2">
<Slider
min={min}
max={max}
step={step}
value={[Number(numericVal)]}
onValueChange={(vals) => setLocalValue(vals[0])} // Update only local visual
onPointerUp={() => handleUpdate(localValue)} // Commit on release
/>
<span className="text-muted-foreground min-w-[2.5rem] text-right text-[10px] tabular-nums">
{step < 1 ? Number(numericVal).toFixed(2) : Number(numericVal).toString()}
</span>
</div>
<div className="text-muted-foreground mt-1 flex justify-between text-[10px]">
<span>{min}</span>
<span>{max}</span>
</div>
</div>
);
} else {
control = (
<Input
type="number"
value={numericVal}
onChange={(e) => handleUpdate(parseFloat(e.target.value) || 0)}
onBlur={handleCommit}
className="mt-1 h-7 w-full text-xs"
/>
);
}
}
return (
<div className="space-y-1">
<Label className="flex items-center gap-2 text-xs">
{param.name}
<span className="text-muted-foreground font-normal">
{param.type === "number" &&
(param.min !== undefined || param.max !== undefined) &&
typeof rawValue === "number" &&
`( ${rawValue} )`}
</span>
</Label>
{param.description && (
<div className="text-muted-foreground text-[10px]">
{param.description}
</div>
)}
{control}
</div>
);
});

0
src/components/experiments/designer/StepPreview.tsx Normal file → Executable file
View File

View File

View File

@@ -12,6 +12,7 @@ import {
useDndMonitor,
type DragEndEvent,
type DragStartEvent,
type DragOverEvent,
} from "@dnd-kit/core";
import {
useSortable,
@@ -68,7 +69,7 @@ interface FlowWorkspaceProps {
onActionCreate?: (stepId: string, action: ExperimentAction) => void;
}
interface VirtualItem {
export interface VirtualItem {
index: number;
top: number;
height: number;
@@ -77,6 +78,232 @@ interface VirtualItem {
visible: boolean;
}
interface StepRowProps {
item: VirtualItem;
selectedStepId: string | null | undefined;
selectedActionId: string | null | undefined;
renamingStepId: string | null;
onSelectStep: (id: string | undefined) => void;
onSelectAction: (stepId: string, actionId: string | undefined) => void;
onToggleExpanded: (step: ExperimentStep) => void;
onRenameStep: (step: ExperimentStep, name: string) => void;
onDeleteStep: (step: ExperimentStep) => void;
onDeleteAction: (stepId: string, actionId: string) => void;
setRenamingStepId: (id: string | null) => void;
registerMeasureRef: (stepId: string, el: HTMLDivElement | null) => void;
}
const StepRow = React.memo(function StepRow({
item,
selectedStepId,
selectedActionId,
renamingStepId,
onSelectStep,
onSelectAction,
onToggleExpanded,
onRenameStep,
onDeleteStep,
onDeleteAction,
setRenamingStepId,
registerMeasureRef,
}: StepRowProps) {
const step = item.step;
const insertionProjection = useDesignerStore((s) => s.insertionProjection);
const displayActions = useMemo(() => {
if (
insertionProjection?.stepId === step.id &&
insertionProjection.parentId === null
) {
const copy = [...step.actions];
// Insert placeholder action
// Ensure specific ID doesn't crash keys if collision (collision unlikely for library items)
// Actually, standard array key is action.id.
copy.splice(insertionProjection.index, 0, insertionProjection.action);
return copy;
}
return step.actions;
}, [step.actions, step.id, insertionProjection]);
const {
setNodeRef,
transform,
transition,
attributes,
listeners,
isDragging,
} = useSortable({
id: sortableStepId(step.id),
data: {
type: "step",
step: step,
},
});
const style: React.CSSProperties = {
position: "absolute",
top: item.top,
left: 0,
right: 0,
width: "100%",
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 25 : undefined,
};
return (
<div ref={setNodeRef} style={style} data-step-id={step.id}>
<div
ref={(el) => registerMeasureRef(step.id, el)}
className="relative px-3 py-4"
data-step-id={step.id}
>
<StepDroppableArea stepId={step.id} />
<div
className={cn(
"mb-2 rounded border shadow-sm transition-colors",
selectedStepId === step.id
? "border-border bg-accent/30"
: "hover:bg-accent/30",
isDragging && "opacity-80 ring-1 ring-blue-300",
)}
>
<div
className="flex items-center justify-between gap-2 border-b px-2 py-1.5"
onClick={(e) => {
const tag = (e.target as HTMLElement).tagName.toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "button")
return;
onSelectStep(step.id);
onSelectAction(step.id, undefined);
}}
role="button"
tabIndex={0}
>
<div className="flex items-center gap-2">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onToggleExpanded(step);
}}
className="text-muted-foreground hover:bg-accent/60 hover:text-foreground rounded p-1"
aria-label={step.expanded ? "Collapse step" : "Expand step"}
>
{step.expanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] font-normal"
>
{step.order + 1}
</Badge>
{renamingStepId === step.id ? (
<Input
autoFocus
defaultValue={step.name}
className="h-7 w-40 text-xs"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Enter") {
onRenameStep(
step,
(e.target as HTMLInputElement).value.trim() ||
step.name,
);
setRenamingStepId(null);
} else if (e.key === "Escape") {
setRenamingStepId(null);
}
}}
onBlur={(e) => {
onRenameStep(step, e.target.value.trim() || step.name);
setRenamingStepId(null);
}}
/>
) : (
<div className="flex items-center gap-1">
<span className="text-sm font-medium">{step.name}</span>
<button
type="button"
className="text-muted-foreground hover:text-foreground p-1 opacity-0 group-hover:opacity-100"
aria-label="Rename step"
onClick={(e) => {
e.stopPropagation();
setRenamingStepId(step.id);
}}
>
<Edit3 className="h-3.5 w-3.5" />
</button>
</div>
)}
<span className="text-muted-foreground hidden text-[11px] md:inline">
{step.actions.length} actions
</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-[11px] text-red-500 hover:text-red-600"
onClick={(e) => {
e.stopPropagation();
onDeleteStep(step);
}}
aria-label="Delete step"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<div
className="text-muted-foreground cursor-grab p-1"
aria-label="Drag step"
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4" />
</div>
</div>
</div>
{/* Action List (Collapsible/Virtual content) */}
{step.expanded && (
<div className="bg-background/40 min-h-[3rem] space-y-2 p-2 pb-8">
<SortableContext
items={displayActions.map((a) => sortableActionId(a.id))}
strategy={verticalListSortingStrategy}
>
<div className="flex w-full flex-col gap-2">
{displayActions.length === 0 ? (
<div className="flex h-12 items-center justify-center rounded border border-dashed text-xs text-muted-foreground">
Drop actions here
</div>
) : (
displayActions.map((action) => (
<SortableActionChip
key={action.id}
stepId={step.id}
action={action}
parentId={null}
selectedActionId={selectedActionId}
onSelectAction={onSelectAction}
onDeleteAction={onDeleteAction}
/>
))
)}
</div>
</SortableContext>
</div>
)}
</div>
</div>
</div>
);
});
/* -------------------------------------------------------------------------- */
/* Utility */
/* -------------------------------------------------------------------------- */
@@ -111,7 +338,7 @@ function StepDroppableArea({ stepId }: { stepId: string }) {
className={cn(
"pointer-events-none absolute inset-0 rounded-md transition-colors",
isOver &&
"bg-blue-50/40 ring-2 ring-blue-400/60 ring-offset-0 dark:bg-blue-950/20",
"bg-blue-50/40 ring-2 ring-blue-400/60 ring-offset-0 dark:bg-blue-950/20",
)}
/>
);
@@ -122,37 +349,125 @@ function StepDroppableArea({ stepId }: { stepId: string }) {
/* -------------------------------------------------------------------------- */
interface ActionChipProps {
stepId: string;
action: ExperimentAction;
isSelected: boolean;
onSelect: () => void;
onDelete: () => void;
parentId: string | null;
selectedActionId: string | null | undefined;
onSelectAction: (stepId: string, actionId: string | undefined) => void;
onDeleteAction: (stepId: string, actionId: string) => void;
dragHandle?: boolean;
}
function SortableActionChip({
stepId,
action,
isSelected,
onSelect,
onDelete,
parentId,
selectedActionId,
onSelectAction,
onDeleteAction,
dragHandle,
}: ActionChipProps) {
const def = actionRegistry.getAction(action.type);
const isSelected = selectedActionId === action.id;
const insertionProjection = useDesignerStore((s) => s.insertionProjection);
const displayChildren = useMemo(() => {
if (
insertionProjection?.stepId === stepId &&
insertionProjection.parentId === action.id
) {
const copy = [...(action.children || [])];
copy.splice(insertionProjection.index, 0, insertionProjection.action);
return copy;
}
return action.children;
}, [action.children, action.id, stepId, insertionProjection]);
/* ------------------------------------------------------------------------ */
/* Main Sortable Logic */
/* ------------------------------------------------------------------------ */
const isPlaceholder = action.id === "projection-placeholder";
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
isDragging: isSortableDragging,
} = useSortable({
id: sortableActionId(action.id),
disabled: isPlaceholder, // Disable sortable for placeholder
data: {
type: "action",
stepId,
parentId,
id: action.id,
},
});
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
// Use local dragging state or passed prop
const isDragging = isSortableDragging || dragHandle;
const style = {
transform: CSS.Translate.toString(transform),
transition,
zIndex: isDragging ? 30 : undefined,
};
/* ------------------------------------------------------------------------ */
/* Nested Droppable (for control flow containers) */
/* ------------------------------------------------------------------------ */
const nestedDroppableId = `container-${action.id}`;
const {
isOver: isOverNested,
setNodeRef: setNestedNodeRef
} = useDroppable({
id: nestedDroppableId,
disabled: !def?.nestable || isPlaceholder, // Disable droppable for placeholder
data: {
type: "container",
stepId,
parentId: action.id,
action // Pass full action for projection logic
}
});
const shouldRenderChildren = def?.nestable;
if (isPlaceholder) {
const { setNodeRef: setPlaceholderRef } = useDroppable({
id: "projection-placeholder",
data: { type: "placeholder" }
});
// Render simplified placeholder without hooks refs
// We still render the content matching the action type for visual fidelity
return (
<div
ref={setPlaceholderRef}
className="group relative flex w-full flex-col items-start gap-1 rounded border-2 border-dashed border-blue-300 bg-blue-50/50 px-3 py-2 text-[11px] opacity-70"
>
<div className="flex w-full items-center gap-2">
<span className={cn(
"h-2.5 w-2.5 rounded-full",
def ? {
wizard: "bg-blue-500",
robot: "bg-emerald-500",
control: "bg-amber-500",
observation: "bg-purple-500",
}[def.category] : "bg-gray-400"
)} />
<span className="font-medium text-foreground">{def?.name ?? action.name}</span>
</div>
{def?.description && (
<div className="text-muted-foreground line-clamp-3 w-full text-[10px] leading-snug">
{def.description}
</div>
)}
</div>
);
}
return (
<div
ref={setNodeRef}
@@ -162,8 +477,13 @@ function SortableActionChip({
"bg-muted/40 hover:bg-accent/40 cursor-pointer",
isSelected && "border-border bg-accent/30",
isDragging && "opacity-70 shadow-lg",
// Visual feedback for nested drop
isOverNested && !isDragging && "ring-2 ring-blue-400 ring-offset-1 bg-blue-50/50"
)}
onClick={onSelect}
onClick={(e) => {
e.stopPropagation();
onSelectAction(stepId, action.id);
}}
{...attributes}
role="button"
aria-pressed={isSelected}
@@ -182,11 +502,11 @@ function SortableActionChip({
"h-2.5 w-2.5 rounded-full",
def
? {
wizard: "bg-blue-500",
robot: "bg-emerald-500",
control: "bg-amber-500",
observation: "bg-purple-500",
}[def.category]
wizard: "bg-blue-500",
robot: "bg-emerald-500",
control: "bg-amber-500",
observation: "bg-purple-500",
}[def.category]
: "bg-slate-400",
)}
/>
@@ -197,7 +517,7 @@ function SortableActionChip({
type="button"
onClick={(e) => {
e.stopPropagation();
onDelete();
onDeleteAction(stepId, action.id);
}}
className="text-muted-foreground hover:text-foreground rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100"
aria-label="Delete action"
@@ -221,12 +541,45 @@ function SortableActionChip({
</span>
))}
{def.parameters.length > 4 && (
<span className="text-muted-foreground text-[9px]">
+{def.parameters.length - 4} more
</span>
<span className="text-[9px] text-muted-foreground">+{def.parameters.length - 4}</span>
)}
</div>
) : null}
{/* Nested Actions Container */}
{shouldRenderChildren && (
<div
ref={setNestedNodeRef}
className={cn(
"mt-2 w-full flex flex-col gap-2 pl-4 border-l-2 border-border/40 transition-all min-h-[0.5rem] pb-4",
)}
>
<SortableContext
items={(displayChildren ?? action.children ?? [])
.filter(c => c.id !== "projection-placeholder")
.map(c => sortableActionId(c.id))}
strategy={verticalListSortingStrategy}
>
{(displayChildren || action.children || []).map((child) => (
<SortableActionChip
key={child.id}
stepId={stepId}
action={child}
parentId={action.id}
selectedActionId={selectedActionId}
onSelectAction={onSelectAction}
onDeleteAction={onDeleteAction}
/>
))}
{(!displayChildren?.length && !action.children?.length) && (
<div className="text-[10px] text-muted-foreground/60 italic py-1">
Drag actions here
</div>
)}
</SortableContext>
</div>
)}
</div>
);
}
@@ -254,7 +607,7 @@ export function FlowWorkspace({
const removeAction = useDesignerStore((s) => s.removeAction);
const reorderStep = useDesignerStore((s) => s.reorderStep);
const reorderAction = useDesignerStore((s) => s.reorderAction);
const moveAction = useDesignerStore((s) => s.moveAction);
const recomputeHash = useDesignerStore((s) => s.recomputeHash);
/* Local state */
@@ -382,7 +735,10 @@ export function FlowWorkspace({
description: "",
type: "sequential",
order: steps.length,
trigger: { type: "trial_start", conditions: {} },
trigger:
steps.length === 0
? { type: "trial_start", conditions: {} }
: { type: "previous_step", conditions: {} },
actions: [],
expanded: true,
};
@@ -472,34 +828,77 @@ export function FlowWorkspace({
}
}
}
// Action reorder (within same parent only)
// Action reorder (supports nesting)
if (activeId.startsWith("s-act-") && overId.startsWith("s-act-")) {
const fromActionId = parseSortableAction(activeId);
const toActionId = parseSortableAction(overId);
if (fromActionId && toActionId && fromActionId !== toActionId) {
const fromParent = actionParentMap.get(fromActionId);
const toParent = actionParentMap.get(toActionId);
if (fromParent && toParent && fromParent === toParent) {
const step = steps.find((s) => s.id === fromParent);
if (step) {
const fromIdx = step.actions.findIndex(
(a) => a.id === fromActionId,
);
const toIdx = step.actions.findIndex((a) => a.id === toActionId);
if (fromIdx >= 0 && toIdx >= 0) {
reorderAction(step.id, fromIdx, toIdx);
void recomputeHash();
}
}
const activeData = active.data.current;
const overData = over.data.current;
if (
activeData && overData &&
activeData.stepId === overData.stepId &&
activeData.type === 'action' && overData.type === 'action'
) {
const stepId = activeData.stepId as string;
const activeActionId = activeData.action.id;
const overActionId = overData.action.id;
if (activeActionId !== overActionId) {
const newParentId = overData.parentId as string | null;
const newIndex = overData.sortable.index; // index within that parent's list
moveAction(stepId, activeActionId, newParentId, newIndex);
void recomputeHash();
}
}
}
},
[steps, reorderStep, reorderAction, actionParentMap, recomputeHash],
[steps, reorderStep, moveAction, recomputeHash],
);
/* ------------------------------------------------------------------------ */
/* Drag Over (Live Sorting) */
/* ------------------------------------------------------------------------ */
const handleLocalDragOver = useCallback(
(event: DragOverEvent) => {
const { active, over } = event;
if (!over) return;
const activeId = active.id.toString();
const overId = over.id.toString();
// Only handle action reordering
if (activeId.startsWith("s-act-") && overId.startsWith("s-act-")) {
const activeData = active.data.current;
const overData = over.data.current;
if (
activeData &&
overData &&
activeData.type === 'action' &&
overData.type === 'action'
) {
const activeActionId = activeData.action.id;
const overActionId = overData.action.id;
const activeStepId = activeData.stepId;
const overStepId = overData.stepId;
const activeParentId = activeData.parentId;
const overParentId = overData.parentId;
// If moving between different lists (parents/steps), move immediately to visualize snap
if (activeParentId !== overParentId || activeStepId !== overStepId) {
// Determine new index
// verification of safe move handled by store
moveAction(overStepId, activeActionId, overParentId, overData.sortable.index);
}
}
}
},
[moveAction]
);
useDndMonitor({
onDragStart: handleLocalDragStart,
onDragOver: handleLocalDragOver,
onDragEnd: handleLocalDragEnd,
onDragCancel: () => {
// no-op
@@ -509,204 +908,22 @@ export function FlowWorkspace({
/* ------------------------------------------------------------------------ */
/* Step Row (Sortable + Virtualized) */
/* ------------------------------------------------------------------------ */
function StepRow({ item }: { item: VirtualItem }) {
const step = item.step;
const {
setNodeRef,
transform,
transition,
attributes,
listeners,
isDragging,
} = useSortable({
id: sortableStepId(step.id),
});
// StepRow moved outside of component to prevent re-mounting on every render (flashing fix)
const style: React.CSSProperties = {
position: "absolute",
top: item.top,
left: 0,
right: 0,
width: "100%",
transform: CSS.Transform.toString(transform),
transition,
zIndex: isDragging ? 25 : undefined,
};
const setMeasureRef = (el: HTMLDivElement | null) => {
const prev = measureRefs.current.get(step.id) ?? null;
const registerMeasureRef = useCallback(
(stepId: string, el: HTMLDivElement | null) => {
const prev = measureRefs.current.get(stepId) ?? null;
if (prev && prev !== el) {
roRef.current?.unobserve(prev);
measureRefs.current.delete(step.id);
measureRefs.current.delete(stepId);
}
if (el) {
measureRefs.current.set(step.id, el);
measureRefs.current.set(stepId, el);
roRef.current?.observe(el);
}
};
return (
<div ref={setNodeRef} style={style} data-step-id={step.id}>
<div
ref={setMeasureRef}
className="relative px-3 py-4"
data-step-id={step.id}
>
<StepDroppableArea stepId={step.id} />
<div
className={cn(
"mb-2 rounded border shadow-sm transition-colors",
selectedStepId === step.id
? "border-border bg-accent/30"
: "hover:bg-accent/30",
isDragging && "opacity-80 ring-1 ring-blue-300",
)}
>
<div
className="flex items-center justify-between gap-2 border-b px-2 py-1.5"
onClick={(e) => {
// Avoid selecting step when interacting with controls or inputs
const tag = (e.target as HTMLElement).tagName.toLowerCase();
if (tag === "input" || tag === "textarea" || tag === "button")
return;
selectStep(step.id);
selectAction(step.id, undefined);
}}
role="button"
tabIndex={0}
>
<div className="flex items-center gap-2">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
toggleExpanded(step);
}}
className="text-muted-foreground hover:bg-accent/60 hover:text-foreground rounded p-1"
aria-label={step.expanded ? "Collapse step" : "Expand step"}
>
{step.expanded ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
</button>
<Badge
variant="outline"
className="h-5 px-1.5 text-[10px] font-normal"
>
{step.order + 1}
</Badge>
{renamingStepId === step.id ? (
<Input
autoFocus
defaultValue={step.name}
className="h-7 w-40 text-xs"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === "Enter") {
renameStep(
step,
(e.target as HTMLInputElement).value.trim() ||
step.name,
);
setRenamingStepId(null);
void recomputeHash();
} else if (e.key === "Escape") {
setRenamingStepId(null);
}
}}
onBlur={(e) => {
renameStep(step, e.target.value.trim() || step.name);
setRenamingStepId(null);
void recomputeHash();
}}
/>
) : (
<div className="flex items-center gap-1">
<span className="text-sm font-medium">{step.name}</span>
<button
type="button"
className="text-muted-foreground hover:text-foreground p-1 opacity-0 group-hover:opacity-100"
aria-label="Rename step"
onClick={(e) => {
e.stopPropagation();
setRenamingStepId(step.id);
}}
>
<Edit3 className="h-3.5 w-3.5" />
</button>
</div>
)}
<span className="text-muted-foreground hidden text-[11px] md:inline">
{step.actions.length} actions
</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 text-[11px] text-red-500 hover:text-red-600"
onClick={(e) => {
e.stopPropagation();
deleteStep(step);
}}
aria-label="Delete step"
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
<div
className="text-muted-foreground cursor-grab p-1"
aria-label="Drag step"
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4" />
</div>
</div>
</div>
{step.expanded && (
<div className="space-y-2 px-3 py-3">
<div className="flex flex-wrap gap-2">
{step.actions.length > 0 && (
<SortableContext
items={step.actions.map((a) => sortableActionId(a.id))}
strategy={verticalListSortingStrategy}
>
<div className="flex w-full flex-col gap-2">
{step.actions.map((action) => (
<SortableActionChip
key={action.id}
action={action}
isSelected={
selectedStepId === step.id &&
selectedActionId === action.id
}
onSelect={() => {
selectStep(step.id);
selectAction(step.id, action.id);
}}
onDelete={() => deleteAction(step.id, action.id)}
/>
))}
</div>
</SortableContext>
)}
</div>
{/* Persistent centered bottom drop hint */}
<div className="mt-3 flex w-full items-center justify-center">
<div className="text-muted-foreground border-muted-foreground/30 rounded border border-dashed px-2 py-1 text-[11px]">
Drop actions here
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
}
},
[],
);
/* ------------------------------------------------------------------------ */
/* Render */
@@ -767,7 +984,27 @@ export function FlowWorkspace({
>
<div style={{ height: totalHeight, position: "relative" }}>
{virtualItems.map(
(vi) => vi.visible && <StepRow key={vi.key} item={vi} />,
(vi) =>
vi.visible && (
<StepRow
key={vi.key}
item={vi}
selectedStepId={selectedStepId}
selectedActionId={selectedActionId}
renamingStepId={renamingStepId}
onSelectStep={selectStep}
onSelectAction={selectAction}
onToggleExpanded={toggleExpanded}
onRenameStep={(step, name) => {
renameStep(step, name);
void recomputeHash();
}}
onDeleteStep={deleteStep}
onDeleteAction={deleteAction}
setRenamingStepId={setRenamingStepId}
registerMeasureRef={registerMeasureRef}
/>
),
)}
</div>
</SortableContext>
@@ -777,4 +1014,6 @@ export function FlowWorkspace({
);
}
export default FlowWorkspace;
// Wrap in React.memo to prevent unnecessary re-renders causing flashing
export default React.memo(FlowWorkspace);

View File

@@ -40,7 +40,7 @@ export interface BottomStatusBarProps {
onValidate?: () => void;
onExport?: () => void;
onOpenCommandPalette?: () => void;
onToggleVersionStrategy?: () => void;
onRecalculateHash?: () => void;
className?: string;
saving?: boolean;
validating?: boolean;
@@ -56,7 +56,7 @@ export function BottomStatusBar({
onValidate,
onExport,
onOpenCommandPalette,
onToggleVersionStrategy,
onRecalculateHash,
className,
saving,
validating,
@@ -198,9 +198,9 @@ export function BottomStatusBar({
if (onOpenCommandPalette) onOpenCommandPalette();
}, [onOpenCommandPalette]);
const handleToggleVersionStrategy = useCallback(() => {
if (onToggleVersionStrategy) onToggleVersionStrategy();
}, [onToggleVersionStrategy]);
const handleRecalculateHash = useCallback(() => {
if (onRecalculateHash) onRecalculateHash();
}, [onRecalculateHash]);
/* ------------------------------------------------------------------------ */
/* Render */
@@ -265,12 +265,21 @@ export function BottomStatusBar({
{autoSaveEnabled ? "auto-save on" : "auto-save off"}
</div>
<div
className="hidden cursor-pointer items-center gap-1 sm:flex"
title={`Version strategy: ${versionStrategy}`}
onClick={handleToggleVersionStrategy}
className="hidden items-center gap-1 font-mono text-[10px] sm:flex"
title={`Current design hash: ${currentDesignHash ?? 'Not computed'}`}
>
<Wand2 className="h-3 w-3" />
{versionStrategy.replace(/_/g, " ")}
<Hash className="h-3 w-3" />
{currentDesignHash?.slice(0, 16) ?? '—'}
<Button
variant="ghost"
size="sm"
className="h-5 px-1 ml-1"
onClick={handleRecalculateHash}
aria-label="Recalculate hash"
title="Recalculate hash"
>
<RefreshCw className="h-3 w-3" />
</Button>
</div>
<div
className="text-muted-foreground/80 hidden items-center gap-1 text-[10px] font-normal tracking-wide md:flex"

View File

@@ -53,6 +53,30 @@ export interface PanelsContainerProps {
* - Resize handles are absolutely positioned over the grid at the left and right boundaries.
* - Fractions are clamped with configurable min/max so panels remain usable at all sizes.
*/
const Panel: React.FC<React.PropsWithChildren<{
className?: string;
panelClassName?: string;
contentClassName?: string;
}>> = ({
className: panelCls,
panelClassName,
contentClassName,
children,
}) => (
<section
className={cn("min-w-0 overflow-hidden", panelCls, panelClassName)}
>
<div
className={cn(
"h-full min-h-0 w-full overflow-x-hidden overflow-y-auto",
contentClassName,
)}
>
{children}
</div>
</section>
);
export function PanelsContainer({
left,
center,
@@ -209,10 +233,10 @@ export function PanelsContainer({
// CSS variables for the grid fractions
const styleVars: React.CSSProperties & Record<string, string> = hasCenter
? {
"--col-left": `${(hasLeft ? l : 0) * 100}%`,
"--col-center": `${c * 100}%`,
"--col-right": `${(hasRight ? r : 0) * 100}%`,
}
"--col-left": `${(hasLeft ? l : 0) * 100}%`,
"--col-center": `${c * 100}%`,
"--col-right": `${(hasRight ? r : 0) * 100}%`,
}
: {};
// Explicit grid template depending on which side panels exist
@@ -229,28 +253,12 @@ export function PanelsContainer({
const centerDividers =
showDividers && hasCenter
? cn({
"border-l": hasLeft,
"border-r": hasRight,
})
"border-l": hasLeft,
"border-r": hasRight,
})
: undefined;
const Panel: React.FC<React.PropsWithChildren<{ className?: string }>> = ({
className: panelCls,
children,
}) => (
<section
className={cn("min-w-0 overflow-hidden", panelCls, panelClassName)}
>
<div
className={cn(
"h-full min-h-0 w-full overflow-x-hidden overflow-y-auto",
contentClassName,
)}
>
{children}
</div>
</section>
);
return (
<div
@@ -263,11 +271,33 @@ export function PanelsContainer({
className,
)}
>
{hasLeft && <Panel>{left}</Panel>}
{hasLeft && (
<Panel
panelClassName={panelClassName}
contentClassName={contentClassName}
>
{left}
</Panel>
)}
{hasCenter && <Panel className={centerDividers}>{center}</Panel>}
{hasCenter && (
<Panel
className={centerDividers}
panelClassName={panelClassName}
contentClassName={contentClassName}
>
{center}
</Panel>
)}
{hasRight && <Panel>{right}</Panel>}
{hasRight && (
<Panel
panelClassName={panelClassName}
contentClassName={contentClassName}
>
{right}
</Panel>
)}
{/* Resize handles (only render where applicable) */}
{hasCenter && hasLeft && (

View File

@@ -88,8 +88,8 @@ function DraggableAction({
const style: React.CSSProperties = transform
? {
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
}
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
}
: {};
const IconComponent = iconMap[action.icon] ?? Sparkles;
@@ -174,7 +174,7 @@ export function ActionLibraryPanel() {
const [search, setSearch] = useState("");
const [selectedCategories, setSelectedCategories] = useState<
Set<ActionCategory>
>(new Set<ActionCategory>(["wizard"]));
>(new Set<ActionCategory>(["wizard", "robot", "control", "observation"]));
const [favorites, setFavorites] = useState<FavoritesState>({
favorites: new Set<string>(),
});
@@ -293,9 +293,7 @@ export function ActionLibraryPanel() {
setShowOnlyFavorites(false);
}, [categories]);
useEffect(() => {
setSelectedCategories(new Set(categories.map((c) => c.key)));
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const filtered = useMemo(() => {
const activeCats = selectedCategories;
@@ -487,4 +485,6 @@ export function ActionLibraryPanel() {
);
}
export default ActionLibraryPanel;
// Wrap in React.memo to prevent unnecessary re-renders causing flashing in categories
export default React.memo(ActionLibraryPanel);

View File

@@ -48,9 +48,18 @@ export interface InspectorPanelProps {
*/
onTabChange?: (tab: "properties" | "issues" | "dependencies") => void;
/**
* Whether to auto-switch to properties tab when selection changes.
* If true, auto-switch to "properties" when a selection occurs.
*/
autoFocusOnSelection?: boolean;
/**
* Study plugins with name and metadata
*/
studyPlugins?: Array<{
id: string;
robotId: string;
name: string;
version: string;
}>;
}
export function InspectorPanel({
@@ -58,6 +67,7 @@ export function InspectorPanel({
activeTab,
onTabChange,
autoFocusOnSelection = true,
studyPlugins,
}: InspectorPanelProps) {
/* ------------------------------------------------------------------------ */
/* Store Selectors */
@@ -274,14 +284,17 @@ export function InspectorPanel({
<div className="flex-1 overflow-x-hidden overflow-y-auto">
<div className="w-full px-0 py-2 break-words whitespace-normal">
<PropertiesPanel
design={{
id: "design",
name: "Design",
description: "",
version: 1,
steps,
lastSaved: new Date(),
}}
design={useMemo(
() => ({
id: "design",
name: "Design",
description: "",
version: 1,
steps,
lastSaved: new Date(),
}),
[steps],
)}
selectedStep={selectedStep}
selectedAction={selectedAction}
onActionUpdate={handleActionUpdate}
@@ -339,6 +352,7 @@ export function InspectorPanel({
steps={steps}
actionSignatureDrift={actionSignatureDrift}
actionDefinitions={actionRegistry.getAllActions()}
studyPlugins={studyPlugins}
onReconcileAction={(actionId) => {
// Placeholder: future diff modal / signature update

41
src/components/experiments/designer/state/hashing.ts Normal file → Executable file
View File

@@ -130,7 +130,7 @@ export interface DesignHashOptions {
}
const DEFAULT_OPTIONS: Required<DesignHashOptions> = {
includeParameterValues: false,
includeParameterValues: true, // Changed to true so parameter changes trigger hash updates
includeActionNames: true,
includeStepNames: true,
};
@@ -155,8 +155,9 @@ function projectActionForDesign(
pluginVersion: action.source.pluginVersion,
baseActionId: action.source.baseActionId,
},
execution: projectExecutionDescriptor(action.execution),
execution: action.execution ? projectExecutionDescriptor(action.execution) : null,
parameterKeysOrValues: parameterProjection,
children: action.children?.map(c => projectActionForDesign(c, options)) ?? [],
};
if (options.includeActionNames) {
@@ -175,16 +176,16 @@ function projectExecutionDescriptor(
timeoutMs: exec.timeoutMs ?? null,
ros2: exec.ros2
? {
topic: exec.ros2.topic ?? null,
service: exec.ros2.service ?? null,
action: exec.ros2.action ?? null,
}
topic: exec.ros2.topic ?? null,
service: exec.ros2.service ?? null,
action: exec.ros2.action ?? null,
}
: null,
rest: exec.rest
? {
method: exec.rest.method,
path: exec.rest.path,
}
method: exec.rest.method,
path: exec.rest.path,
}
: null,
};
}
@@ -244,10 +245,10 @@ export async function computeActionSignature(
baseActionId: def.baseActionId ?? null,
execution: def.execution
? {
transport: def.execution.transport,
retryable: def.execution.retryable ?? false,
timeoutMs: def.execution.timeoutMs ?? null,
}
transport: def.execution.transport,
retryable: def.execution.retryable ?? false,
timeoutMs: def.execution.timeoutMs ?? null,
}
: null,
schema: def.parameterSchemaRaw ? canonicalize(def.parameterSchemaRaw) : null,
};
@@ -301,7 +302,12 @@ export async function computeIncrementalDesignHash(
// First compute per-action hashes
for (const step of steps) {
for (const action of step.actions) {
const existing = previous?.actionHashes.get(action.id);
// Only reuse cached hash if we're NOT including parameter values
// (because parameter values can change without changing the action ID)
const existing = !options.includeParameterValues
? previous?.actionHashes.get(action.id)
: undefined;
if (existing) {
// Simple heuristic: if shallow structural keys unchanged, reuse
// (We still project to confirm minimal structure; deeper diff omitted for performance.)
@@ -316,7 +322,12 @@ export async function computeIncrementalDesignHash(
// Then compute step hashes (including ordered list of action hashes)
for (const step of steps) {
const existing = previous?.stepHashes.get(step.id);
// Only reuse cached hash if we're NOT including parameter values
// (because parameter values in actions can change without changing the step ID)
const existing = !options.includeParameterValues
? previous?.stepHashes.get(step.id)
: undefined;
if (existing) {
stepHashes.set(step.id, existing);
continue;

164
src/components/experiments/designer/state/store.ts Normal file → Executable file
View File

@@ -79,6 +79,23 @@ export interface DesignerState {
busyHashing: boolean;
busyValidating: boolean;
/* ---------------------- DnD Projection (Transient) ----------------------- */
insertionProjection: {
stepId: string;
parentId: string | null;
index: number;
action: ExperimentAction;
} | null;
setInsertionProjection: (
projection: {
stepId: string;
parentId: string | null;
index: number;
action: ExperimentAction;
} | null
) => void;
/* ------------------------------ Mutators --------------------------------- */
// Selection
@@ -92,9 +109,10 @@ export interface DesignerState {
reorderStep: (from: number, to: number) => void;
// Actions
upsertAction: (stepId: string, action: ExperimentAction) => void;
upsertAction: (stepId: string, action: ExperimentAction, parentId?: string | null, index?: number) => void;
removeAction: (stepId: string, actionId: string) => void;
reorderAction: (stepId: string, from: number, to: number) => void;
moveAction: (stepId: string, actionId: string, newParentId: string | null, newIndex: number) => void;
// Dirty
markDirty: (id: string) => void;
@@ -159,17 +177,73 @@ function reindexActions(actions: ExperimentAction[]): ExperimentAction[] {
return actions.map((a) => ({ ...a }));
}
function updateActionList(
existing: ExperimentAction[],
function findActionById(
list: ExperimentAction[],
id: string,
): ExperimentAction | null {
for (const action of list) {
if (action.id === id) return action;
if (action.children) {
const found = findActionById(action.children, id);
if (found) return found;
}
}
return null;
}
function updateActionInTree(
list: ExperimentAction[],
action: ExperimentAction,
): ExperimentAction[] {
const idx = existing.findIndex((a) => a.id === action.id);
if (idx >= 0) {
const copy = [...existing];
copy[idx] = { ...action };
return list.map((a) => {
if (a.id === action.id) return { ...action };
if (a.children) {
return { ...a, children: updateActionInTree(a.children, action) };
}
return a;
});
}
// Immutable removal
function removeActionFromTree(
list: ExperimentAction[],
id: string,
): ExperimentAction[] {
return list
.filter((a) => a.id !== id)
.map((a) => ({
...a,
children: a.children ? removeActionFromTree(a.children, id) : undefined,
}));
}
// Immutable insertion
function insertActionIntoTree(
list: ExperimentAction[],
action: ExperimentAction,
parentId: string | null,
index: number,
): ExperimentAction[] {
if (!parentId) {
// Insert at root level
const copy = [...list];
copy.splice(index, 0, action);
return copy;
}
return [...existing, { ...action }];
return list.map((a) => {
if (a.id === parentId) {
const children = a.children ? [...a.children] : [];
children.splice(index, 0, action);
return { ...a, children };
}
if (a.children) {
return {
...a,
children: insertActionIntoTree(a.children, action, parentId, index),
};
}
return a;
});
}
/* -------------------------------------------------------------------------- */
@@ -187,6 +261,7 @@ export const useDesignerStore = create<DesignerState>((set, get) => ({
autoSaveEnabled: true,
busyHashing: false,
busyValidating: false,
insertionProjection: null,
/* ------------------------------ Selection -------------------------------- */
selectStep: (id) =>
@@ -263,16 +338,31 @@ export const useDesignerStore = create<DesignerState>((set, get) => ({
}),
/* ------------------------------- Actions --------------------------------- */
upsertAction: (stepId: string, action: ExperimentAction) =>
upsertAction: (stepId: string, action: ExperimentAction, parentId: string | null = null, index?: number) =>
set((state: DesignerState) => {
const stepsDraft: ExperimentStep[] = state.steps.map((s) =>
s.id === stepId
? {
...s,
actions: reindexActions(updateActionList(s.actions, action)),
}
: s,
);
const stepsDraft: ExperimentStep[] = state.steps.map((s) => {
if (s.id !== stepId) return s;
// Check if exists (update)
const exists = findActionById(s.actions, action.id);
if (exists) {
// If updating, we don't (currently) support moving via upsert.
// Use moveAction for moving.
return {
...s,
actions: updateActionInTree(s.actions, action)
};
}
// Add new
// If index is provided, use it. Otherwise append.
const insertIndex = index ?? s.actions.length;
return {
...s,
actions: insertActionIntoTree(s.actions, action, parentId, insertIndex)
};
});
return {
steps: stepsDraft,
dirtyEntities: new Set<string>([
@@ -288,11 +378,9 @@ export const useDesignerStore = create<DesignerState>((set, get) => ({
const stepsDraft: ExperimentStep[] = state.steps.map((s) =>
s.id === stepId
? {
...s,
actions: reindexActions(
s.actions.filter((a) => a.id !== actionId),
),
}
...s,
actions: removeActionFromTree(s.actions, actionId),
}
: s,
);
const dirty = new Set<string>(state.dirtyEntities);
@@ -308,31 +396,29 @@ export const useDesignerStore = create<DesignerState>((set, get) => ({
};
}),
reorderAction: (stepId: string, from: number, to: number) =>
moveAction: (stepId: string, actionId: string, newParentId: string | null, newIndex: number) =>
set((state: DesignerState) => {
const stepsDraft: ExperimentStep[] = state.steps.map((s) => {
const stepsDraft = state.steps.map((s) => {
if (s.id !== stepId) return s;
if (
from < 0 ||
to < 0 ||
from >= s.actions.length ||
to >= s.actions.length ||
from === to
) {
return s;
}
const actionsDraft = [...s.actions];
const [moved] = actionsDraft.splice(from, 1);
if (!moved) return s;
actionsDraft.splice(to, 0, moved);
return { ...s, actions: reindexActions(actionsDraft) };
const actionToMove = findActionById(s.actions, actionId);
if (!actionToMove) return s;
const pruned = removeActionFromTree(s.actions, actionId);
const inserted = insertActionIntoTree(pruned, actionToMove, newParentId, newIndex);
return { ...s, actions: inserted };
});
return {
steps: stepsDraft,
dirtyEntities: new Set<string>([...state.dirtyEntities, stepId]),
dirtyEntities: new Set<string>([...state.dirtyEntities, stepId, actionId]),
};
}),
reorderAction: (stepId: string, from: number, to: number) =>
get().moveAction(stepId, get().steps.find(s => s.id === stepId)?.actions[from]?.id!, null, to), // Legacy compat support (only works for root level reorder)
setInsertionProjection: (projection) => set({ insertionProjection: projection }),
/* -------------------------------- Dirty ---------------------------------- */
markDirty: (id: string) =>
set((state: DesignerState) => ({

View File

@@ -643,13 +643,13 @@ export function validateExecution(
if (trialStartSteps.length > 1) {
trialStartSteps.slice(1).forEach((step) => {
issues.push({
severity: "warning",
severity: "info",
message:
"Multiple steps with trial_start trigger may cause execution conflicts",
"This step will start immediately at trial start. For sequential flow, use 'Previous Step' trigger.",
category: "execution",
field: "trigger.type",
stepId: step.id,
suggestion: "Consider using sequential triggers for subsequent steps",
suggestion: "Change trigger to 'Previous Step' if this step should follow the previous one",
});
});
}

8
src/components/experiments/experiments-columns.tsx Normal file → Executable file
View File

@@ -114,14 +114,14 @@ function ExperimentActionsCell({ experiment }: { experiment: Experiment }) {
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/experiments/${experiment.id}`}>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}>
<Eye className="mr-2 h-4 w-4" />
View Details
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/experiments/${experiment.id}/designer`}>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/designer`}>
<FlaskConical className="mr-2 h-4 w-4" />
Open Designer
</Link>
@@ -129,7 +129,7 @@ function ExperimentActionsCell({ experiment }: { experiment: Experiment }) {
{experiment.canEdit && (
<DropdownMenuItem asChild>
<Link href={`/experiments/${experiment.id}/edit`}>
<Link href={`/studies/${experiment.studyId}/experiments/${experiment.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit Experiment
</Link>
@@ -202,7 +202,7 @@ export const experimentsColumns: ColumnDef<Experiment>[] = [
return (
<div className="max-w-[200px] min-w-0 space-y-1">
<Link
href={`/experiments/${experiment.id}`}
href={`/studies/${experiment.studyId}/experiments/${experiment.id}`}
className="block truncate font-medium hover:underline"
title={experiment.name}
>

0
src/components/experiments/experiments-data-table.tsx Normal file → Executable file
View File

81
src/components/participants/ParticipantForm.tsx Normal file → Executable file
View File

@@ -114,39 +114,39 @@ export function ParticipantForm({
{ label: "Studies", href: "/studies" },
...(contextStudyId
? [
{
label: participant?.study?.name ?? "Study",
href: `/studies/${contextStudyId}`,
},
{
label: "Participants",
href: `/studies/${contextStudyId}/participants`,
},
...(mode === "edit" && participant
? [
{
label: participant.name ?? participant.participantCode,
href: `/studies/${contextStudyId}/participants/${participant.id}`,
},
{ label: "Edit" },
]
: [{ label: "New Participant" }]),
]
{
label: participant?.study?.name ?? "Study",
href: `/studies/${contextStudyId}`,
},
{
label: "Participants",
href: `/studies/${contextStudyId}/participants`,
},
...(mode === "edit" && participant
? [
{
label: participant.name ?? participant.participantCode,
href: `/studies/${contextStudyId}/participants/${participant.id}`,
},
{ label: "Edit" },
]
: [{ label: "New Participant" }]),
]
: [
{
label: "Participants",
href: `/studies/${contextStudyId}/participants`,
},
...(mode === "edit" && participant
? [
{
label: participant.name ?? participant.participantCode,
href: `/studies/${contextStudyId}/participants/${participant.id}`,
},
{ label: "Edit" },
]
: [{ label: "New Participant" }]),
]),
{
label: "Participants",
href: `/studies/${contextStudyId}/participants`,
},
...(mode === "edit" && participant
? [
{
label: participant.name ?? participant.participantCode,
href: `/studies/${contextStudyId}/participants/${participant.id}`,
},
{ label: "Edit" },
]
: [{ label: "New Participant" }]),
]),
];
useBreadcrumbsEffect(breadcrumbs);
@@ -203,7 +203,7 @@ export function ParticipantForm({
email: data.email ?? undefined,
demographics,
});
router.push(`/participants/${newParticipant.id}`);
router.push(`/studies/${data.studyId}/participants/${newParticipant.id}`);
} else {
const updatedParticipant = await updateParticipantMutation.mutateAsync({
id: participantId!,
@@ -212,7 +212,7 @@ export function ParticipantForm({
email: data.email ?? undefined,
demographics,
});
router.push(`/participants/${updatedParticipant.id}`);
router.push(`/studies/${contextStudyId}/participants/${updatedParticipant.id}`);
}
} catch (error) {
setError(
@@ -385,11 +385,11 @@ export function ParticipantForm({
form.setValue(
"gender",
value as
| "male"
| "female"
| "non_binary"
| "prefer_not_to_say"
| "other",
| "male"
| "female"
| "non_binary"
| "prefer_not_to_say"
| "other",
)
}
>
@@ -505,7 +505,8 @@ export function ParticipantForm({
error={error}
onDelete={mode === "edit" ? onDelete : undefined}
isDeleting={isDeleting}
sidebar={sidebar}
isDeleting={isDeleting}
// sidebar={sidebar} // Removed for cleaner UI per user request
submitText={mode === "create" ? "Register Participant" : "Save Changes"}
>
{formFields}

38
src/components/participants/ParticipantsTable.tsx Normal file → Executable file
View File

@@ -1,7 +1,7 @@
"use client";
import { type ColumnDef } from "@tanstack/react-table";
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
import { ArrowUpDown, MoreHorizontal, Copy, Eye, Edit, Mail, Trash2 } from "lucide-react";
import * as React from "react";
import { formatDistanceToNow } from "date-fns";
@@ -27,6 +27,7 @@ import { api } from "~/trpc/react";
export type Participant = {
id: string;
studyId: string;
participantCode: string;
email: string | null;
name: string | null;
@@ -75,7 +76,7 @@ export const columns: ColumnDef<Participant>[] = [
cell: ({ row }) => (
<div className="font-mono text-sm">
<Link
href={`/participants/${row.original.id}`}
href={`/studies/${row.original.studyId ?? ""}/participants/${row.original.id}`}
className="hover:underline"
>
{row.getValue("participantCode")}
@@ -176,6 +177,13 @@ export const columns: ColumnDef<Participant>[] = [
enableHiding: false,
cell: ({ row }) => {
const participant = row.original;
// Use studyId from participant or fallback might be needed but for now presume row has it?
// Wait, the Participant type definition above doesn't have studyId!
// I need to add studyId to the type definition in this file or rely on context if I'm inside the component,
// but 'columns' is defined outside.
// Best practice: Add studyId to the Participant type.
const studyId = participant.studyId;
return (
<DropdownMenu>
@@ -190,26 +198,27 @@ export const columns: ColumnDef<Participant>[] = [
<DropdownMenuItem
onClick={() => navigator.clipboard.writeText(participant.id)}
>
Copy participant ID
<Copy className="mr-2 h-4 w-4" />
Copy ID
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/participants/${participant.id}`}>View details</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/participants/${participant.id}/edit`}>
<Link href={`/studies/${studyId}/participants/${participant.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit participant
</Link>
</Link >
</DropdownMenuItem >
<DropdownMenuItem disabled>
<Mail className="mr-2 h-4 w-4" />
Send consent
</DropdownMenuItem>
{!participant.consentGiven && (
<DropdownMenuItem>Send consent form</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem className="text-red-600">
Remove participant
<Trash2 className="mr-2 h-4 w-4" />
Remove
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</DropdownMenuContent >
</DropdownMenu >
);
},
},
@@ -250,6 +259,7 @@ export function ParticipantsTable({ studyId }: ParticipantsTableProps = {}) {
return participantsData.participants.map(
(p): Participant => ({
id: p.id,
studyId: p.studyId,
participantCode: p.participantCode,
email: p.email,
name: p.name,

0
src/components/participants/ParticipantsView.tsx Normal file → Executable file
View File

0
src/components/plugins/plugin-store-browse.tsx Normal file → Executable file
View File

0
src/components/plugins/plugins-columns.tsx Normal file → Executable file
View File

0
src/components/plugins/plugins-data-table.tsx Normal file → Executable file
View File

0
src/components/profile/password-change-form.tsx Normal file → Executable file
View File

0
src/components/profile/profile-edit-form.tsx Normal file → Executable file
View File

0
src/components/studies/InviteMemberDialog.tsx Normal file → Executable file
View File

0
src/components/studies/StudiesGrid.tsx Normal file → Executable file
View File

0
src/components/studies/StudiesTable.tsx Normal file → Executable file
View File

0
src/components/studies/StudyCard.tsx Normal file → Executable file
View File

0
src/components/studies/StudyForm.tsx Normal file → Executable file
View File

0
src/components/studies/studies-columns.tsx Normal file → Executable file
View File

0
src/components/studies/studies-data-table.tsx Normal file → Executable file
View File

0
src/components/theme/index.ts Normal file → Executable file
View File

0
src/components/theme/theme-provider.tsx Normal file → Executable file
View File

0
src/components/theme/theme-script.tsx Normal file → Executable file
View File

0
src/components/theme/theme-toggle.tsx Normal file → Executable file
View File

0
src/components/theme/toaster.tsx Normal file → Executable file
View File

56
src/components/trials/TrialForm.tsx Normal file → Executable file
View File

@@ -96,33 +96,33 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
{ label: "Studies", href: "/studies" },
...(contextStudyId
? [
{
label: "Study",
href: `/studies/${contextStudyId}`,
},
{ label: "Trials", href: `/studies/${contextStudyId}/trials` },
...(mode === "edit" && trial
? [
{
label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
href: `/studies/${contextStudyId}/trials/${trial.id}`,
},
{ label: "Edit" },
]
: [{ label: "New Trial" }]),
]
{
label: "Study",
href: `/studies/${contextStudyId}`,
},
{ label: "Trials", href: `/studies/${contextStudyId}/trials` },
...(mode === "edit" && trial
? [
{
label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
href: `/studies/${contextStudyId}/trials/${trial.id}`,
},
{ label: "Edit" },
]
: [{ label: "New Trial" }]),
]
: [
{ label: "Trials", href: `/studies/${contextStudyId}/trials` },
...(mode === "edit" && trial
? [
{
label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
href: `/studies/${contextStudyId}/trials/${trial.id}`,
},
{ label: "Edit" },
]
: [{ label: "New Trial" }]),
]),
{ label: "Trials", href: `/studies/${contextStudyId}/trials` },
...(mode === "edit" && trial
? [
{
label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
href: `/studies/${contextStudyId}/trials/${trial.id}`,
},
{ label: "Edit" },
]
: [{ label: "New Trial" }]),
]),
];
useBreadcrumbsEffect(breadcrumbs);
@@ -161,7 +161,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
sessionNumber: data.sessionNumber ?? 1,
notes: data.notes ?? undefined,
});
router.push(`/trials/${newTrial!.id}`);
router.push(`/studies/${contextStudyId}/trials/${newTrial!.id}`);
} else {
const updatedTrial = await updateTrialMutation.mutateAsync({
id: trialId!,
@@ -170,7 +170,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
sessionNumber: data.sessionNumber ?? 1,
notes: data.notes ?? undefined,
});
router.push(`/trials/${updatedTrial!.id}`);
router.push(`/studies/${contextStudyId}/trials/${updatedTrial!.id}`);
}
} catch (error) {
setError(

0
src/components/trials/TrialsGrid.tsx Normal file → Executable file
View File

Some files were not shown because too many files have changed in this diff Show More