Files
breakpilot-lehrer/studio-v2/components/spatial-ui/FloatingMessage.tsx
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

497 lines
16 KiB
TypeScript

'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'} &bull; 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 &bull; 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>
)
}