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 - Follow the established file naming: PascalCase for components, camelCase for utilities
- Use Server Components by default, minimize 'use client' directives - Use Server Components by default, minimize 'use client' directives
- Implement proper loading states and error boundaries for all async operations - 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 ### TypeScript Standards
- 100% type safety - NO `any` types allowed in production code - 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 - Minimize database queries with efficient joins and proper indexing
- Follow WCAG 2.1 AA accessibility standards throughout - 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 ## Development Commands
- `bun install` - Install dependencies - `bun install` - Install dependencies
- `bun dev` - Start development server (ONLY when actively developing) - `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": { "ct3aMetadata": {
"initVersion": "7.39.3" "initVersion": "7.39.3"
} },
"trustedDependencies": [
"@tailwindcss/oxide",
"unrs-resolver"
]
} }

View File

@@ -233,8 +233,8 @@ async function main() {
}, },
{ {
studyId: study1.id, studyId: study1.id,
userId: seanUser.id, // Sean (observer) userId: seanUser.id, // Sean (researcher)
role: "observer" as const, role: "researcher" as const,
joinedAt: new Date(), 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 { formatDistanceToNow } from "date-fns";
import { import {
AlertCircle, AlertCircle,
ArrowLeft, ArrowLeft,
Calendar, Calendar,
CheckCircle,
Edit, Edit,
FileText, FileText,
Mail, Mail,
@@ -10,21 +13,26 @@ import {
Shield, Shield,
Trash2, Trash2,
Users, Users,
XCircle,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { useEffect, useState } from "react";
import { Alert, AlertDescription } from "~/components/ui/alert"; import { Alert, AlertDescription } from "~/components/ui/alert";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { import {
Card, EntityView,
CardContent, EntityViewHeader,
CardDescription, EntityViewSection,
CardHeader, EntityViewSidebar,
CardTitle, EmptyState,
} from "~/components/ui/card"; InfoGrid,
import { auth } from "~/server/auth"; QuickActions,
import { api } from "~/trpc/server"; } 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 { interface ParticipantDetailPageProps {
params: Promise<{ params: Promise<{
@@ -32,65 +40,81 @@ interface ParticipantDetailPageProps {
}>; }>;
} }
export default async function ParticipantDetailPage({ export default function ParticipantDetailPage({
params, params,
}: ParticipantDetailPageProps) { }: ParticipantDetailPageProps) {
const resolvedParams = await params; const { data: session } = useSession();
const session = await auth(); 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) { if (!session?.user) {
return notFound(); return notFound();
} }
try { if (loading || !participant) {
const participant = await api.participants.get({ id: resolvedParams.id }); return <div>Loading...</div>;
if (!participant) {
return notFound();
} }
const userRole = session.user.roles?.[0]?.role ?? "observer"; const userRole = session.user.roles?.[0]?.role ?? "observer";
const canEdit = ["administrator", "researcher"].includes(userRole); const canEdit = ["administrator", "researcher"].includes(userRole);
// canDelete removed - not used in component
// Get participant's trials
const trials = await api.trials.list({
participantId: resolvedParams.id,
limit: 10,
});
return ( return (
<div className="container mx-auto max-w-6xl px-4 py-8"> <EntityView>
{/* Header */} {/* Header */}
<div className="mb-8"> <EntityViewHeader
<div className="mb-4 flex items-center gap-4"> title={participant.name ?? participant.participantCode}
<Button variant="ghost" size="sm" asChild> subtitle={
<Link href="/participants"> participant.name
<ArrowLeft className="mr-2 h-4 w-4" />
Back to Participants
</Link>
</Button>
</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}` ? `Code: ${participant.participantCode}`
: "Participant"} : "Participant"
</p> }
</div> icon="Users"
</div> actions={
canEdit && (
{canEdit && ( <>
<div className="flex gap-2">
<Button variant="outline" asChild> <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 className="mr-2 h-4 w-4" />
@@ -101,48 +125,34 @@ export default async function ParticipantDetailPage({
<Trash2 className="mr-2 h-4 w-4" /> <Trash2 className="mr-2 h-4 w-4" />
Delete Delete
</Button> </Button>
</div> </>
)} )
</div> }
</div> />
<div className="grid gap-6 lg:grid-cols-3"> <div className="grid gap-6 lg:grid-cols-3">
{/* Main Content */} {/* Main Content */}
<div className="space-y-6 lg:col-span-2"> <div className="space-y-6 lg:col-span-2">
{/* Participant Information */} {/* Participant Information */}
<Card> <EntityViewSection title="Participant Information" icon="FileText">
<CardHeader> <InfoGrid
<CardTitle className="flex items-center gap-2"> items={[
<FileText className="h-5 w-5" /> {
Participant Information label: "Participant Code",
</CardTitle> value: (
</CardHeader> <code className="bg-muted rounded px-2 py-1 font-mono text-sm">
<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} {participant.participantCode}
</p> </code>
</div> ),
},
{participant.name && ( {
<div> label: "Name",
<h4 className="text-muted-foreground text-sm font-medium"> value: participant.name || "Not provided",
Name },
</h4> {
<p className="text-sm">{participant.name}</p> label: "Email",
</div> value: participant.email ? (
)} <div className="flex items-center gap-2">
{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" /> <Mail className="h-4 w-4" />
<a <a
href={`mailto:${participant.email}`} href={`mailto:${participant.email}`}
@@ -150,69 +160,62 @@ export default async function ParticipantDetailPage({
> >
{participant.email} {participant.email}
</a> </a>
</p>
</div> </div>
)} ) : (
"Not provided"
<div> ),
<h4 className="text-muted-foreground text-sm font-medium"> },
Study {
</h4> label: "Study",
<p className="text-sm"> value: participant.study ? (
<Link <Link
href={`/studies/${participant.study?.id}`} href={`/studies/${participant.study.id}`}
className="text-primary hover:underline" className="text-primary hover:underline"
> >
{participant.study?.name} {participant.study.name}
</Link> </Link>
</p> ) : (
</div> "No study assigned"
</div> ),
},
]}
/>
{/* Demographics */}
{participant.demographics && {participant.demographics &&
typeof participant.demographics === "object" && typeof participant.demographics === "object" &&
participant.demographics !== null && participant.demographics !== null &&
Object.keys(participant.demographics).length > 0 ? ( Object.keys(participant.demographics).length > 0 && (
<div className="border-t pt-4"> <div className="border-t pt-4">
<h4 className="text-muted-foreground mb-2 text-sm font-medium"> <h4 className="text-muted-foreground mb-3 text-sm font-medium">
Demographics Demographics
</h4> </h4>
<div className="grid gap-4 md:grid-cols-2"> <InfoGrid
{(() => { items={(() => {
const demo = participant.demographics as Record< const demo = participant.demographics as Record<
string, string,
unknown unknown
>; >;
return ( return [
<> demo.age && {
{demo.age && ( label: "Age",
<div> value:
<span className="text-sm font-medium"> typeof demo.age === "number"
Age:
</span>{" "}
<span className="text-sm">
{typeof demo.age === "number"
? demo.age.toString() ? demo.age.toString()
: String(demo.age)} : String(demo.age),
</span> },
</div> demo.gender && {
)} label: "Gender",
{demo.gender && ( value: String(demo.gender),
<div> },
<span className="text-sm font-medium"> ].filter(Boolean) as Array<{
Gender: label: string;
</span>{" "} value: string;
<span className="text-sm"> }>;
{String(demo.gender)}
</span>
</div>
)}
</>
);
})()} })()}
/>
</div> </div>
</div> )}
) : null}
{/* Notes */} {/* Notes */}
{participant.notes && ( {participant.notes && (
@@ -220,37 +223,28 @@ export default async function ParticipantDetailPage({
<h4 className="text-muted-foreground mb-2 text-sm font-medium"> <h4 className="text-muted-foreground mb-2 text-sm font-medium">
Notes Notes
</h4> </h4>
<p className="bg-muted rounded p-3 text-sm whitespace-pre-wrap"> <div className="bg-muted rounded p-3 text-sm whitespace-pre-wrap">
{participant.notes} {participant.notes}
</p> </div>
</div> </div>
)} )}
</CardContent> </EntityViewSection>
</Card>
{/* Trial History */} {/* Trial History */}
<Card> <EntityViewSection
<CardHeader> title="Trial History"
<div className="flex items-center justify-between"> icon="Play"
<CardTitle className="flex items-center gap-2"> description="Experimental sessions for this participant"
<Play className="h-5 w-5" /> actions={
Trial History canEdit && (
</CardTitle>
{canEdit && (
<Button size="sm" asChild> <Button size="sm" asChild>
<Link <Link href={`/trials/new?participantId=${resolvedParams.id}`}>
href={`/trials/new?participantId=${resolvedParams.id}`}
>
Schedule Trial Schedule Trial
</Link> </Link>
</Button> </Button>
)} )
</div> }
<CardDescription> >
Experimental sessions for this participant
</CardDescription>
</CardHeader>
<CardContent>
{trials.length > 0 ? ( {trials.length > 0 ? (
<div className="space-y-3"> <div className="space-y-3">
{trials.map((trial) => ( {trials.map((trial) => (
@@ -289,22 +283,19 @@ export default async function ParticipantDetailPage({
: "Not scheduled"} : "Not scheduled"}
</span> </span>
{trial.duration && ( {trial.duration && (
<span> <span>{Math.round(trial.duration / 60)} minutes</span>
{Math.round(trial.duration / 60)} minutes
</span>
)} )}
</div> </div>
</div> </div>
))} ))}
</div> </div>
) : ( ) : (
<div className="py-8 text-center"> <EmptyState
<Play className="text-muted-foreground mx-auto mb-4 h-12 w-12" /> icon="Play"
<h3 className="mb-2 font-medium">No Trials Yet</h3> title="No Trials Yet"
<p className="text-muted-foreground mb-4 text-sm"> description="This participant hasn't been assigned to any trials."
This participant hasn&apos;t been assigned to any trials. action={
</p> canEdit && (
{canEdit && (
<Button asChild> <Button asChild>
<Link <Link
href={`/trials/new?participantId=${resolvedParams.id}`} href={`/trials/new?participantId=${resolvedParams.id}`}
@@ -312,33 +303,34 @@ export default async function ParticipantDetailPage({
Schedule First Trial Schedule First Trial
</Link> </Link>
</Button> </Button>
)
}
/>
)} )}
</div> </EntityViewSection>
)}
</CardContent>
</Card>
</div> </div>
{/* Sidebar */} {/* Sidebar */}
<div className="space-y-6"> <EntityViewSidebar>
{/* Consent Status */} {/* Consent Status */}
<Card> <EntityViewSection title="Consent Status" icon="Shield">
<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="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm">Informed Consent</span> <span className="text-sm">Informed Consent</span>
<Badge <Badge
variant={ variant={participant.consentGiven ? "default" : "destructive"}
participant.consentGiven ? "default" : "destructive"
}
> >
{participant.consentGiven ? "Given" : "Not Given"} {participant.consentGiven ? (
<>
<CheckCircle className="mr-1 h-3 w-3" />
Given
</>
) : (
<>
<XCircle className="mr-1 h-3 w-3" />
Not Given
</>
)}
</Badge> </Badge>
</div> </div>
@@ -360,87 +352,60 @@ export default async function ParticipantDetailPage({
</Alert> </Alert>
)} )}
</div> </div>
</CardContent> </EntityViewSection>
</Card>
{/* Registration Details */} {/* Registration Details */}
<Card> <EntityViewSection title="Registration Details" icon="Calendar">
<CardHeader> <InfoGrid
<CardTitle className="text-base"> columns={1}
Registration Details items={[
</CardTitle> {
</CardHeader> label: "Registered",
<CardContent className="space-y-3"> value: formatDistanceToNow(participant.createdAt, {
<div>
<h4 className="text-muted-foreground text-sm font-medium">
Registered
</h4>
<p className="text-sm">
{formatDistanceToNow(participant.createdAt, {
addSuffix: true, addSuffix: true,
})} }),
</p> },
</div> ...(participant.updatedAt &&
participant.updatedAt !== participant.createdAt
{participant.updatedAt && ? [
participant.updatedAt !== participant.createdAt && ( {
<div> label: "Last Updated",
<h4 className="text-muted-foreground text-sm font-medium"> value: formatDistanceToNow(participant.updatedAt, {
Last Updated
</h4>
<p className="text-sm">
{formatDistanceToNow(participant.updatedAt, {
addSuffix: true, addSuffix: true,
})} }),
</p> },
</div> ]
)} : []),
</CardContent> ]}
</Card> />
</EntityViewSection>
{/* Quick Actions */} {/* Quick Actions */}
{canEdit && ( {canEdit && (
<Card> <EntityViewSection title="Quick Actions" icon="Edit">
<CardHeader> <QuickActions
<CardTitle className="text-base">Quick Actions</CardTitle> actions={[
</CardHeader> {
<CardContent className="space-y-2"> label: "Schedule Trial",
<Button icon: "Play",
variant="outline" href: `/trials/new?participantId=${resolvedParams.id}`,
className="w-full justify-start" },
asChild {
> label: "Edit Information",
<Link icon: "Edit",
href={`/trials/new?participantId=${resolvedParams.id}`} href: `/participants/${resolvedParams.id}/edit`,
> },
<Play className="mr-2 h-4 w-4" /> {
Schedule Trial label: "Export Data",
</Link> icon: "FileText",
</Button> href: `/participants/${resolvedParams.id}/export`,
},
<Button ]}
variant="outline" />
className="w-full justify-start" </EntityViewSection>
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>
)} )}
</EntityViewSidebar>
</div> </div>
</div> </EntityView>
</div>
); );
} catch {
return notFound();
}
} }

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -13,7 +13,6 @@ import {
LogOut, LogOut,
MoreHorizontal, MoreHorizontal,
Settings, Settings,
User,
Users, Users,
UserCheck, UserCheck,
TestTube, TestTube,
@@ -40,6 +39,7 @@ import {
SidebarMenuItem, SidebarMenuItem,
SidebarRail, SidebarRail,
} from "~/components/ui/sidebar"; } from "~/components/ui/sidebar";
import { Avatar, AvatarImage, AvatarFallback } from "~/components/ui/avatar";
import { Logo } from "~/components/ui/logo"; import { Logo } from "~/components/ui/logo";
import { useStudyManagement } from "~/hooks/useStudyManagement"; import { useStudyManagement } from "~/hooks/useStudyManagement";
@@ -292,18 +292,60 @@ export function AppSidebar({
<SidebarMenuItem> <SidebarMenuItem>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<SidebarMenuButton size="lg"> <SidebarMenuButton
<User className="h-4 w-4" /> size="lg"
<span>{session?.user?.name ?? "User"}</span> 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"
<MoreHorizontal className="ml-auto h-4 w-4" /> >
<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> </SidebarMenuButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
className="w-[--radix-popper-anchor-width]" className="w-[--radix-popper-anchor-width] min-w-56 rounded-lg"
side="bottom"
align="end" align="end"
sideOffset={4}
> >
<DropdownMenuLabel> <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"} {session?.user?.name ?? "User"}
</span>
<span className="truncate text-xs">
{session?.user?.email ?? ""}
</span>
</div>
</div>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem asChild> <DropdownMenuItem asChild>

View File

@@ -70,6 +70,7 @@ export type Trial = {
logs: number; logs: number;
}; };
userRole?: "owner" | "researcher" | "wizard" | "observer"; userRole?: "owner" | "researcher" | "wizard" | "observer";
canAccess?: boolean;
canEdit?: boolean; canEdit?: boolean;
canDelete?: boolean; canDelete?: boolean;
canExecute?: boolean; canExecute?: boolean;
@@ -162,12 +163,19 @@ function TrialActionsCell({ trial }: { trial: Trial }) {
<DropdownMenuLabel>Actions</DropdownMenuLabel> <DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{trial.canAccess ? (
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
<Link href={`/trials/${trial.id}`}> <Link href={`/trials/${trial.id}`}>
<Eye className="mr-2 h-4 w-4" /> <Eye className="mr-2 h-4 w-4" />
View Details View Details
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
) : (
<DropdownMenuItem disabled>
<Eye className="mr-2 h-4 w-4" />
View Details (Restricted)
</DropdownMenuItem>
)}
{trial.canEdit && ( {trial.canEdit && (
<DropdownMenuItem asChild> <DropdownMenuItem asChild>
@@ -272,6 +280,8 @@ export const trialsColumns: ColumnDef<Trial>[] = [
const trial = row.original; const trial = row.original;
return ( return (
<div className="max-w-[140px] min-w-0"> <div className="max-w-[140px] min-w-0">
<div className="flex items-center gap-2">
{trial.canAccess ? (
<Link <Link
href={`/trials/${trial.id}`} href={`/trials/${trial.id}`}
className="block truncate font-medium hover:underline" className="block truncate font-medium hover:underline"
@@ -279,6 +289,24 @@ export const trialsColumns: ColumnDef<Trial>[] = [
> >
{trial.name} {trial.name}
</Link> </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> </div>
); );
}, },
@@ -290,9 +318,11 @@ export const trialsColumns: ColumnDef<Trial>[] = [
), ),
cell: ({ row }) => { cell: ({ row }) => {
const status = row.getValue("status") as Trial["status"]; const status = row.getValue("status") as Trial["status"];
const trial = row.original;
const config = statusConfig[status]; const config = statusConfig[status];
return ( return (
<div className="flex flex-col gap-1">
<Badge <Badge
variant="secondary" variant="secondary"
className={`${config.className} whitespace-nowrap`} className={`${config.className} whitespace-nowrap`}
@@ -300,6 +330,16 @@ export const trialsColumns: ColumnDef<Trial>[] = [
> >
{config.label} {config.label}
</Badge> </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[]) => { filterFn: (row, id, value: string[]) => {

View File

@@ -1,7 +1,7 @@
"use client"; "use client";
import React from "react"; import React from "react";
import { Plus, TestTube } from "lucide-react"; import { Plus, TestTube, Eye } from "lucide-react";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { DataTable } from "~/components/ui/data-table"; import { DataTable } from "~/components/ui/data-table";
@@ -111,14 +111,19 @@ export function TrialsDataTable() {
actions: trial._count?.events ?? 0, actions: trial._count?.events ?? 0,
logs: trial._count?.mediaCaptures ?? 0, logs: trial._count?.mediaCaptures ?? 0,
}, },
userRole: undefined, userRole: trial.userRole,
canEdit: trial.status === "scheduled" || trial.status === "aborted", canAccess: trial.canAccess ?? false,
canEdit:
trial.canAccess &&
(trial.status === "scheduled" || trial.status === "aborted"),
canDelete: canDelete:
trial.status === "scheduled" || trial.canAccess &&
(trial.status === "scheduled" ||
trial.status === "aborted" || trial.status === "aborted" ||
trial.status === "failed", trial.status === "failed"),
canExecute: canExecute:
trial.status === "scheduled" || trial.status === "in_progress", trial.canAccess &&
(trial.status === "scheduled" || trial.status === "in_progress"),
})); }));
}, [trialsData]); }, [trialsData]);
@@ -204,6 +209,29 @@ export function TrialsDataTable() {
/> />
<div className="space-y-4"> <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 <DataTable
columns={trialsColumns} columns={trialsColumns}
data={filteredTrials} 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 { Bot } from "lucide-react";
import { cn } from "~/lib/utils" import { cn } from "~/lib/utils";
interface LogoProps { interface LogoProps {
className?: string className?: string;
iconSize?: "sm" | "md" | "lg" iconSize?: "sm" | "md" | "lg";
showText?: boolean showText?: boolean;
} }
const iconSizes = { const iconSizes = {
sm: "h-4 w-4", sm: "h-4 w-4",
md: "h-6 w-6", 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 ( return (
<div className={cn("flex items-center gap-2", className)}> <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]} /> <Bot className={iconSizes[iconSize]} />
</div> </div>
{showText && ( {showText && (
<div className="grid flex-1 text-left text-sm leading-tight"> <div className="grid flex-1 text-left text-sm leading-tight">
<div className="flex items-baseline gap-0"> <div className="flex items-baseline gap-0">
<span className="text-base font-extrabold tracking-tight">HRI</span> <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> </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>
)} )}
</div> </div>
) );
} }

View File

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

View File

@@ -4,7 +4,8 @@
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
@theme { @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"; "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-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border); --color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring); --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 { :root {
--radius: 0.625rem; --radius:
--background: oklch(1 0 0); 0rem;
--foreground: oklch(0.145 0 0); --background:
--card: oklch(1 0 0); oklch(1.0000 0 0);
--card-foreground: oklch(0.145 0 0); --foreground:
--popover: oklch(1 0 0); oklch(0.1448 0 0);
--popover-foreground: oklch(0.145 0 0); --card:
--primary: oklch(0.205 0 0); oklch(1.0000 0 0);
--primary-foreground: oklch(0.985 0 0); --card-foreground:
--secondary: oklch(0.97 0 0); oklch(0.1448 0 0);
--secondary-foreground: oklch(0.205 0 0); --popover:
--muted: oklch(0.97 0 0); oklch(1.0000 0 0);
--muted-foreground: oklch(0.556 0 0); --popover-foreground:
--accent: oklch(0.97 0 0); oklch(0.1448 0 0);
--accent-foreground: oklch(0.205 0 0); --primary:
--destructive: oklch(0.577 0.245 27.325); oklch(0.5555 0 0);
--border: oklch(0.922 0 0); --primary-foreground:
--input: oklch(0.922 0 0); oklch(0.9851 0 0);
--ring: oklch(0.708 0 0); --secondary:
--chart-1: oklch(0.646 0.222 41.116); oklch(0.9702 0 0);
--chart-2: oklch(0.6 0.118 184.704); --secondary-foreground:
--chart-3: oklch(0.398 0.07 227.392); oklch(0.2046 0 0);
--chart-4: oklch(0.828 0.189 84.429); --muted:
--chart-5: oklch(0.769 0.188 70.08); oklch(0.9702 0 0);
--sidebar: oklch(0.985 0 0); --muted-foreground:
--sidebar-foreground: oklch(0.145 0 0); oklch(0.5486 0 0);
--sidebar-primary: oklch(0.205 0 0); --accent:
--sidebar-primary-foreground: oklch(0.985 0 0); oklch(0.9702 0 0);
--sidebar-accent: oklch(0.97 0 0); --accent-foreground:
--sidebar-accent-foreground: oklch(0.205 0 0); oklch(0.2046 0 0);
--sidebar-border: oklch(0.922 0 0); --destructive:
--sidebar-ring: oklch(0.708 0 0); 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 { @media (prefers-color-scheme: dark) {
--background: oklch(0.145 0 0); :root {
--foreground: oklch(0.985 0 0); --background: 2 6 23;
--card: oklch(0.205 0 0); --foreground: 248 250 252;
--card-foreground: oklch(0.985 0 0); --card: 15 23 42;
--popover: oklch(0.205 0 0); --card-foreground: 248 250 252;
--popover-foreground: oklch(0.985 0 0); --popover: 15 23 42;
--primary: oklch(0.922 0 0); --popover-foreground: 248 250 252;
--primary-foreground: oklch(0.205 0 0); --primary: 148 163 184;
--secondary: oklch(0.269 0 0); --primary-foreground: 15 23 42;
--secondary-foreground: oklch(0.985 0 0); --secondary: 30 41 59;
--muted: oklch(0.269 0 0); --secondary-foreground: 248 250 252;
--muted-foreground: oklch(0.708 0 0); --muted: 30 41 59;
--accent: oklch(0.269 0 0); --muted-foreground: 148 163 184;
--accent-foreground: oklch(0.985 0 0); --accent: 30 41 59;
--destructive: oklch(0.704 0.191 22.216); --accent-foreground: 248 250 252;
--border: oklch(1 0 0 / 10%); --destructive: 239 68 68;
--input: oklch(1 0 0 / 15%); --border: 51 65 85;
--ring: oklch(0.556 0 0); --input: 51 65 85;
--chart-1: oklch(0.488 0.243 264.376); --ring: 148 163 184;
--chart-2: oklch(0.696 0.17 162.48); --chart-1: 148 163 184;
--chart-3: oklch(0.769 0.188 70.08); --chart-2: 100 116 139;
--chart-4: oklch(0.627 0.265 303.9); --chart-3: 239 68 68;
--chart-5: oklch(0.645 0.246 16.439); --chart-4: 245 158 11;
--sidebar: oklch(0.205 0 0); --chart-5: 34 197 94;
--sidebar-foreground: oklch(0.985 0 0); --sidebar: 15 23 42;
--sidebar-primary: oklch(0.488 0.243 264.376); --sidebar-foreground: 148 163 184;
--sidebar-primary-foreground: oklch(0.985 0 0); --sidebar-primary: 148 163 184;
--sidebar-accent: oklch(0.269 0 0); --sidebar-primary-foreground: 15 23 42;
--sidebar-accent-foreground: oklch(0.985 0 0); --sidebar-accent: 30 41 59;
--sidebar-border: oklch(1 0 0 / 10%); --sidebar-accent-foreground: 248 250 252;
--sidebar-ring: oklch(0.556 0 0); --sidebar-border: 51 65 85;
--sidebar-ring: 148 163 184;
--destructive-foreground: 255 255 255;
}
} }
@layer base { @layer base {
@@ -121,5 +235,114 @@
} }
body { body {
@apply bg-background text-foreground; @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);
}