Add reusable Skeleton components and route-level loading files across public pages and admin lists so layouts stay stable while data loads. Co-authored-by: Cursor <cursoragent@cursor.com>
228 lines
8.4 KiB
TypeScript
228 lines
8.4 KiB
TypeScript
import clsx from 'clsx';
|
|
|
|
// Skeleton loading previews. The base block uses Tailwind's animate-pulse and
|
|
// the light-gray surface tokens so placeholders match real card/table styling.
|
|
// Composites below mirror the layouts they stand in for (event cards, admin
|
|
// tables, FAQ accordions, article bodies) to avoid layout shift when data lands.
|
|
|
|
interface SkeletonProps {
|
|
className?: string;
|
|
}
|
|
|
|
export function Skeleton({ className }: SkeletonProps) {
|
|
return (
|
|
<div
|
|
aria-hidden="true"
|
|
className={clsx('animate-pulse rounded-lg bg-secondary-light-gray/70', className)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// Screen-reader announcement wrapper for a group of skeleton blocks.
|
|
export function SkeletonGroup({ className, children }: SkeletonProps & { children: React.ReactNode }) {
|
|
return (
|
|
<div role="status" aria-busy="true" className={className}>
|
|
<span className="sr-only">Loading…</span>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Paragraph-style stack of text lines with a shorter last line.
|
|
export function SkeletonText({ lines = 3, className }: SkeletonProps & { lines?: number }) {
|
|
return (
|
|
<div className={clsx('space-y-2.5', className)} aria-hidden="true">
|
|
{Array.from({ length: lines }).map((_, i) => (
|
|
<Skeleton key={i} className={clsx('h-4', i === lines - 1 ? 'w-2/3' : 'w-full')} />
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Mirrors the event card in EventsClient: banner, title, meta rows, price + button.
|
|
export function EventCardSkeleton() {
|
|
return (
|
|
<div className="bg-white rounded-card overflow-hidden shadow-card h-full" aria-hidden="true">
|
|
<Skeleton className="h-40 w-full rounded-none" />
|
|
<div className="p-6">
|
|
<Skeleton className="h-6 w-3/4" />
|
|
<div className="mt-4 space-y-2">
|
|
<Skeleton className="h-4 w-2/3" />
|
|
<Skeleton className="h-4 w-1/2" />
|
|
<Skeleton className="h-4 w-2/5" />
|
|
</div>
|
|
<div className="mt-6 flex items-center justify-between">
|
|
<Skeleton className="h-7 w-24" />
|
|
<Skeleton className="h-9 w-24 rounded-btn" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function EventGridSkeleton({ count = 6 }: { count?: number }) {
|
|
return (
|
|
<SkeletonGroup className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
{Array.from({ length: count }).map((_, i) => (
|
|
<EventCardSkeleton key={i} />
|
|
))}
|
|
</SkeletonGroup>
|
|
);
|
|
}
|
|
|
|
// Mirrors the featured "next event" hero card on the homepage: side banner + info column.
|
|
export function FeaturedEventSkeleton() {
|
|
return (
|
|
<SkeletonGroup>
|
|
<div className="bg-gray-50 border border-gray-200 rounded-2xl overflow-hidden shadow-lg">
|
|
<div className="flex flex-col md:flex-row">
|
|
<Skeleton className="w-full md:w-2/5 flex-shrink-0 h-48 md:h-auto md:min-h-[280px] rounded-none" />
|
|
<div className="flex-1 p-5 md:p-8">
|
|
<Skeleton className="h-7 w-2/3" />
|
|
<Skeleton className="mt-3 h-4 w-full" />
|
|
<Skeleton className="mt-2 h-4 w-5/6" />
|
|
<div className="mt-4 md:mt-5 space-y-2.5">
|
|
<Skeleton className="h-4 w-48" />
|
|
<Skeleton className="h-4 w-32" />
|
|
<Skeleton className="h-4 w-56" />
|
|
</div>
|
|
<div className="mt-5 md:mt-6 flex items-center justify-between gap-4">
|
|
<Skeleton className="h-9 w-28" />
|
|
<Skeleton className="h-10 w-28 rounded-xl" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</SkeletonGroup>
|
|
);
|
|
}
|
|
|
|
// Accordion-style rows for FAQ lists. `compact` matches the homepage variant
|
|
// (rounded-btn, px-5); the default matches the /faq page cards (px-6, more spacing).
|
|
export function FaqListSkeleton({ count = 5, compact = false }: { count?: number; compact?: boolean }) {
|
|
return (
|
|
<SkeletonGroup className={compact ? 'space-y-3' : 'space-y-4'}>
|
|
{Array.from({ length: count }).map((_, i) => (
|
|
<div
|
|
key={i}
|
|
className={clsx(
|
|
'bg-white border border-gray-200 flex items-center justify-between',
|
|
compact ? 'rounded-btn px-5 py-4' : 'rounded-card px-6 py-4 shadow-card border-0'
|
|
)}
|
|
aria-hidden="true"
|
|
>
|
|
<Skeleton className={clsx('h-5', i % 2 === 0 ? 'w-3/5' : 'w-2/5')} />
|
|
<Skeleton className="h-5 w-5 rounded-full flex-shrink-0" />
|
|
</div>
|
|
))}
|
|
</SkeletonGroup>
|
|
);
|
|
}
|
|
|
|
// Long-form content: heading, meta line, then paragraph blocks.
|
|
export function ArticleSkeleton({ withBanner = false }: { withBanner?: boolean }) {
|
|
return (
|
|
<SkeletonGroup>
|
|
{withBanner && <Skeleton className="h-56 md:h-80 w-full rounded-card mb-8" />}
|
|
<Skeleton className="h-9 w-3/4 md:w-1/2" />
|
|
<Skeleton className="mt-3 h-4 w-40" />
|
|
<div className="mt-8 space-y-6">
|
|
<SkeletonText lines={4} />
|
|
<SkeletonText lines={3} />
|
|
<Skeleton className="h-6 w-1/3" />
|
|
<SkeletonText lines={4} />
|
|
</div>
|
|
</SkeletonGroup>
|
|
);
|
|
}
|
|
|
|
// Stacked list of card rows (dashboard tickets/payments, contact lists).
|
|
export function CardListSkeleton({ count = 3 }: { count?: number }) {
|
|
return (
|
|
<SkeletonGroup className="space-y-4">
|
|
{Array.from({ length: count }).map((_, i) => (
|
|
<div key={i} className="bg-white rounded-card shadow-card p-5" aria-hidden="true">
|
|
<div className="flex items-start justify-between gap-4">
|
|
<div className="flex-1">
|
|
<Skeleton className="h-5 w-1/3" />
|
|
<Skeleton className="mt-2.5 h-4 w-1/2" />
|
|
<Skeleton className="mt-2 h-4 w-2/5" />
|
|
</div>
|
|
<Skeleton className="h-6 w-20 rounded-full" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</SkeletonGroup>
|
|
);
|
|
}
|
|
|
|
// Admin data tables: desktop table inside a Card, mobile stacked cards —
|
|
// mirrors the two-breakpoint layout every admin list page renders.
|
|
export function TableSkeleton({ rows = 6, cols = 5 }: { rows?: number; cols?: number }) {
|
|
return (
|
|
<SkeletonGroup>
|
|
{/* Desktop: table */}
|
|
<div className="bg-white rounded-card overflow-hidden shadow-card hidden md:block" aria-hidden="true">
|
|
<div className="bg-secondary-gray px-4 py-3 flex gap-4">
|
|
{Array.from({ length: cols }).map((_, i) => (
|
|
<Skeleton key={i} className="h-3.5 flex-1 bg-secondary-light-gray" />
|
|
))}
|
|
</div>
|
|
<div className="divide-y divide-secondary-light-gray">
|
|
{Array.from({ length: rows }).map((_, r) => (
|
|
<div key={r} className="px-4 py-3.5 flex items-center gap-4">
|
|
<div className="flex items-center gap-3 flex-1">
|
|
<Skeleton className="w-10 h-10 rounded-lg flex-shrink-0" />
|
|
<Skeleton className="h-4 w-2/3" />
|
|
</div>
|
|
{Array.from({ length: cols - 1 }).map((_, c) => (
|
|
<Skeleton key={c} className={clsx('h-4 flex-1', c === cols - 2 && 'max-w-[80px]')} />
|
|
))}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
{/* Mobile: card list */}
|
|
<div className="md:hidden space-y-3" aria-hidden="true">
|
|
{Array.from({ length: Math.min(rows, 4) }).map((_, i) => (
|
|
<div key={i} className="bg-white rounded-card shadow-card p-4">
|
|
<div className="flex items-center gap-3">
|
|
<Skeleton className="w-12 h-12 rounded-lg flex-shrink-0" />
|
|
<div className="flex-1">
|
|
<Skeleton className="h-4 w-3/4" />
|
|
<Skeleton className="mt-2 h-3.5 w-1/2" />
|
|
</div>
|
|
<Skeleton className="h-6 w-16 rounded-full" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</SkeletonGroup>
|
|
);
|
|
}
|
|
|
|
// Full admin page placeholder: title bar + table. Used by the `if (loading)`
|
|
// gates that replace the entire page, so it includes the header row.
|
|
export function AdminPageSkeleton({ rows = 6, cols = 5 }: { rows?: number; cols?: number }) {
|
|
return (
|
|
<div>
|
|
<div className="flex items-center justify-between mb-6">
|
|
<Skeleton className="h-8 w-48" />
|
|
<Skeleton className="h-10 w-36 rounded-btn hidden md:block" />
|
|
</div>
|
|
<TableSkeleton rows={rows} cols={cols} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Thumbnail grid (admin gallery/media pages).
|
|
export function ImageGridSkeleton({ count = 12 }: { count?: number }) {
|
|
return (
|
|
<SkeletonGroup className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
|
{Array.from({ length: count }).map((_, i) => (
|
|
<Skeleton key={i} className="aspect-square w-full rounded-card" />
|
|
))}
|
|
</SkeletonGroup>
|
|
);
|
|
}
|