Begin plugins system

This commit is contained in:
2025-08-07 01:12:58 -04:00
parent 544207e9a2
commit 3a443d1727
53 changed files with 5873 additions and 2547 deletions

View File

@@ -1,235 +1,409 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { AdminUserTable } from "~/components/admin/admin-user-table";
import { RoleManagement } from "~/components/admin/role-management";
import { SystemStats } from "~/components/admin/system-stats";
import { Badge } from "~/components/ui/badge";
import {
Shield,
Users,
Database,
Settings,
Activity,
AlertTriangle,
CheckCircle2,
Clock,
BarChart3,
FileText,
UserCheck,
Plus,
} 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 { Separator } from "~/components/ui/separator";
import { requireAdmin } from "~/server/auth/utils";
import { Badge } from "~/components/ui/badge";
export default async function AdminPage() {
const session = await requireAdmin();
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { PageHeader, ActionButton } from "~/components/ui/page-header";
// System Overview Cards
function SystemOverview() {
// Mock data - replace with actual API calls when available
const stats = {
totalUsers: 0,
activeStudies: 0,
systemHealth: 100,
pluginRepositories: 0,
};
const cards = [
{
title: "Total Users",
value: stats.totalUsers,
description: "Registered platform users",
icon: Users,
color: "text-blue-600",
bg: "bg-blue-50",
},
{
title: "Active Studies",
value: stats.activeStudies,
description: "Currently running studies",
icon: Activity,
color: "text-green-600",
bg: "bg-green-50",
},
{
title: "System Health",
value: `${stats.systemHealth}%`,
description: "Overall system status",
icon: CheckCircle2,
color: "text-emerald-600",
bg: "bg-emerald-50",
},
{
title: "Plugin Repos",
value: stats.pluginRepositories,
description: "Configured repositories",
icon: Database,
color: "text-purple-600",
bg: "bg-purple-50",
},
];
return (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100">
<div className="container mx-auto px-4 py-8">
{/* Header */}
<div className="mb-8 flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-slate-900">
System Administration
</h1>
<p className="text-slate-600">
Manage users, roles, and system settings
</p>
</div>
<div className="flex items-center gap-4">
<Badge variant="destructive">Administrator</Badge>
<span className="text-sm text-slate-600">
{session.user.name ?? session.user.email}
</span>
<div className="flex gap-2">
<Button asChild variant="outline" size="sm">
<Link href="/profile">Profile</Link>
</Button>
<Button asChild variant="outline">
<Link href="/"> Back to Home</Link>
</Button>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{cards.map((card) => (
<Card key={card.title}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{card.title}</CardTitle>
<div className={`rounded-md p-2 ${card.bg}`}>
<card.icon className={`h-4 w-4 ${card.color}`} />
</div>
</div>
</div>
{/* Admin Dashboard Grid */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-4">
{/* System Overview */}
<div className="lg:col-span-4">
<Card>
<CardHeader>
<CardTitle>System Overview</CardTitle>
<CardDescription>
Current system status and statistics
</CardDescription>
</CardHeader>
<CardContent>
<SystemStats />
</CardContent>
</Card>
</div>
{/* Quick Actions */}
<div className="lg:col-span-1">
<Card>
<CardHeader>
<CardTitle>Quick Actions</CardTitle>
<CardDescription>Common admin tasks</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<Button className="w-full justify-start" variant="outline" disabled>
<svg
className="mr-2 h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"
/>
</svg>
Create User
</Button>
<Button className="w-full justify-start" variant="outline" disabled>
<svg
className="mr-2 h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
System Health
</Button>
<Button className="w-full justify-start" variant="outline" disabled>
<svg
className="mr-2 h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
Export Data
</Button>
<Button className="w-full justify-start" variant="outline" disabled>
<svg
className="mr-2 h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
Settings
</Button>
<Separator />
<Button className="w-full justify-start" variant="outline" disabled>
<svg
className="mr-2 h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
Audit Logs
</Button>
</CardContent>
</Card>
{/* Role Management */}
<Card className="mt-6">
<CardHeader>
<CardTitle>Role Management</CardTitle>
<CardDescription>System role definitions</CardDescription>
</CardHeader>
<CardContent>
<RoleManagement />
</CardContent>
</Card>
</div>
{/* User Management */}
<div className="lg:col-span-3">
<Card>
<CardHeader>
<CardTitle>User Management</CardTitle>
<CardDescription>
Manage user accounts and role assignments
</CardDescription>
</CardHeader>
<CardContent>
<AdminUserTable />
</CardContent>
</Card>
</div>
</div>
{/* Security Warning */}
<div className="mt-8">
<Card className="border-yellow-200 bg-yellow-50">
<CardContent className="pt-6">
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-yellow-100">
<svg
className="h-5 w-5 text-yellow-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.728-.833-2.498 0L4.316 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
</div>
<div>
<h3 className="font-semibold text-yellow-900">
Administrator Access
</h3>
<p className="mt-1 text-sm text-yellow-800">
You have full administrative access to this system. Please use these
privileges responsibly. All administrative actions are logged for
security purposes.
</p>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{card.value}</div>
<p className="text-muted-foreground text-xs">{card.description}</p>
</CardContent>
</Card>
))}
</div>
);
}
// Recent Admin Activity
function RecentActivity() {
// Mock data - replace with actual audit log API
const activities = [
{
id: "1",
type: "user_created",
title: "New user registered",
description: "researcher@university.edu joined the platform",
time: "2 hours ago",
status: "success",
},
{
id: "2",
type: "repository_added",
title: "Plugin repository added",
description: "Official TurtleBot3 repository configured",
time: "4 hours ago",
status: "info",
},
{
id: "3",
type: "role_updated",
title: "User role modified",
description: "john.doe@lab.edu promoted to researcher",
time: "6 hours ago",
status: "success",
},
{
id: "4",
type: "system_update",
title: "System maintenance",
description: "Database optimization completed",
time: "1 day ago",
status: "success",
},
];
const getStatusIcon = (status: string) => {
switch (status) {
case "success":
return <CheckCircle2 className="h-4 w-4 text-green-600" />;
case "pending":
return <Clock className="h-4 w-4 text-yellow-600" />;
case "error":
return <AlertTriangle className="h-4 w-4 text-red-600" />;
default:
return <Activity className="h-4 w-4 text-blue-600" />;
}
};
return (
<Card>
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
<CardDescription>
Latest administrative actions and system events
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{activities.map((activity) => (
<div key={activity.id} className="flex items-center space-x-4">
{getStatusIcon(activity.status)}
<div className="flex-1 space-y-1">
<p className="text-sm leading-none font-medium">
{activity.title}
</p>
<p className="text-muted-foreground text-sm">
{activity.description}
</p>
</div>
<div className="text-muted-foreground text-sm">
{activity.time}
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}
// System Status
function SystemStatus() {
// Mock data - replace with actual system health checks
const services = [
{
name: "Database",
status: "healthy",
uptime: "99.9%",
responseTime: "12ms",
},
{
name: "Authentication",
status: "healthy",
uptime: "100%",
responseTime: "8ms",
},
{
name: "File Storage",
status: "healthy",
uptime: "99.8%",
responseTime: "45ms",
},
{
name: "Plugin System",
status: "healthy",
uptime: "99.5%",
responseTime: "23ms",
},
];
const getStatusBadge = (status: string) => {
switch (status) {
case "healthy":
return <Badge className="bg-green-100 text-green-800">Healthy</Badge>;
case "warning":
return <Badge className="bg-yellow-100 text-yellow-800">Warning</Badge>;
case "error":
return <Badge className="bg-red-100 text-red-800">Error</Badge>;
default:
return <Badge variant="secondary">Unknown</Badge>;
}
};
return (
<Card>
<CardHeader>
<CardTitle>System Status</CardTitle>
<CardDescription>
Current status of core system services
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{services.map((service) => (
<div
key={service.name}
className="flex items-center justify-between"
>
<div className="space-y-1">
<p className="text-sm font-medium">{service.name}</p>
<p className="text-muted-foreground text-xs">
Uptime: {service.uptime} Response: {service.responseTime}
</p>
</div>
{getStatusBadge(service.status)}
</div>
))}
</div>
</CardContent>
</Card>
);
}
// Quick Admin Actions
function QuickActions() {
const actions = [
{
title: "Manage Users",
description: "View and modify user accounts",
href: "/admin/users",
icon: Users,
disabled: true, // Enable when route exists
},
{
title: "Plugin Repositories",
description: "Configure plugin sources",
href: "/admin/repositories",
icon: Database,
disabled: false,
},
{
title: "System Settings",
description: "Configure platform settings",
href: "/admin/settings",
icon: Settings,
disabled: true, // Enable when route exists
},
{
title: "View Audit Logs",
description: "Review system activity",
href: "/admin/audit",
icon: FileText,
disabled: true, // Enable when route exists
},
{
title: "Role Management",
description: "Manage user permissions",
href: "/admin/roles",
icon: UserCheck,
disabled: true, // Enable when route exists
},
{
title: "Analytics",
description: "Platform usage statistics",
href: "/admin/analytics",
icon: BarChart3,
disabled: true, // Enable when route exists
},
];
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{actions.map((action) => (
<Card
key={action.title}
className={`group transition-all ${
action.disabled
? "cursor-not-allowed opacity-50"
: "cursor-pointer hover:shadow-md"
}`}
>
<CardContent className="p-6">
<div className="mb-3 flex items-center space-x-3">
<div className="rounded-lg bg-slate-100 p-2">
<action.icon className="h-5 w-5 text-slate-600" />
</div>
<div className="flex-1">
<h3 className="text-sm font-medium">{action.title}</h3>
</div>
</div>
<p className="text-muted-foreground mb-4 text-sm">
{action.description}
</p>
{action.disabled ? (
<Button disabled className="w-full" variant="outline">
Coming Soon
</Button>
) : (
<Button asChild className="w-full" variant="outline">
<Link href={action.href}>Access</Link>
</Button>
)}
</CardContent>
</Card>
))}
</div>
);
}
export default function AdminPage() {
// Set breadcrumbs
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Administration" },
]);
return (
<div className="space-y-6">
{/* Header */}
<PageHeader
title="Administration"
description="System administration and platform management"
icon={Shield}
actions={
<div className="flex items-center space-x-2">
<Badge variant="secondary" className="bg-red-100 text-red-800">
<Shield className="mr-1 h-3 w-3" />
Administrator
</Badge>
<ActionButton href="/admin/repositories">
<Plus className="mr-2 h-4 w-4" />
Add Repository
</ActionButton>
</div>
}
/>
{/* System Overview */}
<SystemOverview />
{/* Main Content Grid */}
<div className="grid gap-6 lg:grid-cols-2">
<div className="space-y-6">
<RecentActivity />
</div>
<div className="space-y-6">
<SystemStatus />
</div>
</div>
{/* Quick Actions */}
<div className="space-y-4">
<h2 className="text-xl font-semibold">Administrative Tools</h2>
<QuickActions />
</div>
{/* Security Notice */}
<Card className="border-amber-200 bg-amber-50">
<CardContent className="pt-6">
<div className="flex items-start space-x-3">
<div className="rounded-lg bg-amber-100 p-2">
<AlertTriangle className="h-5 w-5 text-amber-600" />
</div>
<div className="flex-1">
<h3 className="mb-1 font-semibold text-amber-900">
Administrator Access
</h3>
<p className="text-sm text-amber-800">
You have full administrative access to this system. All actions
are logged for security purposes. Please use these privileges
responsibly.
</p>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { RepositoriesDataTable } from "~/components/admin/repositories-data-table";
export default function AdminRepositoriesPage() {
return <RepositoriesDataTable />;
}

View File

@@ -1,5 +1,6 @@
import { notFound } from "next/navigation";
import { EnhancedBlockDesigner } from "~/components/experiments/designer/EnhancedBlockDesigner";
import type { ExperimentBlock } from "~/components/experiments/designer/EnhancedBlockDesigner";
import { api } from "~/trpc/server";
interface ExperimentDesignerPageProps {
@@ -19,17 +20,33 @@ export default async function ExperimentDesignerPage({
notFound();
}
// Parse existing visual design if available
const existingDesign = experiment.visualDesign as {
blocks?: unknown[];
version?: number;
lastSaved?: string;
} | null;
// Only pass initialDesign if there's existing visual design data
const initialDesign =
existingDesign?.blocks && existingDesign.blocks.length > 0
? {
id: experiment.id,
name: experiment.name,
description: experiment.description ?? "",
blocks: existingDesign.blocks as ExperimentBlock[],
version: existingDesign.version ?? 1,
lastSaved:
typeof existingDesign.lastSaved === "string"
? new Date(existingDesign.lastSaved)
: new Date(),
}
: undefined;
return (
<EnhancedBlockDesigner
experimentId={experiment.id}
initialDesign={{
id: experiment.id,
name: experiment.name,
description: experiment.description ?? "",
blocks: [],
version: 1,
lastSaved: new Date(),
}}
initialDesign={initialDesign}
/>
);
} catch (error) {
@@ -40,7 +57,10 @@ export default async function ExperimentDesignerPage({
export async function generateMetadata({
params,
}: ExperimentDesignerPageProps) {
}: ExperimentDesignerPageProps): Promise<{
title: string;
description: string;
}> {
try {
const resolvedParams = await params;
const experiment = await api.experiments.get({ id: resolvedParams.id });

View File

@@ -1,23 +1,7 @@
"use client";
import { formatDistanceToNow } from "date-fns";
import {
ArrowLeft,
BarChart3,
Bot,
Calendar,
CheckCircle,
Edit,
FileText,
FlaskConical,
Play,
Settings,
Share,
Target,
Users,
AlertTriangle,
XCircle,
} from "lucide-react";
import { Calendar, Clock, Edit, Play, Settings, Users } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { useEffect, useState } from "react";
@@ -27,20 +11,17 @@ import {
EntityView,
EntityViewHeader,
EntityViewSection,
EntityViewSidebar,
EmptyState,
InfoGrid,
QuickActions,
StatsGrid,
} from "~/components/ui/entity-view";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { useSession } from "next-auth/react";
import { api } from "~/trpc/react";
import { useSession } from "next-auth/react";
interface ExperimentDetailPageProps {
params: Promise<{
id: string;
}>;
params: Promise<{ id: string }>;
}
const statusConfig = {
@@ -52,7 +33,7 @@ const statusConfig = {
testing: {
label: "Testing",
variant: "outline" as const,
icon: "FlaskConical" as const,
icon: "TestTube" as const,
},
ready: {
label: "Ready",
@@ -66,89 +47,141 @@ const statusConfig = {
},
};
type Experiment = {
id: string;
name: string;
description: string | null;
status: string;
createdAt: Date;
updatedAt: Date;
study: { id: string; name: string };
robot: { id: string; name: string; description: string | null } | null;
protocol?: { blocks: unknown[] } | null;
visualDesign?: unknown;
studyId: string;
createdBy: string;
robotId: string | null;
version: number;
};
type Trial = {
id: string;
status: string;
createdAt: Date;
duration: number | null;
participant: {
id: string;
participantCode: string;
name?: string | null;
} | null;
experiment: { name: string } | null;
participantId: string | null;
experimentId: string;
startedAt: Date | null;
completedAt: Date | null;
notes: string | null;
updatedAt: Date;
canAccess: boolean;
userRole: string;
};
export default function ExperimentDetailPage({
params,
}: ExperimentDetailPageProps) {
const { data: session } = useSession();
const [experiment, setExperiment] = useState<any>(null);
const [trials, setTrials] = useState<any[]>([]);
const [experiment, setExperiment] = useState<Experiment | null>(null);
const [trials, setTrials] = useState<Trial[]>([]);
const [loading, setLoading] = useState(true);
const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(
null,
);
useEffect(() => {
async function resolveParams() {
const resolveParams = async () => {
const resolved = await params;
setResolvedParams(resolved);
}
resolveParams();
};
void resolveParams();
}, [params]);
const { data: experimentData } = api.experiments.get.useQuery(
const experimentQuery = api.experiments.get.useQuery(
{ id: resolvedParams?.id ?? "" },
{ enabled: !!resolvedParams?.id },
);
const { data: trialsData } = api.trials.list.useQuery(
{ experimentId: resolvedParams?.id ?? "", limit: 10 },
const trialsQuery = api.trials.list.useQuery(
{ experimentId: resolvedParams?.id ?? "" },
{ enabled: !!resolvedParams?.id },
);
useEffect(() => {
if (experimentData) {
setExperiment(experimentData);
if (experimentQuery.data) {
setExperiment(experimentQuery.data);
}
if (trialsData) {
setTrials(trialsData);
}, [experimentQuery.data]);
useEffect(() => {
if (trialsQuery.data) {
setTrials(trialsQuery.data);
}
if (experimentData !== undefined) {
}, [trialsQuery.data]);
useEffect(() => {
if (experimentQuery.isLoading || trialsQuery.isLoading) {
setLoading(true);
} else {
setLoading(false);
}
}, [experimentData, trialsData]);
}, [experimentQuery.isLoading, trialsQuery.isLoading]);
// Set breadcrumbs
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Experiments", href: "/experiments" },
{ label: experiment?.name || "Experiment" },
{
label: "Dashboard",
href: "/",
},
{
label: "Studies",
href: "/studies",
},
{
label: experiment?.study?.name ?? "Unknown Study",
href: `/studies/${experiment?.study?.id}`,
},
{
label: "Experiments",
href: `/studies/${experiment?.study?.id}/experiments`,
},
{
label: experiment?.name ?? "Experiment",
},
]);
if (!session?.user) {
return notFound();
}
if (loading) return <div>Loading...</div>;
if (experimentQuery.error) return notFound();
if (!experiment) return notFound();
if (loading || !experiment) {
return <div>Loading...</div>;
}
const displayName = experiment.name ?? "Untitled Experiment";
const description = experiment.description;
const userRole = session.user.roles?.[0]?.role ?? "observer";
const canEdit = ["administrator", "researcher"].includes(userRole);
// 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];
// TODO: Get actual stats from API
const mockStats = {
totalTrials: trials.length,
completedTrials: trials.filter((t) => t.status === "completed").length,
averageDuration: "—",
successRate:
trials.length > 0
? `${Math.round((trials.filter((t) => t.status === "completed").length / trials.length) * 100)}%`
: "—",
};
const statusInfo =
statusConfig[experiment.status as keyof typeof statusConfig];
return (
<EntityView>
{/* Header */}
<EntityViewHeader
title={experiment.name}
subtitle={experiment.description}
icon="FlaskConical"
title={displayName}
subtitle={description ?? undefined}
icon="TestTube"
status={{
label: statusInfo.label,
variant: statusInfo.variant,
icon: statusInfo.icon,
label: statusInfo?.label ?? "Unknown",
variant: statusInfo?.variant ?? "secondary",
icon: statusInfo?.icon ?? "TestTube",
}}
actions={
canEdit ? (
@@ -172,23 +205,16 @@ export default function ExperimentDetailPage({
</Link>
</Button>
</>
) : (
<Button asChild>
<Link href={`/trials/new?experimentId=${experiment.id}`}>
<Play className="mr-2 h-4 w-4" />
Start Trial
</Link>
</Button>
)
) : undefined
}
/>
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
{/* Main Content */}
<div className="space-y-8 lg:col-span-2">
{/* Experiment Information */}
<EntityViewSection title="Experiment Information" icon="FlaskConical">
<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",
@@ -204,8 +230,8 @@ export default function ExperimentDetailPage({
),
},
{
label: "Robot Platform",
value: experiment.robot?.name || "Not specified",
label: "Status",
value: statusInfo?.label ?? "Unknown",
},
{
label: "Created",
@@ -223,10 +249,10 @@ export default function ExperimentDetailPage({
/>
</EntityViewSection>
{/* Protocol Overview */}
{/* Protocol Section */}
<EntityViewSection
title="Protocol Overview"
icon="Target"
title="Experiment Protocol"
icon="FileText"
actions={
canEdit && (
<Button asChild variant="outline" size="sm">
@@ -242,22 +268,22 @@ export default function ExperimentDetailPage({
typeof experiment.protocol === "object" &&
experiment.protocol !== null ? (
<div className="space-y-3">
<div className="bg-muted rounded-lg p-4">
<h4 className="mb-2 font-medium">Protocol Structure</h4>
<p className="text-muted-foreground text-sm">
Visual protocol designed with{" "}
{Array.isArray((experiment.protocol as any).blocks)
? (experiment.protocol as any).blocks.length
: 0}{" "}
blocks
</p>
<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="Target"
title="No Protocol Defined"
description="Use the experiment designer to create your protocol"
icon="FileText"
title="No protocol defined"
description="Create an experiment protocol using the visual designer"
action={
canEdit && (
<Button asChild>
@@ -275,12 +301,10 @@ export default function ExperimentDetailPage({
<EntityViewSection
title="Recent Trials"
icon="Play"
description="Latest experimental sessions"
actions={
<Button asChild variant="outline" size="sm">
<Link href={`/trials/new?experimentId=${experiment.id}`}>
<Play className="mr-2 h-4 w-4" />
Start Trial
<Link href={`/studies/${experiment.study?.id}/trials`}>
View All
</Link>
</Button>
}
@@ -310,78 +334,70 @@ export default function ExperimentDetailPage({
: "outline"
}
>
{trial.status.replace("_", " ")}
{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" />
{trial.createdAt
? formatDistanceToNow(new Date(trial.createdAt), {
addSuffix: true,
})
: "Not scheduled"}
{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.name ??
trial.participant.participantCode}
</span>
)}
</div>
</div>
))}
{trials.length > 5 && (
<div className="pt-2 text-center">
<Button variant="outline" size="sm" asChild>
<Link href={`/trials?experimentId=${experiment.id}`}>
View All Trials ({trials.length})
</Link>
</Button>
</div>
)}
</div>
) : (
<EmptyState
icon="Play"
title="No Trials Yet"
description="Start your first trial to begin collecting data"
title="No trials yet"
description="Start your first trial to collect data"
action={
<Button asChild>
<Link href={`/trials/new?experimentId=${experiment.id}`}>
Start First Trial
</Link>
</Button>
canEdit && (
<Button asChild>
<Link href={`/trials/new?experimentId=${experiment.id}`}>
Start Trial
</Link>
</Button>
)
}
/>
)}
</EntityViewSection>
</div>
{/* Sidebar */}
<EntityViewSidebar>
{/* Quick Stats */}
<EntityViewSection title="Statistics" icon="BarChart3">
<div className="space-y-6">
{/* Statistics */}
<EntityViewSection title="Statistics" icon="BarChart">
<StatsGrid
stats={[
{
label: "Total Trials",
value: mockStats.totalTrials,
value: trials.length,
},
{
label: "Completed",
value: mockStats.completedTrials,
color: "success",
value: trials.filter((t) => t.status === "completed").length,
},
{
label: "Success Rate",
value: mockStats.successRate,
color: "success",
},
{
label: "Avg. Duration",
value: mockStats.averageDuration,
label: "In Progress",
value: trials.filter((t) => t.status === "in_progress")
.length,
},
]}
/>
@@ -399,11 +415,7 @@ export default function ExperimentDetailPage({
},
{
label: "Type",
value: experiment.robot.type || "Not specified",
},
{
label: "Connection",
value: experiment.robot.connectionType || "Not configured",
value: experiment.robot.description ?? "Not specified",
},
]}
/>
@@ -411,29 +423,24 @@ export default function ExperimentDetailPage({
)}
{/* Quick Actions */}
<EntityViewSection title="Quick Actions" icon="Settings">
<EntityViewSection title="Quick Actions" icon="Zap">
<QuickActions
actions={[
{
label: "View All Trials",
icon: "Play",
href: `/trials?experimentId=${experiment.id}`,
},
{
label: "Export Data",
icon: "Share",
icon: "Download" as const,
href: `/experiments/${experiment.id}/export`,
},
...(canEdit
? [
{
label: "Edit Experiment",
icon: "Edit",
icon: "Edit" as const,
href: `/experiments/${experiment.id}/edit`,
},
{
label: "Protocol Designer",
icon: "Settings",
label: "Open Designer",
icon: "Palette" as const,
href: `/experiments/${experiment.id}/designer`,
},
]
@@ -441,7 +448,7 @@ export default function ExperimentDetailPage({
]}
/>
</EntityViewSection>
</EntityViewSidebar>
</div>
</div>
</EntityView>
);

View File

@@ -3,16 +3,11 @@
import { formatDistanceToNow } from "date-fns";
import {
AlertCircle,
ArrowLeft,
Calendar,
CheckCircle,
Edit,
FileText,
Mail,
Play,
Shield,
Trash2,
Users,
XCircle,
} from "lucide-react";
import Link from "next/link";
@@ -44,8 +39,31 @@ export default function ParticipantDetailPage({
params,
}: ParticipantDetailPageProps) {
const { data: session } = useSession();
const [participant, setParticipant] = useState<any>(null);
const [trials, setTrials] = useState<any[]>([]);
const [participant, setParticipant] = useState<{
id: string;
name: string | null;
email: string | null;
participantCode: string;
study: { id: string; name: string } | null;
demographics: unknown;
notes: string | null;
consentGiven: boolean;
consentDate: Date | null;
createdAt: Date;
updatedAt: Date;
studyId: string;
trials: unknown[];
consents: unknown[];
} | null>(null);
const [trials, setTrials] = useState<
{
id: string;
status: string;
createdAt: Date;
duration: number | null;
experiment: { name: string } | null;
}[]
>([]);
const [loading, setLoading] = useState(true);
const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(
null,
@@ -56,7 +74,7 @@ export default function ParticipantDetailPage({
const resolved = await params;
setResolvedParams(resolved);
}
resolveParams();
void resolveParams();
}, [params]);
const { data: participantData } = api.participants.get.useQuery(
@@ -86,7 +104,7 @@ export default function ParticipantDetailPage({
{ label: "Dashboard", href: "/dashboard" },
{ label: "Participants", href: "/participants" },
{
label: participant?.name || participant?.participantCode || "Participant",
label: participant?.name ?? participant?.participantCode ?? "Participant",
},
]);
@@ -116,7 +134,7 @@ export default function ParticipantDetailPage({
canEdit && (
<>
<Button variant="outline" asChild>
<Link href={`/participants/${resolvedParams.id}/edit`}>
<Link href={`/participants/${resolvedParams?.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
@@ -147,16 +165,16 @@ export default function ParticipantDetailPage({
},
{
label: "Name",
value: participant.name || "Not provided",
value: participant?.name ?? "Not provided",
},
{
label: "Email",
value: participant.email ? (
value: participant?.email ? (
<div className="flex items-center gap-2">
<Mail className="h-4 w-4" />
<a
href={`mailto:${participant.email}`}
className="hover:underline"
className="text-primary hover:underline"
>
{participant.email}
</a>
@@ -167,7 +185,7 @@ export default function ParticipantDetailPage({
},
{
label: "Study",
value: participant.study ? (
value: participant?.study ? (
<Link
href={`/studies/${participant.study.id}`}
className="text-primary hover:underline"
@@ -182,43 +200,53 @@ export default function ParticipantDetailPage({
/>
{/* Demographics */}
{participant.demographics &&
typeof participant.demographics === "object" &&
participant.demographics !== null &&
Object.keys(participant.demographics).length > 0 && (
<div className="border-t pt-4">
<h4 className="text-muted-foreground mb-3 text-sm font-medium">
Demographics
</h4>
<InfoGrid
items={(() => {
const demo = participant.demographics as Record<
string,
unknown
>;
return [
demo.age && {
label: "Age",
value:
typeof demo.age === "number"
? demo.age.toString()
: String(demo.age),
},
demo.gender && {
label: "Gender",
value: String(demo.gender),
},
].filter(Boolean) as Array<{
label: string;
value: string;
}>;
})()}
/>
</div>
)}
{participant?.demographics &&
typeof participant.demographics === "object" &&
participant.demographics !== null &&
Object.keys(participant.demographics as Record<string, unknown>)
.length > 0 ? (
<div className="border-t pt-4">
<h4 className="text-muted-foreground mb-3 text-sm font-medium">
Demographics
</h4>
<InfoGrid
items={(() => {
const demo = participant.demographics as Record<
string,
unknown
>;
const items: Array<{ label: string; value: string }> = [];
if (demo.age) {
items.push({
label: "Age",
value:
typeof demo.age === "number"
? demo.age.toString()
: typeof demo.age === "string"
? demo.age
: "Unknown",
});
}
if (demo.gender) {
items.push({
label: "Gender",
value:
typeof demo.gender === "string"
? demo.gender
: "Unknown",
});
}
return items;
})()}
/>
</div>
) : null}
{/* Notes */}
{participant.notes && (
{participant?.notes && (
<div className="border-t pt-4">
<h4 className="text-muted-foreground mb-2 text-sm font-medium">
Notes
@@ -238,7 +266,9 @@ export default function ParticipantDetailPage({
actions={
canEdit && (
<Button size="sm" asChild>
<Link href={`/trials/new?participantId=${resolvedParams.id}`}>
<Link
href={`/trials/new?participantId=${resolvedParams?.id}`}
>
Schedule Trial
</Link>
</Button>
@@ -257,7 +287,7 @@ export default function ParticipantDetailPage({
href={`/trials/${trial.id}`}
className="font-medium hover:underline"
>
{trial.experiment?.name || "Trial"}
{trial.experiment?.name ?? "Trial"}
</Link>
<Badge
variant={
@@ -283,7 +313,7 @@ export default function ParticipantDetailPage({
: "Not scheduled"}
</span>
{trial.duration && (
<span>{Math.round(trial.duration / 60)} minutes</span>
<span>{Math.round(trial.duration / 60)} min</span>
)}
</div>
</div>
@@ -298,7 +328,7 @@ export default function ParticipantDetailPage({
canEdit && (
<Button asChild>
<Link
href={`/trials/new?participantId=${resolvedParams.id}`}
href={`/trials/new?participantId=${resolvedParams?.id}`}
>
Schedule First Trial
</Link>
@@ -318,9 +348,11 @@ export default function ParticipantDetailPage({
<div className="flex items-center justify-between">
<span className="text-sm">Informed Consent</span>
<Badge
variant={participant.consentGiven ? "default" : "destructive"}
variant={
participant?.consentGiven ? "default" : "destructive"
}
>
{participant.consentGiven ? (
{participant?.consentGiven ? (
<>
<CheckCircle className="mr-1 h-3 w-3" />
Given
@@ -334,10 +366,10 @@ export default function ParticipantDetailPage({
</Badge>
</div>
{participant.consentDate && (
{participant?.consentDate && (
<div className="text-muted-foreground text-sm">
Consented:{" "}
{formatDistanceToNow(participant.consentDate, {
{formatDistanceToNow(new Date(participant.consentDate), {
addSuffix: true,
})}
</div>
@@ -361,7 +393,7 @@ export default function ParticipantDetailPage({
items={[
{
label: "Registered",
value: formatDistanceToNow(participant.createdAt, {
value: formatDistanceToNow(participant?.createdAt, {
addSuffix: true,
}),
},
@@ -388,17 +420,17 @@ export default function ParticipantDetailPage({
{
label: "Schedule Trial",
icon: "Play",
href: `/trials/new?participantId=${resolvedParams.id}`,
href: `/trials/new?participantId=${resolvedParams?.id}`,
},
{
label: "Edit Information",
icon: "Edit",
href: `/participants/${resolvedParams.id}/edit`,
href: `/participants/${resolvedParams?.id}/edit`,
},
{
label: "Export Data",
icon: "FileText",
href: `/participants/${resolvedParams.id}/export`,
href: `/participants/${resolvedParams?.id}/export`,
},
]}
/>

View File

@@ -0,0 +1,5 @@
import { PluginStoreBrowse } from "~/components/plugins/plugin-store-browse";
export default function PluginStoreBrowsePage() {
return <PluginStoreBrowse />;
}

View File

@@ -0,0 +1,5 @@
import { PluginsDataTable } from "~/components/plugins/plugins-data-table";
export default function PluginsPage() {
return <PluginsDataTable />;
}

View File

@@ -1,26 +1,10 @@
"use client";
import { formatDistanceToNow } from "date-fns";
import {
ArrowLeft,
BarChart3,
Building,
Calendar,
CheckCircle,
Clock,
Edit,
FileText,
FlaskConical,
Plus,
Settings,
Shield,
Users,
XCircle,
} from "lucide-react";
import { Plus, Settings, Shield } from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { useEffect, useState } from "react";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import {
EntityView,
@@ -32,7 +16,6 @@ import {
QuickActions,
StatsGrid,
} from "~/components/ui/entity-view";
import { Separator } from "~/components/ui/separator";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { useSession } from "next-auth/react";
import { api } from "~/trpc/react";
@@ -66,21 +49,40 @@ const statusConfig = {
},
};
type Study = {
id: string;
name: string;
description: string | null;
status: string;
institution: string | null;
irbProtocol: string | null;
createdAt: Date;
updatedAt: Date;
};
type Member = {
role: string;
user: {
name: string | null;
email: string;
};
};
export default function StudyDetailPage({ params }: StudyDetailPageProps) {
const { data: session } = useSession();
const [study, setStudy] = useState<any>(null);
const [members, setMembers] = useState<any[]>([]);
const [study, setStudy] = useState<Study | null>(null);
const [members, setMembers] = useState<Member[]>([]);
const [loading, setLoading] = useState(true);
const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(
null,
);
useEffect(() => {
async function resolveParams() {
const resolveParams = async () => {
const resolved = await params;
setResolvedParams(resolved);
}
resolveParams();
};
void resolveParams();
}, [params]);
const { data: studyData } = api.studies.get.useQuery(
@@ -109,7 +111,7 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Studies", href: "/studies" },
{ label: study?.name || "Study" },
{ label: study?.name ?? "Study" },
]);
if (!session?.user) {
@@ -120,7 +122,7 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
return <div>Loading...</div>;
}
const statusInfo = statusConfig[study.status];
const statusInfo = statusConfig[study.status as keyof typeof statusConfig];
// TODO: Get actual stats from API
const mockStats = {
@@ -135,12 +137,12 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
{/* Header */}
<EntityViewHeader
title={study.name}
subtitle={study.description}
subtitle={study.description ?? undefined}
icon="Building"
status={{
label: statusInfo.label,
variant: statusInfo.variant,
icon: statusInfo.icon,
label: statusInfo?.label ?? "Unknown",
variant: statusInfo?.variant ?? "secondary",
icon: statusInfo?.icon ?? "FileText",
}}
actions={
<>
@@ -169,11 +171,11 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
items={[
{
label: "Institution",
value: study.institution,
value: study.institution ?? "Not specified",
},
{
label: "IRB Protocol",
value: study.irbProtocol || "Not specified",
value: study.irbProtocol ?? "Not required",
},
{
label: "Created",
@@ -244,9 +246,9 @@ export default function StudyDetailPage({ params }: StudyDetailPageProps) {
}
>
<div className="space-y-3">
{members.map((member) => (
{members.map((member, index) => (
<div
key={member.user.id}
key={`${member.user.email}-${index}`}
className="flex items-center space-x-3"
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-100">

View File

@@ -8,7 +8,7 @@ import { useActiveStudy } from "~/hooks/useActiveStudy";
export default function StudyParticipantsPage() {
const params = useParams();
const studyId = typeof params.id === "string" ? params.id : "";
const studyId: string = typeof params.id === "string" ? params.id : "";
const { setActiveStudy, activeStudy } = useActiveStudy();
// Set the active study if it doesn't match the current route

View File

@@ -8,7 +8,7 @@ import { useActiveStudy } from "~/hooks/useActiveStudy";
export default function StudyTrialsPage() {
const params = useParams();
const studyId = typeof params.id === "string" ? params.id : "";
const studyId: string = typeof params.id === "string" ? params.id : "";
const { setActiveStudy, activeStudy } = useActiveStudy();
// Set the active study if it doesn't match the current route

View File

@@ -1,56 +1,36 @@
"use client";
import { format, formatDistanceToNow } from "date-fns";
import { formatDistanceToNow } from "date-fns";
import {
Activity,
AlertTriangle,
ArrowLeft,
BarChart3,
Bot,
AlertCircle,
Calendar,
CheckCircle,
Clock,
Download,
Edit,
Eye,
FileText,
Info,
Play,
Settings,
Share,
Target,
Timer,
User,
Users,
XCircle,
Zap,
} from "lucide-react";
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { useEffect, useState } from "react";
import { Alert, AlertDescription } from "~/components/ui/alert";
import { Badge } from "~/components/ui/badge";
import { useSession } from "next-auth/react";
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
import { Button } from "~/components/ui/button";
import {
EntityView,
EntityViewHeader,
EntityViewSection,
EntityViewSidebar,
EmptyState,
InfoGrid,
QuickActions,
StatsGrid,
} from "~/components/ui/entity-view";
import { Progress } from "~/components/ui/progress";
import { Separator } from "~/components/ui/separator";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { useSession } from "next-auth/react";
import { api } from "~/trpc/react";
interface TrialDetailPageProps {
params: Promise<{
trialId: string;
}>;
searchParams: Promise<{
error?: string;
}>;
params: Promise<{ trialId: string }>;
searchParams: Promise<{ error?: string }>;
}
const statusConfig = {
@@ -72,22 +52,57 @@ const statusConfig = {
failed: {
label: "Failed",
variant: "destructive" as const,
icon: "XCircle" as const,
icon: "AlertCircle" as const,
},
cancelled: {
label: "Cancelled",
variant: "destructive" as const,
icon: "XCircle" as const,
variant: "outline" as const,
icon: "AlertCircle" as const,
},
};
type Trial = {
id: string;
participantId: string | null;
experimentId: string;
wizardId?: string | null;
sessionNumber?: number;
status: string;
startedAt: Date | null;
completedAt: Date | null;
duration: number | null;
notes: string | null;
createdAt: Date;
updatedAt: Date;
experiment: {
id: string;
name: string;
studyId: string;
} | null;
participant: {
id: string;
participantCode: string;
name?: string | null;
} | null;
};
type TrialEvent = {
id: string;
trialId: string;
eventType: string;
actionId: string | null;
timestamp: Date;
data: unknown;
createdBy: string | null;
};
export default function TrialDetailPage({
params,
searchParams,
}: TrialDetailPageProps) {
const { data: session } = useSession();
const [trial, setTrial] = useState<any>(null);
const [events, setEvents] = useState<any[]>([]);
const [trial, setTrial] = useState<Trial | null>(null);
const [events, setEvents] = useState<TrialEvent[]>([]);
const [loading, setLoading] = useState(true);
const [resolvedParams, setResolvedParams] = useState<{
trialId: string;
@@ -97,90 +112,110 @@ export default function TrialDetailPage({
} | null>(null);
useEffect(() => {
async function resolveParams() {
const resolvedP = await params;
const resolvedSP = await searchParams;
setResolvedParams(resolvedP);
setResolvedSearchParams(resolvedSP);
}
resolveParams();
}, [params, searchParams]);
const resolveParams = async () => {
const resolved = await params;
setResolvedParams(resolved);
};
void resolveParams();
}, [params]);
const { data: trialData } = api.trials.get.useQuery(
useEffect(() => {
const resolveSearchParams = async () => {
const resolved = await searchParams;
setResolvedSearchParams(resolved);
};
void resolveSearchParams();
}, [searchParams]);
const trialQuery = api.trials.get.useQuery(
{ id: resolvedParams?.trialId ?? "" },
{ enabled: !!resolvedParams?.trialId },
);
const { data: eventsData } = api.trials.getEvents.useQuery(
const eventsQuery = api.trials.getEvents.useQuery(
{ trialId: resolvedParams?.trialId ?? "" },
{ enabled: !!resolvedParams?.trialId },
);
useEffect(() => {
if (trialData) {
setTrial(trialData);
if (trialQuery.data) {
setTrial(trialQuery.data as Trial);
}
if (eventsData) {
setEvents(eventsData);
}, [trialQuery.data]);
useEffect(() => {
if (eventsQuery.data) {
setEvents(eventsQuery.data as TrialEvent[]);
}
if (trialData !== undefined) {
}, [eventsQuery.data]);
useEffect(() => {
if (trialQuery.isLoading || eventsQuery.isLoading) {
setLoading(true);
} else {
setLoading(false);
}
}, [trialData, eventsData]);
}, [trialQuery.isLoading, eventsQuery.isLoading]);
// Set breadcrumbs
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Trials", href: "/trials" },
{ label: trial ? `Trial #${trial.id.slice(-6)}` : "Trial" },
{
label: "Dashboard",
href: "/",
},
{
label: "Studies",
href: "/studies",
},
{
label: "Study",
href: trial?.experiment?.studyId
? `/studies/${trial.experiment.studyId}`
: "/studies",
},
{
label: "Trials",
href: trial?.experiment?.studyId
? `/studies/${trial.experiment.studyId}/trials`
: "/trials",
},
{
label: `Trial #${resolvedParams?.trialId?.slice(-6) ?? "Unknown"}`,
},
]);
if (!session?.user) {
redirect("/auth/signin");
}
if (loading) return <div>Loading...</div>;
if (trialQuery.error || !trial) return <div>Trial not found</div>;
if (loading || !trial) {
return <div>Loading...</div>;
}
const statusInfo = statusConfig[trial.status as keyof typeof statusConfig];
const userRoles = session?.user?.roles?.map((r) => r.role) ?? [];
const canControl =
userRoles.includes("wizard") || userRoles.includes("researcher");
const userRole = session.user.roles?.[0]?.role ?? "observer";
const canEdit = ["administrator", "researcher"].includes(userRole);
const canControl = ["administrator", "researcher", "wizard"].includes(
userRole,
);
const statusInfo = statusConfig[trial.status];
// Calculate trial stats
const totalEvents = events.length;
const errorEvents = events.filter((e) => e.eventType === "error").length;
const completedSteps = events.filter(
(e) => e.eventType === "step_completed",
).length;
const progress = trial.experiment
? (completedSteps / (trial.experiment._count?.steps || 1)) * 100
: 0;
const displayName = `Trial #${trial.id.slice(-6)}`;
const experimentName = trial.experiment?.name ?? "Unknown Experiment";
return (
<EntityView>
{/* Error Alert */}
{resolvedSearchParams.error && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
{resolvedSearchParams?.error && (
<Alert variant="destructive" className="mb-6">
<AlertCircle className="h-4 w-4" />
<AlertTitle>Error</AlertTitle>
<AlertDescription>{resolvedSearchParams.error}</AlertDescription>
</Alert>
)}
{/* Header */}
<EntityViewHeader
title={`Trial #${trial.id.slice(-6)}`}
subtitle={trial.experiment?.name || "No experiment assigned"}
icon="Target"
status={{
label: statusInfo.label,
variant: statusInfo.variant,
icon: statusInfo.icon,
}}
title={displayName}
subtitle={`${experimentName} - ${trial.participant?.participantCode ?? "Unknown Participant"}`}
icon="Play"
status={
statusInfo && {
label: statusInfo.label,
variant: statusInfo.variant,
icon: statusInfo.icon,
}
}
actions={
<>
{canControl && trial.status === "scheduled" && (
@@ -199,19 +234,11 @@ export default function TrialDetailPage({
</Link>
</Button>
)}
{canEdit && (
<Button asChild variant="outline">
<Link href={`/trials/${trial.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
)}
{trial.status === "completed" && (
<Button asChild variant="outline">
<Link href={`/trials/${trial.id}/analysis`}>
<BarChart3 className="mr-2 h-4 w-4" />
Analysis
<Info className="mr-2 h-4 w-4" />
View Analysis
</Link>
</Button>
)}
@@ -219,12 +246,12 @@ export default function TrialDetailPage({
}
/>
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
{/* Main Content */}
<div className="space-y-8 lg:col-span-2">
<div className="grid gap-6 lg:grid-cols-3">
<div className="space-y-6 lg:col-span-2">
{/* Trial Information */}
<EntityViewSection title="Trial Information" icon="FileText">
<EntityViewSection title="Trial Information" icon="Info">
<InfoGrid
columns={2}
items={[
{
label: "Experiment",
@@ -236,7 +263,7 @@ export default function TrialDetailPage({
{trial.experiment.name}
</Link>
) : (
"No experiment assigned"
"Unknown"
),
},
{
@@ -246,34 +273,34 @@ export default function TrialDetailPage({
href={`/participants/${trial.participant.id}`}
className="text-primary hover:underline"
>
{trial.participant.name ||
{trial.participant.name ??
trial.participant.participantCode}
</Link>
) : (
"No participant assigned"
"Unknown"
),
},
{
label: "Study",
value: trial.study ? (
value: trial.experiment?.studyId ? (
<Link
href={`/studies/${trial.study.id}`}
href={`/studies/${trial.experiment.studyId}`}
className="text-primary hover:underline"
>
{trial.study.name}
Study
</Link>
) : (
"No study assigned"
"Unknown"
),
},
{
label: "Robot Platform",
value: trial.experiment?.robot?.name || "Not specified",
label: "Status",
value: statusInfo?.label ?? trial.status,
},
{
label: "Scheduled",
value: trial.scheduledAt
? format(trial.scheduledAt, "PPp")
value: trial.createdAt
? formatDistanceToNow(trial.createdAt, { addSuffix: true })
: "Not scheduled",
},
{
@@ -281,100 +308,59 @@ export default function TrialDetailPage({
value: trial.duration
? `${Math.round(trial.duration / 60)} minutes`
: trial.status === "in_progress"
? "In progress..."
: "Not started",
? "Ongoing"
: "Not available",
},
]}
/>
{/* Progress Bar for In-Progress Trials */}
{trial.status === "in_progress" && trial.experiment && (
<div className="border-t pt-4">
<div className="mb-2 flex items-center justify-between">
<span className="text-sm font-medium">Progress</span>
<span className="text-muted-foreground text-sm">
{completedSteps} of {trial.experiment._count?.steps || 0}{" "}
steps
</span>
</div>
<Progress value={progress} className="h-2" />
</div>
)}
{/* Trial Notes */}
{trial.notes && (
<div className="border-t pt-4">
<h4 className="text-muted-foreground mb-2 text-sm font-medium">
Notes
</h4>
<div className="bg-muted rounded p-3 text-sm whitespace-pre-wrap">
{trial.notes}
</div>
</div>
)}
</EntityViewSection>
{/* Trial Timeline */}
{/* Trial Notes */}
{trial.notes && (
<EntityViewSection title="Notes" icon="FileText">
<div className="prose prose-sm max-w-none">
<p className="text-muted-foreground">{trial.notes}</p>
</div>
</EntityViewSection>
)}
{/* Event Timeline */}
<EntityViewSection
title="Trial Timeline"
title="Event Timeline"
icon="Activity"
description="Real-time events and interactions"
actions={
<Button variant="outline" size="sm" asChild>
<Link href={`/trials/${trial.id}/events`}>
<Eye className="mr-2 h-4 w-4" />
View All Events
</Link>
</Button>
}
description={`${events.length} events recorded`}
>
{events.length > 0 ? (
<div className="space-y-3">
{events.slice(-10).map((event, index) => (
<div
key={event.id}
className="flex items-start gap-3 rounded-lg border p-3"
>
<div className="flex-shrink-0">
{event.eventType === "error" ? (
<div className="rounded-full bg-red-100 p-1">
<XCircle className="h-4 w-4 text-red-600" />
</div>
) : event.eventType === "step_completed" ? (
<div className="rounded-full bg-green-100 p-1">
<CheckCircle className="h-4 w-4 text-green-600" />
</div>
) : (
<div className="rounded-full bg-blue-100 p-1">
<Activity className="h-4 w-4 text-blue-600" />
</div>
)}
<div className="space-y-4">
{events.slice(0, 10).map((event) => (
<div key={event.id} className="rounded-lg border p-4">
<div className="mb-2 flex items-center justify-between">
<span className="font-medium">
{event.eventType
.replace(/_/g, " ")
.replace(/\b\w/g, (l) => l.toUpperCase())}
</span>
<span className="text-muted-foreground text-sm">
{formatDistanceToNow(event.timestamp, {
addSuffix: true,
})}
</span>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between">
<p className="text-sm font-medium">
{event.eventType.replace("_", " ")}
</p>
<time className="text-muted-foreground text-xs">
{format(event.timestamp, "HH:mm:ss")}
</time>
{event.data ? (
<div className="text-muted-foreground text-sm">
<pre className="text-xs">
{typeof event.data === "object" && event.data !== null
? JSON.stringify(event.data, null, 2)
: String(event.data as string | number | boolean)}
</pre>
</div>
{event.eventData && (
<p className="text-muted-foreground text-xs">
{typeof event.eventData === "string"
? event.eventData
: JSON.stringify(event.eventData)}
</p>
)}
</div>
) : null}
</div>
))}
{events.length > 10 && (
<div className="pt-2 text-center">
<Button variant="outline" size="sm" asChild>
<Link href={`/trials/${trial.id}/events`}>
View All {events.length} Events
</Link>
<div className="text-center">
<Button variant="outline" size="sm">
View All Events ({events.length})
</Button>
</div>
)}
@@ -382,47 +368,22 @@ export default function TrialDetailPage({
) : (
<EmptyState
icon="Activity"
title="No Events Yet"
description="Trial events will appear here once the trial begins"
title="No events recorded"
description="Events will appear here as the trial progresses"
/>
)}
</EntityViewSection>
</div>
{/* Sidebar */}
<EntityViewSidebar>
{/* Trial Stats */}
<EntityViewSection title="Statistics" icon="BarChart3">
<div className="space-y-6">
{/* Statistics */}
<EntityViewSection title="Statistics" icon="BarChart">
<StatsGrid
stats={[
{
label: "Total Events",
value: totalEvents,
label: "Events",
value: events.length,
},
{
label: "Completed Steps",
value: completedSteps,
color: "success",
},
{
label: "Error Events",
value: errorEvents,
color: errorEvents > 0 ? "error" : "default",
},
{
label: "Progress",
value: `${Math.round(progress)}%`,
color: progress === 100 ? "success" : "default",
},
]}
/>
</EntityViewSection>
{/* Session Details */}
<EntityViewSection title="Session Details" icon="Clock">
<InfoGrid
columns={1}
items={[
{
label: "Created",
value: formatDistanceToNow(trial.createdAt, {
@@ -432,35 +393,34 @@ export default function TrialDetailPage({
{
label: "Started",
value: trial.startedAt
? format(trial.startedAt, "PPp")
? formatDistanceToNow(trial.startedAt, { addSuffix: true })
: "Not started",
},
{
label: "Completed",
value: trial.completedAt
? format(trial.completedAt, "PPp")
? formatDistanceToNow(trial.completedAt, {
addSuffix: true,
})
: "Not completed",
},
{
label: "Created By",
value:
trial.createdBy?.name ||
trial.createdBy?.email ||
"Unknown",
value: "System",
},
]}
/>
</EntityViewSection>
{/* Quick Actions */}
<EntityViewSection title="Quick Actions" icon="Settings">
<EntityViewSection title="Quick Actions" icon="Zap">
<QuickActions
actions={[
...(canControl && trial.status === "scheduled"
? [
{
label: "Start Trial",
icon: "Play",
icon: "Play" as const,
href: `/trials/${trial.id}/wizard`,
variant: "default" as const,
},
@@ -470,7 +430,7 @@ export default function TrialDetailPage({
? [
{
label: "Monitor Trial",
icon: "Eye",
icon: "Eye" as const,
href: `/trials/${trial.id}/wizard`,
},
]
@@ -479,29 +439,25 @@ export default function TrialDetailPage({
? [
{
label: "View Analysis",
icon: "BarChart3",
icon: "BarChart" as const,
href: `/trials/${trial.id}/analysis`,
},
{
label: "Export Data",
icon: "Download",
icon: "Download" as const,
href: `/trials/${trial.id}/export`,
},
]
: []),
...(canEdit
? [
{
label: "Edit Trial",
icon: "Edit",
href: `/trials/${trial.id}/edit`,
},
]
: []),
{
label: "Share Results",
icon: "Share",
href: `/trials/${trial.id}/share`,
label: "View Events",
icon: "Activity" as const,
href: `/trials/${trial.id}/events`,
},
{
label: "Export Report",
icon: "FileText" as const,
href: `/trials/${trial.id}/report`,
},
]}
/>
@@ -510,32 +466,22 @@ export default function TrialDetailPage({
{/* Participant Info */}
{trial.participant && (
<EntityViewSection title="Participant" icon="User">
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-blue-100">
<User className="h-5 w-5 text-blue-600" />
</div>
<div>
<p className="font-medium">
{trial.participant.name ||
trial.participant.participantCode}
</p>
<p className="text-muted-foreground text-xs">
{trial.participant.name
? trial.participant.participantCode
: "Participant"}
</p>
</div>
</div>
<Button variant="outline" size="sm" className="w-full" asChild>
<Link href={`/participants/${trial.participant.id}`}>
View Profile
</Link>
</Button>
</div>
<InfoGrid
columns={1}
items={[
{
label: "Code",
value: trial.participant.participantCode,
},
{
label: "Name",
value: trial.participant.name ?? "Not provided",
},
]}
/>
</EntityViewSection>
)}
</EntityViewSidebar>
</div>
</div>
</EntityView>
);

View File

@@ -4,21 +4,21 @@ import { useState } from "react";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import type { SystemRole } from "~/lib/auth-client";
import { formatRole, getAvailableRoles } from "~/lib/auth-client";
@@ -35,7 +35,7 @@ interface UserWithRoles {
export function AdminUserTable() {
const [search, setSearch] = useState("");
const [selectedRole, setSelectedRole] = useState<SystemRole | "">("");
const [selectedRole, setSelectedRole] = useState<SystemRole | "all">("all");
const [page, setPage] = useState(1);
const [selectedUser, setSelectedUser] = useState<UserWithRoles | null>(null);
const [roleToAssign, setRoleToAssign] = useState<SystemRole | "">("");
@@ -48,7 +48,7 @@ export function AdminUserTable() {
page,
limit: 10,
search: search || undefined,
role: selectedRole || undefined,
role: selectedRole === "all" ? undefined : selectedRole,
});
const assignRole = api.users.assignRole.useMutation({
@@ -108,13 +108,15 @@ export function AdminUserTable() {
<Label htmlFor="role-filter">Filter by Role</Label>
<Select
value={selectedRole}
onValueChange={(value) => setSelectedRole(value as SystemRole | "")}
onValueChange={(value) =>
setSelectedRole(value as SystemRole | "all")
}
>
<SelectTrigger>
<SelectValue placeholder="All roles" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">All roles</SelectItem>
<SelectItem value="all">All roles</SelectItem>
{availableRoles.map((role) => (
<SelectItem key={role.value} value={role.value}>
{role.label}

View File

@@ -0,0 +1,433 @@
"use client";
import { type ColumnDef } from "@tanstack/react-table";
import { formatDistanceToNow } from "date-fns";
import {
Copy,
ExternalLink,
MoreHorizontal,
Database,
RefreshCw,
Settings,
Trash2,
Shield,
CheckCircle,
XCircle,
Clock,
AlertTriangle,
} from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Checkbox } from "~/components/ui/checkbox";
import { DataTableColumnHeader } from "~/components/ui/data-table-column-header";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { api } from "~/trpc/react";
// Define error type for mutations
interface TRPCError {
message: string;
}
export type Repository = {
id: string;
name: string;
url: string;
description: string | null;
trustLevel: "official" | "verified" | "community";
isEnabled: boolean;
isOfficial: boolean;
lastSyncAt: Date | null;
syncStatus: string | null;
syncError: string | null;
createdAt: Date;
updatedAt: Date;
};
const trustLevelConfig = {
official: {
label: "Official",
className: "bg-blue-100 text-blue-800 hover:bg-blue-200",
icon: Shield,
description: "Official HRIStudio repository",
},
verified: {
label: "Verified",
className: "bg-green-100 text-green-800 hover:bg-green-200",
icon: Shield,
description: "Verified by the community",
},
community: {
label: "Community",
className: "bg-yellow-100 text-yellow-800 hover:bg-yellow-200",
icon: Shield,
description: "Community repository",
},
};
const syncStatusConfig = {
pending: {
label: "Pending",
className: "bg-gray-100 text-gray-800",
icon: Clock,
description: "Waiting to sync",
},
syncing: {
label: "Syncing",
className: "bg-blue-100 text-blue-800",
icon: RefreshCw,
description: "Currently syncing",
},
completed: {
label: "Success",
className: "bg-green-100 text-green-800",
icon: CheckCircle,
description: "Last sync completed successfully",
},
failed: {
label: "Failed",
className: "bg-red-100 text-red-800",
icon: AlertTriangle,
description: "Last sync failed",
},
};
function RepositoryActionsCell({ repository }: { repository: Repository }) {
const utils = api.useUtils();
const syncMutation = api.admin.repositories.sync.useMutation({
onSuccess: () => {
toast.success("Repository sync started");
void utils.admin.repositories.list.invalidate();
},
onError: (error: TRPCError) => {
toast.error(error.message ?? "Failed to sync repository");
},
});
const deleteMutation = api.admin.repositories.delete.useMutation({
onSuccess: () => {
toast.success("Repository deleted successfully");
void utils.admin.repositories.list.invalidate();
},
onError: (error: TRPCError) => {
toast.error(error.message ?? "Failed to delete repository");
},
});
const handleSync = async () => {
syncMutation.mutate({ id: repository.id });
};
const handleDelete = async () => {
if (
window.confirm(`Are you sure you want to delete "${repository.name}"?`)
) {
deleteMutation.mutate({ id: repository.id });
}
};
const handleCopyId = () => {
void navigator.clipboard.writeText(repository.id);
toast.success("Repository ID copied to clipboard");
};
const handleCopyUrl = () => {
void navigator.clipboard.writeText(repository.url);
toast.success("Repository URL copied to clipboard");
};
const canDelete = !repository.isOfficial;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleSync}
disabled={syncMutation.isPending}
>
<RefreshCw
className={`mr-2 h-4 w-4 ${syncMutation.isPending ? "animate-spin" : ""}`}
/>
Sync Repository
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/repositories/${repository.id}/edit`}>
<Settings className="mr-2 h-4 w-4" />
Edit Repository
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<a href={repository.url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="mr-2 h-4 w-4" />
View Repository
</a>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleCopyId}>
<Copy className="mr-2 h-4 w-4" />
Copy Repository ID
</DropdownMenuItem>
<DropdownMenuItem onClick={handleCopyUrl}>
<Copy className="mr-2 h-4 w-4" />
Copy Repository URL
</DropdownMenuItem>
{canDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleDelete}
disabled={deleteMutation.isPending}
className="text-red-600 focus:text-red-600"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete Repository
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
export const repositoriesColumns: ColumnDef<Repository>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Repository Name" />
),
cell: ({ row }) => {
const repository = row.original;
return (
<div className="max-w-[200px] min-w-0 space-y-1">
<div className="flex items-center space-x-2">
<Database className="text-muted-foreground h-4 w-4 flex-shrink-0" />
<Link
href={`/admin/repositories/${repository.id}`}
className="truncate font-medium hover:underline"
title={repository.name}
>
{repository.name}
</Link>
{repository.isOfficial && (
<Badge variant="outline" className="text-xs">
Official
</Badge>
)}
</div>
{repository.description && (
<p
className="text-muted-foreground line-clamp-1 truncate text-sm"
title={repository.description}
>
{repository.description}
</p>
)}
</div>
);
},
},
{
accessorKey: "url",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Repository URL" />
),
cell: ({ row }) => {
const url = row.original.url;
return (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="max-w-[300px] truncate text-sm text-blue-600 hover:underline"
title={url}
>
{url}
</a>
);
},
},
{
accessorKey: "trustLevel",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Trust Level" />
),
cell: ({ row }) => {
const trustLevel = row.original.trustLevel;
const config = trustLevelConfig[trustLevel];
const TrustIcon = config.icon;
return (
<Badge
variant="secondary"
className={config.className}
title={config.description}
>
<TrustIcon className="mr-1 h-3 w-3" />
{config.label}
</Badge>
);
},
filterFn: (row, id, value: string[]) => {
return value.includes(row.original.trustLevel);
},
},
{
accessorKey: "isEnabled",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => {
const isEnabled = row.original.isEnabled;
return (
<Badge
variant="secondary"
className={
isEnabled
? "bg-green-100 text-green-800"
: "bg-red-100 text-red-800"
}
>
{isEnabled ? (
<CheckCircle className="mr-1 h-3 w-3" />
) : (
<XCircle className="mr-1 h-3 w-3" />
)}
{isEnabled ? "Enabled" : "Disabled"}
</Badge>
);
},
filterFn: (row, id, value: string[]) => {
const isEnabled = row.original.isEnabled;
return value.includes(isEnabled ? "enabled" : "disabled");
},
},
{
accessorKey: "syncStatus",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Sync Status" />
),
cell: ({ row }) => {
const syncStatus = row.original.syncStatus;
const lastSyncAt = row.original.lastSyncAt;
const syncError = row.original.syncError;
if (!syncStatus) return "-";
const config =
syncStatusConfig[syncStatus as keyof typeof syncStatusConfig];
if (!config) return syncStatus;
const SyncIcon = config.icon;
return (
<div className="space-y-1">
<Badge
variant="secondary"
className={config.className}
title={config.description}
>
<SyncIcon
className={`mr-1 h-3 w-3 ${syncStatus === "syncing" ? "animate-spin" : ""}`}
/>
{config.label}
</Badge>
{lastSyncAt && syncStatus === "completed" && (
<div className="text-muted-foreground text-xs">
{formatDistanceToNow(lastSyncAt, { addSuffix: true })}
</div>
)}
{syncError && syncStatus === "failed" && (
<div
className="max-w-[150px] truncate text-xs text-red-600"
title={syncError}
>
{syncError}
</div>
)}
</div>
);
},
},
{
accessorKey: "createdAt",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Created" />
),
cell: ({ row }) => {
const date = row.original.createdAt;
return (
<div className="text-sm whitespace-nowrap">
{formatDistanceToNow(date, { addSuffix: true })}
</div>
);
},
},
{
accessorKey: "updatedAt",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Updated" />
),
cell: ({ row }) => {
const date = row.original.updatedAt;
return (
<div className="text-sm whitespace-nowrap">
{formatDistanceToNow(date, { addSuffix: true })}
</div>
);
},
},
{
id: "actions",
header: "Actions",
cell: ({ row }) => <RepositoryActionsCell repository={row.original} />,
enableSorting: false,
enableHiding: false,
},
];

View File

@@ -0,0 +1,220 @@
"use client";
import { Plus, Database } from "lucide-react";
import React from "react";
import { Button } from "~/components/ui/button";
import { DataTable } from "~/components/ui/data-table";
import { EmptyState } from "~/components/ui/entity-view";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { ActionButton, PageHeader } from "~/components/ui/page-header";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { api } from "~/trpc/react";
import {
repositoriesColumns,
type Repository,
} from "~/components/admin/repositories-columns";
export function RepositoriesDataTable() {
const [trustLevelFilter, setTrustLevelFilter] = React.useState("all");
const [enabledFilter, setEnabledFilter] = React.useState("all");
const {
data: repositoriesData,
isLoading,
error,
refetch,
} = api.admin.repositories.list.useQuery(
{
trustLevel:
trustLevelFilter === "all"
? undefined
: (trustLevelFilter as "official" | "verified" | "community"),
isEnabled:
enabledFilter === "all" ? undefined : enabledFilter === "enabled",
limit: 50,
},
{
refetchOnWindowFocus: false,
},
);
// Auto-refresh repositories when component mounts to catch external changes
React.useEffect(() => {
const interval = setInterval(() => {
void refetch();
}, 30000); // Refresh every 30 seconds
return () => clearInterval(interval);
}, [refetch]);
// Set breadcrumbs
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Administration", href: "/admin" },
{ label: "Plugin Repositories" },
]);
// Transform repositories data to match the Repository type expected by columns
const repositories: Repository[] = React.useMemo(() => {
if (!repositoriesData) return [];
return repositoriesData as Repository[];
}, [repositoriesData]);
// Trust level filter options
const trustLevelOptions = [
{ label: "All Trust Levels", value: "all" },
{ label: "Official", value: "official" },
{ label: "Verified", value: "verified" },
{ label: "Community", value: "community" },
];
// Enabled filter options
const enabledOptions = [
{ label: "All Repositories", value: "all" },
{ label: "Enabled", value: "enabled" },
{ label: "Disabled", value: "disabled" },
];
// Filter repositories based on selected filters
const filteredRepositories = React.useMemo(() => {
return repositories.filter((repository) => {
const trustLevelMatch =
trustLevelFilter === "all" ||
repository.trustLevel === trustLevelFilter;
const enabledMatch =
enabledFilter === "all" ||
(enabledFilter === "enabled" && repository.isEnabled) ||
(enabledFilter === "disabled" && !repository.isEnabled);
return trustLevelMatch && enabledMatch;
});
}, [repositories, trustLevelFilter, enabledFilter]);
const filters = (
<div className="flex items-center space-x-2">
<Select value={trustLevelFilter} onValueChange={setTrustLevelFilter}>
<SelectTrigger className="h-8 w-[160px]">
<SelectValue placeholder="Trust Level" />
</SelectTrigger>
<SelectContent>
{trustLevelOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={enabledFilter} onValueChange={setEnabledFilter}>
<SelectTrigger className="h-8 w-[140px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
{enabledOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
// Show error state
if (error) {
return (
<div className="space-y-6">
<PageHeader
title="Plugin Repositories"
description="Manage plugin repositories for the HRIStudio platform"
icon={Database}
actions={
<ActionButton href="/admin/repositories/new">
<Plus className="mr-2 h-4 w-4" />
Add Repository
</ActionButton>
}
/>
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-center">
<div className="text-red-800">
<h3 className="mb-2 text-lg font-semibold">
Failed to Load Repositories
</h3>
<p className="mb-4">
{(error as unknown as Error)?.message ??
"An error occurred while loading repositories."}
</p>
<Button onClick={() => void refetch()} variant="outline">
Try Again
</Button>
</div>
</div>
</div>
);
}
// Show empty state if no repositories
if (!isLoading && repositories.length === 0) {
return (
<div className="space-y-6">
<PageHeader
title="Plugin Repositories"
description="Manage plugin repositories for the HRIStudio platform"
icon={Database}
actions={
<ActionButton href="/admin/repositories/new">
<Plus className="mr-2 h-4 w-4" />
Add Repository
</ActionButton>
}
/>
<EmptyState
icon="Database"
title="No Plugin Repositories"
description="Add plugin repositories to enable users to browse and install plugins."
action={
<Button asChild>
<a href="/admin/repositories/new">Add First Repository</a>
</Button>
}
/>
</div>
);
}
return (
<div className="space-y-6">
<PageHeader
title="Plugin Repositories"
description="Manage plugin repositories for the HRIStudio platform"
icon={Database}
actions={
<ActionButton href="/admin/repositories/new">
<Plus className="mr-2 h-4 w-4" />
Add Repository
</ActionButton>
}
/>
<div className="space-y-4">
{/* Data Table */}
<DataTable
columns={repositoriesColumns}
data={filteredRepositories}
searchKey="name"
searchPlaceholder="Search repositories..."
isLoading={isLoading}
loadingRowCount={5}
filters={filters}
/>
</div>
</div>
);
}

View File

@@ -12,6 +12,7 @@ import {
Home,
LogOut,
MoreHorizontal,
Puzzle,
Settings,
Users,
UserCheck,
@@ -71,6 +72,11 @@ const navigationItems = [
url: "/trials",
icon: TestTube,
},
{
title: "Plugins",
url: "/plugins",
icon: Puzzle,
},
{
title: "Analytics",
url: "/analytics",

View File

@@ -63,7 +63,7 @@ export function ExperimentForm({ mode, experimentId }: ExperimentFormProps) {
resolver: zodResolver(experimentSchema),
defaultValues: {
status: "draft" as const,
studyId: selectedStudyId || "",
studyId: selectedStudyId ?? "",
},
});
@@ -84,13 +84,36 @@ export function ExperimentForm({ mode, experimentId }: ExperimentFormProps) {
// Set breadcrumbs
const breadcrumbs = [
{ label: "Dashboard", href: "/dashboard" },
{ label: "Experiments", href: "/experiments" },
...(mode === "edit" && experiment
{ label: "Studies", href: "/studies" },
...(selectedStudyId
? [
{ label: experiment.name, href: `/experiments/${experiment.id}` },
{ label: "Edit" },
{
label: experiment?.study?.name ?? "Study",
href: `/studies/${selectedStudyId}`,
},
{ label: "Experiments", href: "/experiments" },
...(mode === "edit" && experiment
? [
{
label: experiment.name,
href: `/experiments/${experiment.id}`,
},
{ label: "Edit" },
]
: [{ label: "New Experiment" }]),
]
: [{ label: "New Experiment" }]),
: [
{ label: "Experiments", href: "/experiments" },
...(mode === "edit" && experiment
? [
{
label: experiment.name,
href: `/experiments/${experiment.id}`,
},
{ label: "Edit" },
]
: [{ label: "New Experiment" }]),
]),
];
useBreadcrumbsEffect(breadcrumbs);
@@ -128,14 +151,14 @@ export function ExperimentForm({ mode, experimentId }: ExperimentFormProps) {
if (mode === "create") {
const newExperiment = await createExperimentMutation.mutateAsync({
...data,
estimatedDuration: data.estimatedDuration || undefined,
estimatedDuration: data.estimatedDuration ?? undefined,
});
router.push(`/experiments/${newExperiment.id}/designer`);
} else {
const updatedExperiment = await updateExperimentMutation.mutateAsync({
id: experimentId!,
...data,
estimatedDuration: data.estimatedDuration || undefined,
estimatedDuration: data.estimatedDuration ?? undefined,
});
router.push(`/experiments/${updatedExperiment.id}`);
}

File diff suppressed because it is too large Load Diff

View File

@@ -183,14 +183,16 @@ export const experimentsColumns: ColumnDef<Experiment>[] = [
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
onCheckedChange={(value: boolean) =>
table.toggleAllPageRowsSelected(!!value)
}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
onCheckedChange={(value: boolean) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
@@ -231,12 +233,13 @@ export const experimentsColumns: ColumnDef<Experiment>[] = [
<DataTableColumnHeader column={column} title="Study" />
),
cell: ({ row }) => {
const study = row.getValue("study") as Experiment["study"];
const study = row.original.study;
if (!study?.id || !study?.name)
return <span className="text-muted-foreground">No study</span>;
return (
<Link
href={`/studies/${study.id}`}
className="block max-w-[140px] truncate text-sm hover:underline"
title={study.name}
className="text-primary hover:underline"
>
{study.name}
</Link>
@@ -250,8 +253,8 @@ export const experimentsColumns: ColumnDef<Experiment>[] = [
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => {
const status = row.getValue("status") as keyof typeof statusConfig;
const config = statusConfig[status];
const status = row.getValue("status");
const config = statusConfig[status as keyof typeof statusConfig];
return (
<Badge
@@ -264,7 +267,7 @@ export const experimentsColumns: ColumnDef<Experiment>[] = [
);
},
filterFn: (row, id, value: string[]) => {
return value.includes(row.getValue(id) as string);
return value.includes(row.getValue(id));
},
},
{
@@ -296,20 +299,23 @@ export const experimentsColumns: ColumnDef<Experiment>[] = [
<DataTableColumnHeader column={column} title="Owner" />
),
cell: ({ row }) => {
const owner = row.getValue("owner") as Experiment["owner"];
const owner = row.original.owner;
if (!owner) {
return <span className="text-muted-foreground">No owner</span>;
}
return (
<div className="max-w-[140px] space-y-1">
<div
className="truncate text-sm font-medium"
title={owner?.name ?? "Unknown"}
title={owner.name ?? "Unknown"}
>
{owner?.name ?? "Unknown"}
{owner.name ?? "Unknown"}
</div>
<div
className="text-muted-foreground truncate text-xs"
title={owner?.email}
title={owner.email ?? ""}
>
{owner?.email}
{owner.email ?? ""}
</div>
</div>
);

View File

@@ -46,10 +46,16 @@ export function ExperimentsDataTable() {
// Set breadcrumbs
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Studies", href: "/studies" },
...(activeStudy
? [{ label: activeStudy.title, href: `/studies/${activeStudy.id}` }]
: []),
{ label: "Experiments" },
? [
{
label: (activeStudy as { title: string; id: string }).title,
href: `/studies/${(activeStudy as { id: string }).id}`,
},
{ label: "Experiments" },
]
: [{ label: "Experiments" }]),
]);
// Transform experiments data to match the Experiment type expected by columns
@@ -101,7 +107,7 @@ export function ExperimentsDataTable() {
const filters = (
<div className="flex items-center space-x-2">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[140px]">
<SelectTrigger className="h-8 w-[140px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>

View File

@@ -27,6 +27,20 @@ import {
import { useStudyContext } from "~/lib/study-context";
import { api } from "~/trpc/react";
type DemographicsData = {
age?: number;
gender?: string;
occupation?: string;
education?: string;
primaryLanguage?: string;
language?: string;
location?: string;
city?: string;
robotExperience?: string;
experience?: string;
grade?: number;
};
const participantSchema = z.object({
participantCode: z
.string()
@@ -67,7 +81,7 @@ export function ParticipantForm({
}: ParticipantFormProps) {
const router = useRouter();
const { selectedStudyId } = useStudyContext();
const contextStudyId = studyId || selectedStudyId;
const contextStudyId = studyId ?? selectedStudyId;
const [isSubmitting, setIsSubmitting] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -76,7 +90,7 @@ export function ParticipantForm({
resolver: zodResolver(participantSchema),
defaultValues: {
consentGiven: false,
studyId: contextStudyId || "",
studyId: contextStudyId ?? "",
},
});
@@ -97,16 +111,39 @@ export function ParticipantForm({
// Set breadcrumbs
const breadcrumbs = [
{ label: "Dashboard", href: "/dashboard" },
{ label: "Participants", href: "/participants" },
...(mode === "edit" && participant
{ label: "Studies", href: "/studies" },
...(contextStudyId
? [
{
label: participant.name || participant.participantCode,
href: `/participants/${participant.id}`,
label: participant?.study?.name ?? "Study",
href: `/studies/${contextStudyId}`,
},
{ label: "Edit" },
{
label: "Participants",
href: `/studies/${contextStudyId}/participants`,
},
...(mode === "edit" && participant
? [
{
label: participant.name ?? participant.participantCode,
href: `/participants/${participant.id}`,
},
{ label: "Edit" },
]
: [{ label: "New Participant" }]),
]
: [{ label: "New Participant" }]),
: [
{ label: "Participants", href: "/participants" },
...(mode === "edit" && participant
? [
{
label: participant.name ?? participant.participantCode,
href: `/participants/${participant.id}`,
},
{ label: "Edit" },
]
: [{ label: "New Participant" }]),
]),
];
useBreadcrumbsEffect(breadcrumbs);
@@ -116,11 +153,18 @@ export function ParticipantForm({
if (mode === "edit" && participant) {
form.reset({
participantCode: participant.participantCode,
name: participant.name || "",
email: participant.email || "",
name: participant.name ?? "",
email: participant.email ?? "",
studyId: participant.studyId,
age: (participant.demographics as any)?.age || undefined,
gender: (participant.demographics as any)?.gender || undefined,
age: (participant.demographics as DemographicsData)?.age ?? undefined,
gender:
((participant.demographics as DemographicsData)?.gender as
| "male"
| "female"
| "non_binary"
| "prefer_not_to_say"
| "other"
| undefined) ?? undefined,
consentGiven: true, // Assume consent was given if participant exists
});
}
@@ -144,16 +188,16 @@ export function ParticipantForm({
try {
const demographics = {
age: data.age || null,
gender: data.gender || null,
age: data.age ?? null,
gender: data.gender ?? null,
};
if (mode === "create") {
const newParticipant = await createParticipantMutation.mutateAsync({
studyId: data.studyId,
participantCode: data.participantCode,
name: data.name || undefined,
email: data.email || undefined,
name: data.name ?? undefined,
email: data.email ?? undefined,
demographics,
});
router.push(`/participants/${newParticipant.id}`);
@@ -161,8 +205,8 @@ export function ParticipantForm({
const updatedParticipant = await updateParticipantMutation.mutateAsync({
id: participantId!,
participantCode: data.participantCode,
name: data.name || undefined,
email: data.email || undefined,
name: data.name ?? undefined,
email: data.email ?? undefined,
demographics,
});
router.push(`/participants/${updatedParticipant.id}`);
@@ -333,7 +377,7 @@ export function ParticipantForm({
<FormField>
<Label htmlFor="gender">Gender</Label>
<Select
value={form.watch("gender") || ""}
value={form.watch("gender") ?? ""}
onValueChange={(value) =>
form.setValue(
"gender",
@@ -444,7 +488,7 @@ export function ParticipantForm({
title={
mode === "create"
? "Register New Participant"
: `Edit ${participant?.name || participant?.participantCode || "Participant"}`
: `Edit ${participant?.name ?? participant?.participantCode ?? "Participant"}`
}
description={
mode === "create"

View File

@@ -177,7 +177,7 @@ export const participantsColumns: ColumnDef<Participant>[] = [
<DataTableColumnHeader column={column} title="Name" />
),
cell: ({ row }) => {
const name = row.getValue("name") as string | null;
const name = row.original.name;
const email = row.original.email;
return (
<div className="max-w-[160px] space-y-1">
@@ -193,8 +193,8 @@ export const participantsColumns: ColumnDef<Participant>[] = [
{email && (
<div className="text-muted-foreground flex items-center space-x-1 text-xs">
<Mail className="h-3 w-3 flex-shrink-0" />
<span className="truncate" title={email}>
{email}
<span className="truncate" title={email ?? ""}>
{email ?? ""}
</span>
</div>
)}
@@ -237,7 +237,7 @@ export const participantsColumns: ColumnDef<Participant>[] = [
);
},
filterFn: (row, id, value) => {
const consentGiven = row.getValue(id) as boolean;
const consentGiven = row.getValue(id);
if (value === "consented") return !!consentGiven;
if (value === "pending") return !consentGiven;
return true;
@@ -249,12 +249,12 @@ export const participantsColumns: ColumnDef<Participant>[] = [
<DataTableColumnHeader column={column} title="Trials" />
),
cell: ({ row }) => {
const trialCount = row.getValue("trialCount") as number;
const trialCount = row.original.trialCount;
return (
<div className="flex items-center space-x-1 text-sm whitespace-nowrap">
<TestTube className="text-muted-foreground h-3 w-3" />
<span>{trialCount as number}</span>
<span>{trialCount ?? 0}</span>
</div>
);
},
@@ -265,10 +265,10 @@ export const participantsColumns: ColumnDef<Participant>[] = [
<DataTableColumnHeader column={column} title="Created" />
),
cell: ({ row }) => {
const date = row.getValue("createdAt") as Date;
const date = row.original.createdAt;
return (
<div className="text-sm whitespace-nowrap">
{formatDistanceToNow(date, { addSuffix: true })}
{formatDistanceToNow(date ?? new Date(), { addSuffix: true })}
</div>
);
},

View File

@@ -15,11 +15,13 @@ import {
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { useStudyContext } from "~/lib/study-context";
import { api } from "~/trpc/react";
import { participantsColumns, type Participant } from "./participants-columns";
export function ParticipantsDataTable() {
const [consentFilter, setConsentFilter] = React.useState("all");
const { selectedStudyId } = useStudyContext();
const {
data: participantsData,
@@ -45,10 +47,22 @@ export function ParticipantsDataTable() {
return () => clearInterval(interval);
}, [refetch]);
// Get study data for breadcrumbs
const { data: studyData } = api.studies.get.useQuery(
{ id: selectedStudyId! },
{ enabled: !!selectedStudyId },
);
// Set breadcrumbs
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Participants" },
{ label: "Studies", href: "/studies" },
...(selectedStudyId && studyData
? [
{ label: studyData.name, href: `/studies/${selectedStudyId}` },
{ label: "Participants" },
]
: [{ label: "Participants" }]),
]);
// Transform participants data to match the Participant type expected by columns
@@ -60,12 +74,18 @@ export function ParticipantsDataTable() {
participantCode: p.participantCode,
email: p.email,
name: p.name,
consentGiven: (p as any).hasConsent || false,
consentDate: (p as any).latestConsent?.signedAt
? new Date((p as any).latestConsent.signedAt as unknown as string)
consentGiven:
(p as unknown as { hasConsent?: boolean }).hasConsent ?? false,
consentDate: (p as unknown as { latestConsent?: { signedAt: string } })
.latestConsent?.signedAt
? new Date(
(
p as unknown as { latestConsent: { signedAt: string } }
).latestConsent.signedAt,
)
: null,
createdAt: p.createdAt,
trialCount: (p as any).trialCount || 0,
trialCount: (p as unknown as { trialCount?: number }).trialCount ?? 0,
userRole: undefined,
canEdit: true,
canDelete: true,
@@ -92,7 +112,7 @@ export function ParticipantsDataTable() {
const filters = (
<div className="flex items-center space-x-2">
<Select value={consentFilter} onValueChange={setConsentFilter}>
<SelectTrigger className="w-[160px]">
<SelectTrigger className="h-8 w-[160px]">
<SelectValue placeholder="Consent Status" />
</SelectTrigger>
<SelectContent>

View File

@@ -0,0 +1,464 @@
"use client";
import {
Puzzle,
Search,
Filter,
ExternalLink,
Download,
Shield,
User,
Calendar,
Database,
} from "lucide-react";
import React from "react";
import { formatDistanceToNow } from "date-fns";
import { toast } from "sonner";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { PageHeader } from "~/components/ui/page-header";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { useStudyContext } from "~/lib/study-context";
import { api } from "~/trpc/react";
interface PluginStoreItem {
id: string;
robotId: string | null;
name: string;
version: string;
description: string | null;
author: string | null;
repositoryUrl: string | null;
trustLevel: "official" | "verified" | "community" | null;
status: "active" | "deprecated" | "disabled";
createdAt: Date;
updatedAt: Date;
}
const trustLevelConfig = {
official: {
label: "Official",
className: "bg-blue-100 text-blue-800",
icon: Shield,
description: "Official HRIStudio plugin",
},
verified: {
label: "Verified",
className: "bg-green-100 text-green-800",
icon: Shield,
description: "Verified by the community",
},
community: {
label: "Community",
className: "bg-yellow-100 text-yellow-800",
icon: User,
description: "Community contributed",
},
};
function PluginCard({
plugin,
onInstall,
repositoryName,
}: {
plugin: PluginStoreItem;
onInstall: (pluginId: string) => void;
repositoryName?: string;
}) {
const trustLevel = plugin.trustLevel;
const trustConfig = trustLevel ? trustLevelConfig[trustLevel] : null;
const TrustIcon = trustConfig?.icon ?? User;
return (
<Card className="flex h-full flex-col">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex min-w-0 flex-1 items-center space-x-2">
<Puzzle className="text-muted-foreground h-5 w-5 flex-shrink-0" />
<div className="min-w-0">
<CardTitle className="truncate text-base">
{plugin.name}
</CardTitle>
<div className="mt-1 flex items-center space-x-2">
<Badge variant="outline" className="font-mono text-xs">
v{plugin.version}
</Badge>
{trustConfig && (
<Badge
variant="secondary"
className={`${trustConfig.className} text-xs`}
>
<TrustIcon className="mr-1 h-3 w-3" />
{trustConfig.label}
</Badge>
)}
</div>
</div>
</div>
</div>
{plugin.description && (
<CardDescription className="line-clamp-2 text-sm">
{plugin.description}
</CardDescription>
)}
</CardHeader>
<CardContent className="flex-1 pb-3">
<div className="text-muted-foreground space-y-2 text-sm">
{plugin.author && (
<div className="flex items-center space-x-2">
<User className="h-3 w-3" />
<span className="truncate">{plugin.author}</span>
</div>
)}
<div className="flex items-center space-x-2">
<Calendar className="h-3 w-3" />
<span>
Updated{" "}
{formatDistanceToNow(plugin.updatedAt, { addSuffix: true })}
</span>
</div>
{repositoryName && (
<div className="flex items-center space-x-2">
<Database className="h-3 w-3" />
<span className="truncate text-xs">{repositoryName}</span>
</div>
)}
</div>
</CardContent>
<CardFooter className="flex items-center justify-between pt-3">
<div className="flex space-x-2">
<Button
size="sm"
onClick={() => onInstall(plugin.id)}
disabled={plugin.status !== "active"}
>
<Download className="mr-2 h-3 w-3" />
Install
</Button>
{plugin.repositoryUrl && (
<Button variant="outline" size="sm" asChild>
<a
href={plugin.repositoryUrl}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="h-3 w-3" />
</a>
</Button>
)}
</div>
{plugin.status !== "active" && (
<Badge variant="secondary" className="text-xs">
{plugin.status}
</Badge>
)}
</CardFooter>
</Card>
);
}
export function PluginStoreBrowse() {
const [searchTerm, setSearchTerm] = React.useState("");
const [statusFilter, setStatusFilter] = React.useState("all");
const [trustLevelFilter, setTrustLevelFilter] = React.useState("all");
const { selectedStudyId } = useStudyContext();
// Get enabled repositories first
const { data: repositories } = api.admin.repositories.list.useQuery(
{
isEnabled: true,
limit: 100,
},
{
refetchOnWindowFocus: false,
},
) as { data: Array<{ url: string; name: string }> | undefined };
const {
data: availablePlugins,
isLoading,
error,
refetch,
} = api.robots.plugins.list.useQuery(
{
status:
statusFilter === "all"
? undefined
: (statusFilter as "active" | "deprecated" | "disabled"),
limit: 50,
},
{
refetchOnWindowFocus: false,
enabled: Boolean(repositories?.length),
},
);
const installPluginMutation = api.robots.plugins.install.useMutation({
onSuccess: () => {
toast.success("Plugin installed successfully!");
void refetch();
},
onError: (error) => {
toast.error(error.message || "Failed to install plugin");
},
});
// Get study data for breadcrumbs
const { data: studyData } = api.studies.get.useQuery(
{ id: selectedStudyId! },
{ enabled: !!selectedStudyId },
);
// Set breadcrumbs
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Studies", href: "/studies" },
...(selectedStudyId && studyData
? [
{ label: studyData.name, href: `/studies/${selectedStudyId}` },
{ label: "Plugins", href: "/plugins" },
{ label: "Browse" },
]
: [{ label: "Plugins", href: "/plugins" }, { label: "Browse" }]),
]);
const handleInstall = React.useCallback(
(pluginId: string) => {
if (!selectedStudyId) {
toast.error("Please select a study first");
return;
}
installPluginMutation.mutate({
studyId: selectedStudyId,
pluginId,
});
},
[selectedStudyId, installPluginMutation],
);
// Transform and filter plugins
const filteredPlugins = React.useMemo(() => {
if (!availablePlugins) return [];
return availablePlugins.filter((plugin) => {
const matchesSearch =
searchTerm === "" ||
plugin.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(plugin.description?.toLowerCase().includes(searchTerm.toLowerCase()) ??
false) ||
(plugin.author?.toLowerCase().includes(searchTerm.toLowerCase()) ??
false);
const matchesStatus =
statusFilter === "all" || plugin.status === statusFilter;
const matchesTrustLevel =
trustLevelFilter === "all" || plugin.trustLevel === trustLevelFilter;
return matchesSearch && matchesStatus && matchesTrustLevel;
});
}, [availablePlugins, searchTerm, statusFilter, trustLevelFilter]);
// Status filter options
const statusOptions = [
{ label: "All Statuses", value: "all" },
{ label: "Active", value: "active" },
{ label: "Deprecated", value: "deprecated" },
{ label: "Disabled", value: "disabled" },
];
// Trust level filter options
const trustLevelOptions = [
{ label: "All Trust Levels", value: "all" },
{ label: "Official", value: "official" },
{ label: "Verified", value: "verified" },
{ label: "Community", value: "community" },
];
return (
<div className="space-y-6">
<PageHeader
title="Plugin Store"
description="Browse and install robot plugins for your study"
icon={Puzzle}
/>
{!selectedStudyId && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
<div className="flex items-center space-x-2 text-amber-800">
<Shield className="h-5 w-5" />
<p className="text-sm font-medium">
Select a study from the sidebar to install plugins
</p>
</div>
</div>
)}
{repositories?.length === 0 && (
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
<div className="flex items-center space-x-2 text-blue-800">
<Database className="h-5 w-5" />
<div>
<p className="text-sm font-medium">
No Plugin Repositories Configured
</p>
<p className="mt-1 text-xs">
Contact your administrator to add plugin repositories.
</p>
</div>
</div>
</div>
)}
{/* Search and Filters */}
<div className="flex flex-col space-y-4 sm:flex-row sm:items-center sm:justify-between sm:space-y-0 sm:space-x-4">
<div className="flex flex-1 items-center space-x-2">
<div className="relative max-w-sm flex-1">
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="Search plugins..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9"
/>
</div>
</div>
<div className="flex items-center space-x-2">
<Filter className="text-muted-foreground h-4 w-4" />
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
{statusOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={trustLevelFilter} onValueChange={setTrustLevelFilter}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="Trust Level" />
</SelectTrigger>
<SelectContent>
{trustLevelOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Loading State */}
{isLoading && (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<Card key={i} className="h-48">
<CardHeader>
<div className="bg-muted h-4 animate-pulse rounded" />
<div className="bg-muted h-3 w-2/3 animate-pulse rounded" />
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="bg-muted h-3 animate-pulse rounded" />
<div className="bg-muted h-3 w-1/2 animate-pulse rounded" />
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* Error State */}
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-center">
<div className="text-red-800">
<h3 className="mb-2 text-lg font-semibold">
Failed to Load Plugins
</h3>
<p className="mb-4">
{error.message ||
"An error occurred while loading the plugin store."}
</p>
<Button onClick={() => refetch()} variant="outline">
Try Again
</Button>
</div>
</div>
)}
{/* Plugin Grid */}
{!isLoading && !error && (
<>
{filteredPlugins.length === 0 ? (
<div className="py-12 text-center">
<Puzzle className="text-muted-foreground mx-auto h-12 w-12" />
<h3 className="mt-4 text-lg font-semibold">No Plugins Found</h3>
<p className="text-muted-foreground mt-2">
{searchTerm ||
statusFilter !== "all" ||
trustLevelFilter !== "all"
? "Try adjusting your search or filters"
: "No plugins are currently available"}
</p>
</div>
) : (
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
{filteredPlugins.map((plugin) => {
// Find repository for this plugin (this would need to be enhanced with actual repository mapping)
const repository = repositories?.find((repo) =>
plugin.repositoryUrl?.includes(repo.url),
);
return (
<PluginCard
key={plugin.id}
plugin={plugin}
onInstall={handleInstall}
repositoryName={repository?.name}
/>
);
})}
</div>
)}
{/* Results Count */}
{filteredPlugins.length > 0 && (
<div className="text-muted-foreground text-center text-sm">
Showing {filteredPlugins.length} plugin
{filteredPlugins.length !== 1 ? "s" : ""}
{availablePlugins &&
filteredPlugins.length < availablePlugins.length &&
` of ${availablePlugins.length} total`}
</div>
)}
</>
)}
</div>
);
}

View File

@@ -0,0 +1,323 @@
"use client";
import { type ColumnDef } from "@tanstack/react-table";
import { formatDistanceToNow } from "date-fns";
import {
Copy,
ExternalLink,
MoreHorizontal,
Puzzle,
Settings,
Trash2,
User,
} from "lucide-react";
import { toast } from "sonner";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Checkbox } from "~/components/ui/checkbox";
import { DataTableColumnHeader } from "~/components/ui/data-table-column-header";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
export type Plugin = {
plugin: {
id: string;
robotId: string | null;
name: string;
version: string;
description: string | null;
author: string | null;
repositoryUrl: string | null;
trustLevel: "official" | "verified" | "community" | null;
status: "active" | "deprecated" | "disabled";
createdAt: Date;
updatedAt: Date;
};
installation: {
id: string;
configuration: Record<string, unknown>;
installedAt: Date;
installedBy: string;
};
};
const trustLevelConfig = {
official: {
label: "Official",
className: "bg-blue-100 text-blue-800 hover:bg-blue-200",
description: "Official HRIStudio plugin",
},
verified: {
label: "Verified",
className: "bg-green-100 text-green-800 hover:bg-green-200",
description: "Verified by the community",
},
community: {
label: "Community",
className: "bg-yellow-100 text-yellow-800 hover:bg-yellow-200",
description: "Community contributed",
},
};
const statusConfig = {
active: {
label: "Active",
className: "bg-green-100 text-green-800 hover:bg-green-200",
description: "Plugin is active and working",
},
deprecated: {
label: "Deprecated",
className: "bg-orange-100 text-orange-800 hover:bg-orange-200",
description: "Plugin is deprecated",
},
disabled: {
label: "Disabled",
className: "bg-red-100 text-red-800 hover:bg-red-200",
description: "Plugin is disabled",
},
};
function PluginActionsCell({ plugin }: { plugin: Plugin }) {
const handleUninstall = async () => {
if (
window.confirm(
`Are you sure you want to uninstall "${plugin.plugin.name}"?`,
)
) {
try {
// TODO: Implement uninstall mutation
toast.success("Plugin uninstalled successfully");
} catch {
toast.error("Failed to uninstall plugin");
}
}
};
const handleCopyId = () => {
void navigator.clipboard.writeText(plugin.plugin.id);
toast.success("Plugin ID copied to clipboard");
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<span className="sr-only">Open menu</span>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Settings className="mr-2 h-4 w-4" />
Configure
</DropdownMenuItem>
{plugin.plugin.repositoryUrl && (
<DropdownMenuItem asChild>
<a
href={plugin.plugin.repositoryUrl}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="mr-2 h-4 w-4" />
View Repository
</a>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleCopyId}>
<Copy className="mr-2 h-4 w-4" />
Copy Plugin ID
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleUninstall}
className="text-red-600 focus:text-red-600"
>
<Trash2 className="mr-2 h-4 w-4" />
Uninstall
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
export const pluginsColumns: ColumnDef<Plugin>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "plugin.name",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Plugin Name" />
),
cell: ({ row }) => {
const plugin = row.original;
return (
<div className="max-w-[200px] min-w-0 space-y-1">
<div className="flex items-center space-x-2">
<Puzzle className="text-muted-foreground h-4 w-4 flex-shrink-0" />
<span className="truncate font-medium" title={plugin.plugin.name}>
{plugin.plugin.name}
</span>
</div>
{plugin.plugin.description && (
<p
className="text-muted-foreground line-clamp-1 truncate text-sm"
title={plugin.plugin.description}
>
{plugin.plugin.description}
</p>
)}
</div>
);
},
},
{
accessorKey: "plugin.version",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Version" />
),
cell: ({ row }) => {
const version = row.original.plugin.version;
return (
<Badge variant="outline" className="font-mono text-xs">
v{version}
</Badge>
);
},
},
{
accessorKey: "plugin.author",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Author" />
),
cell: ({ row }) => {
const author = row.original.plugin.author;
return (
<div className="flex items-center space-x-1 text-sm">
<User className="text-muted-foreground h-3 w-3" />
<span className="max-w-[120px] truncate" title={author ?? undefined}>
{author ?? "Unknown"}
</span>
</div>
);
},
},
{
accessorKey: "plugin.trustLevel",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Trust Level" />
),
cell: ({ row }) => {
const trustLevel = row.original.plugin.trustLevel;
if (!trustLevel) return "-";
const config = trustLevelConfig[trustLevel];
return (
<Badge
variant="secondary"
className={config.className}
title={config.description}
>
{config.label}
</Badge>
);
},
filterFn: (row, id, value: string[]) => {
const trustLevel = row.original.plugin.trustLevel;
return trustLevel ? value.includes(trustLevel) : false;
},
},
{
accessorKey: "plugin.status",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => {
const status = row.original.plugin.status;
const config = statusConfig[status];
return (
<Badge
variant="secondary"
className={config.className}
title={config.description}
>
{config.label}
</Badge>
);
},
filterFn: (row, id, value: string[]) => {
return value.includes(row.original.plugin.status);
},
},
{
accessorKey: "installation.installedAt",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Installed" />
),
cell: ({ row }) => {
const date = row.original.installation.installedAt;
return (
<div className="text-sm whitespace-nowrap">
{formatDistanceToNow(date, { addSuffix: true })}
</div>
);
},
},
{
accessorKey: "plugin.updatedAt",
header: ({ column }) => (
<DataTableColumnHeader column={column} title="Updated" />
),
cell: ({ row }) => {
const date = row.original.plugin.updatedAt;
return (
<div className="text-sm whitespace-nowrap">
{formatDistanceToNow(date, { addSuffix: true })}
</div>
);
},
},
{
id: "actions",
header: "Actions",
cell: ({ row }) => <PluginActionsCell plugin={row.original} />,
enableSorting: false,
enableHiding: false,
},
];

View File

@@ -0,0 +1,247 @@
"use client";
import { Plus, Puzzle } from "lucide-react";
import Link from "next/link";
import React from "react";
import { Button } from "~/components/ui/button";
import { DataTable } from "~/components/ui/data-table";
import { EmptyState } from "~/components/ui/entity-view";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { ActionButton, PageHeader } from "~/components/ui/page-header";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { useStudyContext } from "~/lib/study-context";
import { api } from "~/trpc/react";
import { pluginsColumns, type Plugin } from "./plugins-columns";
export function PluginsDataTable() {
const [statusFilter, setStatusFilter] = React.useState("all");
const [trustLevelFilter, setTrustLevelFilter] = React.useState("all");
const { selectedStudyId } = useStudyContext();
const {
data: pluginsData,
isLoading,
error,
refetch,
} = api.robots.plugins.getStudyPlugins.useQuery(
{
studyId: selectedStudyId!,
},
{
enabled: !!selectedStudyId,
refetchOnWindowFocus: false,
},
);
// Auto-refresh plugins when component mounts to catch external changes
React.useEffect(() => {
const interval = setInterval(() => {
void refetch();
}, 30000); // Refresh every 30 seconds
return () => clearInterval(interval);
}, [refetch]);
// Get study data for breadcrumbs
const { data: studyData } = api.studies.get.useQuery(
{ id: selectedStudyId! },
{ enabled: !!selectedStudyId },
);
// Set breadcrumbs
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Studies", href: "/studies" },
...(selectedStudyId && studyData
? [
{ label: studyData.name, href: `/studies/${selectedStudyId}` },
{ label: "Plugins" },
]
: [{ label: "Plugins" }]),
]);
// Transform plugins data to match the Plugin type expected by columns
const plugins: Plugin[] = React.useMemo(() => {
if (!pluginsData) return [];
return pluginsData as Plugin[];
}, [pluginsData]);
// Status filter options
const statusOptions = [
{ label: "All Statuses", value: "all" },
{ label: "Active", value: "active" },
{ label: "Deprecated", value: "deprecated" },
{ label: "Disabled", value: "disabled" },
];
// Trust level filter options
const trustLevelOptions = [
{ label: "All Trust Levels", value: "all" },
{ label: "Official", value: "official" },
{ label: "Verified", value: "verified" },
{ label: "Community", value: "community" },
];
// Filter plugins based on selected filters
const filteredPlugins = React.useMemo(() => {
return plugins.filter((plugin) => {
const statusMatch =
statusFilter === "all" || plugin.plugin.status === statusFilter;
const trustLevelMatch =
trustLevelFilter === "all" ||
plugin.plugin.trustLevel === trustLevelFilter;
return statusMatch && trustLevelMatch;
});
}, [plugins, statusFilter, trustLevelFilter]);
const filters = (
<div className="flex items-center space-x-2">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="h-8 w-[140px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
{statusOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={trustLevelFilter} onValueChange={setTrustLevelFilter}>
<SelectTrigger className="h-8 w-[160px]">
<SelectValue placeholder="Trust Level" />
</SelectTrigger>
<SelectContent>
{trustLevelOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
// Show message if no study is selected
if (!selectedStudyId) {
return (
<div className="space-y-6">
<PageHeader
title="Plugins"
description="Manage robot plugins for your study"
icon={Puzzle}
/>
<EmptyState
icon="Building"
title="No Study Selected"
description="Please select a study from the sidebar to view and manage plugins."
action={
<Button asChild>
<Link href="/studies">Select Study</Link>
</Button>
}
/>
</div>
);
}
// Show error state
if (error) {
return (
<div className="space-y-6">
<PageHeader
title="Plugins"
description="Manage robot plugins for your study"
icon={Puzzle}
actions={
<ActionButton href="/plugins/browse">
<Plus className="mr-2 h-4 w-4" />
Browse Plugins
</ActionButton>
}
/>
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-center">
<div className="text-red-800">
<h3 className="mb-2 text-lg font-semibold">
Failed to Load Plugins
</h3>
<p className="mb-4">
{error.message || "An error occurred while loading plugins."}
</p>
<Button onClick={() => refetch()} variant="outline">
Try Again
</Button>
</div>
</div>
</div>
);
}
// Show empty state if no plugins
if (!isLoading && plugins.length === 0) {
return (
<div className="space-y-6">
<PageHeader
title="Plugins"
description="Manage robot plugins for your study"
icon={Puzzle}
actions={
<ActionButton href="/plugins/browse">
<Plus className="mr-2 h-4 w-4" />
Browse Plugins
</ActionButton>
}
/>
<EmptyState
icon="Puzzle"
title="No Plugins Installed"
description="Install plugins to extend robot capabilities for your experiments."
action={
<Button asChild>
<Link href="/plugins/browse">Browse Plugin Store</Link>
</Button>
}
/>
</div>
);
}
return (
<div className="space-y-6">
<PageHeader
title="Plugins"
description="Manage robot plugins for your study"
icon={Puzzle}
actions={
<ActionButton href="/plugins/browse">
<Plus className="mr-2 h-4 w-4" />
Browse Plugins
</ActionButton>
}
/>
<div className="space-y-4">
{/* Data Table */}
<DataTable
columns={pluginsColumns}
data={filteredPlugins}
searchKey="plugin.name"
searchPlaceholder="Search plugins..."
isLoading={isLoading}
loadingRowCount={5}
filters={filters}
/>
</div>
</div>
);
}

View File

@@ -234,8 +234,8 @@ export const studiesColumns: ColumnDef<Study>[] = [
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => {
const status = row.getValue("status") as keyof typeof statusConfig;
const config = statusConfig[status];
const status = row.getValue("status");
const config = statusConfig[status as keyof typeof statusConfig];
return (
<Badge
@@ -248,7 +248,7 @@ export const studiesColumns: ColumnDef<Study>[] = [
);
},
filterFn: (row, id, value: string[]) => {
return value.includes(row.getValue(id) as string);
return value.includes(row.getValue(id));
},
},
{
@@ -257,7 +257,7 @@ export const studiesColumns: ColumnDef<Study>[] = [
<DataTableColumnHeader column={column} title="Institution" />
),
cell: ({ row }) => {
const institution = row.getValue("institution") as string | null;
const institution = row.original.institution;
return (
<span
className="block max-w-[120px] truncate text-sm"
@@ -274,20 +274,23 @@ export const studiesColumns: ColumnDef<Study>[] = [
<DataTableColumnHeader column={column} title="Owner" />
),
cell: ({ row }) => {
const owner = row.getValue("owner") as Study["owner"];
const owner = row.original.owner;
if (!owner) {
return <span className="text-muted-foreground">No owner</span>;
}
return (
<div className="max-w-[140px] space-y-1">
<div
className="truncate text-sm font-medium"
title={owner?.name ?? "Unknown"}
title={owner.name ?? "Unknown"}
>
{owner?.name ?? "Unknown"}
{owner.name ?? "Unknown"}
</div>
<div
className="text-muted-foreground truncate text-xs"
title={owner?.email}
title={owner.email ?? ""}
>
{owner?.email}
{owner.email ?? ""}
</div>
</div>
);
@@ -342,7 +345,7 @@ export const studiesColumns: ColumnDef<Study>[] = [
);
},
filterFn: (row, id, value: string[]) => {
return value.includes(row.getValue(id) as string);
return value.includes(row.getValue(id));
},
},
{
@@ -351,10 +354,10 @@ export const studiesColumns: ColumnDef<Study>[] = [
<DataTableColumnHeader column={column} title="Created" />
),
cell: ({ row }) => {
const date = row.getValue("createdAt") as Date;
const date = row.original.createdAt;
return (
<div className="text-sm whitespace-nowrap">
{formatDistanceToNow(date, { addSuffix: true })}
{formatDistanceToNow(date ?? new Date(), { addSuffix: true })}
</div>
);
},
@@ -365,10 +368,10 @@ export const studiesColumns: ColumnDef<Study>[] = [
<DataTableColumnHeader column={column} title="Updated" />
),
cell: ({ row }) => {
const date = row.getValue("updatedAt") as Date;
const date = row.original.updatedAt;
return (
<div className="text-sm whitespace-nowrap">
{formatDistanceToNow(date, { addSuffix: true })}
{formatDistanceToNow(date ?? new Date(), { addSuffix: true })}
</div>
);
},

View File

@@ -94,7 +94,7 @@ export function StudiesDataTable() {
const filters = (
<div className="flex items-center space-x-2">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[140px]">
<SelectTrigger className="h-8 w-[140px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
@@ -107,7 +107,7 @@ export function StudiesDataTable() {
</Select>
<Select value={roleFilter} onValueChange={setRoleFilter}>
<SelectTrigger className="w-[140px]">
<SelectTrigger className="h-8 w-[140px]">
<SelectValue placeholder="Role" />
</SelectTrigger>
<SelectContent>

View File

@@ -50,9 +50,9 @@ interface TrialFormProps {
export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
const router = useRouter();
const { selectedStudyId } = useStudyContext();
const contextStudyId = studyId || selectedStudyId;
const contextStudyId = studyId ?? selectedStudyId;
const [isSubmitting, setIsSubmitting] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isDeleting] = useState(false);
const [error, setError] = useState<string | null>(null);
const form = useForm<TrialFormData>({
@@ -93,16 +93,36 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
// Set breadcrumbs
const breadcrumbs = [
{ label: "Dashboard", href: "/dashboard" },
{ label: "Trials", href: "/trials" },
...(mode === "edit" && trial
{ label: "Studies", href: "/studies" },
...(contextStudyId
? [
{
label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
href: `/trials/${trial.id}`,
label: "Study",
href: `/studies/${contextStudyId}`,
},
{ label: "Edit" },
{ label: "Trials", href: `/studies/${contextStudyId}/trials` },
...(mode === "edit" && trial
? [
{
label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
href: `/trials/${trial.id}`,
},
{ label: "Edit" },
]
: [{ label: "New Trial" }]),
]
: [{ label: "New Trial" }]),
: [
{ label: "Trials", href: "/trials" },
...(mode === "edit" && trial
? [
{
label: `Trial ${trial.sessionNumber || trial.id.slice(-8)}`,
href: `/trials/${trial.id}`,
},
{ label: "Edit" },
]
: [{ label: "New Trial" }]),
]),
];
useBreadcrumbsEffect(breadcrumbs);
@@ -112,13 +132,13 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
if (mode === "edit" && trial) {
form.reset({
experimentId: trial.experimentId,
participantId: trial.participantId || "",
participantId: trial?.participantId ?? "",
scheduledAt: trial.scheduledAt
? new Date(trial.scheduledAt).toISOString().slice(0, 16)
: "",
wizardId: trial.wizardId || undefined,
notes: trial.notes || "",
sessionNumber: trial.sessionNumber || 1,
wizardId: trial.wizardId ?? undefined,
notes: trial.notes ?? "",
sessionNumber: trial.sessionNumber ?? 1,
});
}
}, [trial, mode, form]);
@@ -138,8 +158,8 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
participantId: data.participantId,
scheduledAt: new Date(data.scheduledAt),
wizardId: data.wizardId,
sessionNumber: data.sessionNumber || 1,
notes: data.notes || undefined,
sessionNumber: data.sessionNumber ?? 1,
notes: data.notes ?? undefined,
});
router.push(`/trials/${newTrial!.id}`);
} else {
@@ -147,8 +167,8 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
id: trialId!,
scheduledAt: new Date(data.scheduledAt),
wizardId: data.wizardId,
sessionNumber: data.sessionNumber || 1,
notes: data.notes || undefined,
sessionNumber: data.sessionNumber ?? 1,
notes: data.notes ?? undefined,
});
router.push(`/trials/${updatedTrial!.id}`);
}
@@ -244,7 +264,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
<SelectContent>
{participantsData?.participants?.map((participant) => (
<SelectItem key={participant.id} value={participant.id}>
{participant.name || participant.participantCode} (
{participant.name ?? participant.participantCode} (
{participant.participantCode})
</SelectItem>
))}
@@ -312,7 +332,7 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
<FormField>
<Label htmlFor="wizardId">Assigned Wizard</Label>
<Select
value={form.watch("wizardId") || "none"}
value={form.watch("wizardId") ?? "none"}
onValueChange={(value) =>
form.setValue("wizardId", value === "none" ? undefined : value)
}
@@ -329,11 +349,13 @@ export function TrialForm({ mode, trialId, studyId }: TrialFormProps) {
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No wizard assigned</SelectItem>
{usersData?.map((user) => (
<SelectItem key={user.id} value={user.id}>
{user.name} ({user.email})
</SelectItem>
))}
{usersData?.map(
(user: { id: string; name: string; email: string }) => (
<SelectItem key={user.id} value={user.id}>
{user.name} ({user.email})
</SelectItem>
),
)}
</SelectContent>
</Select>
<p className="text-muted-foreground text-xs">

View File

@@ -58,6 +58,7 @@ export type Trial = {
id: string;
name: string;
email: string;
participantCode?: string;
};
wizard: {
id: string;
@@ -119,7 +120,7 @@ function TrialActionsCell({ trial }: { trial: Trial }) {
};
const handleCopyId = () => {
navigator.clipboard.writeText(trial.id);
void navigator.clipboard.writeText(trial.id);
toast.success("Trial ID copied to clipboard");
};
@@ -301,7 +302,7 @@ export const trialsColumns: ColumnDef<Trial>[] = [
<Badge
variant="outline"
className="ml-auto shrink-0 border-amber-200 bg-amber-50 text-amber-700"
title={`Access restricted - You are an ${trial.userRole || "observer"} on this study`}
title={`Access restricted - You are an ${trial.userRole ?? "observer"} on this study`}
>
{trial.userRole === "observer" ? "View Only" : "Restricted"}
</Badge>
@@ -317,9 +318,9 @@ export const trialsColumns: ColumnDef<Trial>[] = [
<DataTableColumnHeader column={column} title="Status" />
),
cell: ({ row }) => {
const status = row.getValue("status") as Trial["status"];
const status = row.getValue("status");
const trial = row.original;
const config = statusConfig[status];
const config = statusConfig[status as keyof typeof statusConfig];
return (
<div className="flex flex-col gap-1">
@@ -343,7 +344,7 @@ export const trialsColumns: ColumnDef<Trial>[] = [
);
},
filterFn: (row, id, value: string[]) => {
const status = row.getValue(id) as string;
const status = row.getValue(id) as string; // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion
return value.includes(status);
},
},
@@ -353,16 +354,22 @@ export const trialsColumns: ColumnDef<Trial>[] = [
<DataTableColumnHeader column={column} title="Participant" />
),
cell: ({ row }) => {
const participant = row.getValue("participant") as Trial["participant"];
const participant = row.original.participant;
return (
<div className="max-w-[120px]">
<div className="flex items-center space-x-1">
<User className="text-muted-foreground h-3 w-3 flex-shrink-0" />
<span
className="truncate text-sm font-medium"
title={participant.name || "Unnamed Participant"}
title={
participant?.name ??
participant?.participantCode ??
"Unnamed Participant"
}
>
{participant.name || "Unnamed Participant"}
{participant?.name ??
participant?.participantCode ??
"Unnamed Participant"}
</span>
</div>
</div>
@@ -376,16 +383,16 @@ export const trialsColumns: ColumnDef<Trial>[] = [
<DataTableColumnHeader column={column} title="Experiment" />
),
cell: ({ row }) => {
const experiment = row.getValue("experiment") as Trial["experiment"];
const experiment = row.original.experiment;
return (
<div className="flex max-w-[140px] items-center space-x-2">
<FlaskConical className="text-muted-foreground h-3 w-3 flex-shrink-0" />
<Link
href={`/experiments/${experiment.id}`}
href={`/experiments/${experiment?.id ?? ""}`}
className="truncate text-sm hover:underline"
title={experiment.name || "Unnamed Experiment"}
title={experiment?.name ?? "Unnamed Experiment"}
>
{experiment.name || "Unnamed Experiment"}
{experiment?.name ?? "Unnamed Experiment"}
</Link>
</div>
);
@@ -402,7 +409,7 @@ export const trialsColumns: ColumnDef<Trial>[] = [
<DataTableColumnHeader column={column} title="Wizard" />
),
cell: ({ row }) => {
const wizard = row.getValue("wizard") as Trial["wizard"];
const wizard = row.original.wizard;
if (!wizard) {
return (
<span className="text-muted-foreground text-sm">Not assigned</span>
@@ -418,9 +425,9 @@ export const trialsColumns: ColumnDef<Trial>[] = [
</div>
<div
className="text-muted-foreground truncate text-xs"
title={wizard.email}
title={wizard.email ?? ""}
>
{wizard.email}
{wizard.email ?? ""}
</div>
</div>
);
@@ -437,7 +444,7 @@ export const trialsColumns: ColumnDef<Trial>[] = [
<DataTableColumnHeader column={column} title="Scheduled" />
),
cell: ({ row }) => {
const date = row.getValue("scheduledAt") as Date | null;
const date = row.getValue("scheduledAt") as Date | null; // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion
if (!date) {
return (
<span className="text-muted-foreground text-sm">Not scheduled</span>
@@ -527,7 +534,7 @@ export const trialsColumns: ColumnDef<Trial>[] = [
<DataTableColumnHeader column={column} title="Created" />
),
cell: ({ row }) => {
const date = row.getValue("createdAt") as Date;
const date = row.getValue("createdAt") as Date; // eslint-disable-line @typescript-eslint/no-unnecessary-type-assertion
return (
<div className="text-sm whitespace-nowrap">
{formatDistanceToNow(date, { addSuffix: true })}

View File

@@ -59,10 +59,22 @@ export function TrialsDataTable() {
return () => clearInterval(interval);
}, [refetch]);
// Get study data for breadcrumbs
const { data: studyData } = api.studies.get.useQuery(
{ id: selectedStudyId! },
{ enabled: !!selectedStudyId },
);
// Set breadcrumbs
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Trials" },
{ label: "Studies", href: "/studies" },
...(selectedStudyId && studyData
? [
{ label: studyData.name, href: `/studies/${selectedStudyId}` },
{ label: "Trials" },
]
: [{ label: "Trials" }]),
]);
// Transform trials data to match the Trial type expected by columns
@@ -149,7 +161,7 @@ export function TrialsDataTable() {
const filters = (
<div className="flex items-center space-x-2">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[140px]">
<SelectTrigger className="h-8 w-[140px]">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
@@ -222,10 +234,10 @@ export function TrialsDataTable() {
Limited Trial Access
</h3>
<p className="mt-1 text-sm text-amber-700">
Some trials are marked as "View Only" or "Restricted" because
you have observer-level access to their studies. Only
researchers, wizards, and study owners can view detailed trial
information.
Some trials are marked as &ldquo;View Only&rdquo; or
&ldquo;Restricted&rdquo; because you have observer-level
access to their studies. Only researchers, wizards, and study
owners can view detailed trial information.
</p>
</div>
</div>

View File

@@ -115,8 +115,10 @@ export function DataTable<TData, TValue>({
// Initialize with defaultHidden columns set to false
const initialVisibility: VisibilityState = {};
safeColumns.forEach((column) => {
if ((column.meta as any)?.defaultHidden) {
const columnKey = column.id || (column as any).accessorKey;
const meta = column.meta as { defaultHidden?: boolean } | undefined;
if (meta?.defaultHidden) {
const columnKey =
column.id ?? (column as { accessorKey?: string }).accessorKey;
if (columnKey) {
initialVisibility[columnKey] = false;
}
@@ -183,7 +185,7 @@ export function DataTable<TData, TValue>({
<div className="flex-shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="ml-2">
<Button variant="outline" className="h-8">
Columns <ChevronDown className="ml-2 h-4 w-4" />
</Button>
</DropdownMenuTrigger>

View File

@@ -77,9 +77,9 @@ export function useActiveStudy() {
localStorage.setItem(ACTIVE_STUDY_KEY, studyId);
// Invalidate all related queries when study changes
utils.participants.invalidate();
utils.trials.invalidate();
utils.experiments.invalidate();
void utils.participants.invalidate();
void utils.trials.invalidate();
void utils.experiments.invalidate();
toast.success("Active study updated");
@@ -95,9 +95,9 @@ export function useActiveStudy() {
localStorage.removeItem(ACTIVE_STUDY_KEY);
// Invalidate all related queries when clearing study
utils.participants.invalidate();
utils.trials.invalidate();
utils.experiments.invalidate();
void utils.participants.invalidate();
void utils.trials.invalidate();
void utils.experiments.invalidate();
toast.success("Active study cleared");
@@ -116,15 +116,18 @@ export function useActiveStudy() {
activeStudy && typeof activeStudy === "object"
? {
id: activeStudy.id,
title: (activeStudy as any).name || "",
description: (activeStudy as any).description || "",
title: (activeStudy as { name?: string }).name ?? "",
description:
(activeStudy as { description?: string }).description ?? "",
}
: null,
userStudies: userStudies.map((study: any) => ({
id: study.id as string,
title: study.name as string,
description: (study.description as string) || "",
})),
userStudies: userStudies.map(
(study: { id: string; name: string; description?: string | null }) => ({
id: study.id,
title: study.name,
description: study.description ?? "",
}),
),
// Loading states
isLoadingActiveStudy,

View File

@@ -1,10 +1,32 @@
import { TRPCError } from "@trpc/server";
import { and, count, desc, eq, gte, inArray, lte, type SQL } from "drizzle-orm";
import {
and,
count,
desc,
eq,
gte,
ilike,
inArray,
lte,
or,
type SQL,
} from "drizzle-orm";
import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import type { db } from "~/server/db";
import {
annotations, auditLogs, experiments, mediaCaptures, participants, studies, systemSettings, trials, users, userSystemRoles
annotations,
auditLogs,
experiments,
mediaCaptures,
participants,
pluginRepositories,
studies,
systemSettings,
trials,
trustLevelEnum,
users,
userSystemRoles,
} from "~/server/db/schema";
// Helper function to check if user has system admin access
@@ -28,6 +50,12 @@ async function checkSystemAdminAccess(database: typeof db, userId: string) {
}
}
// Admin procedure with system admin access check
const adminProcedure = protectedProcedure.use(async ({ ctx, next }) => {
await checkSystemAdminAccess(ctx.db, ctx.session.user.id);
return next();
});
export const adminRouter = createTRPCRouter({
getSystemStats: protectedProcedure
.input(
@@ -306,8 +334,8 @@ export const adminRouter = createTRPCRouter({
}
if (input.dateRange) {
conditions.push(
gte(auditLogs.createdAt, input.dateRange.startDate),
lte(auditLogs.createdAt, input.dateRange.endDate),
gte(auditLogs.createdAt, input.dateRange.startDate),
lte(auditLogs.createdAt, input.dateRange.endDate),
);
}
@@ -539,4 +567,291 @@ export const adminRouter = createTRPCRouter({
return { success: true };
}),
// Repository management
repositories: createTRPCRouter({
list: adminProcedure
.input(
z.object({
search: z.string().optional(),
trustLevel: z.enum(trustLevelEnum.enumValues).optional(),
isEnabled: z.boolean().optional(),
limit: z.number().min(1).max(100).default(50),
offset: z.number().min(0).default(0),
}),
)
.query(async ({ ctx, input }) => {
const { db } = ctx;
const conditions = [];
if (input.search) {
conditions.push(
or(
ilike(pluginRepositories.name, `%${input.search}%`),
ilike(pluginRepositories.description, `%${input.search}%`),
ilike(pluginRepositories.url, `%${input.search}%`),
),
);
}
if (input.trustLevel) {
conditions.push(eq(pluginRepositories.trustLevel, input.trustLevel));
}
if (input.isEnabled !== undefined) {
conditions.push(eq(pluginRepositories.isEnabled, input.isEnabled));
}
const query = db
.select({
id: pluginRepositories.id,
name: pluginRepositories.name,
url: pluginRepositories.url,
description: pluginRepositories.description,
trustLevel: pluginRepositories.trustLevel,
isEnabled: pluginRepositories.isEnabled,
isOfficial: pluginRepositories.isOfficial,
lastSyncAt: pluginRepositories.lastSyncAt,
syncStatus: pluginRepositories.syncStatus,
syncError: pluginRepositories.syncError,
createdAt: pluginRepositories.createdAt,
updatedAt: pluginRepositories.updatedAt,
})
.from(pluginRepositories);
const results = await (
conditions.length > 0 ? query.where(and(...conditions)) : query
)
.orderBy(desc(pluginRepositories.createdAt))
.limit(input.limit)
.offset(input.offset);
return results;
}),
get: adminProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const { db } = ctx;
const repository = await db
.select()
.from(pluginRepositories)
.where(eq(pluginRepositories.id, input.id))
.limit(1);
if (!repository[0]) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Repository not found",
});
}
return repository[0];
}),
create: adminProcedure
.input(
z.object({
name: z.string().min(1).max(255),
url: z.string().url(),
description: z.string().optional(),
trustLevel: z.enum(trustLevelEnum.enumValues).default("community"),
isEnabled: z.boolean().default(true),
isOfficial: z.boolean().default(false),
}),
)
.mutation(async ({ ctx, input }) => {
const { db } = ctx;
const userId = ctx.session.user.id;
// Check if repository URL already exists
const existing = await db
.select()
.from(pluginRepositories)
.where(eq(pluginRepositories.url, input.url))
.limit(1);
if (existing[0]) {
throw new TRPCError({
code: "CONFLICT",
message: "Repository URL already exists",
});
}
const repositories = await db
.insert(pluginRepositories)
.values({
name: input.name,
url: input.url,
description: input.description,
trustLevel: input.trustLevel,
isEnabled: input.isEnabled,
isOfficial: input.isOfficial,
createdBy: userId,
})
.returning();
const repository = repositories[0];
if (!repository) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to create repository",
});
}
return repository;
}),
update: adminProcedure
.input(
z.object({
id: z.string(),
name: z.string().min(1).max(255).optional(),
url: z.string().url().optional(),
description: z.string().optional(),
trustLevel: z.enum(trustLevelEnum.enumValues).optional(),
isEnabled: z.boolean().optional(),
isOfficial: z.boolean().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
const { db } = ctx;
// Check if repository exists
const existing = await db
.select()
.from(pluginRepositories)
.where(eq(pluginRepositories.id, input.id))
.limit(1);
if (!existing[0]) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Repository not found",
});
}
// If updating URL, check for conflicts
if (input.url && input.url !== existing[0].url) {
const urlExists = await db
.select()
.from(pluginRepositories)
.where(eq(pluginRepositories.url, input.url))
.limit(1);
if (urlExists[0]) {
throw new TRPCError({
code: "CONFLICT",
message: "Repository URL already exists",
});
}
}
const updateData: {
updatedAt: Date;
name?: string;
url?: string;
description?: string;
trustLevel?: "official" | "verified" | "community";
isEnabled?: boolean;
isOfficial?: boolean;
} = {
updatedAt: new Date(),
};
if (input.name !== undefined) updateData.name = input.name;
if (input.url !== undefined) updateData.url = input.url;
if (input.description !== undefined)
updateData.description = input.description;
if (input.trustLevel !== undefined)
updateData.trustLevel = input.trustLevel;
if (input.isEnabled !== undefined)
updateData.isEnabled = input.isEnabled;
if (input.isOfficial !== undefined)
updateData.isOfficial = input.isOfficial;
const repositories = await db
.update(pluginRepositories)
.set(updateData)
.where(eq(pluginRepositories.id, input.id))
.returning();
const repository = repositories[0];
if (!repository) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Failed to update repository",
});
}
return repository;
}),
delete: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const { db } = ctx;
const deletedRepositories = await db
.delete(pluginRepositories)
.where(eq(pluginRepositories.id, input.id))
.returning();
if (!deletedRepositories[0]) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Repository not found",
});
}
return { success: true };
}),
sync: adminProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
const { db } = ctx;
// Check if repository exists
const repository = await db
.select()
.from(pluginRepositories)
.where(eq(pluginRepositories.id, input.id))
.limit(1);
if (!repository[0]) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Repository not found",
});
}
// Update sync status to in_progress
await db
.update(pluginRepositories)
.set({
syncStatus: "syncing",
syncError: null,
updatedAt: new Date(),
})
.where(eq(pluginRepositories.id, input.id));
// TODO: Implement actual repository synchronization
// This would fetch plugins from the repository URL and update the plugins table
// For now, just mark as completed
await db
.update(pluginRepositories)
.set({
syncStatus: "completed",
lastSyncAt: new Date(),
updatedAt: new Date(),
})
.where(eq(pluginRepositories.id, input.id));
return { success: true };
}),
}),
});

View File

@@ -14,15 +14,29 @@ import {
steps,
stepTypeEnum,
studyMembers,
userSystemRoles,
} from "~/server/db/schema";
// Helper function to check study access
// Helper function to check study access (with admin bypass)
async function checkStudyAccess(
database: typeof db,
userId: string,
studyId: string,
requiredRole?: string[],
) {
// Check if user is system administrator (bypass study permissions)
const adminRole = await database.query.userSystemRoles.findFirst({
where: and(
eq(userSystemRoles.userId, userId),
eq(userSystemRoles.role, "administrator"),
),
});
if (adminRole) {
return { role: "administrator", studyId, userId, joinedAt: new Date() };
}
// Check study membership
const membership = await database.query.studyMembers.findFirst({
where: and(
eq(studyMembers.studyId, studyId),
@@ -332,6 +346,7 @@ export const experimentsRouter = createTRPCRouter({
status: z.enum(experimentStatusEnum.enumValues).optional(),
estimatedDuration: z.number().int().min(1).optional(),
metadata: z.record(z.string(), z.any()).optional(),
visualDesign: z.record(z.string(), z.any()).optional(),
}),
)
.mutation(async ({ ctx, input }) => {

View File

@@ -4,7 +4,12 @@ import { z } from "zod";
import { createTRPCRouter, protectedProcedure } from "~/server/api/trpc";
import type { db } from "~/server/db";
import {
communicationProtocolEnum, plugins, pluginStatusEnum, robots, studyMembers, studyPlugins
communicationProtocolEnum,
plugins,
pluginStatusEnum,
robots,
studyMembers,
studyPlugins,
} from "~/server/db/schema";
// Helper function to check if user has study access for robot operations
@@ -21,7 +26,12 @@ async function checkStudyAccess(
and(
eq(studyMembers.studyId, studyId),
eq(studyMembers.userId, userId),
inArray(studyMembers.role, requiredRoles as Array<"owner" | "researcher" | "wizard" | "observer">),
inArray(
studyMembers.role,
requiredRoles as Array<
"owner" | "researcher" | "wizard" | "observer"
>,
),
),
)
.limit(1);
@@ -67,9 +77,7 @@ export const robotsRouter = createTRPCRouter({
.from(robots);
const results = await (
conditions.length > 0
? query.where(and(...conditions))
: query
conditions.length > 0 ? query.where(and(...conditions)) : query
)
.orderBy(desc(robots.updatedAt))
.limit(input.limit)
@@ -429,5 +437,52 @@ export const robotsRouter = createTRPCRouter({
return plugin[0].actionDefinitions ?? [];
}),
getStudyPlugins: protectedProcedure
.input(
z.object({
studyId: z.string(),
}),
)
.query(async ({ ctx, input }) => {
const { db } = ctx;
const userId = ctx.session.user.id;
await checkStudyAccess(db, userId, input.studyId, [
"owner",
"researcher",
"wizard",
"observer",
]);
const installedPlugins = await db
.select({
plugin: {
id: plugins.id,
robotId: plugins.robotId,
name: plugins.name,
version: plugins.version,
description: plugins.description,
author: plugins.author,
repositoryUrl: plugins.repositoryUrl,
trustLevel: plugins.trustLevel,
status: plugins.status,
createdAt: plugins.createdAt,
updatedAt: plugins.updatedAt,
},
installation: {
id: studyPlugins.id,
configuration: studyPlugins.configuration,
installedAt: studyPlugins.installedAt,
installedBy: studyPlugins.installedBy,
},
})
.from(studyPlugins)
.innerJoin(plugins, eq(studyPlugins.pluginId, plugins.id))
.where(eq(studyPlugins.studyId, input.studyId))
.orderBy(desc(studyPlugins.installedAt));
return installedPlugins;
}),
}),
});

View File

@@ -617,6 +617,35 @@ export const studyPlugins = createTable(
}),
);
export const pluginRepositories = createTable(
"plugin_repository",
{
id: uuid("id").notNull().primaryKey().defaultRandom(),
name: varchar("name", { length: 255 }).notNull(),
url: text("url").notNull(),
description: text("description"),
trustLevel: trustLevelEnum("trust_level").default("community").notNull(),
isEnabled: boolean("is_enabled").default(true).notNull(),
isOfficial: boolean("is_official").default(false).notNull(),
lastSyncAt: timestamp("last_sync_at", { withTimezone: true }),
syncStatus: varchar("sync_status", { length: 50 }).default("pending"),
syncError: text("sync_error"),
metadata: jsonb("metadata").default({}),
createdAt: timestamp("created_at", { withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
createdBy: uuid("created_by")
.notNull()
.references(() => users.id),
},
(table) => ({
urlUnique: unique().on(table.url),
}),
);
// Experiment Execution and Data Capture
export const trialEvents = createTable(
"trial_event",

View File

@@ -45,24 +45,15 @@
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
--font-sans:
Geist Mono, monospace;
--font-mono:
Geist Mono, monospace;
--font-serif:
Geist Mono, monospace;
--radius:
0rem;
--tracking-tighter:
calc(var(--tracking-normal) - 0.05em);
--tracking-tight:
calc(var(--tracking-normal) - 0.025em);
--tracking-wide:
calc(var(--tracking-normal) + 0.025em);
--tracking-wider:
calc(var(--tracking-normal) + 0.05em);
--tracking-widest:
calc(var(--tracking-normal) + 0.1em);
--font-sans: Geist Mono, monospace;
--font-mono: Geist Mono, monospace;
--font-serif: Geist Mono, monospace;
--radius: 0rem;
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
--tracking-normal: var(--tracking-normal);
--shadow-2xl: var(--shadow-2xl);
--shadow-xl: var(--shadow-xl);
@@ -84,148 +75,99 @@
}
:root {
--radius:
0rem;
--background:
oklch(1.0000 0 0);
--foreground:
oklch(0.1448 0 0);
--card:
oklch(1.0000 0 0);
--card-foreground:
oklch(0.1448 0 0);
--popover:
oklch(1.0000 0 0);
--popover-foreground:
oklch(0.1448 0 0);
--primary:
oklch(0.5555 0 0);
--primary-foreground:
oklch(0.9851 0 0);
--secondary:
oklch(0.9702 0 0);
--secondary-foreground:
oklch(0.2046 0 0);
--muted:
oklch(0.9702 0 0);
--muted-foreground:
oklch(0.5486 0 0);
--accent:
oklch(0.9702 0 0);
--accent-foreground:
oklch(0.2046 0 0);
--destructive:
oklch(0.5830 0.2387 28.4765);
--border:
oklch(0.9219 0 0);
--input:
oklch(0.9219 0 0);
--ring:
oklch(0.7090 0 0);
--chart-1:
oklch(0.5555 0 0);
--chart-2:
oklch(0.5555 0 0);
--chart-3:
oklch(0.5555 0 0);
--chart-4:
oklch(0.5555 0 0);
--chart-5:
oklch(0.5555 0 0);
--sidebar:
oklch(0.9851 0 0);
--sidebar-foreground:
oklch(0.1448 0 0);
--sidebar-primary:
oklch(0.2046 0 0);
--sidebar-primary-foreground:
oklch(0.9851 0 0);
--sidebar-accent:
oklch(0.9702 0 0);
--sidebar-accent-foreground:
oklch(0.2046 0 0);
--sidebar-border:
oklch(0.9219 0 0);
--sidebar-ring:
oklch(0.7090 0 0);
--destructive-foreground:
oklch(0.9702 0 0);
--font-sans:
Geist Mono, monospace;
--font-serif:
Geist Mono, monospace;
--font-mono:
Geist Mono, monospace;
--shadow-color:
hsl(0 0% 0%);
--shadow-opacity:
0;
--shadow-blur:
0px;
--shadow-spread:
0px;
--shadow-offset-x:
0px;
--shadow-offset-y:
1px;
--letter-spacing:
0em;
--spacing:
0.25rem;
--shadow-2xs:
0px 1px 0px 0px hsl(0 0% 0% / 0.00);
--shadow-xs:
0px 1px 0px 0px hsl(0 0% 0% / 0.00);
--radius: 0rem;
--background: oklch(0.98 0.005 60);
--foreground: oklch(0.15 0.005 240);
--card: oklch(0.995 0.001 60);
--card-foreground: oklch(0.15 0.005 240);
--popover: oklch(0.99 0.002 60);
--popover-foreground: oklch(0.15 0.005 240);
--primary: oklch(0.55 0.08 240);
--primary-foreground: oklch(0.98 0.01 250);
--secondary: oklch(0.94 0.01 240);
--secondary-foreground: oklch(0.25 0.02 240);
--muted: oklch(0.95 0.008 240);
--muted-foreground: oklch(0.52 0.015 240);
--accent: oklch(0.92 0.015 240);
--accent-foreground: oklch(0.2 0.02 240);
--destructive: oklch(0.583 0.2387 28.4765);
--border: oklch(0.9 0.008 240);
--input: oklch(0.96 0.005 240);
--ring: oklch(0.55 0.08 240);
--chart-1: oklch(0.55 0.08 240);
--chart-2: oklch(0.6 0.1 200);
--chart-3: oklch(0.65 0.12 160);
--chart-4: oklch(0.7 0.1 120);
--chart-5: oklch(0.6 0.15 80);
--sidebar: oklch(0.97 0.015 250);
--sidebar-foreground: oklch(0.2 0.03 240);
--sidebar-primary: oklch(0.3 0.08 240);
--sidebar-primary-foreground: oklch(0.98 0.01 250);
--sidebar-accent: oklch(0.92 0.025 245);
--sidebar-accent-foreground: oklch(0.25 0.05 240);
--sidebar-border: oklch(0.85 0.03 245);
--sidebar-ring: oklch(0.6 0.05 240);
--destructive-foreground: oklch(0.9702 0 0);
--font-sans: Geist Mono, monospace;
--font-serif: Geist Mono, monospace;
--font-mono: Geist Mono, monospace;
--shadow-color: hsl(0 0% 0%);
--shadow-opacity: 0;
--shadow-blur: 0px;
--shadow-spread: 0px;
--shadow-offset-x: 0px;
--shadow-offset-y: 1px;
--letter-spacing: 0em;
--spacing: 0.25rem;
--shadow-2xs: 0px 1px 0px 0px hsl(0 0% 0% / 0);
--shadow-xs: 0px 1px 0px 0px hsl(0 0% 0% / 0);
--shadow-sm:
0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 1px 2px -1px hsl(0 0% 0% / 0.00);
--shadow:
0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 1px 2px -1px hsl(0 0% 0% / 0.00);
0px 1px 0px 0px hsl(0 0% 0% / 0), 0px 1px 2px -1px hsl(0 0% 0% / 0);
--shadow: 0px 1px 0px 0px hsl(0 0% 0% / 0), 0px 1px 2px -1px hsl(0 0% 0% / 0);
--shadow-md:
0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 2px 4px -1px hsl(0 0% 0% / 0.00);
0px 1px 0px 0px hsl(0 0% 0% / 0), 0px 2px 4px -1px hsl(0 0% 0% / 0);
--shadow-lg:
0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 4px 6px -1px hsl(0 0% 0% / 0.00);
0px 1px 0px 0px hsl(0 0% 0% / 0), 0px 4px 6px -1px hsl(0 0% 0% / 0);
--shadow-xl:
0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 8px 10px -1px hsl(0 0% 0% / 0.00);
--shadow-2xl:
0px 1px 0px 0px hsl(0 0% 0% / 0.00);
--tracking-normal:
0em;
0px 1px 0px 0px hsl(0 0% 0% / 0), 0px 8px 10px -1px hsl(0 0% 0% / 0);
--shadow-2xl: 0px 1px 0px 0px hsl(0 0% 0% / 0);
--tracking-normal: 0em;
}
@media (prefers-color-scheme: dark) {
:root {
--background: 2 6 23;
--foreground: 248 250 252;
--card: 15 23 42;
--card-foreground: 248 250 252;
--popover: 15 23 42;
--popover-foreground: 248 250 252;
--primary: 148 163 184;
--primary-foreground: 15 23 42;
--secondary: 30 41 59;
--secondary-foreground: 248 250 252;
--muted: 30 41 59;
--muted-foreground: 148 163 184;
--accent: 30 41 59;
--accent-foreground: 248 250 252;
--destructive: 239 68 68;
--border: 51 65 85;
--input: 51 65 85;
--ring: 148 163 184;
--chart-1: 148 163 184;
--chart-2: 100 116 139;
--chart-3: 239 68 68;
--chart-4: 245 158 11;
--chart-5: 34 197 94;
--sidebar: 15 23 42;
--sidebar-foreground: 148 163 184;
--sidebar-primary: 148 163 184;
--sidebar-primary-foreground: 15 23 42;
--sidebar-accent: 30 41 59;
--sidebar-accent-foreground: 248 250 252;
--sidebar-border: 51 65 85;
--sidebar-ring: 148 163 184;
--destructive-foreground: 255 255 255;
--background: oklch(0.12 0.008 250);
--foreground: oklch(0.95 0.005 250);
--card: oklch(0.18 0.008 250);
--card-foreground: oklch(0.95 0.005 250);
--popover: oklch(0.2 0.01 250);
--popover-foreground: oklch(0.95 0.005 250);
--primary: oklch(0.65 0.1 240);
--primary-foreground: oklch(0.08 0.02 250);
--secondary: oklch(0.25 0.015 245);
--secondary-foreground: oklch(0.92 0.008 250);
--muted: oklch(0.22 0.01 250);
--muted-foreground: oklch(0.65 0.02 245);
--accent: oklch(0.35 0.025 245);
--accent-foreground: oklch(0.92 0.008 250);
--destructive: oklch(0.7022 0.1892 22.2279);
--border: oklch(0.3 0.015 250);
--input: oklch(0.28 0.015 250);
--ring: oklch(0.65 0.1 240);
--chart-1: oklch(0.65 0.1 240);
--chart-2: oklch(0.7 0.12 200);
--chart-3: oklch(0.75 0.15 160);
--chart-4: oklch(0.8 0.12 120);
--chart-5: oklch(0.7 0.18 80);
--sidebar: oklch(0.14 0.025 250);
--sidebar-foreground: oklch(0.88 0.02 250);
--sidebar-primary: oklch(0.8 0.06 240);
--sidebar-primary-foreground: oklch(0.12 0.025 250);
--sidebar-accent: oklch(0.22 0.04 245);
--sidebar-accent-foreground: oklch(0.88 0.02 250);
--sidebar-border: oklch(0.32 0.035 250);
--sidebar-ring: oklch(0.55 0.08 240);
--destructive-foreground: oklch(0.95 0.01 250);
}
}
@@ -235,114 +177,6 @@
}
body {
@apply bg-background text-foreground;
letter-spacing:
var(--tracking-normal);
letter-spacing: var(--tracking-normal);
}
}
.dark {
--background:
oklch(0.1448 0 0);
--foreground:
oklch(0.9851 0 0);
--card:
oklch(0.2134 0 0);
--card-foreground:
oklch(0.9851 0 0);
--popover:
oklch(0.2686 0 0);
--popover-foreground:
oklch(0.9851 0 0);
--primary:
oklch(0.5555 0 0);
--primary-foreground:
oklch(0.9851 0 0);
--secondary:
oklch(0.2686 0 0);
--secondary-foreground:
oklch(0.9851 0 0);
--muted:
oklch(0.2686 0 0);
--muted-foreground:
oklch(0.7090 0 0);
--accent:
oklch(0.3715 0 0);
--accent-foreground:
oklch(0.9851 0 0);
--destructive:
oklch(0.7022 0.1892 22.2279);
--destructive-foreground:
oklch(0.2686 0 0);
--border:
oklch(0.3407 0 0);
--input:
oklch(0.4386 0 0);
--ring:
oklch(0.5555 0 0);
--chart-1:
oklch(0.5555 0 0);
--chart-2:
oklch(0.5555 0 0);
--chart-3:
oklch(0.5555 0 0);
--chart-4:
oklch(0.5555 0 0);
--chart-5:
oklch(0.5555 0 0);
--sidebar:
oklch(0.2046 0 0);
--sidebar-foreground:
oklch(0.9851 0 0);
--sidebar-primary:
oklch(0.9851 0 0);
--sidebar-primary-foreground:
oklch(0.2046 0 0);
--sidebar-accent:
oklch(0.2686 0 0);
--sidebar-accent-foreground:
oklch(0.9851 0 0);
--sidebar-border:
oklch(1.0000 0 0);
--sidebar-ring:
oklch(0.4386 0 0);
--radius:
0rem;
--font-sans:
Geist Mono, monospace;
--font-serif:
Geist Mono, monospace;
--font-mono:
Geist Mono, monospace;
--shadow-color:
hsl(0 0% 0%);
--shadow-opacity:
0;
--shadow-blur:
0px;
--shadow-spread:
0px;
--shadow-offset-x:
0px;
--shadow-offset-y:
1px;
--letter-spacing:
0em;
--spacing:
0.25rem;
--shadow-2xs:
0px 1px 0px 0px hsl(0 0% 0% / 0.00);
--shadow-xs:
0px 1px 0px 0px hsl(0 0% 0% / 0.00);
--shadow-sm:
0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 1px 2px -1px hsl(0 0% 0% / 0.00);
--shadow:
0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 1px 2px -1px hsl(0 0% 0% / 0.00);
--shadow-md:
0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 2px 4px -1px hsl(0 0% 0% / 0.00);
--shadow-lg:
0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 4px 6px -1px hsl(0 0% 0% / 0.00);
--shadow-xl:
0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 8px 10px -1px hsl(0 0% 0% / 0.00);
--shadow-2xl:
0px 1px 0px 0px hsl(0 0% 0% / 0.00);
}