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>
315 lines
8.3 KiB
TypeScript
315 lines
8.3 KiB
TypeScript
'use client'
|
|
|
|
import React, { useState, useRef, useCallback, useMemo } from 'react'
|
|
import { useTheme } from '@/lib/ThemeContext'
|
|
import {
|
|
SHADOWS,
|
|
SHADOWS_DARK,
|
|
MOTION,
|
|
PARALLAX,
|
|
MATERIALS,
|
|
calculateParallax,
|
|
} from '@/lib/spatial-ui/depth-system'
|
|
import { usePerformance } from '@/lib/spatial-ui/PerformanceContext'
|
|
|
|
/**
|
|
* SpatialCard - A card component with depth-aware interactions
|
|
*
|
|
* Features:
|
|
* - Dynamic shadows that respond to hover/active states
|
|
* - Subtle parallax effect based on cursor position
|
|
* - Material-based styling (glass, solid, acrylic)
|
|
* - Elevation changes with physics-based motion
|
|
* - Performance-adaptive (degrades gracefully)
|
|
*/
|
|
|
|
export type CardMaterial = 'solid' | 'glass' | 'thinGlass' | 'thickGlass' | 'acrylic'
|
|
export type CardElevation = 'flat' | 'raised' | 'floating'
|
|
|
|
interface SpatialCardProps {
|
|
children: React.ReactNode
|
|
/** Material style */
|
|
material?: CardMaterial
|
|
/** Base elevation level */
|
|
elevation?: CardElevation
|
|
/** Enable hover lift effect */
|
|
hoverLift?: boolean
|
|
/** Enable subtle parallax on hover */
|
|
parallax?: boolean
|
|
/** Parallax intensity (uses PARALLAX constants) */
|
|
parallaxIntensity?: number
|
|
/** Click handler */
|
|
onClick?: () => void
|
|
/** Additional CSS classes */
|
|
className?: string
|
|
/** Custom padding */
|
|
padding?: 'none' | 'sm' | 'md' | 'lg'
|
|
/** Border radius */
|
|
rounded?: 'none' | 'md' | 'lg' | 'xl' | '2xl' | '3xl'
|
|
/** Glow color on hover (RGB values) */
|
|
glowColor?: string
|
|
/** Disable all effects */
|
|
static?: boolean
|
|
}
|
|
|
|
const PADDING_MAP = {
|
|
none: '',
|
|
sm: 'p-3',
|
|
md: 'p-4',
|
|
lg: 'p-6',
|
|
}
|
|
|
|
const ROUNDED_MAP = {
|
|
none: 'rounded-none',
|
|
md: 'rounded-md',
|
|
lg: 'rounded-lg',
|
|
xl: 'rounded-xl',
|
|
'2xl': 'rounded-2xl',
|
|
'3xl': 'rounded-3xl',
|
|
}
|
|
|
|
const ELEVATION_SHADOWS = {
|
|
flat: { rest: 'none', hover: 'sm', active: 'xs' },
|
|
raised: { rest: 'sm', hover: 'lg', active: 'md' },
|
|
floating: { rest: 'lg', hover: 'xl', active: 'lg' },
|
|
}
|
|
|
|
export function SpatialCard({
|
|
children,
|
|
material = 'glass',
|
|
elevation = 'raised',
|
|
hoverLift = true,
|
|
parallax = false,
|
|
parallaxIntensity = PARALLAX.subtle,
|
|
onClick,
|
|
className = '',
|
|
padding = 'md',
|
|
rounded = '2xl',
|
|
glowColor,
|
|
static: isStatic = false,
|
|
}: SpatialCardProps) {
|
|
const { isDark } = useTheme()
|
|
const { settings, reportAnimationStart, reportAnimationEnd, canStartAnimation } = usePerformance()
|
|
|
|
const [isHovered, setIsHovered] = useState(false)
|
|
const [isActive, setIsActive] = useState(false)
|
|
const [parallaxOffset, setParallaxOffset] = useState({ x: 0, y: 0 })
|
|
|
|
const cardRef = useRef<HTMLDivElement>(null)
|
|
const animatingRef = useRef(false)
|
|
|
|
// Determine if effects should be enabled
|
|
const effectsEnabled = !isStatic && settings.enableShadows
|
|
const parallaxEnabled = parallax && settings.enableParallax && !isStatic
|
|
|
|
// Shadow key type (excludes glow function)
|
|
type ShadowKey = 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
|
|
|
|
// Get current shadow based on state
|
|
const currentShadow = useMemo((): string => {
|
|
if (!effectsEnabled) return 'none'
|
|
|
|
const shadows = ELEVATION_SHADOWS[elevation]
|
|
const shadowKey = (isActive ? shadows.active : isHovered ? shadows.hover : shadows.rest) as ShadowKey
|
|
const shadowSet = isDark ? SHADOWS_DARK : SHADOWS
|
|
|
|
// Get base shadow as string (not the glow function)
|
|
const baseShadow = shadowSet[shadowKey] as string
|
|
|
|
// Add glow effect on hover if specified
|
|
if (isHovered && glowColor && settings.enableShadows) {
|
|
const glowFn = isDark ? SHADOWS_DARK.glow : SHADOWS.glow
|
|
return `${baseShadow}, ${glowFn(glowColor, 0.2)}`
|
|
}
|
|
|
|
return baseShadow
|
|
}, [effectsEnabled, elevation, isActive, isHovered, isDark, glowColor, settings.enableShadows])
|
|
|
|
// Get material styles
|
|
const materialStyles = useMemo(() => {
|
|
const mat = MATERIALS[material]
|
|
const bg = isDark ? mat.backgroundDark : mat.background
|
|
const border = isDark ? mat.borderDark : mat.border
|
|
const blur = settings.enableBlur ? mat.blur * settings.blurIntensity : 0
|
|
|
|
return {
|
|
background: bg,
|
|
backdropFilter: blur > 0 ? `blur(${blur}px)` : 'none',
|
|
WebkitBackdropFilter: blur > 0 ? `blur(${blur}px)` : 'none',
|
|
borderColor: border,
|
|
}
|
|
}, [material, isDark, settings.enableBlur, settings.blurIntensity])
|
|
|
|
// Calculate transform based on state
|
|
const transform = useMemo(() => {
|
|
if (isStatic || !settings.enableSpringAnimations) {
|
|
return 'translateY(0) scale(1)'
|
|
}
|
|
|
|
let y = 0
|
|
let scale = 1
|
|
|
|
if (isActive) {
|
|
y = 1
|
|
scale = 0.98
|
|
} else if (isHovered && hoverLift) {
|
|
y = -3
|
|
scale = 1.01
|
|
}
|
|
|
|
// Add parallax offset
|
|
const px = parallaxEnabled ? parallaxOffset.x : 0
|
|
const py = parallaxEnabled ? parallaxOffset.y : 0
|
|
|
|
return `translateY(${y}px) translateX(${px}px) translateZ(${py}px) scale(${scale})`
|
|
}, [isStatic, settings.enableSpringAnimations, isActive, isHovered, hoverLift, parallaxEnabled, parallaxOffset])
|
|
|
|
// Get transition timing
|
|
const transitionDuration = useMemo(() => {
|
|
const base = isActive ? MOTION.micro.duration : MOTION.standard.duration
|
|
return Math.round(base * settings.animationSpeed)
|
|
}, [isActive, settings.animationSpeed])
|
|
|
|
// Handlers
|
|
const handleMouseEnter = useCallback(() => {
|
|
if (isStatic) return
|
|
if (canStartAnimation() && !animatingRef.current) {
|
|
animatingRef.current = true
|
|
reportAnimationStart()
|
|
}
|
|
setIsHovered(true)
|
|
}, [isStatic, canStartAnimation, reportAnimationStart])
|
|
|
|
const handleMouseLeave = useCallback(() => {
|
|
setIsHovered(false)
|
|
setParallaxOffset({ x: 0, y: 0 })
|
|
if (animatingRef.current) {
|
|
animatingRef.current = false
|
|
reportAnimationEnd()
|
|
}
|
|
}, [reportAnimationEnd])
|
|
|
|
const handleMouseMove = useCallback(
|
|
(e: React.MouseEvent) => {
|
|
if (!parallaxEnabled || !cardRef.current) return
|
|
|
|
const rect = cardRef.current.getBoundingClientRect()
|
|
const offset = calculateParallax(
|
|
e.clientX,
|
|
e.clientY,
|
|
rect,
|
|
parallaxIntensity * settings.parallaxIntensity
|
|
)
|
|
setParallaxOffset(offset)
|
|
},
|
|
[parallaxEnabled, parallaxIntensity, settings.parallaxIntensity]
|
|
)
|
|
|
|
const handleMouseDown = useCallback(() => {
|
|
if (!isStatic) setIsActive(true)
|
|
}, [isStatic])
|
|
|
|
const handleMouseUp = useCallback(() => {
|
|
setIsActive(false)
|
|
}, [])
|
|
|
|
const handleClick = useCallback(() => {
|
|
onClick?.()
|
|
}, [onClick])
|
|
|
|
return (
|
|
<div
|
|
ref={cardRef}
|
|
className={`
|
|
border
|
|
${PADDING_MAP[padding]}
|
|
${ROUNDED_MAP[rounded]}
|
|
${onClick ? 'cursor-pointer' : ''}
|
|
${className}
|
|
`}
|
|
style={{
|
|
...materialStyles,
|
|
boxShadow: currentShadow,
|
|
transform,
|
|
transition: `
|
|
box-shadow ${transitionDuration}ms ${MOTION.standard.easing},
|
|
transform ${transitionDuration}ms ${settings.enableSpringAnimations ? MOTION.spring.easing : MOTION.standard.easing},
|
|
background ${transitionDuration}ms ${MOTION.standard.easing}
|
|
`,
|
|
willChange: effectsEnabled ? 'transform, box-shadow' : 'auto',
|
|
}}
|
|
onMouseEnter={handleMouseEnter}
|
|
onMouseLeave={handleMouseLeave}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseDown={handleMouseDown}
|
|
onMouseUp={handleMouseUp}
|
|
onClick={handleClick}
|
|
>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* SpatialCardHeader - Header section for SpatialCard
|
|
*/
|
|
export function SpatialCardHeader({
|
|
children,
|
|
className = '',
|
|
}: {
|
|
children: React.ReactNode
|
|
className?: string
|
|
}) {
|
|
const { isDark } = useTheme()
|
|
|
|
return (
|
|
<div
|
|
className={`
|
|
flex items-center justify-between mb-4
|
|
${isDark ? 'text-white' : 'text-slate-900'}
|
|
${className}
|
|
`}
|
|
>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* SpatialCardContent - Main content area
|
|
*/
|
|
export function SpatialCardContent({
|
|
children,
|
|
className = '',
|
|
}: {
|
|
children: React.ReactNode
|
|
className?: string
|
|
}) {
|
|
return <div className={className}>{children}</div>
|
|
}
|
|
|
|
/**
|
|
* SpatialCardFooter - Footer section
|
|
*/
|
|
export function SpatialCardFooter({
|
|
children,
|
|
className = '',
|
|
}: {
|
|
children: React.ReactNode
|
|
className?: string
|
|
}) {
|
|
const { isDark } = useTheme()
|
|
|
|
return (
|
|
<div
|
|
className={`
|
|
mt-4 pt-4 border-t
|
|
${isDark ? 'border-white/10' : 'border-slate-200'}
|
|
${className}
|
|
`}
|
|
>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|