mirror of
https://github.com/soconnor0919/hristudio.git
synced 2025-12-11 22:54:45 -05:00
feat: Add skeleton loaders for improved loading states in studies and participants pages
- Implemented skeleton components for studies list, study details, and participants table - Enhanced loading experience by replacing simple "Loading..." placeholders with detailed skeleton loaders - Created reusable skeleton components in `src/components/ui/skeleton.tsx` - Updated studies and participants pages to use new skeleton loaders
This commit is contained in:
@@ -7,6 +7,7 @@ import { PageHeader } from "~/components/layout/page-header";
|
|||||||
import { PageContent } from "~/components/layout/page-content";
|
import { PageContent } from "~/components/layout/page-content";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { use } from "react";
|
import { use } from "react";
|
||||||
|
import { CardSkeleton } from "~/components/ui/skeleton";
|
||||||
|
|
||||||
export default function EditStudyPage({ params }: { params: Promise<{ id: string }> }) {
|
export default function EditStudyPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -29,7 +30,27 @@ export default function EditStudyPage({ params }: { params: Promise<{ id: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isLoadingStudy) {
|
if (isLoadingStudy) {
|
||||||
return <div>Loading...</div>;
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Edit Study"
|
||||||
|
description="Loading study details..."
|
||||||
|
/>
|
||||||
|
<PageContent className="max-w-2xl">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Study Details</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Please wait while we load the study information.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CardSkeleton />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</PageContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!study) {
|
if (!study) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { StudyParticipants } from "~/components/studies/study-participants";
|
|||||||
import { StudyMembers } from "~/components/studies/study-members";
|
import { StudyMembers } from "~/components/studies/study-members";
|
||||||
import { StudyMetadata } from "~/components/studies/study-metadata";
|
import { StudyMetadata } from "~/components/studies/study-metadata";
|
||||||
import { StudyActivity } from "~/components/studies/study-activity";
|
import { StudyActivity } from "~/components/studies/study-activity";
|
||||||
|
import { StudyDetailsSkeleton } from "~/components/ui/skeleton";
|
||||||
|
|
||||||
export default function StudyPage({ params }: { params: Promise<{ id: string }> }) {
|
export default function StudyPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -24,7 +25,28 @@ export default function StudyPage({ params }: { params: Promise<{ id: string }>
|
|||||||
const { data: study, isLoading: isLoadingStudy } = api.study.getById.useQuery({ id });
|
const { data: study, isLoading: isLoadingStudy } = api.study.getById.useQuery({ id });
|
||||||
|
|
||||||
if (isLoadingStudy) {
|
if (isLoadingStudy) {
|
||||||
return <div>Loading...</div>;
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Loading..."
|
||||||
|
description="Please wait while we load the study details"
|
||||||
|
/>
|
||||||
|
<PageContent>
|
||||||
|
<Tabs defaultValue="overview" className="space-y-4">
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||||
|
<TabsTrigger value="participants">Participants</TabsTrigger>
|
||||||
|
<TabsTrigger value="members">Members</TabsTrigger>
|
||||||
|
<TabsTrigger value="metadata">Metadata</TabsTrigger>
|
||||||
|
<TabsTrigger value="activity">Activity</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="overview">
|
||||||
|
<StudyDetailsSkeleton />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</PageContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!study) {
|
if (!study) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { ParticipantForm, type ParticipantFormValues } from "~/components/partic
|
|||||||
import { use } from "react";
|
import { use } from "react";
|
||||||
import { useToast } from "~/hooks/use-toast";
|
import { useToast } from "~/hooks/use-toast";
|
||||||
import { ROLES } from "~/lib/permissions/constants";
|
import { ROLES } from "~/lib/permissions/constants";
|
||||||
|
import { CardSkeleton } from "~/components/ui/skeleton";
|
||||||
|
|
||||||
export default function EditParticipantPage({
|
export default function EditParticipantPage({
|
||||||
params,
|
params,
|
||||||
@@ -50,7 +51,27 @@ export default function EditParticipantPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <div>Loading...</div>;
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
title="Edit Participant"
|
||||||
|
description="Loading participant information..."
|
||||||
|
/>
|
||||||
|
<PageContent>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Participant Details</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Please wait while we load the participant information.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<CardSkeleton />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</PageContent>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!study || !participant) {
|
if (!study || !participant) {
|
||||||
|
|||||||
@@ -7,15 +7,12 @@ import { PageContent } from "~/components/layout/page-content";
|
|||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card";
|
||||||
import { Plus as PlusIcon } from "lucide-react";
|
import { Plus as PlusIcon } from "lucide-react";
|
||||||
|
import { StudyListSkeleton } from "~/components/ui/skeleton";
|
||||||
|
|
||||||
export default function StudiesPage() {
|
export default function StudiesPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data: studies, isLoading } = api.study.getMyStudies.useQuery();
|
const { data: studies, isLoading } = api.study.getMyStudies.useQuery();
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return <div>Loading...</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -31,8 +28,9 @@ export default function StudiesPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
<PageContent>
|
<PageContent>
|
||||||
<div className="grid gap-6">
|
{isLoading ? (
|
||||||
{!studies || studies.length === 0 ? (
|
<StudyListSkeleton />
|
||||||
|
) : !studies || studies.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>No Studies</CardTitle>
|
<CardTitle>No Studies</CardTitle>
|
||||||
@@ -42,7 +40,8 @@ export default function StudiesPage() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
studies.map((study) => (
|
<div className="grid gap-6">
|
||||||
|
{studies.map((study) => (
|
||||||
<Card
|
<Card
|
||||||
key={study.id}
|
key={study.id}
|
||||||
className="hover:bg-muted/50 cursor-pointer transition-colors"
|
className="hover:bg-muted/50 cursor-pointer transition-colors"
|
||||||
@@ -60,9 +59,9 @@ export default function StudiesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))
|
))}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</PageContent>
|
</PageContent>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { ROLES } from "~/lib/permissions/constants";
|
|||||||
import { Switch } from "~/components/ui/switch";
|
import { Switch } from "~/components/ui/switch";
|
||||||
import { Label } from "~/components/ui/label";
|
import { Label } from "~/components/ui/label";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { TableSkeleton } from "~/components/ui/skeleton";
|
||||||
|
|
||||||
interface StudyParticipantsProps {
|
interface StudyParticipantsProps {
|
||||||
studyId: number;
|
studyId: number;
|
||||||
@@ -38,7 +39,21 @@ export function StudyParticipants({ studyId, role }: StudyParticipantsProps) {
|
|||||||
.includes(role.toLowerCase());
|
.includes(role.toLowerCase());
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <div>Loading...</div>;
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Study Participants</CardTitle>
|
||||||
|
<CardDescription>Loading participants...</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<TableSkeleton />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { cn } from "~/lib/utils"
|
import { cn } from "~/lib/utils"
|
||||||
|
import { Card, CardContent, CardHeader } from "~/components/ui/card"
|
||||||
|
|
||||||
function Skeleton({
|
function Skeleton({
|
||||||
className,
|
className,
|
||||||
@@ -6,10 +7,132 @@ function Skeleton({
|
|||||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn("animate-pulse rounded-md bg-primary/10", className)}
|
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Skeleton }
|
function TableRowSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="flex space-x-4 p-4">
|
||||||
|
<Skeleton className="h-5 w-[20%]" />
|
||||||
|
<Skeleton className="h-5 w-[15%]" />
|
||||||
|
<Skeleton className="h-5 w-[30%]" />
|
||||||
|
<Skeleton className="h-5 w-[35%]" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex space-x-4 p-4 border-b">
|
||||||
|
<Skeleton className="h-4 w-[20%]" />
|
||||||
|
<Skeleton className="h-4 w-[15%]" />
|
||||||
|
<Skeleton className="h-4 w-[30%]" />
|
||||||
|
<Skeleton className="h-4 w-[35%]" />
|
||||||
|
</div>
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<TableRowSkeleton key={i} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-4">
|
||||||
|
<Skeleton className="h-7 w-[40%]" />
|
||||||
|
<Skeleton className="h-4 w-[60%]" />
|
||||||
|
<div className="pt-4">
|
||||||
|
<Skeleton className="h-4 w-[25%]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StudyListSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<div key={i} className="rounded-lg border bg-card">
|
||||||
|
<CardSkeleton />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function StudyDetailsSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Overview Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-7 w-[150px] mb-2" />
|
||||||
|
<Skeleton className="h-4 w-[250px]" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Skeleton className="h-4 w-[100px] mb-1" />
|
||||||
|
<Skeleton className="h-5 w-[150px]" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Skeleton className="h-4 w-[100px] mb-1" />
|
||||||
|
<Skeleton className="h-5 w-[200px]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<Card key={i}>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<Skeleton className="h-4 w-[120px]" />
|
||||||
|
<Skeleton className="h-4 w-4" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Skeleton className="h-7 w-[60px] mb-1" />
|
||||||
|
<Skeleton className="h-4 w-[100px]" />
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Activity Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-7 w-[150px] mb-2" />
|
||||||
|
<Skeleton className="h-4 w-[200px]" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<div key={i} className="flex gap-4">
|
||||||
|
<Skeleton className="h-8 w-8 rounded-full" />
|
||||||
|
<div className="flex-1 space-y-2">
|
||||||
|
<Skeleton className="h-4 w-[150px]" />
|
||||||
|
<Skeleton className="h-4 w-[250px]" />
|
||||||
|
<Skeleton className="h-3 w-[100px]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Skeleton,
|
||||||
|
TableRowSkeleton,
|
||||||
|
TableSkeleton,
|
||||||
|
CardSkeleton,
|
||||||
|
StudyListSkeleton,
|
||||||
|
StudyDetailsSkeleton,
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user