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

10
.rules
View File

@@ -157,6 +157,9 @@ export const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
- Follow the established file naming: PascalCase for components, camelCase for utilities
- Use Server Components by default, minimize 'use client' directives
- Implement proper loading states and error boundaries for all async operations
- NEVER use emojis - use Lucide icons exclusively for all visual elements
- Maximize component reusability - create shared patterns and abstractions
- ALL entity view pages must follow consistent patterns and layouts
### TypeScript Standards
- 100% type safety - NO `any` types allowed in production code
@@ -179,6 +182,13 @@ export const adminProcedure = protectedProcedure.use(({ ctx, next }) => {
- Minimize database queries with efficient joins and proper indexing
- Follow WCAG 2.1 AA accessibility standards throughout
## UI/UX Standards
- **Icons**: Use Lucide icons exclusively - NO emojis anywhere in the codebase
- **Reusability**: Maximize component reuse through shared patterns and abstractions
- **Entity Views**: All entity view pages (studies, experiments, participants, trials) must follow consistent patterns
- **Page Structure**: Use global page headers, breadcrumbs, and consistent layout patterns
- **Visual Consistency**: Maintain consistent spacing, typography, and interaction patterns
## Development Commands
- `bun install` - Install dependencies
- `bun dev` - Start development server (ONLY when actively developing)

1471
bun.lock

File diff suppressed because it is too large Load Diff

View File

@@ -97,5 +97,9 @@
},
"ct3aMetadata": {
"initVersion": "7.39.3"
}
},
"trustedDependencies": [
"@tailwindcss/oxide",
"unrs-resolver"
]
}

View File

@@ -233,8 +233,8 @@ async function main() {
},
{
studyId: study1.id,
userId: seanUser.id, // Sean (observer)
role: "observer" as const,
userId: seanUser.id, // Sean (researcher)
role: "researcher" as const,
joinedAt: new Date(),
},

View File

