Enhance development standards and UI components

- Updated .rules to enforce stricter UI/UX standards, including exclusive use of Lucide icons and consistent patterns for entity view pages.
- Added new UI components for entity views, including headers, sections, and quick actions to improve layout and reusability.
- Refactored existing pages (experiments, participants, studies, trials) to utilize the new entity view components, enhancing consistency across the dashboard.
- Improved accessibility and user experience by implementing loading states and error boundaries in async operations.
- Updated package dependencies to ensure compatibility and performance improvements.

Features:
- Comprehensive guidelines for component reusability and visual consistency.
- Enhanced user interface with new entity view components for better organization and navigation.

Breaking Changes: None - existing functionality remains intact.
This commit is contained in:
2025-08-05 02:36:44 -04:00
parent 7cdc1a2340
commit 544207e9a2
16 changed files with 3643 additions and 1531 deletions

View File

@@ -45,7 +45,7 @@ export function SystemStats() {
{/* Total Users */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-slate-600">
<CardTitle className="text-muted-foreground text-sm font-medium">
Total Users
</CardTitle>
</CardHeader>
@@ -62,7 +62,7 @@ export function SystemStats() {
{/* Total Studies */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-slate-600">
<CardTitle className="text-muted-foreground text-sm font-medium">
Studies
</CardTitle>
</CardHeader>
@@ -79,7 +79,7 @@ export function SystemStats() {
{/* Total Experiments */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-slate-600">
<CardTitle className="text-muted-foreground text-sm font-medium">
Experiments
</CardTitle>
</CardHeader>
@@ -98,7 +98,7 @@ export function SystemStats() {
{/* Total Trials */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-slate-600">
<CardTitle className="text-muted-foreground text-sm font-medium">
Trials
</CardTitle>
</CardHeader>
@@ -115,7 +115,7 @@ export function SystemStats() {
{/* System Health */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-slate-600">
<CardTitle className="text-muted-foreground text-sm font-medium">
System Health
</CardTitle>
</CardHeader>
@@ -124,11 +124,11 @@ export function SystemStats() {
<div className="flex h-3 w-3 items-center justify-center">
<div className="h-2 w-2 rounded-full bg-green-500"></div>
</div>
<span className="text-sm font-medium text-green-700">
<span className="text-sm font-medium text-green-600">
{displayStats.systemHealth === "healthy" ? "Healthy" : "Issues"}
</span>
</div>
<div className="mt-1 text-xs text-slate-500">
<div className="text-muted-foreground mt-1 text-xs">
All services operational
</div>
</CardContent>
@@ -137,41 +137,49 @@ export function SystemStats() {
{/* Uptime */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-slate-600">
<CardTitle className="text-muted-foreground text-sm font-medium">
Uptime
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-sm font-medium">{displayStats.uptime}</div>
<div className="mt-1 text-xs text-slate-500">Since last restart</div>
<div className="text-muted-foreground mt-1 text-xs">
Since last restart
</div>
</CardContent>
</Card>
{/* Storage Usage */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-slate-600">
<CardTitle className="text-muted-foreground text-sm font-medium">
Storage Used
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-sm font-medium">{displayStats.storageUsed}</div>
<div className="mt-1 text-xs text-slate-500">Media & database</div>
<div className="text-muted-foreground mt-1 text-xs">
Media & database
</div>
</CardContent>
</Card>
{/* Recent Activity */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-slate-600">
<CardTitle className="text-muted-foreground text-sm font-medium">
Recent Activity
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1">
<div className="text-xs text-slate-600">2 trials started today</div>
<div className="text-xs text-slate-600">1 new user registered</div>
<div className="text-xs text-slate-600">
<div className="text-muted-foreground text-xs">
2 trials started today
</div>
<div className="text-muted-foreground text-xs">
1 new user registered
</div>
<div className="text-muted-foreground text-xs">
3 experiments published
</div>
</div>

View File

@@ -13,7 +13,6 @@ import {
LogOut,
MoreHorizontal,
Settings,
User,
Users,
UserCheck,
TestTube,
@@ -40,6 +39,7 @@ import {
SidebarMenuItem,
SidebarRail,
} from "~/components/ui/sidebar";
import { Avatar, AvatarImage, AvatarFallback } from "~/components/ui/avatar";
import { Logo } from "~/components/ui/logo";
import { useStudyManagement } from "~/hooks/useStudyManagement";
@@ -292,18 +292,60 @@ export function AppSidebar({
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton size="lg">
<User className="h-4 w-4" />
<span>{session?.user?.name ?? "User"}</span>
<MoreHorizontal className="ml-auto h-4 w-4" />
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground group-data-[collapsible=icon]:h-8 group-data-[collapsible=icon]:w-8 group-data-[collapsible=icon]:justify-center"
>
<Avatar className="h-6 w-6 border-2 border-slate-300 group-data-[collapsible=icon]:h-6 group-data-[collapsible=icon]:w-6">
<AvatarImage
src={session?.user?.image ?? undefined}
alt={session?.user?.name ?? "User"}
/>
<AvatarFallback className="bg-slate-600 text-xs text-white">
{(session?.user?.name ?? session?.user?.email ?? "U")
.charAt(0)
.toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight group-data-[collapsible=icon]:hidden">
<span className="truncate font-semibold">
{session?.user?.name ?? "User"}
</span>
<span className="truncate text-xs">
{session?.user?.email ?? ""}
</span>
</div>
<MoreHorizontal className="ml-auto size-4 group-data-[collapsible=icon]:hidden" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-popper-anchor-width]"
className="w-[--radix-popper-anchor-width] min-w-56 rounded-lg"
side="bottom"
align="end"
sideOffset={4}
>
<DropdownMenuLabel>
{session?.user?.name ?? "User"}
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<Avatar className="h-8 w-8 border-2 border-slate-300">
<AvatarImage
src={session?.user?.image ?? undefined}
alt={session?.user?.name ?? "User"}
/>
<AvatarFallback className="bg-slate-600 text-xs text-white">
{(session?.user?.name ?? session?.user?.email ?? "U")
.charAt(0)
.toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-semibold">
{session?.user?.name ?? "User"}
</span>
<span className="truncate text-xs">
{session?.user?.email ?? ""}
</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>

View File

@@ -70,6 +70,7 @@ export type Trial = {
logs: number;
};
userRole?: "owner" | "researcher" | "wizard" | "observer";
canAccess?: boolean;
canEdit?: boolean;
canDelete?: boolean;
canExecute?: boolean;
@@ -162,12 +163,19 @@ function TrialActionsCell({ trial }: { trial: Trial }) {
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/trials/${trial.id}`}>
{trial.canAccess ? (
<DropdownMenuItem asChild>
<Link href={`/trials/${trial.id}`}>
<Eye className="mr-2 h-4 w-4" />
View Details
</Link>
</DropdownMenuItem>
) : (
<DropdownMenuItem disabled>
<Eye className="mr-2 h-4 w-4" />
View Details
</Link>
</DropdownMenuItem>
View Details (Restricted)
</DropdownMenuItem>
)}
{trial.canEdit && (
<DropdownMenuItem asChild>
@@ -272,13 +280,33 @@ export const trialsColumns: ColumnDef<Trial>[] = [
const trial = row.original;
return (
<div className="max-w-[140px] min-w-0">
<Link
href={`/trials/${trial.id}`}
className="block truncate font-medium hover:underline"
title={trial.name}
>
{trial.name}
</Link>
<div className="flex items-center gap-2">
{trial.canAccess ? (
<Link
href={`/trials/${trial.id}`}
className="block truncate font-medium hover:underline"
title={trial.name}
>
{trial.name}
</Link>
) : (
<div
className="text-muted-foreground block cursor-not-allowed truncate font-medium"
title={`${trial.name} (View access restricted)`}
>
{trial.name}
</div>
)}
{!trial.canAccess && (
<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`}
>
{trial.userRole === "observer" ? "View Only" : "Restricted"}
</Badge>
)}
</div>
</div>
);
},
@@ -290,16 +318,28 @@ export const trialsColumns: ColumnDef<Trial>[] = [
),
cell: ({ row }) => {
const status = row.getValue("status") as Trial["status"];
const trial = row.original;
const config = statusConfig[status];
return (
<Badge
variant="secondary"
className={`${config.className} whitespace-nowrap`}
title={config.description}
>
{config.label}
</Badge>
<div className="flex flex-col gap-1">
<Badge
variant="secondary"
className={`${config.className} whitespace-nowrap`}
title={config.description}
>
{config.label}
</Badge>
{trial.userRole && (
<Badge
variant="outline"
className="text-xs"
title={`Your role in this study: ${trial.userRole}`}
>
{trial.userRole}
</Badge>
)}
</div>
);
},
filterFn: (row, id, value: string[]) => {

View File

@@ -1,7 +1,7 @@
"use client";
import React from "react";
import { Plus, TestTube } from "lucide-react";
import { Plus, TestTube, Eye } from "lucide-react";
import { Button } from "~/components/ui/button";
import { DataTable } from "~/components/ui/data-table";
@@ -111,14 +111,19 @@ export function TrialsDataTable() {
actions: trial._count?.events ?? 0,
logs: trial._count?.mediaCaptures ?? 0,
},
userRole: undefined,
canEdit: trial.status === "scheduled" || trial.status === "aborted",
userRole: trial.userRole,
canAccess: trial.canAccess ?? false,
canEdit:
trial.canAccess &&
(trial.status === "scheduled" || trial.status === "aborted"),
canDelete:
trial.status === "scheduled" ||
trial.status === "aborted" ||
trial.status === "failed",
trial.canAccess &&
(trial.status === "scheduled" ||
trial.status === "aborted" ||
trial.status === "failed"),
canExecute:
trial.status === "scheduled" || trial.status === "in_progress",
trial.canAccess &&
(trial.status === "scheduled" || trial.status === "in_progress"),
}));
}, [trialsData]);
@@ -204,6 +209,29 @@ export function TrialsDataTable() {
/>
<div className="space-y-4">
{filteredTrials.some((trial) => !trial.canAccess) && (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex-shrink-0">
<div className="rounded-full bg-amber-100 p-1">
<Eye className="h-4 w-4 text-amber-600" />
</div>
</div>
<div>
<h3 className="text-sm font-medium text-amber-800">
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.
</p>
</div>
</div>
</div>
)}
<DataTable
columns={trialsColumns}
data={filteredTrials}

View File

@@ -0,0 +1,260 @@
"use client";
import * as LucideIcons from "lucide-react";
import { type ReactNode } from "react";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
type IconName = keyof typeof LucideIcons;
function getIcon(iconName: IconName) {
const Icon = LucideIcons[iconName] as React.ComponentType<{
className?: string;
}>;
return Icon;
}
interface EntityViewHeaderProps {
title: string;
subtitle?: string;
icon: IconName;
status?: {
label: string;
variant: "default" | "secondary" | "destructive" | "outline";
icon?: IconName;
};
actions?: ReactNode;
}
interface EntityViewSectionProps {
title: string;
icon: IconName;
description?: string;
actions?: ReactNode;
children: ReactNode;
}
interface EntityViewSidebarProps {
children: ReactNode;
}
interface EntityViewProps {
children: ReactNode;
}
export function EntityViewHeader({
title,
subtitle,
icon,
status,
actions,
}: EntityViewHeaderProps) {
const Icon = getIcon(icon);
const StatusIcon = status?.icon ? getIcon(status.icon) : null;
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="bg-primary text-primary-foreground flex h-16 w-16 items-center justify-center rounded-lg">
<Icon className="h-8 w-8" />
</div>
<div>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold">{title}</h1>
{status && (
<Badge variant={status.variant}>
{StatusIcon && <StatusIcon className="mr-1 h-3 w-3" />}
{status.label}
</Badge>
)}
</div>
{subtitle && (
<p className="text-muted-foreground text-lg">{subtitle}</p>
)}
</div>
</div>
{actions && <div className="flex items-center gap-2">{actions}</div>}
</div>
);
}
export function EntityViewSection({
title,
icon,
description,
actions,
children,
}: EntityViewSectionProps) {
const Icon = getIcon(icon);
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Icon className="h-5 w-5" />
<span>{title}</span>
</CardTitle>
{actions && actions}
</div>
{description && <CardDescription>{description}</CardDescription>}
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
}
export function EntityViewSidebar({ children }: EntityViewSidebarProps) {
return <div className="space-y-6">{children}</div>;
}
export function EntityView({ children }: EntityViewProps) {
return <div className="space-y-6">{children}</div>;
}
// Utility component for empty states
interface EmptyStateProps {
icon: IconName;
title: string;
description: string;
action?: ReactNode;
}
export function EmptyState({
icon,
title,
description,
action,
}: EmptyStateProps) {
const Icon = getIcon(icon);
return (
<div className="py-8 text-center">
<Icon className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
<h3 className="mb-2 font-medium">{title}</h3>
<p className="text-muted-foreground mb-4 text-sm">{description}</p>
{action && action}
</div>
);
}
// Utility component for key-value information display
interface InfoGridProps {
items: Array<{
label: string;
value: ReactNode;
fullWidth?: boolean;
}>;
columns?: 1 | 2 | 3;
}
export function InfoGrid({ items, columns = 2 }: InfoGridProps) {
return (
<div
className={`grid gap-4 ${
columns === 1
? "grid-cols-1"
: columns === 2
? "md:grid-cols-2"
: "md:grid-cols-2 lg:grid-cols-3"
}`}
>
{items.map((item, index) => (
<div
key={index}
className={item.fullWidth ? "md:col-span-full" : undefined}
>
<h4 className="text-muted-foreground text-sm font-medium">
{item.label}
</h4>
<div className="text-sm">{item.value}</div>
</div>
))}
</div>
);
}
// Utility component for statistics display
interface StatsGridProps {
stats: Array<{
label: string;
value: string | number;
color?: "default" | "success" | "warning" | "error";
}>;
}
export function StatsGrid({ stats }: StatsGridProps) {
const getValueColor = (color?: string) => {
switch (color) {
case "success":
return "text-green-600";
case "warning":
return "text-amber-600";
case "error":
return "text-red-600";
default:
return "font-medium";
}
};
return (
<div className="space-y-3">
{stats.map((stat, index) => (
<div key={index} className="flex justify-between">
<span className="text-muted-foreground text-sm">{stat.label}:</span>
<span className={getValueColor(stat.color)}>{stat.value}</span>
</div>
))}
</div>
);
}
// Utility component for quick actions
interface QuickActionsProps {
actions: Array<{
label: string;
icon: IconName;
href?: string;
onClick?: () => void;
variant?: "default" | "outline" | "secondary" | "destructive";
}>;
}
export function QuickActions({ actions }: QuickActionsProps) {
return (
<div className="space-y-2">
{actions.map((action, index) => {
const ActionIcon = getIcon(action.icon);
return (
<Button
key={index}
variant={action.variant ?? "outline"}
className="w-full justify-start"
asChild={!!action.href}
onClick={action.onClick}
>
{action.href ? (
<a href={action.href}>
<ActionIcon className="mr-2 h-4 w-4" />
{action.label}
</a>
) : (
<>
<ActionIcon className="mr-2 h-4 w-4" />
{action.label}
</>
)}
</Button>
);
})}
</div>
);
}

View File

@@ -1,33 +1,39 @@
import { Bot } from "lucide-react"
import { cn } from "~/lib/utils"
import { Bot } from "lucide-react";
import { cn } from "~/lib/utils";
interface LogoProps {
className?: string
iconSize?: "sm" | "md" | "lg"
showText?: boolean
className?: string;
iconSize?: "sm" | "md" | "lg";
showText?: boolean;
}
const iconSizes = {
sm: "h-4 w-4",
md: "h-6 w-6",
lg: "h-8 w-8"
}
md: "h-6 w-6",
lg: "h-8 w-8",
};
export function Logo({ className, iconSize = "md", showText = true }: LogoProps) {
export function Logo({
className,
iconSize = "md",
showText = true,
}: LogoProps) {
return (
<div className={cn("flex items-center gap-2", className)}>
<div className="flex aspect-square items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground p-1">
<div className="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square items-center justify-center rounded-lg p-1">
<Bot className={iconSizes[iconSize]} />
</div>
{showText && (
<div className="grid flex-1 text-left text-sm leading-tight">
<div className="flex items-baseline gap-0">
<span className="text-base font-extrabold tracking-tight">HRI</span>
<span className="text-base font-light tracking-tight">Studio</span>
<span className="text-base font-normal tracking-tight">Studio</span>
</div>
<span className="truncate text-xs text-muted-foreground">Research Platform</span>
<span className="text-muted-foreground truncate text-xs">
Research Platform
</span>
</div>
)}
</div>
)
}
);
}