mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-24 03:37:51 -04:00
feat: Redesign Landing, Auth, and Dashboard Pages
Also fixed schema type exports and seed script errors.
This commit is contained in:
0
src/app/(dashboard)/admin/page.tsx
Normal file → Executable file
0
src/app/(dashboard)/admin/page.tsx
Normal file → Executable file
0
src/app/(dashboard)/admin/repositories/page.tsx
Normal file → Executable file
0
src/app/(dashboard)/admin/repositories/page.tsx
Normal file → Executable 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'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>
|
||||
);
|
||||
}
|
||||
513
src/app/(dashboard)/debug/page.tsx
Executable file
513
src/app/(dashboard)/debug/page.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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
0
src/app/(dashboard)/layout.tsx
Normal file → Executable file
607
src/app/(dashboard)/nao-test/page.tsx
Executable file
607
src/app/(dashboard)/nao-test/page.tsx
Executable 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
0
src/app/(dashboard)/not-found.tsx
Normal file → Executable 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'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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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'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
0
src/app/(dashboard)/profile/page.tsx
Normal file → Executable file
478
src/app/(dashboard)/studies/[id]/analytics/page.tsx
Normal file → Executable file
478
src/app/(dashboard)/studies/[id]/analytics/page.tsx
Normal file → Executable 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
0
src/app/(dashboard)/studies/[id]/edit/page.tsx
Normal file → Executable 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",
|
||||
@@ -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`,
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
0
src/app/(dashboard)/studies/[id]/experiments/new/page.tsx
Normal file → Executable file
0
src/app/(dashboard)/studies/[id]/experiments/new/page.tsx
Normal file → Executable file
0
src/app/(dashboard)/studies/[id]/experiments/page.tsx
Normal file → Executable file
0
src/app/(dashboard)/studies/[id]/experiments/page.tsx
Normal file → Executable file
17
src/app/(dashboard)/studies/[id]/page.tsx
Normal file → Executable file
17
src/app/(dashboard)/studies/[id]/page.tsx
Normal file → Executable 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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
0
src/app/(dashboard)/studies/[id]/participants/new/page.tsx
Normal file → Executable file
0
src/app/(dashboard)/studies/[id]/participants/new/page.tsx
Normal file → Executable file
0
src/app/(dashboard)/studies/[id]/participants/page.tsx
Normal file → Executable file
0
src/app/(dashboard)/studies/[id]/participants/page.tsx
Normal file → Executable file
0
src/app/(dashboard)/studies/[id]/plugins/browse/page.tsx
Normal file → Executable file
0
src/app/(dashboard)/studies/[id]/plugins/browse/page.tsx
Normal file → Executable file
0
src/app/(dashboard)/studies/[id]/plugins/page.tsx
Normal file → Executable file
0
src/app/(dashboard)/studies/[id]/plugins/page.tsx
Normal file → Executable 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>
|
||||
);
|
||||
}
|
||||
16
src/app/(dashboard)/studies/[id]/trials/[trialId]/page.tsx
Normal file → Executable file
16
src/app/(dashboard)/studies/[id]/trials/[trialId]/page.tsx
Normal file → Executable 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>
|
||||
)}
|
||||
|
||||
0
src/app/(dashboard)/studies/[id]/trials/[trialId]/wizard/page.tsx
Normal file → Executable file
0
src/app/(dashboard)/studies/[id]/trials/[trialId]/wizard/page.tsx
Normal file → Executable file
0
src/app/(dashboard)/studies/[id]/trials/new/page.tsx
Normal file → Executable file
0
src/app/(dashboard)/studies/[id]/trials/new/page.tsx
Normal file → Executable file
0
src/app/(dashboard)/studies/[id]/trials/page.tsx
Normal file → Executable file
0
src/app/(dashboard)/studies/[id]/trials/page.tsx
Normal file → Executable file
0
src/app/(dashboard)/studies/new/page.tsx
Normal file → Executable file
0
src/app/(dashboard)/studies/new/page.tsx
Normal file → Executable file
0
src/app/(dashboard)/studies/page.tsx
Normal file → Executable file
0
src/app/(dashboard)/studies/page.tsx
Normal file → Executable file
0
src/app/api/auth/[...nextauth]/route.ts
Normal file → Executable file
0
src/app/api/auth/[...nextauth]/route.ts
Normal file → Executable file
0
src/app/api/test-trial/route.ts
Normal file → Executable file
0
src/app/api/test-trial/route.ts
Normal file → Executable file
0
src/app/api/trpc/[trpc]/route.ts
Normal file → Executable file
0
src/app/api/trpc/[trpc]/route.ts
Normal file → Executable file
0
src/app/api/upload/route.ts
Normal file → Executable file
0
src/app/api/upload/route.ts
Normal file → Executable file
56
src/app/auth/signin/page.tsx
Normal file → Executable file
56
src/app/auth/signin/page.tsx
Normal file → Executable 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'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.
|
||||
© {new Date().getFullYear()} HRIStudio. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
0
src/app/auth/signout/page.tsx
Normal file → Executable file
0
src/app/auth/signout/page.tsx
Normal file → Executable file
107
src/app/auth/signup/page.tsx
Normal file → Executable file
107
src/app/auth/signup/page.tsx
Normal file → Executable 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.
|
||||
© {new Date().getFullYear()} HRIStudio. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
0
src/app/dashboard/layout.tsx
Normal file → Executable file
0
src/app/dashboard/layout.tsx
Normal file → Executable file
622
src/app/dashboard/page.tsx
Normal file → Executable file
622
src/app/dashboard/page.tsx
Normal file → Executable 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
8
src/app/layout.tsx
Normal file → Executable 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
791
src/app/page.tsx
Normal file → Executable 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">
|
||||
© {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
0
src/app/unauthorized/page.tsx
Normal file → Executable file
Reference in New Issue
Block a user