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