Files
Spanglish/frontend/src/components/ui/Skeleton.tsx
T
MichilisandCursor 75e317d73e Replace spinners with skeleton loading states for faster perceived UX.
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>
2026-07-12 23:14:11 +00:00

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