@@ -0,0 +1,448 @@
"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 Link from "next/link";
import { notFound } from "next/navigation";
import { useEffect, useState } from "react";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import {
EntityView,
EntityViewHeader,
EntityViewSection,
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";
interface ExperimentDetailPageProps {
params: Promise<{
id: string;
}>;
}
const statusConfig = {
draft: {
label: "Draft",
variant: "secondary" as const,
icon: "FileText" as const,
},
testing: {
label: "Testing",
variant: "outline" as const,
icon: "FlaskConical" as const,
},
ready: {
label: "Ready",
variant: "default" as const,
icon: "CheckCircle" as const,
},
deprecated: {
label: "Deprecated",
variant: "destructive" as const,
icon: "AlertTriangle" as const,
},
};
export default function ExperimentDetailPage({
params,
}: ExperimentDetailPageProps) {
const { data: session } = useSession();
const [experiment, setExperiment] = useState<any>(null);
const [trials, setTrials] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(
null,
);
useEffect(() => {
async function resolveParams() {
const resolved = await params;
setResolvedParams(resolved);
}
resolveParams();
}, [params]);
const { data: experimentData } = api.experiments.get.useQuery(
{ id: resolvedParams?.id ?? "" },
{ enabled: !!resolvedParams?.id },
);
const { data: trialsData } = api.trials.list.useQuery(
{ experimentId: resolvedParams?.id ?? "", limit: 10 },
{ enabled: !!resolvedParams?.id },
);
useEffect(() => {
if (experimentData) {
setExperiment(experimentData);
}
if (trialsData) {
setTrials(trialsData);
}
if (experimentData !== undefined) {
setLoading(false);
}
}, [experimentData, trialsData]);
// Set breadcrumbs
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Experiments", href: "/experiments" },
{ label: experiment?.name || "Experiment" },
]);
if (!session?.user) {
return notFound();
}
if (loading || !experiment) {
return <div>Loading...</div>;
}
const userRole = session.user.roles?.[0]?.role ?? "observer";
const canEdit = ["administrator", "researcher"].includes(userRole);
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)}%`
: "—",
};
return (
<EntityView>
{/* Header */}
<EntityViewHeader
title={experiment.name}
subtitle={experiment.description}
icon="FlaskConical"
status={{
label: statusInfo.label,
variant: statusInfo.variant,
icon: statusInfo.icon,
}}
actions={
canEdit ? (
<>
<Button asChild variant="outline">
<Link href={`/experiments/${experiment.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
<Button asChild variant="outline">
<Link href={`/experiments/${experiment.id}/designer`}>
<Settings className="mr-2 h-4 w-4" />
Designer
</Link>
</Button>
<Button asChild>
<Link href={`/trials/new?experimentId=${experiment.id}`}>
<Play className="mr-2 h-4 w-4" />
Start Trial
</Link>
</Button>
</>
) : (
<Button asChild>
<Link href={`/trials/new?experimentId=${experiment.id}`}>
<Play className="mr-2 h-4 w-4" />
Start Trial
</Link>
</Button>
)
}
/>
<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">
<InfoGrid
items={[
{
label: "Study",
value: experiment.study ? (
<Link
href={`/studies/${experiment.study.id}`}
className="text-primary hover:underline"
>
{experiment.study.name}
</Link>
) : (
"No study assigned"
),
},
{
label: "Robot Platform",
value: experiment.robot?.name || "Not specified",
},
{
label: "Created",
value: formatDistanceToNow(experiment.createdAt, {
addSuffix: true,
}),
},
{
label: "Last Updated",
value: formatDistanceToNow(experiment.updatedAt, {
addSuffix: true,
}),
},
]}
/>
</EntityViewSection>
{/* Protocol Overview */}
<EntityViewSection
title="Protocol Overview"
icon="Target"
actions={
canEdit && (
<Button asChild variant="outline" size="sm">
<Link href={`/experiments/${experiment.id}/designer`}>
<Edit className="mr-2 h-4 w-4" />
Edit Protocol
</Link>
</Button>
)
}
>
{experiment.protocol &&
typeof experiment.protocol === "object" &&
experiment.protocol !== null ? (
<div className="space-y-3">
<div className="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>
</div>
) : (
<EmptyState
icon="Target"
title="No Protocol Defined"
description="Use the experiment designer to create your protocol"
action={
canEdit && (
<Button asChild>
<Link href={`/experiments/${experiment.id}/designer`}>
Open Designer
</Link>
</Button>
)
}
/>
)}
</EntityViewSection>
{/* Recent Trials */}
<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>
</Button>
}
>
{trials.length > 0 ? (
<div className="space-y-3">
{trials.slice(0, 5).map((trial) => (
<div
key={trial.id}
className="hover:bg-muted/50 rounded-lg border p-4 transition-colors"
>
<div className="mb-2 flex items-center justify-between">
<Link
href={`/trials/${trial.id}`}
className="font-medium hover:underline"
>
Trial #{trial.id.slice(-6)}
</Link>
<Badge
variant={
trial.status === "completed"
? "default"
: trial.status === "in_progress"
? "secondary"
: trial.status === "failed"
? "destructive"
: "outline"
}
>
{trial.status.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"}
</span>
{trial.participant && (
<span className="flex items-center gap-1">
<Users className="h-4 w-4" />
{trial.participant.name ||
trial.participant.participantCode}
</span>
)}
</div>
</div>
))}
{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"
action={
<Button asChild>
<Link href={`/trials/new?experimentId=${experiment.id}`}>
Start First Trial
</Link>
</Button>
}
/>
)}
</EntityViewSection>
</div>
{/* Sidebar */}
<EntityViewSidebar>
{/* Quick Stats */}
<EntityViewSection title="Statistics" icon="BarChart3">
<StatsGrid
stats={[
{
label: "Total Trials",
value: mockStats.totalTrials,
},
{
label: "Completed",
value: mockStats.completedTrials,
color: "success",
},
{
label: "Success Rate",
value: mockStats.successRate,
color: "success",
},
{
label: "Avg. Duration",
value: mockStats.averageDuration,
},
]}
/>
</EntityViewSection>
{/* Robot Information */}
{experiment.robot && (
<EntityViewSection title="Robot Platform" icon="Bot">
<InfoGrid
columns={1}
items={[
{
label: "Platform",
value: experiment.robot.name,
},
{
label: "Type",
value: experiment.robot.type || "Not specified",
},
{
label: "Connection",
value: experiment.robot.connectionType || "Not configured",
},
]}
/>
</EntityViewSection>
)}
{/* Quick Actions */}
<EntityViewSection title="Quick Actions" icon="Settings">
<QuickActions
actions={[
{
label: "View All Trials",
icon: "Play",
href: `/trials?experimentId=${experiment.id}`,
},
{
label: "Export Data",
icon: "Share",
href: `/experiments/${experiment.id}/export`,
},
...(canEdit
? [
{
label: "Edit Experiment",
icon: "Edit",
href: `/experiments/${experiment.id}/edit`,
},
{
label: "Protocol Designer",
icon: "Settings",
href: `/experiments/${experiment.id}/designer`,
},
]
: []),
]}
/>
</EntityViewSection>
</EntityViewSidebar>
</div>
</EntityView>
);
}

View File

