mirror of
https://github.com/soconnor0919/hristudio.git
synced 2026-03-24 03:37:51 -04:00
feat: Implement digital signatures for participant consent and introduce study forms management.
This commit is contained in:
@@ -46,7 +46,10 @@ export default function DebugPage() {
|
||||
|
||||
const ROS_BRIDGE_URL = "ws://134.82.159.25:9090";
|
||||
|
||||
const addLog = (message: string, type: "info" | "error" | "success" = "info") => {
|
||||
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]);
|
||||
@@ -79,7 +82,9 @@ export default function DebugPage() {
|
||||
setConnectionStatus("connecting");
|
||||
setConnectionAttempts((prev) => prev + 1);
|
||||
setLastError(null);
|
||||
addLog(`Attempting connection #${connectionAttempts + 1} to ${ROS_BRIDGE_URL}`);
|
||||
addLog(
|
||||
`Attempting connection #${connectionAttempts + 1} to ${ROS_BRIDGE_URL}`,
|
||||
);
|
||||
|
||||
const socket = new WebSocket(ROS_BRIDGE_URL);
|
||||
|
||||
@@ -96,7 +101,10 @@ export default function DebugPage() {
|
||||
setConnectionStatus("connected");
|
||||
setRosSocket(socket);
|
||||
setLastError(null);
|
||||
addLog("✅ WebSocket connection established successfully", "success");
|
||||
addLog(
|
||||
"[SUCCESS] WebSocket connection established successfully",
|
||||
"success",
|
||||
);
|
||||
|
||||
// Test basic functionality by advertising
|
||||
const advertiseMsg = {
|
||||
@@ -138,16 +146,20 @@ export default function DebugPage() {
|
||||
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");
|
||||
setLastError(
|
||||
"ROS Bridge server not responding - check if rosbridge_server is running",
|
||||
);
|
||||
addLog(`[ERROR] 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");
|
||||
addLog(`[ERROR] 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");
|
||||
setLastError(
|
||||
`Connection closed with code ${event.code}: ${event.reason || "No reason given"}`,
|
||||
);
|
||||
addLog(`[ERROR] Connection closed: ${reason}`, "error");
|
||||
}
|
||||
|
||||
if (wasConnected) {
|
||||
@@ -160,7 +172,7 @@ export default function DebugPage() {
|
||||
setConnectionStatus("error");
|
||||
const errorMsg = "WebSocket error occurred";
|
||||
setLastError(errorMsg);
|
||||
addLog(`❌ ${errorMsg}`, "error");
|
||||
addLog(`[ERROR] ${errorMsg}`, "error");
|
||||
console.error("WebSocket error details:", error);
|
||||
};
|
||||
};
|
||||
@@ -298,7 +310,7 @@ export default function DebugPage() {
|
||||
>
|
||||
{connectionStatus.toUpperCase()}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Attempts: {connectionAttempts}
|
||||
</span>
|
||||
</div>
|
||||
@@ -306,7 +318,9 @@ export default function DebugPage() {
|
||||
{lastError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertDescription className="text-sm">{lastError}</AlertDescription>
|
||||
<AlertDescription className="text-sm">
|
||||
{lastError}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
@@ -318,7 +332,9 @@ export default function DebugPage() {
|
||||
className="flex-1"
|
||||
>
|
||||
<Play className="mr-2 h-4 w-4" />
|
||||
{connectionStatus === "connecting" ? "Connecting..." : "Connect"}
|
||||
{connectionStatus === "connecting"
|
||||
? "Connecting..."
|
||||
: "Connect"}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -479,27 +495,32 @@ export default function DebugPage() {
|
||||
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"
|
||||
? "border-l-2 border-blue-400 bg-blue-50"
|
||||
: "border-l-2 border-green-400 bg-green-50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<Badge
|
||||
variant={msg.direction === "sent" ? "default" : "secondary"}
|
||||
variant={
|
||||
msg.direction === "sent" ? "default" : "secondary"
|
||||
}
|
||||
className="text-xs"
|
||||
>
|
||||
{msg.direction === "sent" ? "SENT" : "RECEIVED"}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground">{msg.timestamp}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{msg.timestamp}
|
||||
</span>
|
||||
</div>
|
||||
<pre className="whitespace-pre-wrap text-xs">
|
||||
<pre className="text-xs whitespace-pre-wrap">
|
||||
{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 className="text-muted-foreground py-8 text-center">
|
||||
No messages yet. Connect and send a test message to see data
|
||||
here.
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
|
||||
@@ -1,145 +1,146 @@
|
||||
import {
|
||||
BookOpen,
|
||||
FlaskConical,
|
||||
PlayCircle,
|
||||
BarChart3,
|
||||
HelpCircle,
|
||||
FileText,
|
||||
Video,
|
||||
ExternalLink,
|
||||
BookOpen,
|
||||
FlaskConical,
|
||||
PlayCircle,
|
||||
BarChart3,
|
||||
HelpCircle,
|
||||
FileText,
|
||||
Video,
|
||||
ExternalLink,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { PageLayout } from "~/components/ui/page-layout";
|
||||
import Link from "next/link";
|
||||
|
||||
export default function HelpCenterPage() {
|
||||
const guides = [
|
||||
{
|
||||
title: "Getting Started",
|
||||
description: "Learn the basics of HRIStudio and set up your first study.",
|
||||
icon: BookOpen,
|
||||
items: [
|
||||
{ label: "Platform Overview", href: "#" },
|
||||
{ label: "Creating a New Study", href: "#" },
|
||||
{ label: "Managing Team Members", href: "#" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Designing Experiments",
|
||||
description: "Master the visual experiment designer and flow control.",
|
||||
icon: FlaskConical,
|
||||
items: [
|
||||
{ label: "Using the Visual Designer", href: "#" },
|
||||
{ label: "Robot Actions & Plugins", href: "#" },
|
||||
{ label: "Variables & Logic", href: "#" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Running Trials",
|
||||
description: "Execute experiments and manage Wizard of Oz sessions.",
|
||||
icon: PlayCircle,
|
||||
items: [
|
||||
{ label: "Wizard Interface Guide", href: "#" },
|
||||
{ label: "Participant Management", href: "#" },
|
||||
{ label: "Handling Robot Errors", href: "#" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Analysis & Data",
|
||||
description: "Analyze trial results and export research data.",
|
||||
icon: BarChart3,
|
||||
items: [
|
||||
{ label: "Understanding Analytics", href: "#" },
|
||||
{ label: "Exporting Data (CSV/JSON)", href: "#" },
|
||||
{ label: "Video Replay & Annotation", href: "#" },
|
||||
],
|
||||
},
|
||||
];
|
||||
const guides = [
|
||||
{
|
||||
title: "Getting Started",
|
||||
description: "Learn the basics of HRIStudio and set up your first study.",
|
||||
icon: BookOpen,
|
||||
items: [
|
||||
{ label: "Platform Overview", href: "#" },
|
||||
{ label: "Creating a New Study", href: "#" },
|
||||
{ label: "Managing Team Members", href: "#" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Designing Experiments",
|
||||
description: "Master the visual experiment designer and flow control.",
|
||||
icon: FlaskConical,
|
||||
items: [
|
||||
{ label: "Using the Visual Designer", href: "#" },
|
||||
{ label: "Robot Actions & Plugins", href: "#" },
|
||||
{ label: "Variables & Logic", href: "#" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Running Trials",
|
||||
description: "Execute experiments and manage Wizard of Oz sessions.",
|
||||
icon: PlayCircle,
|
||||
items: [
|
||||
{ label: "Wizard Interface Guide", href: "#" },
|
||||
{ label: "Participant Management", href: "#" },
|
||||
{ label: "Handling Robot Errors", href: "#" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Analysis & Data",
|
||||
description: "Analyze trial results and export research data.",
|
||||
icon: BarChart3,
|
||||
items: [
|
||||
{ label: "Understanding Analytics", href: "#" },
|
||||
{ label: "Exporting Data (CSV/JSON)", href: "#" },
|
||||
{ label: "Video Replay & Annotation", href: "#" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="Help Center"
|
||||
description="Documentation, guides, and support for HRIStudio researchers."
|
||||
>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{guides.map((guide, index) => (
|
||||
<Card key={index}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-2 bg-primary/10 rounded-lg">
|
||||
<guide.icon className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">{guide.title}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>{guide.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2">
|
||||
{guide.items.map((item, i) => (
|
||||
<li key={i}>
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 text-foreground hover:text-primary justify-start font-normal"
|
||||
asChild
|
||||
>
|
||||
<Link href={item.href}>
|
||||
<FileText className="mr-2 h-4 w-4 text-muted-foreground" />
|
||||
{item.label}
|
||||
</Link>
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<h2 className="text-2xl font-bold tracking-tight mb-4">
|
||||
Video Tutorials
|
||||
</h2>
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{[
|
||||
"Introduction to HRIStudio",
|
||||
"Advanced Flow Control",
|
||||
"ROS2 Integration Deep Dive",
|
||||
].map((title, i) => (
|
||||
<Card key={i} className="overflow-hidden">
|
||||
<div className="aspect-video bg-muted flex items-center justify-center relative group cursor-pointer hover:bg-muted/80 transition-colors">
|
||||
<PlayCircle className="h-12 w-12 text-muted-foreground group-hover:text-primary transition-colors" />
|
||||
</div>
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
return (
|
||||
<PageLayout
|
||||
title="Help Center"
|
||||
description="Documentation, guides, and support for HRIStudio researchers."
|
||||
>
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{guides.map((guide, index) => (
|
||||
<Card key={index}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-primary/10 rounded-lg p-2">
|
||||
<guide.icon className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 bg-muted/50 rounded-xl p-8 text-center border">
|
||||
<div className="mx-auto w-12 h-12 bg-background rounded-full flex items-center justify-center mb-4 shadow-sm">
|
||||
<HelpCircle className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">Still need help?</h2>
|
||||
<p className="text-muted-foreground mb-6 max-w-md mx-auto">
|
||||
Contact your system administrator or check the official documentation for technical support.
|
||||
</p>
|
||||
<div className="flex justify-center gap-4">
|
||||
<Button variant="outline" className="gap-2">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Official Docs
|
||||
<CardTitle className="text-xl">{guide.title}</CardTitle>
|
||||
</div>
|
||||
<CardDescription>{guide.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2">
|
||||
{guide.items.map((item, i) => (
|
||||
<li key={i}>
|
||||
<Button
|
||||
variant="link"
|
||||
className="text-foreground hover:text-primary h-auto justify-start p-0 font-normal"
|
||||
asChild
|
||||
>
|
||||
<Link href={item.href}>
|
||||
<FileText className="text-muted-foreground mr-2 h-4 w-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button className="gap-2">Contact Support</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-8">
|
||||
<h2 className="mb-4 text-2xl font-bold tracking-tight">
|
||||
Video Tutorials
|
||||
</h2>
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{[
|
||||
"Introduction to HRIStudio",
|
||||
"Advanced Flow Control",
|
||||
"ROS2 Integration Deep Dive",
|
||||
].map((title, i) => (
|
||||
<Card key={i} className="overflow-hidden">
|
||||
<div className="bg-muted group hover:bg-muted/80 relative flex aspect-video cursor-pointer items-center justify-center transition-colors">
|
||||
<PlayCircle className="text-muted-foreground group-hover:text-primary h-12 w-12 transition-colors" />
|
||||
</div>
|
||||
<CardHeader className="p-4">
|
||||
<CardTitle className="text-base">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/50 mt-8 rounded-xl border p-8 text-center">
|
||||
<div className="bg-background mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full shadow-sm">
|
||||
<HelpCircle className="text-primary h-6 w-6" />
|
||||
</div>
|
||||
<h2 className="mb-2 text-xl font-semibold">Still need help?</h2>
|
||||
<p className="text-muted-foreground mx-auto mb-6 max-w-md">
|
||||
Contact your system administrator or check the official documentation
|
||||
for technical support.
|
||||
</p>
|
||||
<div className="flex justify-center gap-4">
|
||||
<Button variant="outline" className="gap-2">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Official Docs
|
||||
</Button>
|
||||
<Button className="gap-2">Contact Support</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -365,7 +365,9 @@ export default function NaoTestPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Walk Speed: {(walkSpeed[0] ?? 0).toFixed(2)} m/s</Label>
|
||||
<Label>
|
||||
Walk Speed: {(walkSpeed[0] ?? 0).toFixed(2)} m/s
|
||||
</Label>
|
||||
<Slider
|
||||
value={walkSpeed}
|
||||
onValueChange={setWalkSpeed}
|
||||
@@ -375,7 +377,9 @@ export default function NaoTestPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Turn Speed: {(turnSpeed[0] ?? 0).toFixed(2)} rad/s</Label>
|
||||
<Label>
|
||||
Turn Speed: {(turnSpeed[0] ?? 0).toFixed(2)} rad/s
|
||||
</Label>
|
||||
<Slider
|
||||
value={turnSpeed}
|
||||
onValueChange={setTurnSpeed}
|
||||
@@ -415,7 +419,9 @@ export default function NaoTestPage() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Head Yaw: {(headYaw[0] ?? 0).toFixed(2)} rad</Label>
|
||||
<Label>
|
||||
Head Yaw: {(headYaw[0] ?? 0).toFixed(2)} rad
|
||||
</Label>
|
||||
<Slider
|
||||
value={headYaw}
|
||||
onValueChange={setHeadYaw}
|
||||
@@ -425,7 +431,9 @@ export default function NaoTestPage() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Head Pitch: {(headPitch[0] ?? 0).toFixed(2)} rad</Label>
|
||||
<Label>
|
||||
Head Pitch: {(headPitch[0] ?? 0).toFixed(2)} rad
|
||||
</Label>
|
||||
<Slider
|
||||
value={headPitch}
|
||||
onValueChange={setHeadPitch}
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
Lock,
|
||||
UserCog,
|
||||
Mail,
|
||||
Fingerprint
|
||||
Fingerprint,
|
||||
} from "lucide-react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { cn } from "~/lib/utils";
|
||||
@@ -43,7 +43,7 @@ interface ProfileUser {
|
||||
|
||||
function ProfileContent({ user }: { user: ProfileUser }) {
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in duration-500">
|
||||
<div className="animate-in fade-in space-y-8 duration-500">
|
||||
<PageHeader
|
||||
title={user.name ?? "User"}
|
||||
description={user.email}
|
||||
@@ -60,17 +60,18 @@ function ProfileContent({ user }: { user: ProfileUser }) {
|
||||
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
|
||||
{/* Main Content (Left Column) */}
|
||||
<div className="space-y-8 lg:col-span-2">
|
||||
|
||||
{/* Personal Information */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2 pb-2 border-b">
|
||||
<User className="h-5 w-5 text-primary" />
|
||||
<div className="flex items-center gap-2 border-b pb-2">
|
||||
<User className="text-primary h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">Personal Information</h3>
|
||||
</div>
|
||||
<Card className="border-border/60 hover:border-border transition-colors">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Contact Details</CardTitle>
|
||||
<CardDescription>Update your public profile information</CardDescription>
|
||||
<CardDescription>
|
||||
Update your public profile information
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ProfileEditForm
|
||||
@@ -87,14 +88,16 @@ function ProfileContent({ user }: { user: ProfileUser }) {
|
||||
|
||||
{/* Security */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2 pb-2 border-b">
|
||||
<Lock className="h-5 w-5 text-primary" />
|
||||
<div className="flex items-center gap-2 border-b pb-2">
|
||||
<Lock className="text-primary h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">Security</h3>
|
||||
</div>
|
||||
<Card className="border-border/60 hover:border-border transition-colors">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Password</CardTitle>
|
||||
<CardDescription>Ensure your account stays secure</CardDescription>
|
||||
<CardDescription>
|
||||
Ensure your account stays secure
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<PasswordChangeForm />
|
||||
@@ -105,11 +108,10 @@ function ProfileContent({ user }: { user: ProfileUser }) {
|
||||
|
||||
{/* Sidebar (Right Column) */}
|
||||
<div className="space-y-8">
|
||||
|
||||
{/* Permissions */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2 pb-2 border-b">
|
||||
<Shield className="h-5 w-5 text-primary" />
|
||||
<div className="flex items-center gap-2 border-b pb-2">
|
||||
<Shield className="text-primary h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">Permissions</h3>
|
||||
</div>
|
||||
<Card>
|
||||
@@ -119,30 +121,40 @@ function ProfileContent({ user }: { user: ProfileUser }) {
|
||||
{user.roles.map((roleInfo, index) => (
|
||||
<div key={index} className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-sm">{formatRole(roleInfo.role)}</span>
|
||||
<span className="text-[10px] text-muted-foreground bg-muted px-1.5 py-0.5 rounded">
|
||||
Since {new Date(roleInfo.grantedAt).toLocaleDateString()}
|
||||
<span className="text-sm font-medium">
|
||||
{formatRole(roleInfo.role)}
|
||||
</span>
|
||||
<span className="text-muted-foreground bg-muted rounded px-1.5 py-0.5 text-[10px]">
|
||||
Since{" "}
|
||||
{new Date(roleInfo.grantedAt).toLocaleDateString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
||||
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||
{getRoleDescription(roleInfo.role)}
|
||||
</p>
|
||||
{index < (user.roles?.length || 0) - 1 && <Separator className="my-2" />}
|
||||
{index < (user.roles?.length || 0) - 1 && (
|
||||
<Separator className="my-2" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="bg-blue-50/50 dark:bg-blue-900/10 p-3 rounded-lg border border-blue-100 dark:border-blue-900/30 text-xs text-muted-foreground mt-4">
|
||||
<div className="flex items-center gap-2 mb-1 text-primary font-medium">
|
||||
<div className="text-muted-foreground mt-4 rounded-lg border border-blue-100 bg-blue-50/50 p-3 text-xs dark:border-blue-900/30 dark:bg-blue-900/10">
|
||||
<div className="text-primary mb-1 flex items-center gap-2 font-medium">
|
||||
<Shield className="h-3 w-3" />
|
||||
<span>Role Management</span>
|
||||
</div>
|
||||
System roles are managed by administrators. Contact support if you need access adjustments.
|
||||
System roles are managed by administrators. Contact
|
||||
support if you need access adjustments.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4">
|
||||
<div className="py-4 text-center">
|
||||
<p className="text-sm font-medium">No Roles Assigned</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">Contact an admin to request access.</p>
|
||||
<Button size="sm" variant="outline" className="mt-3 w-full">Request Access</Button>
|
||||
<p className="text-muted-foreground mt-1 text-xs">
|
||||
Contact an admin to request access.
|
||||
</p>
|
||||
<Button size="sm" variant="outline" className="mt-3 w-full">
|
||||
Request Access
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -151,26 +163,42 @@ function ProfileContent({ user }: { user: ProfileUser }) {
|
||||
|
||||
{/* Data & Privacy */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2 pb-2 border-b">
|
||||
<Download className="h-5 w-5 text-primary" />
|
||||
<div className="flex items-center gap-2 border-b pb-2">
|
||||
<Download className="text-primary h-5 w-5" />
|
||||
<h3 className="text-lg font-semibold">Data & Privacy</h3>
|
||||
</div>
|
||||
|
||||
<Card className="border-destructive/10 bg-destructive/5 overflow-hidden">
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<CardContent className="space-y-4 pt-6">
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold mb-1">Export Data</h4>
|
||||
<p className="text-xs text-muted-foreground mb-3">Download a copy of your personal data.</p>
|
||||
<Button variant="outline" size="sm" className="w-full bg-background" disabled>
|
||||
<h4 className="mb-1 text-sm font-semibold">Export Data</h4>
|
||||
<p className="text-muted-foreground mb-3 text-xs">
|
||||
Download a copy of your personal data.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="bg-background w-full"
|
||||
disabled
|
||||
>
|
||||
<Download className="mr-2 h-3 w-3" />
|
||||
Download Archive
|
||||
</Button>
|
||||
</div>
|
||||
<Separator className="bg-destructive/10" />
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-destructive mb-1">Delete Account</h4>
|
||||
<p className="text-xs text-muted-foreground mb-3">This action is irreversible.</p>
|
||||
<Button variant="destructive" size="sm" className="w-full" disabled>
|
||||
<h4 className="text-destructive mb-1 text-sm font-semibold">
|
||||
Delete Account
|
||||
</h4>
|
||||
<p className="text-muted-foreground mb-3 text-xs">
|
||||
This action is irreversible.
|
||||
</p>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
disabled
|
||||
>
|
||||
<Trash2 className="mr-2 h-3 w-3" />
|
||||
Delete Account
|
||||
</Button>
|
||||
@@ -193,7 +221,11 @@ export default function ProfilePage() {
|
||||
]);
|
||||
|
||||
if (status === "loading") {
|
||||
return <div className="p-8 text-muted-foreground animate-pulse">Loading profile...</div>;
|
||||
return (
|
||||
<div className="text-muted-foreground animate-pulse p-8">
|
||||
Loading profile...
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!session?.user) {
|
||||
|
||||
@@ -20,7 +20,7 @@ export default function StudyAnalyticsPage() {
|
||||
// Fetch list of trials
|
||||
const { data: trialsList, isLoading } = api.trials.list.useQuery(
|
||||
{ studyId, limit: 100 },
|
||||
{ enabled: !!studyId }
|
||||
{ enabled: !!studyId },
|
||||
);
|
||||
|
||||
// Set breadcrumbs
|
||||
@@ -49,19 +49,23 @@ export default function StudyAnalyticsPage() {
|
||||
<div className="bg-transparent">
|
||||
<Suspense fallback={<div>Loading analytics...</div>}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<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 session data...</span>
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<div className="flex animate-pulse flex-col items-center gap-2">
|
||||
<div className="border-primary h-8 w-8 animate-spin rounded-full border-2 border-t-transparent" />
|
||||
<span className="text-muted-foreground text-sm">
|
||||
Loading session data...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<StudyAnalyticsDataTable data={(trialsList ?? []).map(t => ({
|
||||
...t,
|
||||
startedAt: t.startedAt ? new Date(t.startedAt) : null,
|
||||
completedAt: t.completedAt ? new Date(t.completedAt) : null,
|
||||
createdAt: new Date(t.createdAt),
|
||||
}))} />
|
||||
<StudyAnalyticsDataTable
|
||||
data={(trialsList ?? []).map((t) => ({
|
||||
...t,
|
||||
startedAt: t.startedAt ? new Date(t.startedAt) : null,
|
||||
completedAt: t.completedAt ? new Date(t.completedAt) : null,
|
||||
createdAt: new Date(t.createdAt),
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,7 @@ export function DesignerPageClient({
|
||||
const stepCount = initialDesign.steps.length;
|
||||
const actionCount = initialDesign.steps.reduce(
|
||||
(sum, step) => sum + step.actions.length,
|
||||
0
|
||||
0,
|
||||
);
|
||||
|
||||
return { stepCount, actionCount };
|
||||
|
||||
@@ -20,7 +20,9 @@ export default async function ExperimentDesignerPage({
|
||||
}: ExperimentDesignerPageProps) {
|
||||
try {
|
||||
const resolvedParams = await params;
|
||||
const experiment = await api.experiments.get({ id: resolvedParams.experimentId });
|
||||
const experiment = await api.experiments.get({
|
||||
id: resolvedParams.experimentId,
|
||||
});
|
||||
|
||||
if (!experiment) {
|
||||
notFound();
|
||||
@@ -36,13 +38,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) {
|
||||
@@ -220,7 +222,9 @@ export default async function ExperimentDesignerPage({
|
||||
};
|
||||
};
|
||||
|
||||
const actions: ExperimentAction[] = s.actions.map((a) => hydrateAction(a));
|
||||
const actions: ExperimentAction[] = s.actions.map((a) =>
|
||||
hydrateAction(a),
|
||||
);
|
||||
return {
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
@@ -278,7 +282,9 @@ export async function generateMetadata({
|
||||
}> {
|
||||
try {
|
||||
const resolvedParams = await params;
|
||||
const experiment = await api.experiments.get({ id: resolvedParams.experimentId });
|
||||
const experiment = await api.experiments.get({
|
||||
id: resolvedParams.experimentId,
|
||||
});
|
||||
|
||||
return {
|
||||
title: `${experiment?.name} - Designer | HRIStudio`,
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { formatDistanceToNow } from "date-fns";
|
||||
import { Calendar, Clock, Edit, Play, Settings, Users, TestTube } from "lucide-react";
|
||||
import {
|
||||
Calendar,
|
||||
Clock,
|
||||
Edit,
|
||||
Play,
|
||||
Settings,
|
||||
Users,
|
||||
TestTube,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -9,13 +17,13 @@ import { Badge } from "~/components/ui/badge";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { PageHeader } from "~/components/ui/page-header";
|
||||
import {
|
||||
EntityView,
|
||||
EntityViewHeader,
|
||||
EntityViewSection,
|
||||
EmptyState,
|
||||
InfoGrid,
|
||||
QuickActions,
|
||||
StatsGrid,
|
||||
EntityView,
|
||||
EntityViewHeader,
|
||||
EntityViewSection,
|
||||
EmptyState,
|
||||
InfoGrid,
|
||||
QuickActions,
|
||||
StatsGrid,
|
||||
} from "~/components/ui/entity-view";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { api } from "~/trpc/react";
|
||||
@@ -23,436 +31,443 @@ import { useSession } from "next-auth/react";
|
||||
import { useStudyManagement } from "~/hooks/useStudyManagement";
|
||||
|
||||
interface ExperimentDetailPageProps {
|
||||
params: Promise<{ id: string; experimentId: string }>;
|
||||
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,
|
||||
},
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
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,
|
||||
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();
|
||||
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]);
|
||||
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 experimentQuery = api.experiments.get.useQuery(
|
||||
{ id: resolvedParams?.experimentId ?? "" },
|
||||
{ enabled: !!resolvedParams?.experimentId },
|
||||
);
|
||||
|
||||
const trialsQuery = api.trials.list.useQuery(
|
||||
{ experimentId: 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 (experimentQuery.data) {
|
||||
setExperiment(experimentQuery.data);
|
||||
}
|
||||
}, [experimentQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (trialsQuery.data) {
|
||||
setTrials(trialsQuery.data);
|
||||
}
|
||||
}, [trialsQuery.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]);
|
||||
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",
|
||||
},
|
||||
]);
|
||||
// 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();
|
||||
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;
|
||||
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");
|
||||
// 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 statusInfo =
|
||||
statusConfig[experiment.status as keyof typeof statusConfig];
|
||||
|
||||
const studyId = experiment.study.id;
|
||||
const experimentId = experiment.id;
|
||||
const studyId = experiment.study.id;
|
||||
const experimentId = experiment.id;
|
||||
|
||||
return (
|
||||
<EntityView>
|
||||
<PageHeader
|
||||
title={displayName}
|
||||
description={description ?? undefined}
|
||||
icon={TestTube}
|
||||
badges={[
|
||||
{
|
||||
label: statusInfo?.label ?? "Unknown",
|
||||
variant: statusInfo?.variant ?? "secondary",
|
||||
}
|
||||
]}
|
||||
actions={
|
||||
canEdit ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
</div>
|
||||
) : 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,
|
||||
},
|
||||
...(canEdit
|
||||
? [
|
||||
{
|
||||
label: "Open Designer",
|
||||
icon: "Palette" as const,
|
||||
href: `/studies/${studyId}/experiments/${experimentId}/designer`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
</div>
|
||||
return (
|
||||
<EntityView>
|
||||
<PageHeader
|
||||
title={displayName}
|
||||
description={description ?? undefined}
|
||||
icon={TestTube}
|
||||
badges={[
|
||||
{
|
||||
label: statusInfo?.label ?? "Unknown",
|
||||
variant: statusInfo?.variant ?? "secondary",
|
||||
},
|
||||
]}
|
||||
actions={
|
||||
canEdit ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
</div>
|
||||
</EntityView>
|
||||
);
|
||||
) : 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,
|
||||
},
|
||||
...(canEdit
|
||||
? [
|
||||
{
|
||||
label: "Open Designer",
|
||||
icon: "Palette" as const,
|
||||
href: `/studies/${studyId}/experiments/${experimentId}/designer`,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
</EntityViewSection>
|
||||
</div>
|
||||
</div>
|
||||
</EntityView>
|
||||
);
|
||||
}
|
||||
|
||||
317
src/app/(dashboard)/studies/[id]/forms/page.tsx
Normal file
317
src/app/(dashboard)/studies/[id]/forms/page.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { notFound } from "next/navigation";
|
||||
import { FileText, Loader2, Plus, Download, Edit2, Eye, Save } from "lucide-react";
|
||||
import {
|
||||
EntityView,
|
||||
EntityViewHeader,
|
||||
EntityViewSection,
|
||||
EmptyState,
|
||||
} from "~/components/ui/entity-view";
|
||||
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Badge } from "~/components/ui/badge";
|
||||
import { api } from "~/trpc/react";
|
||||
import { toast } from "sonner";
|
||||
import { PageHeader } from "~/components/ui/page-header";
|
||||
import { useEditor, EditorContent } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import { Markdown } from 'tiptap-markdown';
|
||||
import { Table } from '@tiptap/extension-table';
|
||||
import { TableRow } from '@tiptap/extension-table-row';
|
||||
import { TableCell } from '@tiptap/extension-table-cell';
|
||||
import { TableHeader } from '@tiptap/extension-table-header';
|
||||
import { Bold, Italic, List, ListOrdered, Heading1, Heading2, Quote, Table as TableIcon } from "lucide-react";
|
||||
import { downloadPdfFromHtml } from "~/lib/pdf-generator";
|
||||
|
||||
const Toolbar = ({ editor }: { editor: any }) => {
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-input bg-transparent rounded-tr-md rounded-tl-md p-1 flex items-center gap-1 flex-wrap">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
disabled={!editor.can().chain().focus().toggleBold().run()}
|
||||
className={editor.isActive('bold') ? 'bg-muted' : ''}
|
||||
>
|
||||
<Bold className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
disabled={!editor.can().chain().focus().toggleItalic().run()}
|
||||
className={editor.isActive('italic') ? 'bg-muted' : ''}
|
||||
>
|
||||
<Italic className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="w-[1px] h-6 bg-border mx-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
className={editor.isActive('heading', { level: 1 }) ? 'bg-muted' : ''}
|
||||
>
|
||||
<Heading1 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
className={editor.isActive('heading', { level: 2 }) ? 'bg-muted' : ''}
|
||||
>
|
||||
<Heading2 className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="w-[1px] h-6 bg-border mx-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
className={editor.isActive('bulletList') ? 'bg-muted' : ''}
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
className={editor.isActive('orderedList') ? 'bg-muted' : ''}
|
||||
>
|
||||
<ListOrdered className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
className={editor.isActive('blockquote') ? 'bg-muted' : ''}
|
||||
>
|
||||
<Quote className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="w-[1px] h-6 bg-border mx-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()}
|
||||
>
|
||||
<TableIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface StudyFormsPageProps {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default function StudyFormsPage({ params }: StudyFormsPageProps) {
|
||||
const { data: session } = useSession();
|
||||
const utils = api.useUtils();
|
||||
const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(null);
|
||||
const [editorTarget, setEditorTarget] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const resolveParams = async () => {
|
||||
const resolved = await params;
|
||||
setResolvedParams(resolved);
|
||||
};
|
||||
void resolveParams();
|
||||
}, [params]);
|
||||
|
||||
const { data: study } = api.studies.get.useQuery(
|
||||
{ id: resolvedParams?.id ?? "" },
|
||||
{ enabled: !!resolvedParams?.id },
|
||||
);
|
||||
|
||||
const { data: activeConsentForm, refetch: refetchConsentForm } =
|
||||
api.studies.getActiveConsentForm.useQuery(
|
||||
{ studyId: resolvedParams?.id ?? "" },
|
||||
{ enabled: !!resolvedParams?.id },
|
||||
);
|
||||
|
||||
// Only sync once when form loads to avoid resetting user edits
|
||||
useEffect(() => {
|
||||
if (activeConsentForm && !editorTarget) {
|
||||
setEditorTarget(activeConsentForm.content);
|
||||
}
|
||||
}, [activeConsentForm, editorTarget]);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Table.configure({
|
||||
resizable: true,
|
||||
}),
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
Markdown.configure({
|
||||
transformPastedText: true,
|
||||
}),
|
||||
],
|
||||
content: editorTarget || '',
|
||||
immediatelyRender: false,
|
||||
onUpdate: ({ editor }) => {
|
||||
// @ts-ignore
|
||||
setEditorTarget(editor.storage.markdown.getMarkdown());
|
||||
},
|
||||
});
|
||||
|
||||
// Sync Tiptap when editorTarget is set (e.g., from DB) but make sure not to overwrite active edits
|
||||
useEffect(() => {
|
||||
if (editor && editorTarget && editor.isEmpty) {
|
||||
editor.commands.setContent(editorTarget);
|
||||
}
|
||||
}, [editorTarget, editor]);
|
||||
|
||||
const generateConsentMutation = api.studies.generateConsentForm.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success("Default Consent Form Generated!");
|
||||
setEditorTarget(data.content);
|
||||
editor?.commands.setContent(data.content);
|
||||
void refetchConsentForm();
|
||||
void utils.studies.getActivity.invalidate({ studyId: resolvedParams?.id ?? "" });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Error generating consent form", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const updateConsentMutation = api.studies.updateConsentForm.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success("Consent Form Saved Successfully!");
|
||||
void refetchConsentForm();
|
||||
void utils.studies.getActivity.invalidate({ studyId: resolvedParams?.id ?? "" });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Error saving consent form", { description: error.message });
|
||||
},
|
||||
});
|
||||
|
||||
const handleDownloadConsent = async () => {
|
||||
if (!activeConsentForm || !study || !editor) return;
|
||||
|
||||
try {
|
||||
toast.loading("Generating Document...", { id: "pdf-gen" });
|
||||
await downloadPdfFromHtml(editor.getHTML(), {
|
||||
filename: `Consent_Form_${study.name.replace(/\s+/g, "_")}_v${activeConsentForm.version}.pdf`
|
||||
});
|
||||
toast.success("Document Downloaded Successfully!", { id: "pdf-gen" });
|
||||
} catch (error) {
|
||||
toast.error("Error generating PDF", { id: "pdf-gen" });
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
useBreadcrumbsEffect([
|
||||
{ label: "Dashboard", href: "/dashboard" },
|
||||
{ label: "Studies", href: "/studies" },
|
||||
{ label: study?.name ?? "Study", href: `/studies/${resolvedParams?.id}` },
|
||||
{ label: "Forms" },
|
||||
]);
|
||||
|
||||
if (!session?.user) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
if (!study) return <div>Loading...</div>;
|
||||
|
||||
return (
|
||||
<EntityView>
|
||||
<PageHeader
|
||||
title="Study Forms"
|
||||
description="Manage consent forms and future questionnaires for this study"
|
||||
icon={FileText}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-1 gap-8">
|
||||
<EntityViewSection
|
||||
title="Consent Document"
|
||||
icon="FileText"
|
||||
description="Design and manage the consent form that participants must sign before participating in your trials."
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => generateConsentMutation.mutate({ studyId: study.id })}
|
||||
disabled={generateConsentMutation.isPending || updateConsentMutation.isPending}
|
||||
>
|
||||
{generateConsentMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Generate Default Template
|
||||
</Button>
|
||||
{activeConsentForm && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => updateConsentMutation.mutate({ studyId: study.id, content: editorTarget })}
|
||||
disabled={updateConsentMutation.isPending || editorTarget === activeConsentForm.content}
|
||||
>
|
||||
{updateConsentMutation.isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
Save Changes
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{activeConsentForm ? (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium leading-none">
|
||||
{activeConsentForm.title}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
v{activeConsentForm.version} • Status: Active
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleDownloadConsent}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Download PDF
|
||||
</Button>
|
||||
<Badge variant="outline" className="bg-green-50 text-green-700 hover:bg-green-50">Active</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full flex justify-center bg-muted/30 p-8 rounded-md border border-border overflow-hidden">
|
||||
<div className="max-w-4xl w-full bg-white dark:bg-card shadow-xl ring-1 ring-border rounded-sm flex flex-col">
|
||||
<div className="border-b border-border bg-muted/50 dark:bg-muted/10">
|
||||
<Toolbar editor={editor} />
|
||||
</div>
|
||||
<div className="min-h-[850px] px-16 py-20 text-sm editor-container bg-white dark:bg-card">
|
||||
<EditorContent editor={editor} className="prose prose-sm dark:prose-invert max-w-none h-full outline-none focus:outline-none focus-visible:outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
icon="FileText"
|
||||
title="No Consent Form"
|
||||
description="Generate a boilerplate consent form for this study to download and collect signatures."
|
||||
/>
|
||||
)}
|
||||
</EntityViewSection>
|
||||
</div>
|
||||
</EntityView>
|
||||
);
|
||||
}
|
||||
@@ -71,6 +71,7 @@ type Member = {
|
||||
|
||||
export default function StudyDetailPage({ params }: StudyDetailPageProps) {
|
||||
const { data: session } = useSession();
|
||||
const utils = api.useUtils();
|
||||
const [study, setStudy] = useState<Study | null>(null);
|
||||
const [members, setMembers] = useState<Member[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -176,7 +177,7 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
|
||||
{
|
||||
label: statusInfo?.label ?? "Unknown",
|
||||
variant: statusInfo?.variant ?? "secondary",
|
||||
}
|
||||
},
|
||||
]}
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -301,12 +302,18 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/studies/${study.id}/experiments/${experiment.id}/designer`}>
|
||||
<Link
|
||||
href={`/studies/${study.id}/experiments/${experiment.id}/designer`}
|
||||
>
|
||||
Design
|
||||
</Link>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link href={`/studies/${study.id}/experiments/${experiment.id}`}>View</Link>
|
||||
<Link
|
||||
href={`/studies/${study.id}/experiments/${experiment.id}`}
|
||||
>
|
||||
View
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,29 +3,29 @@ import { api } from "~/trpc/server";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
interface EditParticipantPageProps {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
participantId: string;
|
||||
}>;
|
||||
params: Promise<{
|
||||
id: string;
|
||||
participantId: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function EditParticipantPage({
|
||||
params,
|
||||
params,
|
||||
}: EditParticipantPageProps) {
|
||||
const { id: studyId, participantId } = await params;
|
||||
const { id: studyId, participantId } = await params;
|
||||
|
||||
const participant = await api.participants.get({ id: participantId });
|
||||
const participant = await api.participants.get({ id: participantId });
|
||||
|
||||
if (!participant || participant.studyId !== studyId) {
|
||||
notFound();
|
||||
}
|
||||
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}
|
||||
/>
|
||||
);
|
||||
// Transform data to match form expectations if needed, or pass directly
|
||||
return (
|
||||
<ParticipantForm
|
||||
mode="edit"
|
||||
studyId={studyId}
|
||||
participantId={participantId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { api } from "~/trpc/server";
|
||||
import {
|
||||
EntityView,
|
||||
EntityViewHeader,
|
||||
EntityViewSection,
|
||||
EntityView,
|
||||
EntityViewHeader,
|
||||
EntityViewSection,
|
||||
} from "~/components/ui/entity-view";
|
||||
import { ParticipantDocuments } from "./participant-documents";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "~/components/ui/card";
|
||||
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";
|
||||
@@ -17,104 +23,129 @@ import { PageHeader } from "~/components/ui/page-header";
|
||||
import { ParticipantConsentManager } from "~/components/participants/ParticipantConsentManager";
|
||||
|
||||
interface ParticipantDetailPageProps {
|
||||
params: Promise<{ id: string; participantId: string }>;
|
||||
params: Promise<{ id: string; participantId: string }>;
|
||||
}
|
||||
|
||||
export default async function ParticipantDetailPage({
|
||||
params,
|
||||
params,
|
||||
}: ParticipantDetailPageProps) {
|
||||
const { id: studyId, participantId } = await params;
|
||||
const { id: studyId, participantId } = await params;
|
||||
|
||||
const participant = await api.participants.get({ id: participantId });
|
||||
const participant = await api.participants.get({ id: participantId });
|
||||
|
||||
if (!participant) {
|
||||
notFound();
|
||||
}
|
||||
if (!participant) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
// Ensure participant belongs to study
|
||||
if (participant.studyId !== studyId) {
|
||||
notFound();
|
||||
}
|
||||
// Ensure participant belongs to study
|
||||
if (participant.studyId !== studyId) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return (
|
||||
<EntityView>
|
||||
<PageHeader
|
||||
title={participant.participantCode}
|
||||
description={participant.name ?? "Unnamed Participant"}
|
||||
icon={Users}
|
||||
badges={[
|
||||
{
|
||||
label: participant.consentGiven ? "Consent Given" : "No Consent",
|
||||
variant: participant.consentGiven ? "default" : "secondary"
|
||||
}
|
||||
]}
|
||||
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>
|
||||
}
|
||||
return (
|
||||
<EntityView>
|
||||
<PageHeader
|
||||
title={participant.participantCode}
|
||||
description={participant.name ?? "Unnamed Participant"}
|
||||
icon={Users}
|
||||
badges={[
|
||||
{
|
||||
label: participant.consentGiven ? "Consent Given" : "No Consent",
|
||||
variant: participant.consentGiven ? "default" : "secondary",
|
||||
},
|
||||
]}
|
||||
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 grid-cols-1 gap-6">
|
||||
<ParticipantConsentManager
|
||||
studyId={studyId}
|
||||
participantId={participantId}
|
||||
participantName={participant.name}
|
||||
participantCode={participant.participantCode}
|
||||
consentGiven={participant.consentGiven}
|
||||
consentDate={participant.consentDate}
|
||||
existingConsent={participant.consents[0] ?? null}
|
||||
/>
|
||||
<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 mb-1 block">Code</span>
|
||||
<span className="text-base font-medium">
|
||||
{participant.participantCode}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="overview" className="w-full">
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="files">Files & Documents</TabsTrigger>
|
||||
</TabsList>
|
||||
<div>
|
||||
<span className="text-muted-foreground mb-1 block">Name</span>
|
||||
<span className="text-base font-medium">
|
||||
{participant.name || "-"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<TabsContent value="overview">
|
||||
<div className="grid gap-6 grid-cols-1">
|
||||
<ParticipantConsentManager
|
||||
studyId={studyId}
|
||||
participantId={participantId}
|
||||
consentGiven={participant.consentGiven}
|
||||
consentDate={participant.consentDate}
|
||||
existingConsent={participant.consents[0] ?? null}
|
||||
/>
|
||||
<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 mb-1 block">
|
||||
Email
|
||||
</span>
|
||||
<span className="text-base font-medium">
|
||||
{participant.email || "-"}
|
||||
</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 mb-1 block">
|
||||
Added
|
||||
</span>
|
||||
<span className="text-base font-medium">
|
||||
{new Date(participant.createdAt).toLocaleDateString()}
|
||||
</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 mb-1 block">Age</span>
|
||||
<span className="text-base font-medium">
|
||||
{(participant.demographics as any)?.age || "-"}
|
||||
</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 mb-1 block">
|
||||
Gender
|
||||
</span>
|
||||
<span className="text-base font-medium capitalize">
|
||||
{(participant.demographics as any)?.gender?.replace(
|
||||
"_",
|
||||
" ",
|
||||
) || "-"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</EntityViewSection>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<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>
|
||||
);
|
||||
<TabsContent value="files">
|
||||
<EntityViewSection title="Documents" icon="FileText">
|
||||
<ParticipantDocuments participantId={participantId} />
|
||||
</EntityViewSection>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</EntityView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,184 +4,192 @@ 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,
|
||||
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;
|
||||
participantId: string;
|
||||
}
|
||||
|
||||
export function ParticipantDocuments({ participantId }: ParticipantDocumentsProps) {
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
export function ParticipantDocuments({
|
||||
participantId,
|
||||
}: ParticipantDocumentsProps) {
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { data: documents, isLoading } = api.files.listParticipantDocuments.useQuery({
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
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 });
|
||||
// 2. Upload to MinIO/S3
|
||||
const uploadRes = await fetch(url, {
|
||||
method: "PUT",
|
||||
body: file,
|
||||
headers: {
|
||||
"Content-Type": file.type || "application/octet-stream",
|
||||
},
|
||||
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;
|
||||
if (!uploadRes.ok) {
|
||||
throw new Error("Upload to storage failed");
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
// 1. Get presigned URL
|
||||
const { url, storagePath } = await getPresignedUrl.mutateAsync({
|
||||
filename: file.name,
|
||||
contentType: file.type || "application/octet-stream",
|
||||
participantId,
|
||||
});
|
||||
// 3. Register in DB
|
||||
await registerUpload.mutateAsync({
|
||||
participantId,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
storagePath,
|
||||
fileSize: file.size,
|
||||
});
|
||||
|
||||
// 2. Upload to MinIO/S3
|
||||
const uploadRes = await fetch(url, {
|
||||
method: "PUT",
|
||||
body: file,
|
||||
headers: {
|
||||
"Content-Type": file.type || "application/octet-stream",
|
||||
},
|
||||
});
|
||||
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 = "";
|
||||
}
|
||||
};
|
||||
|
||||
if (!uploadRes.ok) {
|
||||
throw new Error("Upload to storage failed");
|
||||
}
|
||||
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");
|
||||
}
|
||||
};
|
||||
|
||||
// 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>
|
||||
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" />
|
||||
) : (
|
||||
<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>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
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="text-muted-foreground h-6 w-6 animate-spin" />
|
||||
</div>
|
||||
) : documents?.length === 0 ? (
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center py-8 text-center">
|
||||
<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="hover:bg-muted/50 flex items-center justify-between rounded-lg border p-3"
|
||||
>
|
||||
<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-muted-foreground text-xs">
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,111 +13,112 @@ 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 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();
|
||||
const { setSelectedStudyId, selectedStudyId } = useStudyContext();
|
||||
const { study } = useSelectedStudyDetails();
|
||||
|
||||
// Get trial data
|
||||
const {
|
||||
data: trial,
|
||||
isLoading,
|
||||
error,
|
||||
} = api.trials.get.useQuery({ id: trialId }, { enabled: !!trialId });
|
||||
// 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" },
|
||||
]);
|
||||
// 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>
|
||||
);
|
||||
// Sync selected study (unified study-context)
|
||||
useEffect(() => {
|
||||
if (studyId && selectedStudyId !== studyId) {
|
||||
setSelectedStudyId(studyId);
|
||||
}
|
||||
}, [studyId, selectedStudyId, setSelectedStudyId]);
|
||||
|
||||
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 customTrialData = {
|
||||
...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,
|
||||
media: trial.media?.map(m => ({
|
||||
...m,
|
||||
mediaType: m.mediaType ?? "video",
|
||||
format: m.format ?? undefined,
|
||||
contentType: m.contentType ?? undefined
|
||||
})) ?? [],
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<TrialAnalysisView
|
||||
trial={customTrialData}
|
||||
backHref={`/studies/${studyId}/trials/${trialId}`}
|
||||
/>
|
||||
<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 customTrialData = {
|
||||
...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,
|
||||
media:
|
||||
trial.media?.map((m) => ({
|
||||
...m,
|
||||
mediaType: m.mediaType ?? "video",
|
||||
format: m.format ?? undefined,
|
||||
contentType: m.contentType ?? undefined,
|
||||
})) ?? [],
|
||||
};
|
||||
|
||||
return (
|
||||
<TrialAnalysisView
|
||||
trial={customTrialData}
|
||||
backHref={`/studies/${studyId}/trials/${trialId}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-96 items-center justify-center">
|
||||
<div className="text-muted-foreground">Loading...</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<AnalysisPageContent />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,14 @@
|
||||
import { useParams } from "next/navigation";
|
||||
import { Suspense, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { Play, Zap, ArrowLeft, User, FlaskConical, LineChart } 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";
|
||||
@@ -144,7 +151,7 @@ function TrialDetailContent() {
|
||||
{
|
||||
label: trial.status.replace("_", " ").toUpperCase(),
|
||||
variant: getStatusBadgeVariant(trial.status),
|
||||
}
|
||||
},
|
||||
]}
|
||||
actions={
|
||||
<div className="flex gap-2">
|
||||
@@ -156,13 +163,13 @@ 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>
|
||||
)}
|
||||
<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}/analysis`}>
|
||||
|
||||
@@ -211,11 +211,7 @@ function WizardPageContent() {
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{renderView()}
|
||||
</div>
|
||||
);
|
||||
return <div>{renderView()}</div>;
|
||||
}
|
||||
|
||||
export default function TrialWizardPage() {
|
||||
|
||||
@@ -25,7 +25,7 @@ const handler = (req: NextRequest) =>
|
||||
env.NODE_ENV === "development"
|
||||
? ({ path, error }) => {
|
||||
console.error(
|
||||
`❌ tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
|
||||
`[tRPC Error] tRPC failed on ${path ?? "<no-path>"}: ${error.message}`,
|
||||
);
|
||||
}
|
||||
: undefined,
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { Checkbox } from "~/components/ui/checkbox";
|
||||
import { Input } from "~/components/ui/input";
|
||||
@@ -61,25 +61,30 @@ export default function SignInPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background px-4">
|
||||
<div className="bg-background relative flex min-h-screen items-center justify-center overflow-hidden 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="bg-primary/20 absolute top-0 left-1/2 -z-10 h-[500px] w-[1000px] -translate-x-1/2 rounded-full opacity-30 blur-3xl dark:opacity-20" />
|
||||
<div className="absolute right-0 bottom-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">
|
||||
<div className="animate-in fade-in zoom-in-95 w-full max-w-md duration-500">
|
||||
{/* Header */}
|
||||
<div className="mb-8 text-center">
|
||||
<Link href="/" className="inline-flex items-center justify-center transition-opacity hover:opacity-80">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center justify-center transition-opacity hover:opacity-80"
|
||||
>
|
||||
<Logo iconSize="lg" showText={true} />
|
||||
</Link>
|
||||
<h1 className="mt-6 text-2xl font-bold tracking-tight text-foreground">Welcome back</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
<h1 className="text-foreground mt-6 text-2xl font-bold tracking-tight">
|
||||
Welcome back
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
Sign in to your research account
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Sign In Card */}
|
||||
<Card className="border-muted/40 bg-card/50 backdrop-blur-sm shadow-xl">
|
||||
<Card className="border-muted/40 bg-card/50 shadow-xl backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Sign In</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -89,7 +94,7 @@ export default function SignInPage() {
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive font-medium border border-destructive/20">
|
||||
<div className="bg-destructive/15 text-destructive border-destructive/20 rounded-md border p-3 text-sm font-medium">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -111,7 +116,12 @@ export default function SignInPage() {
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Link href="#" className="text-xs text-primary hover:underline">Forgot password?</Link>
|
||||
<Link
|
||||
href="#"
|
||||
className="text-primary text-xs hover:underline"
|
||||
>
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
@@ -133,23 +143,30 @@ export default function SignInPage() {
|
||||
/>
|
||||
<label
|
||||
htmlFor="not-robot"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||
className="cursor-pointer text-sm leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
I'm not a robot{" "}
|
||||
<span className="text-muted-foreground text-xs italic">(ironic, isn't it?)</span>
|
||||
<span className="text-muted-foreground text-xs italic">
|
||||
(ironic, isn't it?)
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={isLoading} size="lg">
|
||||
<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-muted-foreground">
|
||||
<div className="text-muted-foreground mt-6 text-center text-sm">
|
||||
Don't have an account?{" "}
|
||||
<Link
|
||||
href="/auth/signup"
|
||||
className="font-medium text-primary hover:text-primary/80"
|
||||
className="text-primary hover:text-primary/80 font-medium"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
@@ -158,7 +175,7 @@ export default function SignInPage() {
|
||||
</Card>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-8 text-center text-xs text-muted-foreground">
|
||||
<div className="text-muted-foreground mt-8 text-center text-xs">
|
||||
<p>
|
||||
© {new Date().getFullYear()} HRIStudio. All rights reserved.
|
||||
</p>
|
||||
|
||||
@@ -6,11 +6,11 @@ import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
|
||||
export default function SignOutPage() {
|
||||
@@ -44,7 +44,7 @@ export default function SignOutPage() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<div className="mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-b-2 border-blue-600"></div>
|
||||
<p className="text-slate-600">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -79,7 +79,8 @@ export default function SignOutPage() {
|
||||
<CardContent className="space-y-4">
|
||||
<div className="rounded-md bg-blue-50 p-3 text-sm text-blue-700">
|
||||
<p className="font-medium">
|
||||
Currently signed in as: {session.user.name ?? session.user.email}
|
||||
Currently signed in as:{" "}
|
||||
{session.user.name ?? session.user.email}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
@@ -56,25 +56,30 @@ export default function SignUpPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-background px-4">
|
||||
<div className="bg-background relative flex min-h-screen items-center justify-center overflow-hidden 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="bg-primary/20 absolute top-0 left-1/2 -z-10 h-[500px] w-[1000px] -translate-x-1/2 rounded-full opacity-30 blur-3xl 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">
|
||||
<div className="animate-in fade-in zoom-in-95 w-full max-w-md duration-500">
|
||||
{/* Header */}
|
||||
<div className="mb-8 text-center">
|
||||
<Link href="/" className="inline-flex items-center justify-center transition-opacity hover:opacity-80">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center justify-center transition-opacity hover:opacity-80"
|
||||
>
|
||||
<Logo iconSize="lg" showText={false} />
|
||||
</Link>
|
||||
<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">
|
||||
<h1 className="text-foreground mt-6 text-2xl font-bold tracking-tight">
|
||||
Create an account
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-2 text-sm">
|
||||
Start your journey in HRI research
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Sign Up Card */}
|
||||
<Card className="border-muted/40 bg-card/50 backdrop-blur-sm shadow-xl">
|
||||
<Card className="border-muted/40 bg-card/50 shadow-xl backdrop-blur-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Sign Up</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -84,7 +89,7 @@ export default function SignUpPage() {
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/15 p-3 text-sm text-destructive font-medium border border-destructive/20">
|
||||
<div className="bg-destructive/15 text-destructive border-destructive/20 rounded-md border p-3 text-sm font-medium">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -155,15 +160,17 @@ export default function SignUpPage() {
|
||||
disabled={createUser.isPending}
|
||||
size="lg"
|
||||
>
|
||||
{createUser.isPending ? "Creating account..." : "Create Account"}
|
||||
{createUser.isPending
|
||||
? "Creating account..."
|
||||
: "Create Account"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center text-sm text-muted-foreground">
|
||||
<div className="text-muted-foreground mt-6 text-center text-sm">
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
href="/auth/signin"
|
||||
className="font-medium text-primary hover:text-primary/80"
|
||||
className="text-primary hover:text-primary/80 font-medium"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
@@ -172,7 +179,7 @@ export default function SignUpPage() {
|
||||
</Card>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="mt-8 text-center text-xs text-muted-foreground">
|
||||
<div className="text-muted-foreground mt-8 text-center text-xs">
|
||||
<p>
|
||||
© {new Date().getFullYear()} HRIStudio. All rights reserved.
|
||||
</p>
|
||||
|
||||
@@ -77,7 +77,7 @@ export default function DashboardPage() {
|
||||
|
||||
const { data: liveTrials } = api.dashboard.getLiveTrials.useQuery(
|
||||
{ studyId: studyFilter ?? undefined },
|
||||
{ refetchInterval: 5000 }
|
||||
{ refetchInterval: 5000 },
|
||||
);
|
||||
|
||||
const { data: recentActivity } = api.dashboard.getRecentActivity.useQuery({
|
||||
@@ -102,11 +102,14 @@ export default function DashboardPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-in fade-in duration-500">
|
||||
<div className="animate-in fade-in space-y-8 duration-500">
|
||||
{/* Header Section */}
|
||||
<div id="dashboard-header" className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div
|
||||
id="dashboard-header"
|
||||
className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"
|
||||
>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-foreground">
|
||||
<h1 className="text-foreground text-3xl font-bold tracking-tight">
|
||||
{getWelcomeMessage()}
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
@@ -115,7 +118,12 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" onClick={() => startTour("dashboard")} title="Start Tour">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => startTour("dashboard")}
|
||||
title="Start Tour"
|
||||
>
|
||||
<HelpCircle className="h-5 w-5" />
|
||||
</Button>
|
||||
<Select
|
||||
@@ -124,7 +132,7 @@ export default function DashboardPage() {
|
||||
setStudyFilter(value === "all" ? null : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-[200px] bg-background">
|
||||
<SelectTrigger className="bg-background w-[200px]">
|
||||
<SelectValue placeholder="All Studies" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -146,7 +154,10 @@ export default function DashboardPage() {
|
||||
</div>
|
||||
|
||||
{/* Main Stats Grid */}
|
||||
<div id="tour-dashboard-stats" className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div
|
||||
id="tour-dashboard-stats"
|
||||
className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"
|
||||
>
|
||||
<StatsCard
|
||||
title="Active Trials"
|
||||
value={stats?.activeTrials ?? 0}
|
||||
@@ -179,9 +190,8 @@ export default function DashboardPage() {
|
||||
|
||||
{/* Action Center & Recent Activity */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||
|
||||
{/* Quick Actions Card */}
|
||||
<Card className="col-span-3 bg-gradient-to-br from-primary/5 to-background border-primary/20 h-fit">
|
||||
<Card className="from-primary/5 to-background border-primary/20 col-span-3 h-fit bg-gradient-to-br">
|
||||
<CardHeader>
|
||||
<CardTitle>Quick Actions</CardTitle>
|
||||
<CardDescription>Common tasks to get you started</CardDescription>
|
||||
@@ -189,35 +199,35 @@ export default function DashboardPage() {
|
||||
<CardContent className="grid gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="justify-start h-auto py-4 px-4 border-primary/20 hover:border-primary/50 hover:bg-primary/5 group"
|
||||
className="border-primary/20 hover:border-primary/50 hover:bg-primary/5 group h-auto justify-start px-4 py-4"
|
||||
asChild
|
||||
>
|
||||
<Link href="/studies/new">
|
||||
<div className="p-2 bg-primary/10 rounded-full mr-4 group-hover:bg-primary/20 transition-colors">
|
||||
<FlaskConical className="h-5 w-5 text-primary" />
|
||||
<div className="bg-primary/10 group-hover:bg-primary/20 mr-4 rounded-full p-2 transition-colors">
|
||||
<FlaskConical className="text-primary h-5 w-5" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">Create New Study</div>
|
||||
<div className="text-xs text-muted-foreground font-normal">
|
||||
<div className="text-muted-foreground text-xs font-normal">
|
||||
Design a new experiment protocol
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight className="ml-auto h-4 w-4 text-muted-foreground group-hover:text-primary opacity-0 group-hover:opacity-100 transition-all" />
|
||||
<ArrowRight className="text-muted-foreground group-hover:text-primary ml-auto h-4 w-4 opacity-0 transition-all group-hover:opacity-100" />
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="justify-start h-auto py-4 px-4 group"
|
||||
className="group h-auto justify-start px-4 py-4"
|
||||
asChild
|
||||
>
|
||||
<Link href="/studies">
|
||||
<div className="p-2 bg-secondary rounded-full mr-4">
|
||||
<Search className="h-5 w-5 text-foreground" />
|
||||
<div className="bg-secondary mr-4 rounded-full p-2">
|
||||
<Search className="text-foreground h-5 w-5" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">Browse Studies</div>
|
||||
<div className="text-xs text-muted-foreground font-normal">
|
||||
<div className="text-muted-foreground text-xs font-normal">
|
||||
Find and manage existing studies
|
||||
</div>
|
||||
</div>
|
||||
@@ -226,16 +236,16 @@ export default function DashboardPage() {
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className="justify-start h-auto py-4 px-4 group"
|
||||
className="group h-auto justify-start px-4 py-4"
|
||||
asChild
|
||||
>
|
||||
<Link href="/trials">
|
||||
<div className="p-2 bg-emerald-500/10 rounded-full mr-4">
|
||||
<div className="mr-4 rounded-full bg-emerald-500/10 p-2">
|
||||
<Activity className="h-5 w-5 text-emerald-600" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">Monitor Active Trials</div>
|
||||
<div className="text-xs text-muted-foreground font-normal">
|
||||
<div className="text-muted-foreground text-xs font-normal">
|
||||
Jump into the Wizard Interface
|
||||
</div>
|
||||
</div>
|
||||
@@ -245,7 +255,10 @@ export default function DashboardPage() {
|
||||
</Card>
|
||||
|
||||
{/* Recent Activity Card */}
|
||||
<Card id="tour-recent-activity" className="col-span-4 border-muted/40 shadow-sm">
|
||||
<Card
|
||||
id="tour-recent-activity"
|
||||
className="border-muted/40 col-span-4 shadow-sm"
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Activity</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -262,37 +275,53 @@ export default function DashboardPage() {
|
||||
eventColor = "bg-blue-500 ring-blue-100 dark:ring-blue-900";
|
||||
Icon = PlayCircle;
|
||||
} else if (activity.type === "trial_completed") {
|
||||
eventColor = "bg-green-500 ring-green-100 dark:ring-green-900";
|
||||
eventColor =
|
||||
"bg-green-500 ring-green-100 dark:ring-green-900";
|
||||
Icon = CheckCircle;
|
||||
} else if (activity.type === "error") {
|
||||
eventColor = "bg-red-500 ring-red-100 dark:ring-red-900";
|
||||
Icon = AlertTriangle;
|
||||
} else if (activity.type === "intervention") {
|
||||
eventColor = "bg-orange-500 ring-orange-100 dark:ring-orange-900";
|
||||
eventColor =
|
||||
"bg-orange-500 ring-orange-100 dark:ring-orange-900";
|
||||
Icon = Gamepad2;
|
||||
} else if (activity.type === "annotation") {
|
||||
eventColor = "bg-yellow-500 ring-yellow-100 dark:ring-yellow-900";
|
||||
eventColor =
|
||||
"bg-yellow-500 ring-yellow-100 dark:ring-yellow-900";
|
||||
Icon = MessageSquare;
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={activity.id} className="relative pl-6 pb-4 border-l last:border-0 border-muted-foreground/20">
|
||||
<span className={`absolute left-[-9px] top-0 h-4 w-4 rounded-full flex items-center justify-center ring-4 ${eventColor}`}>
|
||||
<div
|
||||
key={activity.id}
|
||||
className="border-muted-foreground/20 relative border-l pb-4 pl-6 last:border-0"
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0 left-[-9px] flex h-4 w-4 items-center justify-center rounded-full ring-4 ${eventColor}`}
|
||||
>
|
||||
<Icon className="h-2.5 w-2.5 text-white" />
|
||||
</span>
|
||||
<div className="mb-0.5 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 font-mono">
|
||||
{formatDistanceToNow(new Date(activity.time), { addSuffix: true })}
|
||||
<div className="mb-0.5 text-sm leading-none font-medium">
|
||||
{activity.title}
|
||||
</div>
|
||||
<div className="text-muted-foreground mb-1 text-xs">
|
||||
{activity.description}
|
||||
</div>
|
||||
<div className="text-muted-foreground/70 font-mono text-[10px] uppercase">
|
||||
{formatDistanceToNow(new Date(activity.time), {
|
||||
addSuffix: true,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
})}
|
||||
{!recentActivity?.length && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center text-muted-foreground">
|
||||
<Clock className="h-10 w-10 mb-3 opacity-20" />
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center py-8 text-center">
|
||||
<Clock className="mb-3 h-10 w-10 opacity-20" />
|
||||
<p>No recent activity recorded.</p>
|
||||
<p className="text-xs mt-1">Start a trial to see experiment events stream here.</p>
|
||||
<p className="mt-1 text-xs">
|
||||
Start a trial to see experiment events stream here.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -303,31 +332,40 @@ export default function DashboardPage() {
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||
{/* Live Trials */}
|
||||
<Card id="tour-live-trials" className={`${liveTrials && liveTrials.length > 0 ? 'border-primary shadow-sm bg-primary/5' : 'border-muted/40'} col-span-4 transition-colors duration-500`}>
|
||||
<Card
|
||||
id="tour-live-trials"
|
||||
className={`${liveTrials && liveTrials.length > 0 ? "border-primary bg-primary/5 shadow-sm" : "border-muted/40"} col-span-4 transition-colors duration-500`}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
Live Sessions
|
||||
{liveTrials && liveTrials.length > 0 && <span className="relative flex h-3 w-3">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-400 opacity-75"></span>
|
||||
<span className="relative inline-flex rounded-full h-3 w-3 bg-red-500"></span>
|
||||
</span>}
|
||||
{liveTrials && liveTrials.length > 0 && (
|
||||
<span className="relative flex h-3 w-3">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-red-400 opacity-75"></span>
|
||||
<span className="relative inline-flex h-3 w-3 rounded-full bg-red-500"></span>
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Currently running trials in the Wizard interface
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link href="/trials">View All <ArrowRight className="ml-2 h-4 w-4" /></Link>
|
||||
<Link href="/trials">
|
||||
View All <ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!liveTrials?.length ? (
|
||||
<div className="flex h-[150px] flex-col items-center justify-center rounded-md border border-dashed border-muted-foreground/30 text-center animate-in fade-in-50 bg-background/50">
|
||||
<Radio className="h-8 w-8 text-muted-foreground/50 mb-2" />
|
||||
<p className="text-sm text-muted-foreground">No trials are currently running.</p>
|
||||
<div className="border-muted-foreground/30 animate-in fade-in-50 bg-background/50 flex h-[150px] flex-col items-center justify-center rounded-md border border-dashed text-center">
|
||||
<Radio className="text-muted-foreground/50 mb-2 h-8 w-8" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No trials are currently running.
|
||||
</p>
|
||||
<Button variant="link" size="sm" asChild className="mt-1">
|
||||
<Link href="/trials">Start a Trial</Link>
|
||||
</Button>
|
||||
@@ -335,23 +373,37 @@ export default function DashboardPage() {
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{liveTrials.map((trial) => (
|
||||
<div key={trial.id} className="flex items-center justify-between rounded-lg border border-primary/20 p-3 bg-background shadow-sm hover:shadow transition-all duration-200">
|
||||
<div
|
||||
key={trial.id}
|
||||
className="border-primary/20 bg-background flex items-center justify-between rounded-lg border p-3 shadow-sm transition-all duration-200 hover:shadow"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/40 text-red-600 dark:text-red-400">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-red-100 text-red-600 dark:bg-red-900/40 dark:text-red-400">
|
||||
<Radio className="h-5 w-5 animate-pulse" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-sm">
|
||||
<p className="text-sm font-medium">
|
||||
{trial.participantCode}
|
||||
<span className="ml-2 text-muted-foreground font-normal text-xs">• {trial.experimentName}</span>
|
||||
<span className="text-muted-foreground ml-2 text-xs font-normal">
|
||||
• {trial.experimentName}
|
||||
</span>
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-xs">
|
||||
<Clock className="h-3 w-3" />
|
||||
Started {trial.startedAt ? formatDistanceToNow(new Date(trial.startedAt), { addSuffix: true }) : 'just now'}
|
||||
Started{" "}
|
||||
{trial.startedAt
|
||||
? formatDistanceToNow(new Date(trial.startedAt), {
|
||||
addSuffix: true,
|
||||
})
|
||||
: "just now"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button size="sm" className="gap-2 bg-primary hover:bg-primary/90" asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
className="bg-primary hover:bg-primary/90 gap-2"
|
||||
asChild
|
||||
>
|
||||
<Link href={`/wizard/${trial.id}`}>
|
||||
<Play className="h-3.5 w-3.5" /> Spectate / Jump In
|
||||
</Link>
|
||||
@@ -364,7 +416,7 @@ export default function DashboardPage() {
|
||||
</Card>
|
||||
|
||||
{/* Study Progress */}
|
||||
<Card className="col-span-3 border-muted/40 shadow-sm">
|
||||
<Card className="border-muted/40 col-span-3 shadow-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>Study Progress</CardTitle>
|
||||
<CardDescription>
|
||||
@@ -376,13 +428,18 @@ export default function DashboardPage() {
|
||||
<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 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>
|
||||
<p className="text-muted-foreground py-4 text-center text-sm">
|
||||
No active studies to track.
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -407,16 +464,20 @@ function StatsCard({
|
||||
iconColor?: string;
|
||||
}) {
|
||||
return (
|
||||
<Card className="border-muted/40 shadow-sm hover:shadow-md transition-all duration-200 hover:border-primary/20">
|
||||
<Card className="border-muted/40 hover:border-primary/20 shadow-sm transition-all duration-200 hover:shadow-md">
|
||||
<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 ${iconColor || "text-muted-foreground"}`} />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{value}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{description}
|
||||
{trend && <span className="ml-1 text-green-600 dark:text-green-400 font-medium">{trend}</span>}
|
||||
{trend && (
|
||||
<span className="ml-1 font-medium text-green-600 dark:text-green-400">
|
||||
{trend}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
209
src/app/page.tsx
209
src/app/page.tsx
@@ -16,6 +16,7 @@ import {
|
||||
PlayCircle,
|
||||
Settings2,
|
||||
Share2,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
export default async function Home() {
|
||||
@@ -26,9 +27,9 @@ export default async function Home() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-background text-foreground">
|
||||
<div className="bg-background text-foreground flex min-h-screen flex-col">
|
||||
{/* Navbar */}
|
||||
<header className="sticky top-0 z-50 w-full border-b bg-background/80 backdrop-blur-sm">
|
||||
<header className="bg-background/80 sticky top-0 z-50 w-full border-b 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">
|
||||
@@ -38,7 +39,7 @@ export default async function Home() {
|
||||
<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" />
|
||||
<div className="bg-border hidden h-6 w-px sm:block" />
|
||||
<Button variant="ghost" asChild>
|
||||
<Link href="/auth/signin">Sign In</Link>
|
||||
</Button>
|
||||
@@ -53,11 +54,15 @@ export default async function Home() {
|
||||
{/* 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="bg-primary/20 absolute top-0 left-1/2 -z-10 h-[500px] w-[1000px] -translate-x-1/2 rounded-full opacity-30 blur-3xl 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
|
||||
variant="secondary"
|
||||
className="mb-6 rounded-full px-4 py-1.5 text-sm font-medium"
|
||||
>
|
||||
<Sparkles className="mr-2 h-4 w-4 text-yellow-500" />
|
||||
The Modern Standard for HRI Research
|
||||
</Badge>
|
||||
|
||||
<h1 className="max-w-4xl text-5xl font-extrabold tracking-tight sm:text-6xl md:text-7xl">
|
||||
@@ -67,7 +72,7 @@ export default async function Home() {
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p className="mt-6 max-w-2xl text-lg text-muted-foreground md:text-xl">
|
||||
<p className="text-muted-foreground mt-6 max-w-2xl text-lg 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.
|
||||
@@ -80,22 +85,32 @@ export default async function Home() {
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size="lg" variant="outline" className="h-12 px-8 text-base" asChild>
|
||||
<Link href="https://github.com/robolab/hristudio" target="_blank">
|
||||
<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>
|
||||
|
||||
{/* 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">
|
||||
<div className="bg-background/50 relative mt-20 w-full max-w-5xl rounded-xl border p-2 shadow-2xl backdrop-blur-sm lg:rounded-2xl lg:p-4">
|
||||
<div className="via-foreground/20 absolute inset-x-0 -top-px mx-auto h-px w-3/4 bg-gradient-to-r from-transparent to-transparent" />
|
||||
<div className="bg-muted/50 relative flex aspect-[16/9] w-full items-center justify-center overflow-hidden rounded-lg border">
|
||||
{/* 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 className="p-8 text-center">
|
||||
<LayoutTemplate className="text-muted-foreground/50 mx-auto mb-4 h-16 w-16" />
|
||||
<p className="text-muted-foreground font-medium">
|
||||
Interactive Experiment Designer
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -105,13 +120,17 @@ export default async function Home() {
|
||||
{/* 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>
|
||||
<h2 className="text-3xl font-bold tracking-tight md:text-4xl">
|
||||
Everything You Need
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-4 text-lg">
|
||||
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">
|
||||
<Card className="col-span-1 row-span-2 flex flex-col overflow-hidden bg-gradient-to-br from-blue-500/5 to-violet-500/5 md:col-span-2 lg:col-span-2 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" />
|
||||
@@ -120,16 +139,19 @@ export default async function Home() {
|
||||
</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.
|
||||
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>
|
||||
<div className="bg-background/50 flex h-full min-h-[200px] items-center justify-center rounded-lg border p-4 shadow-inner">
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<span className="bg-accent rounded 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>
|
||||
<span className="bg-primary/10 border-primary/20 text-primary rounded border p-2 font-medium">
|
||||
Robot: Greet
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
<span className="rounded bg-accent p-2">Wait: 5s</span>
|
||||
<span className="bg-accent rounded p-2">Wait: 5s</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -145,14 +167,15 @@ export default async function Home() {
|
||||
</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.
|
||||
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">
|
||||
<Card className="bg-muted/30 col-span-1 md:col-span-1 lg:col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Lock className="h-4 w-4 text-orange-500" />
|
||||
@@ -160,14 +183,15 @@ export default async function Home() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Granular permissions for Principal Investigators, Wizards, and Observers.
|
||||
<p className="text-muted-foreground text-sm">
|
||||
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">
|
||||
<Card className="bg-muted/30 col-span-1 md:col-span-1 lg:col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Database className="h-4 w-4 text-rose-500" />
|
||||
@@ -175,8 +199,9 @@ export default async function Home() {
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Every wizard action, automated response, and sensor reading is time-stamped and logged.
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Every wizard action, automated response, and sensor reading is
|
||||
time-stamped and logged.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -184,41 +209,56 @@ export default async function Home() {
|
||||
</section>
|
||||
|
||||
{/* Architecture Section */}
|
||||
<section id="architecture" className="border-t bg-muted/30 py-24">
|
||||
<section id="architecture" className="bg-muted/30 border-t py-24">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="grid gap-12 lg:grid-cols-2 lg:gap-8 items-center">
|
||||
<div className="grid items-center gap-12 lg:grid-cols-2 lg:gap-8">
|
||||
<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.
|
||||
<h2 className="text-3xl font-bold tracking-tight">
|
||||
Enterprise-Grade Architecture
|
||||
</h2>
|
||||
<p className="text-muted-foreground mt-4 text-lg">
|
||||
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 className="bg-background flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border shadow-sm">
|
||||
<Network className="text-primary h-5 w-5" />
|
||||
</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>
|
||||
<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 className="bg-background flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border shadow-sm">
|
||||
<Share2 className="text-primary h-5 w-5" />
|
||||
</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>
|
||||
<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 className="bg-background flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border shadow-sm">
|
||||
<Settings2 className="text-primary h-5 w-5" />
|
||||
</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>
|
||||
<p className="text-muted-foreground">
|
||||
Native support for ROS2 nodes, topics, and actions right
|
||||
out of the box.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -226,34 +266,46 @@ export default async function Home() {
|
||||
|
||||
<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">
|
||||
<div className="relative z-10 space-y-4">
|
||||
<Card className="relative left-0 cursor-default border-blue-500/20 bg-blue-500/5 transition-all hover:left-2">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-blue-600 dark:text-blue-400 text-sm font-mono">APP LAYER</CardTitle>
|
||||
<CardTitle className="font-mono text-sm text-blue-600 dark:text-blue-400">
|
||||
APP LAYER
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-semibold">Next.js Dashboard + Experiment Designer</p>
|
||||
<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">
|
||||
<Card className="relative left-4 cursor-default border-violet-500/20 bg-violet-500/5 transition-all hover:left-6">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-violet-600 dark:text-violet-400 text-sm font-mono">DATA LAYER</CardTitle>
|
||||
<CardTitle className="font-mono text-sm text-violet-600 dark:text-violet-400">
|
||||
DATA LAYER
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-semibold">PostgreSQL + MinIO + TRPC API</p>
|
||||
<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">
|
||||
<Card className="relative left-8 cursor-default border-green-500/20 bg-green-500/5 transition-all hover:left-10">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-green-600 dark:text-green-400 text-sm font-mono">HARDWARE LAYER</CardTitle>
|
||||
<CardTitle className="font-mono text-sm text-green-600 dark:text-green-400">
|
||||
HARDWARE LAYER
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="font-semibold">ROS2 Bridge + Robot Plugins</p>
|
||||
<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 className="bg-primary/10 absolute top-1/2 left-1/2 -z-10 h-[300px] w-[300px] -translate-x-1/2 -translate-y-1/2 rounded-full blur-3xl" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -261,31 +313,46 @@ export default async function Home() {
|
||||
|
||||
{/* 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.
|
||||
<h2 className="text-3xl font-bold tracking-tight md:text-4xl">
|
||||
Ready to upgrade your lab?
|
||||
</h2>
|
||||
<p className="text-muted-foreground mx-auto mt-4 max-w-2xl text-lg">
|
||||
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>
|
||||
<Button
|
||||
size="lg"
|
||||
className="shadow-primary/20 h-12 px-8 text-base shadow-lg"
|
||||
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">
|
||||
<footer className="bg-muted/40 border-t py-12">
|
||||
<div className="container mx-auto flex flex-col items-center justify-between gap-6 px-4 text-center md:flex-row md:text-left">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Logo iconSize="sm" showText={true} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
© {new Date().getFullYear()} HRIStudio. All rights reserved.
|
||||
</p>
|
||||
</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 className="text-muted-foreground flex gap-6 text-sm">
|
||||
<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>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import Link from "next/link";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "~/components/ui/card";
|
||||
import { auth } from "~/server/auth";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user