Files
Benjamin Boenisch 5a31f52310 Initial commit: breakpilot-lehrer - Lehrer KI Platform
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>
2026-02-11 23:47:26 +01:00

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