@@ -1,8 +1,11 @@
"use client";
import { formatDistanceToNow } from "date-fns";
import {
AlertCircle,
ArrowLeft,
Calendar,
CheckCircle,
Edit,
FileText,
Mail,
@@ -10,21 +13,26 @@ import {
Shield,
Trash2,
Users,
XCircle,
} from "lucide-react";
import Link from "next/link";
import { notFound } from "next/navigation";
import { useEffect, useState } from "react";
import { Alert, AlertDescription } from "~/components/ui/alert";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { auth } from "~/server/auth";
import { api } from "~/trpc/server";
EntityView,
EntityViewHeader,
EntityViewSection,
EntityViewSidebar,
EmptyState,
InfoGrid,
QuickActions,
} from "~/components/ui/entity-view";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { useSession } from "next-auth/react";
import { api } from "~/trpc/react";
interface ParticipantDetailPageProps {
params: Promise<{
@@ -32,415 +40,372 @@ interface ParticipantDetailPageProps {
}>;
}
export default async function ParticipantDetailPage({
export default function ParticipantDetailPage({
params,
}: ParticipantDetailPageProps) {
const resolvedParams = await params;
const session = await auth();
const { data: session } = useSession();
const [participant, setParticipant] = useState<any>(null);
const [trials, setTrials] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(
null,
);
useEffect(() => {
async function resolveParams() {
const resolved = await params;
setResolvedParams(resolved);
}
resolveParams();
}, [params]);
const { data: participantData } = api.participants.get.useQuery(
{ id: resolvedParams?.id ?? "" },
{ enabled: !!resolvedParams?.id },
);
const { data: trialsData } = api.trials.list.useQuery(
{ participantId: resolvedParams?.id ?? "", limit: 10 },
{ enabled: !!resolvedParams?.id },
);
useEffect(() => {
if (participantData) {
setParticipant(participantData);
}
if (trialsData) {
setTrials(trialsData);
}
if (participantData !== undefined) {
setLoading(false);
}
}, [participantData, trialsData]);
// Set breadcrumbs
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Participants", href: "/participants" },
{
label: participant?.name || participant?.participantCode || "Participant",
},
]);
if (!session?.user) {
return notFound();
}
try {
const participant = await api.participants.get({ id: resolvedParams.id });
if (loading || !participant) {
return <div>Loading...</div>;
}
if (!participant) {
return notFound();
}
const userRole = session.user.roles?.[0]?.role ?? "observer";
const canEdit = ["administrator", "researcher"].includes(userRole);
const userRole = session.user.roles?.[0]?.role ?? "observer";
const canEdit = ["administrator", "researcher"].includes(userRole);
// canDelete removed - not used in component
return (
<EntityView>
{/* Header */}
<EntityViewHeader
title={participant.name ?? participant.participantCode}
subtitle={
participant.name
? `Code: ${participant.participantCode}`
: "Participant"
}
icon="Users"
actions={
canEdit && (
<>
<Button variant="outline" asChild>
<Link href={`/participants/${resolvedParams.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
<Button variant="destructive" size="sm">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
</>
)
}
/>
// Get participant's trials
const trials = await api.trials.list({
participantId: resolvedParams.id,
limit: 10,
});
<div className="grid gap-6 lg:grid-cols-3">
{/* Main Content */}
<div className="space-y-6 lg:col-span-2">
{/* Participant Information */}
<EntityViewSection title="Participant Information" icon="FileText">
<InfoGrid
items={[
{
label: "Participant Code",
value: (
<code className="bg-muted rounded px-2 py-1 font-mono text-sm">
{participant.participantCode}
</code>
),
},
{
label: "Name",
value: participant.name || "Not provided",
},
{
label: "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"
>
{participant.email}
</a>
</div>
) : (
"Not provided"
),
},
{
label: "Study",
value: participant.study ? (
<Link
href={`/studies/${participant.study.id}`}
className="text-primary hover:underline"
>
{participant.study.name}
</Link>
) : (
"No study assigned"
),
},
]}
/>
return (
<div className="container mx-auto max-w-6xl px-4 py-8">
{/* Header */}
<div className="mb-8">
<div className="mb-4 flex items-center gap-4">
<Button variant="ghost" size="sm" asChild>
<Link href="/participants">
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Participants
</Link>
</Button>
</div>
{/* 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>
)}
<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">
<Users className="h-8 w-8" />
</div>
<div>
<h1 className="text-foreground text-3xl font-bold">
{participant.name ?? participant.participantCode}
</h1>
<p className="text-muted-foreground text-lg">
{participant.name
? `Code: ${participant.participantCode}`
: "Participant"}
</p>
</div>
</div>
{canEdit && (
<div className="flex gap-2">
<Button variant="outline" asChild>
<Link href={`/participants/${resolvedParams.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit
</Link>
</Button>
<Button variant="destructive" size="sm">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</Button>
{/* Notes */}
{participant.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">
{participant.notes}
</div>
</div>
)}
</div>
</div>
</EntityViewSection>
<div className="grid gap-6 lg:grid-cols-3">
{/* Main Content */}
<div className="space-y-6 lg:col-span-2">
{/* Participant Information */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<FileText className="h-5 w-5" />
Participant Information
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div>
<h4 className="text-muted-foreground text-sm font-medium">
Participant Code
</h4>
<p className="bg-muted rounded px-2 py-1 font-mono text-sm">
{participant.participantCode}
</p>
</div>
{participant.name && (
<div>
<h4 className="text-muted-foreground text-sm font-medium">
Name
</h4>
<p className="text-sm">{participant.name}</p>
</div>
)}
{participant.email && (
<div>
<h4 className="text-muted-foreground text-sm font-medium">
Email
</h4>
<p className="flex items-center gap-2 text-sm">
<Mail className="h-4 w-4" />
<a
href={`mailto:${participant.email}`}
className="hover:underline"
>
{participant.email}
</a>
</p>
</div>
)}
<div>
<h4 className="text-muted-foreground text-sm font-medium">
Study
</h4>
<p className="text-sm">
{/* Trial History */}
<EntityViewSection
title="Trial History"
icon="Play"
description="Experimental sessions for this participant"
actions={
canEdit && (
<Button size="sm" asChild>
<Link href={`/trials/new?participantId=${resolvedParams.id}`}>
Schedule Trial
</Link>
</Button>
)
}
>
{trials.length > 0 ? (
<div className="space-y-3">
{trials.map((trial) => (
<div
key={trial.id}
className="hover:bg-muted/50 rounded-lg border p-4 transition-colors"
>
<div className="mb-2 flex items-center justify-between">
<Link
href={`/studies/${participant.study?.id}`}
className="text-primary hover:underline"
href={`/trials/${trial.id}`}
className="font-medium hover:underline"
>
{participant.study?.name}
{trial.experiment?.name || "Trial"}
</Link>
</p>
</div>
</div>
{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-2 text-sm font-medium">
Demographics
</h4>
<div className="grid gap-4 md:grid-cols-2">
{(() => {
const demo = participant.demographics as Record<
string,
unknown
>;
return (
<>
{demo.age && (
<div>
<span className="text-sm font-medium">
Age:
</span>{" "}
<span className="text-sm">
{typeof demo.age === "number"
? demo.age.toString()
: String(demo.age)}
</span>
</div>
)}
{demo.gender && (
<div>
<span className="text-sm font-medium">
Gender:
</span>{" "}
<span className="text-sm">
{String(demo.gender)}
</span>
</div>
)}
</>
);
})()}
<Badge
variant={
trial.status === "completed"
? "default"
: trial.status === "in_progress"
? "secondary"
: trial.status === "failed"
? "destructive"
: "outline"
}
>
{trial.status.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"}
</span>
{trial.duration && (
<span>{Math.round(trial.duration / 60)} minutes</span>
)}
</div>
</div>
) : null}
{/* Notes */}
{participant.notes && (
<div className="border-t pt-4">
<h4 className="text-muted-foreground mb-2 text-sm font-medium">
Notes
</h4>
<p className="bg-muted rounded p-3 text-sm whitespace-pre-wrap">
{participant.notes}
</p>
</div>
)}
</CardContent>
</Card>
{/* Trial History */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Play className="h-5 w-5" />
Trial History
</CardTitle>
{canEdit && (
<Button size="sm" asChild>
))}
</div>
) : (
<EmptyState
icon="Play"
title="No Trials Yet"
description="This participant hasn't been assigned to any trials."
action={
canEdit && (
<Button asChild>
<Link
href={`/trials/new?participantId=${resolvedParams.id}`}
>
Schedule Trial
Schedule First Trial
</Link>
</Button>
)}
</div>
<CardDescription>
Experimental sessions for this participant
</CardDescription>
</CardHeader>
<CardContent>
{trials.length > 0 ? (
<div className="space-y-3">
{trials.map((trial) => (
<div
key={trial.id}
className="hover:bg-muted/50 rounded-lg border p-4 transition-colors"
>
<div className="mb-2 flex items-center justify-between">
<Link
href={`/trials/${trial.id}`}
className="font-medium hover:underline"
>
{trial.experiment?.name || "Trial"}
</Link>
<Badge
variant={
trial.status === "completed"
? "default"
: trial.status === "in_progress"
? "secondary"
: trial.status === "failed"
? "destructive"
: "outline"
}
>
{trial.status.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"}
</span>
{trial.duration && (
<span>
{Math.round(trial.duration / 60)} minutes
</span>
)}
</div>
</div>
))}
</div>
) : (
<div className="py-8 text-center">
<Play className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
<h3 className="mb-2 font-medium">No Trials Yet</h3>
<p className="text-muted-foreground mb-4 text-sm">
This participant hasn&apos;t been assigned to any trials.
</p>
{canEdit && (
<Button asChild>
<Link
href={`/trials/new?participantId=${resolvedParams.id}`}
>
Schedule First Trial
</Link>
</Button>
)}
</div>
)}
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Consent Status */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Shield className="h-4 w-4" />
Consent Status
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm">Informed Consent</span>
<Badge
variant={
participant.consentGiven ? "default" : "destructive"
}
>
{participant.consentGiven ? "Given" : "Not Given"}
</Badge>
</div>
{participant.consentDate && (
<div className="text-muted-foreground text-sm">
Consented:{" "}
{formatDistanceToNow(participant.consentDate, {
addSuffix: true,
})}
</div>
)}
{!participant.consentGiven && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-sm">
Consent required before trials can be conducted.
</AlertDescription>
</Alert>
)}
</div>
</CardContent>
</Card>
{/* Registration Details */}
<Card>
<CardHeader>
<CardTitle className="text-base">
Registration Details
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<h4 className="text-muted-foreground text-sm font-medium">
Registered
</h4>
<p className="text-sm">
{formatDistanceToNow(participant.createdAt, {
addSuffix: true,
})}
</p>
</div>
{participant.updatedAt &&
participant.updatedAt !== participant.createdAt && (
<div>
<h4 className="text-muted-foreground text-sm font-medium">
Last Updated
</h4>
<p className="text-sm">
{formatDistanceToNow(participant.updatedAt, {
addSuffix: true,
})}
</p>
</div>
)}
</CardContent>
</Card>
{/* Quick Actions */}
{canEdit && (
<Card>
<CardHeader>
<CardTitle className="text-base">Quick Actions</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<Button
variant="outline"
className="w-full justify-start"
asChild
>
<Link
href={`/trials/new?participantId=${resolvedParams.id}`}
>
<Play className="mr-2 h-4 w-4" />
Schedule Trial
</Link>
</Button>
<Button
variant="outline"
className="w-full justify-start"
asChild
>
<Link href={`/participants/${resolvedParams.id}/edit`}>
<Edit className="mr-2 h-4 w-4" />
Edit Information
</Link>
</Button>
<Button variant="outline" className="w-full justify-start">
<FileText className="mr-2 h-4 w-4" />
Export Data
</Button>
</CardContent>
</Card>
)
}
/>
)}
</div>
</EntityViewSection>
</div>
{/* Sidebar */}
<EntityViewSidebar>
{/* Consent Status */}
<EntityViewSection title="Consent Status" icon="Shield">
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm">Informed Consent</span>
<Badge
variant={participant.consentGiven ? "default" : "destructive"}
>
{participant.consentGiven ? (
<>
<CheckCircle className="mr-1 h-3 w-3" />
Given
</>
) : (
<>
<XCircle className="mr-1 h-3 w-3" />
Not Given
</>
)}
</Badge>
</div>
{participant.consentDate && (
<div className="text-muted-foreground text-sm">
Consented:{" "}
{formatDistanceToNow(participant.consentDate, {
addSuffix: true,
})}
</div>
)}
{!participant.consentGiven && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-sm">
Consent required before trials can be conducted.
</AlertDescription>
</Alert>
)}
</div>
</EntityViewSection>
{/* Registration Details */}
<EntityViewSection title="Registration Details" icon="Calendar">
<InfoGrid
columns={1}
items={[
{
label: "Registered",
value: formatDistanceToNow(participant.createdAt, {
addSuffix: true,
}),
},
...(participant.updatedAt &&
participant.updatedAt !== participant.createdAt
? [
{
label: "Last Updated",
value: formatDistanceToNow(participant.updatedAt, {
addSuffix: true,
}),
},
]
: []),
]}
/>
</EntityViewSection>
{/* Quick Actions */}
{canEdit && (
<EntityViewSection title="Quick Actions" icon="Edit">
<QuickActions
actions={[
{
label: "Schedule Trial",
icon: "Play",
href: `/trials/new?participantId=${resolvedParams.id}`,
},
{
label: "Edit Information",
icon: "Edit",
href: `/participants/${resolvedParams.id}/edit`,
},
{
label: "Export Data",
icon: "FileText",
href: `/participants/${resolvedParams.id}/export`,
},
]}
/>
</EntityViewSection>
)}
</EntityViewSidebar>
</div>
);
} catch {
return notFound();
}
</EntityView>
);
}

View File

@@ -1,27 +1,41 @@
"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 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 {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
EntityView,
EntityViewHeader,
EntityViewSection,
EntityViewSidebar,
EmptyState,
InfoGrid,
QuickActions,
StatsGrid,
} from "~/components/ui/entity-view";
import { Separator } from "~/components/ui/separator";
import { api } from "~/trpc/server";
import { useBreadcrumbsEffect } from "~/components/ui/breadcrumb-provider";
import { useSession } from "next-auth/react";
import { api } from "~/trpc/react";
interface StudyDetailPageProps {
params: Promise<{
@@ -32,326 +46,281 @@ interface StudyDetailPageProps {
const statusConfig = {
draft: {
label: "Draft",
className: "bg-gray-100 text-gray-800",
icon: "📝",
variant: "secondary" as const,
icon: "FileText" as const,
},
active: {
label: "Active",
className: "bg-green-100 text-green-800",
icon: "🟢",
variant: "default" as const,
icon: "CheckCircle" as const,
},
completed: {
label: "Completed",
className: "bg-blue-100 text-blue-800",
icon: "✅",
variant: "outline" as const,
icon: "CheckCircle" as const,
},
archived: {
label: "Archived",
className: "bg-orange-100 text-orange-800",
icon: "📦",
variant: "destructive" as const,
icon: "XCircle" as const,
},
};
export default async function StudyDetailPage({
params,
}: StudyDetailPageProps) {
try {
const resolvedParams = await params;
const study = await api.studies.get({ id: resolvedParams.id });
const members = await api.studies.getMembers({
studyId: resolvedParams.id,
});
export default function StudyDetailPage({ params }: StudyDetailPageProps) {
const { data: session } = useSession();
const [study, setStudy] = useState<any>(null);
const [members, setMembers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [resolvedParams, setResolvedParams] = useState<{ id: string } | null>(
null,
);
if (!study) {
notFound();
useEffect(() => {
async function resolveParams() {
const resolved = await params;
setResolvedParams(resolved);
}
resolveParams();
}, [params]);
const statusInfo = statusConfig[study.status];
const { data: studyData } = api.studies.get.useQuery(
{ id: resolvedParams?.id ?? "" },
{ enabled: !!resolvedParams?.id },
);
return (
<div className="p-8">
{/* Header */}
<div className="mb-8">
<div className="mb-4 flex items-center space-x-2 text-sm text-slate-600">
<Link href="/studies" className="hover:text-slate-900">
Studies
</Link>
<span>/</span>
<span className="text-slate-900">{study.name}</span>
</div>
const { data: membersData } = api.studies.getMembers.useQuery(
{ studyId: resolvedParams?.id ?? "" },
{ enabled: !!resolvedParams?.id },
);
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<div className="mb-2 flex items-center space-x-3">
<h1 className="truncate text-3xl font-bold text-slate-900">
{study.name}
</h1>
<Badge className={statusInfo.className} variant="secondary">
<span className="mr-1">{statusInfo.icon}</span>
{statusInfo.label}
</Badge>
</div>
<p className="text-lg text-slate-600">{study.description}</p>
</div>
useEffect(() => {
if (studyData) {
setStudy(studyData);
}
if (membersData) {
setMembers(membersData);
}
if (studyData !== undefined) {
setLoading(false);
}
}, [studyData, membersData]);
<div className="ml-4 flex items-center space-x-2">
<Button asChild variant="outline">
<Link href={`/studies/${study.id}/edit`}>
<Settings className="mr-2 h-4 w-4" />
Edit Study
</Link>
</Button>
<Button asChild>
<Link href={`/studies/${study.id}/experiments/new`}>
<Plus className="mr-2 h-4 w-4" />
New Experiment
</Link>
</Button>
</div>
</div>
</div>
// Set breadcrumbs
useBreadcrumbsEffect([
{ label: "Dashboard", href: "/dashboard" },
{ label: "Studies", href: "/studies" },
{ label: study?.name || "Study" },
]);
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
{/* Main Content */}
<div className="space-y-8 lg:col-span-2">
{/* Study Information */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<Building className="h-5 w-5" />
<span>Study Information</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<label className="text-sm font-medium text-slate-700">
Institution
</label>
<p className="text-slate-900">{study.institution}</p>
</div>
{study.irbProtocol && (
<div>
<label className="text-sm font-medium text-slate-700">
IRB Protocol
</label>
<p className="text-slate-900">{study.irbProtocol}</p>
</div>
)}
<div>
<label className="text-sm font-medium text-slate-700">
Created
</label>
<p className="text-slate-900">
{formatDistanceToNow(study.createdAt, {
addSuffix: true,
})}
</p>
</div>
<div>
<label className="text-sm font-medium text-slate-700">
Last Updated
</label>
<p className="text-slate-900">
{formatDistanceToNow(study.updatedAt, {
addSuffix: true,
})}
</p>
</div>
</div>
</CardContent>
</Card>
{/* Experiments */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center space-x-2">
<FlaskConical className="h-5 w-5" />
<span>Experiments</span>
</CardTitle>
<Button asChild variant="outline" size="sm">
<Link href={`/studies/${study.id}/experiments/new`}>
<Plus className="mr-2 h-4 w-4" />
Add Experiment
</Link>
</Button>
</div>
<CardDescription>
Design and manage experimental protocols for this study
</CardDescription>
</CardHeader>
<CardContent>
{/* Placeholder for experiments list */}
<div className="py-8 text-center">
<FlaskConical className="mx-auto mb-4 h-12 w-12 text-slate-400" />
<h3 className="mb-2 text-lg font-semibold text-slate-900">
No Experiments Yet
</h3>
<p className="mb-4 text-slate-600">
Create your first experiment to start designing research
protocols
</p>
<Button asChild>
<Link href={`/studies/${study.id}/experiments/new`}>
Create First Experiment
</Link>
</Button>
</div>
</CardContent>
</Card>
{/* Recent Activity */}
<Card>
<CardHeader>
<CardTitle className="flex items-center space-x-2">
<BarChart3 className="h-5 w-5" />
<span>Recent Activity</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="py-8 text-center">
<Calendar className="mx-auto mb-4 h-12 w-12 text-slate-400" />
<h3 className="mb-2 text-lg font-semibold text-slate-900">
No Recent Activity
</h3>
<p className="text-slate-600">
Activity will appear here once you start working on this
study
</p>
</div>
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Team Members */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center space-x-2">
<Users className="h-5 w-5" />
<span>Team</span>
</CardTitle>
<Button variant="outline" size="sm">
<Plus className="mr-2 h-4 w-4" />
Invite
</Button>
</div>
<CardDescription>
{members.length} team member{members.length !== 1 ? "s" : ""}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{members.map((member) => (
<div
key={member.user.id}
className="flex items-center space-x-3"
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-100">
<span className="text-sm font-medium text-blue-600">
{(member.user.name ?? member.user.email)
.charAt(0)
.toUpperCase()}
</span>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-slate-900">
{member.user.name ?? member.user.email}
</p>
<p className="text-xs text-slate-500 capitalize">
{member.role}
</p>
</div>
{member.role === "owner" && (
<Shield className="h-4 w-4 text-amber-600" />
)}
</div>
))}
</div>
</CardContent>
</Card>
{/* Quick Stats */}
<Card>
<CardHeader>
<CardTitle>Quick Stats</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-sm text-slate-600">Experiments:</span>
<span className="font-medium">0</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-slate-600">
Total Trials:
</span>
<span className="font-medium">0</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-slate-600">
Participants:
</span>
<span className="font-medium">0</span>
</div>
<Separator />
<div className="flex justify-between">
<span className="text-sm text-slate-600">
Completion Rate:
</span>
<span className="font-medium text-green-600"></span>
</div>
</div>
</CardContent>
</Card>
{/* Quick Actions */}
<Card>
<CardHeader>
<CardTitle>Quick Actions</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<Button
asChild
variant="outline"
className="w-full justify-start"
>
<Link href={`/studies/${study.id}/participants`}>
<Users className="mr-2 h-4 w-4" />
Manage Participants
</Link>
</Button>
<Button
asChild
variant="outline"
className="w-full justify-start"
>
<Link href={`/studies/${study.id}/trials`}>
<Calendar className="mr-2 h-4 w-4" />
Schedule Trials
</Link>
</Button>
<Button
asChild
variant="outline"
className="w-full justify-start"
>
<Link href={`/studies/${study.id}/analytics`}>
<BarChart3 className="mr-2 h-4 w-4" />
View Analytics
</Link>
</Button>
</CardContent>
</Card>
</div>
</div>
</div>
);
} catch (error) {
console.error("Error loading study:", error);
notFound();
if (!session?.user) {
return notFound();
}
if (loading || !study) {
return <div>Loading...</div>;
}
const statusInfo = statusConfig[study.status];
// TODO: Get actual stats from API
const mockStats = {
experiments: 0,
totalTrials: 0,
participants: 0,
completionRate: "—",
};
return (
<EntityView>
{/* Header */}
<EntityViewHeader
title={study.name}
subtitle={study.description}
icon="Building"
status={{
label: statusInfo.label,
variant: statusInfo.variant,
icon: statusInfo.icon,
}}
actions={
<>
<Button asChild variant="outline">
<Link href={`/studies/${study.id}/edit`}>
<Settings className="mr-2 h-4 w-4" />
Edit Study
</Link>
</Button>
<Button asChild>
<Link href={`/experiments/new?studyId=${study.id}`}>
<Plus className="mr-2 h-4 w-4" />
New Experiment
</Link>
</Button>
</>
}
/>
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
{/* Main Content */}
<div className="space-y-8 lg:col-span-2">
{/* Study Information */}
<EntityViewSection title="Study Information" icon="Building">
<InfoGrid
items={[
{
label: "Institution",
value: study.institution,
},
{
label: "IRB Protocol",
value: study.irbProtocol || "Not specified",
},
{
label: "Created",
value: formatDistanceToNow(study.createdAt, {
addSuffix: true,
}),
},
{
label: "Last Updated",
value: formatDistanceToNow(study.updatedAt, {
addSuffix: true,
}),
},
]}
/>
</EntityViewSection>
{/* Experiments */}
<EntityViewSection
title="Experiments"
icon="FlaskConical"
description="Design and manage experimental protocols for this study"
actions={
<Button asChild variant="outline" size="sm">
<Link href={`/experiments/new?studyId=${study.id}`}>
<Plus className="mr-2 h-4 w-4" />
Add Experiment
</Link>
</Button>
}
>
<EmptyState
icon="FlaskConical"
title="No Experiments Yet"
description="Create your first experiment to start designing research protocols"
action={
<Button asChild>
<Link href={`/experiments/new?studyId=${study.id}`}>
Create First Experiment
</Link>
</Button>
}
/>
</EntityViewSection>
{/* Recent Activity */}
<EntityViewSection title="Recent Activity" icon="BarChart3">
<EmptyState
icon="Calendar"
title="No Recent Activity"
description="Activity will appear here once you start working on this study"
/>
</EntityViewSection>
</div>
{/* Sidebar */}
<EntityViewSidebar>
{/* Team Members */}
<EntityViewSection
title="Team"
icon="Users"
description={`${members.length} team member${members.length !== 1 ? "s" : ""}`}
actions={
<Button variant="outline" size="sm">
<Plus className="mr-2 h-4 w-4" />
Invite
</Button>
}
>
<div className="space-y-3">
{members.map((member) => (
<div
key={member.user.id}
className="flex items-center space-x-3"
>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-blue-100">
<span className="text-sm font-medium text-blue-600">
{(member.user.name ?? member.user.email)
.charAt(0)
.toUpperCase()}
</span>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{member.user.name ?? member.user.email}
</p>
<p className="text-muted-foreground text-xs capitalize">
{member.role}
</p>
</div>
{member.role === "owner" && (
<Shield className="h-4 w-4 text-amber-600" />
)}
</div>
))}
</div>
</EntityViewSection>
{/* Quick Stats */}
<EntityViewSection title="Quick Stats" icon="BarChart3">
<StatsGrid
stats={[
{
label: "Experiments",
value: mockStats.experiments,
},
{
label: "Total Trials",
value: mockStats.totalTrials,
},
{
label: "Participants",
value: mockStats.participants,
},
{
label: "Completion Rate",
value: mockStats.completionRate,
color: "success",
},
]}
/>
</EntityViewSection>
{/* Quick Actions */}
<EntityViewSection title="Quick Actions" icon="Settings">
<QuickActions
actions={[
{
label: "Manage Participants",
icon: "Users",
href: `/participants?studyId=${study.id}`,
},
{
label: "Schedule Trials",
icon: "Calendar",
href: `/trials?studyId=${study.id}`,
},
{
label: "View Analytics",
icon: "BarChart3",
href: `/analytics?studyId=${study.id}`,
},
]}
/>
</EntityViewSection>
</EntityViewSidebar>
</div>
</EntityView>
);
}

File diff suppressed because it is too large Load Diff

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"
}
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>
)
);
}

View File

@@ -128,23 +128,24 @@ export const trialsRouter = createTRPCRouter({
id: participants.id,
participantCode: participants.participantCode,
},
userRole: studyMembers.role,
})
.from(trials)
.innerJoin(experiments, eq(trials.experimentId, experiments.id))
.innerJoin(participants, eq(trials.participantId, participants.id))
.innerJoin(studyMembers, eq(studyMembers.studyId, experiments.studyId))
.where(
and(
eq(studyMembers.userId, userId),
inArray(studyMembers.role, ["owner", "researcher", "wizard"]),
...conditions,
),
)
.where(and(eq(studyMembers.userId, userId), ...conditions))
.orderBy(desc(trials.createdAt))
.limit(input.limit)
.offset(input.offset);
return await query;
const results = await query;
// Add permission flags for each trial
return results.map((trial) => ({
...trial,
canAccess: ["owner", "researcher", "wizard"].includes(trial.userRole),
}));
}),
get: protectedProcedure
@@ -575,15 +576,19 @@ export const trialsRouter = createTRPCRouter({
const offset = (page - 1) * limit;
const userId = ctx.session.user.id;
// Get all studies user is a member of
// Get all studies user is a member of with roles
const userStudies = await ctx.db.query.studyMembers.findMany({
where: eq(studyMembers.userId, userId),
columns: {
studyId: true,
role: true,
},
});
let studyIds = userStudies.map((membership) => membership.studyId);
const userStudyRoles = new Map(
userStudies.map((membership) => [membership.studyId, membership.role]),
);
// If studyId is provided, filter to just that study (if user has access)
if (studyId) {
@@ -704,14 +709,22 @@ export const trialsRouter = createTRPCRouter({
const totalCount = totalCountResult[0]?.count ?? 0;
// Transform data to include counts
const transformedTrials = filteredTrials.map((trial) => ({
...trial,
_count: {
events: trial.events?.length ?? 0,
mediaCaptures: trial.mediaCaptures?.length ?? 0,
},
}));
// Transform data to include counts and permission information
const transformedTrials = filteredTrials.map((trial) => {
const userRole = userStudyRoles.get(trial.experiment.studyId);
const canAccess =
userRole && ["owner", "researcher", "wizard"].includes(userRole);
return {
...trial,
_count: {
events: trial.events?.length ?? 0,
mediaCaptures: trial.mediaCaptures?.length ?? 0,
},
userRole,
canAccess,
};
});
return {
trials: transformedTrials,

View File

@@ -4,7 +4,8 @@
@custom-variant dark (&:is(.dark *));
@theme {
--font-sans: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif,
--font-sans:
var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
}
@@ -44,75 +45,188 @@
--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);
--tracking-normal: var(--tracking-normal);
--shadow-2xl: var(--shadow-2xl);
--shadow-xl: var(--shadow-xl);
--shadow-lg: var(--shadow-lg);
--shadow-md: var(--shadow-md);
--shadow: var(--shadow);
--shadow-sm: var(--shadow-sm);
--shadow-xs: var(--shadow-xs);
--shadow-2xs: var(--shadow-2xs);
--spacing: var(--spacing);
--letter-spacing: var(--letter-spacing);
--shadow-offset-y: var(--shadow-offset-y);
--shadow-offset-x: var(--shadow-offset-x);
--shadow-spread: var(--shadow-spread);
--shadow-blur: var(--shadow-blur);
--shadow-opacity: var(--shadow-opacity);
--color-shadow-color: var(--shadow-color);
--color-destructive-foreground: var(--destructive-foreground);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--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);
--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);
--tracking-normal:
0em;
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
@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;
}
}
@layer base {
@@ -121,5 +235,114 @@
}
body {
@apply bg-background text-foreground;
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);
}