fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
496
studio-v2/components/spatial-ui/FloatingMessage.tsx
Normal file
496
studio-v2/components/spatial-ui/FloatingMessage.tsx
Normal file
@@ -0,0 +1,496 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { MOTION, SHADOWS, SHADOWS_DARK, LAYERS } from '@/lib/spatial-ui/depth-system'
|
||||
import { usePerformance } from '@/lib/spatial-ui/PerformanceContext'
|
||||
|
||||
/**
|
||||
* FloatingMessage - Cinematic message notification overlay
|
||||
*
|
||||
* Features:
|
||||
* - Slides in from right with spring animation
|
||||
* - Typewriter effect for message text
|
||||
* - Glassmorphism with depth
|
||||
* - Auto-dismiss with progress indicator
|
||||
* - Quick reply without leaving context
|
||||
* - Queue system for multiple messages
|
||||
*/
|
||||
|
||||
export interface FloatingMessageData {
|
||||
id: string
|
||||
senderName: string
|
||||
senderInitials: string
|
||||
senderAvatar?: string
|
||||
content: string
|
||||
timestamp: Date
|
||||
conversationId: string
|
||||
isGroup?: boolean
|
||||
priority?: 'normal' | 'high' | 'urgent'
|
||||
}
|
||||
|
||||
interface FloatingMessageProps {
|
||||
/** Auto-dismiss after X ms (0 = manual only) */
|
||||
autoDismissMs?: number
|
||||
/** Max messages in queue */
|
||||
maxQueue?: number
|
||||
/** Position on screen */
|
||||
position?: 'top-right' | 'bottom-right' | 'top-left' | 'bottom-left'
|
||||
/** Offset from edge */
|
||||
offset?: { x: number; y: number }
|
||||
/** Callback when message is opened */
|
||||
onOpen?: (message: FloatingMessageData) => void
|
||||
/** Callback when reply is sent */
|
||||
onReply?: (message: FloatingMessageData, replyText: string) => void
|
||||
/** Callback when dismissed */
|
||||
onDismiss?: (message: FloatingMessageData) => void
|
||||
}
|
||||
|
||||
// Demo messages for testing
|
||||
const DEMO_MESSAGES: Omit<FloatingMessageData, 'id' | 'timestamp'>[] = [
|
||||
{
|
||||
senderName: 'Familie Mueller',
|
||||
senderInitials: 'FM',
|
||||
content: 'Guten Tag! Lisa hatte heute leider Fieber und konnte nicht zur Schule kommen. Koennten Sie uns bitte die Hausaufgaben mitteilen?',
|
||||
conversationId: 'conv1',
|
||||
isGroup: false,
|
||||
priority: 'normal',
|
||||
},
|
||||
{
|
||||
senderName: 'Kollegium 7a',
|
||||
senderInitials: 'K7',
|
||||
content: 'Erinnerung: Morgen findet die Klassenkonferenz um 14:00 Uhr statt. Bitte alle Notenlisten vorbereiten.',
|
||||
conversationId: 'conv2',
|
||||
isGroup: true,
|
||||
priority: 'high',
|
||||
},
|
||||
{
|
||||
senderName: 'Schulleitung',
|
||||
senderInitials: 'SL',
|
||||
content: 'Wichtig: Die Abiturklausuren muessen bis Freitag korrigiert sein. Bei Fragen wenden Sie sich an das Sekretariat.',
|
||||
conversationId: 'conv3',
|
||||
isGroup: false,
|
||||
priority: 'urgent',
|
||||
},
|
||||
]
|
||||
|
||||
export function FloatingMessage({
|
||||
autoDismissMs = 8000,
|
||||
maxQueue = 5,
|
||||
position = 'top-right',
|
||||
offset = { x: 24, y: 80 },
|
||||
onOpen,
|
||||
onReply,
|
||||
onDismiss,
|
||||
}: FloatingMessageProps) {
|
||||
const { isDark } = useTheme()
|
||||
const { settings, reportAnimationStart, reportAnimationEnd } = usePerformance()
|
||||
|
||||
const [messageQueue, setMessageQueue] = useState<FloatingMessageData[]>([])
|
||||
const [currentMessage, setCurrentMessage] = useState<FloatingMessageData | null>(null)
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [isExiting, setIsExiting] = useState(false)
|
||||
const [displayedText, setDisplayedText] = useState('')
|
||||
const [isTyping, setIsTyping] = useState(false)
|
||||
const [isReplying, setIsReplying] = useState(false)
|
||||
const [replyText, setReplyText] = useState('')
|
||||
const [dismissProgress, setDismissProgress] = useState(0)
|
||||
|
||||
const typewriterRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const dismissTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const progressRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
// Demo: Add messages periodically
|
||||
useEffect(() => {
|
||||
const addDemoMessage = (index: number) => {
|
||||
const demo = DEMO_MESSAGES[index % DEMO_MESSAGES.length]
|
||||
const message: FloatingMessageData = {
|
||||
...demo,
|
||||
id: `msg-${Date.now()}-${index}`,
|
||||
timestamp: new Date(),
|
||||
}
|
||||
addToQueue(message)
|
||||
}
|
||||
|
||||
// First message after 3 seconds
|
||||
const timer1 = setTimeout(() => addDemoMessage(0), 3000)
|
||||
// Second message after 15 seconds
|
||||
const timer2 = setTimeout(() => addDemoMessage(1), 15000)
|
||||
// Third message after 30 seconds
|
||||
const timer3 = setTimeout(() => addDemoMessage(2), 30000)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer1)
|
||||
clearTimeout(timer2)
|
||||
clearTimeout(timer3)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Add message to queue
|
||||
const addToQueue = useCallback(
|
||||
(message: FloatingMessageData) => {
|
||||
setMessageQueue((prev) => {
|
||||
if (prev.length >= maxQueue) {
|
||||
return [...prev.slice(1), message]
|
||||
}
|
||||
return [...prev, message]
|
||||
})
|
||||
},
|
||||
[maxQueue]
|
||||
)
|
||||
|
||||
// Process queue
|
||||
useEffect(() => {
|
||||
if (!currentMessage && messageQueue.length > 0 && !isExiting) {
|
||||
const next = messageQueue[0]
|
||||
setMessageQueue((prev) => prev.slice(1))
|
||||
setCurrentMessage(next)
|
||||
setIsVisible(true)
|
||||
setDisplayedText('')
|
||||
setIsTyping(true)
|
||||
setDismissProgress(0)
|
||||
reportAnimationStart()
|
||||
}
|
||||
}, [currentMessage, messageQueue, isExiting, reportAnimationStart])
|
||||
|
||||
// Typewriter effect
|
||||
useEffect(() => {
|
||||
if (!currentMessage || !isTyping) return
|
||||
|
||||
const fullText = currentMessage.content
|
||||
let charIndex = 0
|
||||
|
||||
if (settings.enableTypewriter) {
|
||||
const speed = Math.round(25 * settings.animationSpeed)
|
||||
typewriterRef.current = setInterval(() => {
|
||||
charIndex++
|
||||
setDisplayedText(fullText.slice(0, charIndex))
|
||||
|
||||
if (charIndex >= fullText.length) {
|
||||
if (typewriterRef.current) clearInterval(typewriterRef.current)
|
||||
setIsTyping(false)
|
||||
}
|
||||
}, speed)
|
||||
} else {
|
||||
setDisplayedText(fullText)
|
||||
setIsTyping(false)
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (typewriterRef.current) clearInterval(typewriterRef.current)
|
||||
}
|
||||
}, [currentMessage, isTyping, settings.enableTypewriter, settings.animationSpeed])
|
||||
|
||||
// Auto-dismiss timer with progress
|
||||
useEffect(() => {
|
||||
if (!currentMessage || autoDismissMs <= 0 || isTyping || isReplying) return
|
||||
|
||||
const startTime = Date.now()
|
||||
const updateProgress = () => {
|
||||
const elapsed = Date.now() - startTime
|
||||
const progress = Math.min(100, (elapsed / autoDismissMs) * 100)
|
||||
setDismissProgress(progress)
|
||||
|
||||
if (progress >= 100) {
|
||||
handleDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
progressRef.current = setInterval(updateProgress, 50)
|
||||
dismissTimerRef.current = setTimeout(handleDismiss, autoDismissMs)
|
||||
|
||||
return () => {
|
||||
if (progressRef.current) clearInterval(progressRef.current)
|
||||
if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current)
|
||||
}
|
||||
}, [currentMessage, autoDismissMs, isTyping, isReplying])
|
||||
|
||||
// Dismiss handler
|
||||
const handleDismiss = useCallback(() => {
|
||||
if (progressRef.current) clearInterval(progressRef.current)
|
||||
if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current)
|
||||
|
||||
setIsExiting(true)
|
||||
const exitDuration = Math.round(MOTION.accelerate.duration * settings.animationSpeed)
|
||||
|
||||
setTimeout(() => {
|
||||
if (currentMessage) {
|
||||
onDismiss?.(currentMessage)
|
||||
}
|
||||
setCurrentMessage(null)
|
||||
setIsVisible(false)
|
||||
setIsExiting(false)
|
||||
setDisplayedText('')
|
||||
setReplyText('')
|
||||
setIsReplying(false)
|
||||
setDismissProgress(0)
|
||||
reportAnimationEnd()
|
||||
}, exitDuration)
|
||||
}, [currentMessage, onDismiss, reportAnimationEnd, settings.animationSpeed])
|
||||
|
||||
// Open conversation
|
||||
const handleOpen = useCallback(() => {
|
||||
if (currentMessage) {
|
||||
onOpen?.(currentMessage)
|
||||
handleDismiss()
|
||||
}
|
||||
}, [currentMessage, onOpen, handleDismiss])
|
||||
|
||||
// Start reply
|
||||
const handleReplyClick = useCallback(() => {
|
||||
setIsReplying(true)
|
||||
// Cancel auto-dismiss while replying
|
||||
if (progressRef.current) clearInterval(progressRef.current)
|
||||
if (dismissTimerRef.current) clearTimeout(dismissTimerRef.current)
|
||||
}, [])
|
||||
|
||||
// Send reply
|
||||
const handleSendReply = useCallback(() => {
|
||||
if (!replyText.trim() || !currentMessage) return
|
||||
onReply?.(currentMessage, replyText)
|
||||
handleDismiss()
|
||||
}, [replyText, currentMessage, onReply, handleDismiss])
|
||||
|
||||
// Cancel reply
|
||||
const handleCancelReply = useCallback(() => {
|
||||
setIsReplying(false)
|
||||
setReplyText('')
|
||||
}, [])
|
||||
|
||||
// Keyboard handler
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSendReply()
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
if (isReplying) {
|
||||
handleCancelReply()
|
||||
} else {
|
||||
handleDismiss()
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleSendReply, isReplying, handleCancelReply, handleDismiss]
|
||||
)
|
||||
|
||||
if (!isVisible) return null
|
||||
|
||||
// Position styles
|
||||
const positionStyles: React.CSSProperties = {
|
||||
position: 'fixed',
|
||||
zIndex: LAYERS.overlay.zIndex,
|
||||
...(position.includes('top') ? { top: offset.y } : { bottom: offset.y }),
|
||||
...(position.includes('right') ? { right: offset.x } : { left: offset.x }),
|
||||
}
|
||||
|
||||
// Animation styles
|
||||
const animationDuration = Math.round(MOTION.decelerate.duration * settings.animationSpeed)
|
||||
const exitDuration = Math.round(MOTION.accelerate.duration * settings.animationSpeed)
|
||||
|
||||
const transformOrigin = position.includes('right') ? 'right' : 'left'
|
||||
const slideDirection = position.includes('right') ? 'translateX(120%)' : 'translateX(-120%)'
|
||||
|
||||
// Priority color
|
||||
const priorityColor =
|
||||
currentMessage?.priority === 'urgent'
|
||||
? 'from-red-500 to-orange-500'
|
||||
: currentMessage?.priority === 'high'
|
||||
? 'from-amber-500 to-yellow-500'
|
||||
: 'from-purple-500 to-pink-500'
|
||||
|
||||
// Material styles - Ultra transparent for floating effect (4%)
|
||||
const cardBg = isDark
|
||||
? 'rgba(255, 255, 255, 0.04)'
|
||||
: 'rgba(255, 255, 255, 0.08)'
|
||||
|
||||
const shadow = '0 8px 32px rgba(0, 0, 0, 0.3)'
|
||||
const blur = settings.enableBlur ? `blur(${Math.round(24 * settings.blurIntensity)}px) saturate(180%)` : 'none'
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
...positionStyles,
|
||||
transform: isExiting ? slideDirection : 'translateX(0)',
|
||||
opacity: isExiting ? 0 : 1,
|
||||
transition: `
|
||||
transform ${isExiting ? exitDuration : animationDuration}ms ${
|
||||
isExiting ? MOTION.accelerate.easing : MOTION.spring.easing
|
||||
},
|
||||
opacity ${isExiting ? exitDuration : animationDuration}ms ${MOTION.standard.easing}
|
||||
`,
|
||||
transformOrigin,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-96 max-w-[calc(100vw-3rem)] rounded-3xl overflow-hidden"
|
||||
style={{
|
||||
background: cardBg,
|
||||
backdropFilter: blur,
|
||||
WebkitBackdropFilter: blur,
|
||||
boxShadow: shadow,
|
||||
border: '1px solid rgba(255, 255, 255, 0.12)',
|
||||
}}
|
||||
>
|
||||
{/* Progress bar */}
|
||||
{autoDismissMs > 0 && !isReplying && (
|
||||
<div className="h-1 w-full bg-black/5 dark:bg-white/5">
|
||||
<div
|
||||
className={`h-full bg-gradient-to-r ${priorityColor} transition-all duration-100`}
|
||||
style={{ width: `${100 - dismissProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Avatar */}
|
||||
<div
|
||||
className={`w-12 h-12 rounded-2xl flex items-center justify-center text-lg font-semibold text-white bg-gradient-to-br ${priorityColor}`}
|
||||
style={{
|
||||
boxShadow: isDark
|
||||
? '0 4px 12px rgba(0,0,0,0.3)'
|
||||
: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
>
|
||||
{currentMessage?.senderInitials}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-white">
|
||||
{currentMessage?.senderName}
|
||||
</h3>
|
||||
<p className="text-xs text-white/50">
|
||||
{currentMessage?.isGroup ? 'Gruppenchat' : 'Direktnachricht'} • Jetzt
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="p-2 rounded-xl transition-all hover:bg-white/10 text-white/50"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Message content */}
|
||||
<div
|
||||
className="mb-4 p-4 rounded-2xl"
|
||||
style={{ background: 'rgba(255, 255, 255, 0.06)' }}
|
||||
>
|
||||
<p className="text-sm leading-relaxed text-white/90">
|
||||
{displayedText}
|
||||
{isTyping && (
|
||||
<span
|
||||
className={`inline-block w-0.5 h-4 ml-0.5 animate-pulse ${
|
||||
isDark ? 'bg-purple-400' : 'bg-purple-600'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Reply input */}
|
||||
{isReplying && (
|
||||
<div className="mb-4">
|
||||
<textarea
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Antwort schreiben..."
|
||||
autoFocus
|
||||
rows={2}
|
||||
className="w-full px-4 py-3 rounded-xl text-sm resize-none transition-all focus:outline-none focus:ring-2 bg-white/10 border border-white/20 text-white placeholder-white/40 focus:ring-purple-500/50"
|
||||
/>
|
||||
<p className="text-xs mt-1.5 text-white/40">
|
||||
Enter zum Senden • Esc zum Abbrechen
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{isReplying ? (
|
||||
<>
|
||||
<button
|
||||
onClick={handleSendReply}
|
||||
disabled={!replyText.trim()}
|
||||
className={`flex-1 py-2.5 rounded-xl font-medium transition-all disabled:opacity-50 bg-gradient-to-r ${priorityColor} text-white hover:shadow-lg`}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
|
||||
/>
|
||||
</svg>
|
||||
Senden
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelReply}
|
||||
className="px-4 py-2.5 rounded-xl font-medium transition-all bg-white/10 text-white/80 hover:bg-white/20"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={handleReplyClick}
|
||||
className={`flex-1 py-2.5 rounded-xl font-medium transition-all bg-gradient-to-r ${priorityColor} text-white hover:shadow-lg`}
|
||||
>
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 10h10a8 8 0 018 8v2M3 10l6 6m-6-6l6-6"
|
||||
/>
|
||||
</svg>
|
||||
Antworten
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleOpen}
|
||||
className="px-4 py-2.5 rounded-xl font-medium transition-all bg-white/10 text-white/80 hover:bg-white/20"
|
||||
>
|
||||
Oeffnen
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="px-4 py-2.5 rounded-xl font-medium transition-all bg-white/10 text-white/80 hover:bg-white/20"
|
||||
>
|
||||
Spaeter
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Queue indicator */}
|
||||
{messageQueue.length > 0 && (
|
||||
<div
|
||||
className="px-5 py-3 text-center text-sm font-medium border-t bg-white/5 text-purple-300 border-white/10"
|
||||
>
|
||||
+{messageQueue.length} weitere Nachricht{messageQueue.length > 1 ? 'en' : ''}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
314
studio-v2/components/spatial-ui/SpatialCard.tsx
Normal file
314
studio-v2/components/spatial-ui/SpatialCard.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
13
studio-v2/components/spatial-ui/index.ts
Normal file
13
studio-v2/components/spatial-ui/index.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Spatial UI Components
|
||||
*
|
||||
* A collection of components built on the Spatial UI design system.
|
||||
* These components implement depth-aware interactions, adaptive quality,
|
||||
* and cinematic visual effects.
|
||||
*/
|
||||
|
||||
export { SpatialCard, SpatialCardHeader, SpatialCardContent, SpatialCardFooter } from './SpatialCard'
|
||||
export type { CardMaterial, CardElevation } from './SpatialCard'
|
||||
|
||||
export { FloatingMessage } from './FloatingMessage'
|
||||
export type { FloatingMessageData } from './FloatingMessage'
|
||||
Reference in New Issue
Block a user