Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website, Klausur-Service, School-Service, Voice-Service, Geo-Service, BreakPilot Drive, Agent-Core Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
218 lines
5.7 KiB
TypeScript
218 lines
5.7 KiB
TypeScript
/**
|
|
* Skeleton Loading Components
|
|
*
|
|
* Animated placeholder components for loading states.
|
|
* Used throughout the app for smoother UX during async operations.
|
|
*/
|
|
|
|
import { ReactNode } from 'react'
|
|
|
|
interface SkeletonTextProps {
|
|
/** Number of lines to display */
|
|
lines?: number
|
|
/** Width variants for varied line lengths */
|
|
variant?: 'uniform' | 'varied' | 'paragraph'
|
|
/** Custom class names */
|
|
className?: string
|
|
}
|
|
|
|
/**
|
|
* Animated skeleton text placeholder
|
|
*/
|
|
export function SkeletonText({ lines = 3, variant = 'varied', className = '' }: SkeletonTextProps) {
|
|
const getLineWidth = (index: number) => {
|
|
if (variant === 'uniform') return 'w-full'
|
|
if (variant === 'paragraph') {
|
|
// Last line is shorter for paragraph effect
|
|
if (index === lines - 1) return 'w-3/5'
|
|
return 'w-full'
|
|
}
|
|
// Varied widths
|
|
const widths = ['w-full', 'w-4/5', 'w-3/4', 'w-5/6', 'w-2/3']
|
|
return widths[index % widths.length]
|
|
}
|
|
|
|
return (
|
|
<div className={`space-y-3 ${className}`}>
|
|
{Array.from({ length: lines }).map((_, i) => (
|
|
<div
|
|
key={i}
|
|
className={`h-4 bg-slate-200 rounded animate-pulse ${getLineWidth(i)}`}
|
|
style={{ animationDelay: `${i * 100}ms` }}
|
|
/>
|
|
))}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
interface SkeletonBoxProps {
|
|
/** Width (Tailwind class or px) */
|
|
width?: string
|
|
/** Height (Tailwind class or px) */
|
|
height?: string
|
|
/** Custom class names */
|
|
className?: string
|
|
/** Border radius */
|
|
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'full'
|
|
}
|
|
|
|
/**
|
|
* Animated skeleton box for images, avatars, cards
|
|
*/
|
|
export function SkeletonBox({
|
|
width = 'w-full',
|
|
height = 'h-32',
|
|
className = '',
|
|
rounded = 'lg'
|
|
}: SkeletonBoxProps) {
|
|
const roundedClass = {
|
|
none: '',
|
|
sm: 'rounded-sm',
|
|
md: 'rounded-md',
|
|
lg: 'rounded-lg',
|
|
xl: 'rounded-xl',
|
|
full: 'rounded-full'
|
|
}[rounded]
|
|
|
|
return (
|
|
<div
|
|
className={`bg-slate-200 animate-pulse ${width} ${height} ${roundedClass} ${className}`}
|
|
/>
|
|
)
|
|
}
|
|
|
|
interface SkeletonCardProps {
|
|
/** Show image placeholder */
|
|
showImage?: boolean
|
|
/** Number of text lines */
|
|
lines?: number
|
|
/** Custom class names */
|
|
className?: string
|
|
}
|
|
|
|
/**
|
|
* Skeleton card with optional image and text lines
|
|
*/
|
|
export function SkeletonCard({ showImage = true, lines = 3, className = '' }: SkeletonCardProps) {
|
|
return (
|
|
<div className={`bg-white rounded-xl shadow-sm border p-4 ${className}`}>
|
|
{showImage && (
|
|
<SkeletonBox height="h-32" className="mb-4" />
|
|
)}
|
|
<SkeletonBox width="w-2/3" height="h-5" className="mb-3" />
|
|
<SkeletonText lines={lines} variant="paragraph" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
interface SkeletonOCRResultProps {
|
|
/** Custom class names */
|
|
className?: string
|
|
}
|
|
|
|
/**
|
|
* Skeleton specifically for OCR results display
|
|
* Shows loading state while OCR is processing
|
|
*/
|
|
export function SkeletonOCRResult({ className = '' }: SkeletonOCRResultProps) {
|
|
return (
|
|
<div className={`bg-slate-50 rounded-lg p-4 ${className}`}>
|
|
<div className="flex items-center gap-2 mb-4">
|
|
<div className="w-4 h-4 rounded-full bg-purple-200 animate-pulse" />
|
|
<SkeletonBox width="w-32" height="h-4" rounded="md" />
|
|
</div>
|
|
|
|
{/* Text area skeleton */}
|
|
<div className="bg-white border p-3 rounded space-y-2 mb-4">
|
|
<SkeletonText lines={4} variant="paragraph" />
|
|
</div>
|
|
|
|
{/* Metrics grid skeleton */}
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
|
{Array.from({ length: 4 }).map((_, i) => (
|
|
<div key={i} className="bg-white border rounded p-2">
|
|
<SkeletonBox width="w-16" height="h-3" className="mb-2" rounded="sm" />
|
|
<SkeletonBox width="w-12" height="h-5" rounded="sm" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
interface SkeletonWrapperProps {
|
|
/** Whether to show skeleton or children */
|
|
loading: boolean
|
|
/** Content to show when not loading */
|
|
children: ReactNode
|
|
/** Skeleton component or elements to show */
|
|
skeleton?: ReactNode
|
|
/** Fallback skeleton lines (if skeleton prop not provided) */
|
|
lines?: number
|
|
/** Custom class names */
|
|
className?: string
|
|
}
|
|
|
|
/**
|
|
* Wrapper component that toggles between skeleton and content
|
|
*/
|
|
export function SkeletonWrapper({
|
|
loading,
|
|
children,
|
|
skeleton,
|
|
lines = 3,
|
|
className = ''
|
|
}: SkeletonWrapperProps) {
|
|
if (loading) {
|
|
return skeleton ? <>{skeleton}</> : <SkeletonText lines={lines} className={className} />
|
|
}
|
|
return <>{children}</>
|
|
}
|
|
|
|
interface SkeletonProgressProps {
|
|
/** Animation speed in seconds */
|
|
speed?: number
|
|
/** Custom class names */
|
|
className?: string
|
|
}
|
|
|
|
/**
|
|
* Animated progress skeleton with shimmer effect
|
|
*/
|
|
export function SkeletonProgress({ speed = 1.5, className = '' }: SkeletonProgressProps) {
|
|
return (
|
|
<div className={`relative overflow-hidden bg-slate-200 rounded-full h-2 ${className}`}>
|
|
<div
|
|
className="absolute inset-0 bg-gradient-to-r from-slate-200 via-slate-300 to-slate-200 animate-shimmer"
|
|
style={{
|
|
backgroundSize: '200% 100%',
|
|
animation: `shimmer ${speed}s infinite linear`
|
|
}}
|
|
/>
|
|
<style jsx>{`
|
|
@keyframes shimmer {
|
|
0% { background-position: 200% 0; }
|
|
100% { background-position: -200% 0; }
|
|
}
|
|
`}</style>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Pulsing dot indicator for inline loading
|
|
*/
|
|
export function SkeletonDots({ className = '' }: { className?: string }) {
|
|
return (
|
|
<span className={`inline-flex items-center gap-1 ${className}`}>
|
|
{[0, 1, 2].map((i) => (
|
|
<span
|
|
key={i}
|
|
className="w-1.5 h-1.5 rounded-full bg-purple-500 animate-pulse"
|
|
style={{ animationDelay: `${i * 200}ms` }}
|
|
/>
|
|
))}
|
|
</span>
|
|
)
|
|
}
|