// frontend\src\components\ui\ToastProvider.tsx 'use client' import { useEffect, useRef, useState, useMemo, useCallback, createContext, useContext, type CSSProperties, type ReactNode, type PointerEvent } from 'react' import { Transition } from '@headlessui/react' import { CheckCircleIcon, XCircleIcon, InformationCircleIcon, ExclamationTriangleIcon, } from '@heroicons/react/24/outline' import { XMarkIcon } from '@heroicons/react/20/solid' type ToastType = 'success' | 'error' | 'info' | 'warning' export type Toast = { id: string type: ToastType title?: string message?: string imageUrl?: string imageAlt?: string durationMs?: number // auto close onClick?: () => void closeOnClick?: boolean // default true (bei klickbaren Toasts) } type ToastInternal = Toast & { open: boolean } type SwipeState = { pointerId: number | null startX: number startY: number dx: number dy: number dragging: boolean lock: 'none' | 'x' | 'y' dismissing: boolean } type ToastContextValue = { push: (t: Omit) => string remove: (id: string) => void clear: () => void } const ToastContext = createContext(null) const TOAST_LEAVE_MS = 220 const TOAST_SWIPE_DISMISS_PX = 96 const TOAST_SWIPE_LOCK_PX = 10 const TOAST_MESSAGE_MAX_CHARS = 260 const toastMessageClampStyle: CSSProperties = { display: '-webkit-box', WebkitLineClamp: 3, WebkitBoxOrient: 'vertical', overflow: 'hidden', } function compactToastMessage(input?: string, max = TOAST_MESSAGE_MAX_CHARS) { const text = String(input || '') .replace(/\s+/g, ' ') .trim() if (!text) return '' if (text.length <= max) { return text } return `${text.slice(0, max).trimEnd()}…` } function iconFor(type: ToastType) { switch (type) { case 'success': return { Icon: CheckCircleIcon, cls: 'text-emerald-600 dark:text-emerald-400' } case 'error': return { Icon: XCircleIcon, cls: 'text-rose-600 dark:text-rose-400' } case 'warning': return { Icon: ExclamationTriangleIcon, cls: 'text-amber-600 dark:text-amber-400' } default: return { Icon: InformationCircleIcon, cls: 'text-sky-600 dark:text-sky-400' } } } function borderFor(type: ToastType) { switch (type) { case 'success': return 'border-emerald-200/80 dark:border-emerald-400/20' case 'error': return 'border-rose-200/80 dark:border-rose-400/20' case 'warning': return 'border-amber-200/80 dark:border-amber-400/20' default: return 'border-sky-200/80 dark:border-sky-400/20' } } function accentFor(type: ToastType) { switch (type) { case 'success': return { line: 'bg-emerald-500 dark:bg-emerald-400', progressTrack: 'bg-emerald-500/10 dark:bg-emerald-400/10', progressFill: 'bg-emerald-500/70 dark:bg-emerald-400/70', iconBg: 'bg-emerald-50 dark:bg-emerald-400/10', } case 'error': return { line: 'bg-rose-500 dark:bg-rose-400', progressTrack: 'bg-rose-500/10 dark:bg-rose-400/10', progressFill: 'bg-rose-500/70 dark:bg-rose-400/70', iconBg: 'bg-rose-50 dark:bg-rose-400/10', } case 'warning': return { line: 'bg-amber-500 dark:bg-amber-400', progressTrack: 'bg-amber-500/10 dark:bg-amber-400/10', progressFill: 'bg-amber-500/70 dark:bg-amber-400/70', iconBg: 'bg-amber-50 dark:bg-amber-400/10', } default: return { line: 'bg-sky-500 dark:bg-sky-400', progressTrack: 'bg-sky-500/10 dark:bg-sky-400/10', progressFill: 'bg-sky-500/70 dark:bg-sky-400/70', iconBg: 'bg-sky-50 dark:bg-sky-400/10', } } } function titleDefault(type: ToastType) { switch (type) { case 'success': return 'Erfolg' case 'error': return 'Fehler' case 'warning': return 'Hinweis' default: return 'Info' } } function uid() { return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}` } export function ToastProvider({ children, maxToasts = 3, defaultDurationMs = 3500, position = 'bottom-right', }: { children: ReactNode maxToasts?: number defaultDurationMs?: number position?: 'bottom-right' | 'top-right' | 'bottom-left' | 'top-left' }) { const [toasts, setToasts] = useState([]) const [notificationsEnabled, setNotificationsEnabled] = useState(true) const autoCloseTimersRef = useRef>({}) const finalizeRemoveTimersRef = useRef>({}) const toastStartedAtRef = useRef>({}) const toastRemainingMsRef = useRef>({}) const toastPausedRef = useRef>({}) const swipeStateRef = useRef>({}) const [, forceSwipeRender] = useState(0) const clearTimersFor = useCallback((id: string) => { const autoId = autoCloseTimersRef.current[id] if (autoId) { window.clearTimeout(autoId) delete autoCloseTimersRef.current[id] } const finId = finalizeRemoveTimersRef.current[id] if (finId) { window.clearTimeout(finId) delete finalizeRemoveTimersRef.current[id] } delete toastStartedAtRef.current[id] delete toastRemainingMsRef.current[id] delete toastPausedRef.current[id] delete swipeStateRef.current[id] }, []) const loadNotificationSetting = useCallback(async () => { try { const r = await fetch('/api/settings', { cache: 'no-store' }) if (!r.ok) return const data = await r.json() setNotificationsEnabled(!!(data?.enableNotifications ?? true)) } catch { // ignorieren -> default true } }, []) useEffect(() => { loadNotificationSetting() const onUpdated = () => loadNotificationSetting() window.addEventListener('recorder-settings-updated', onUpdated) return () => window.removeEventListener('recorder-settings-updated', onUpdated) }, [loadNotificationSetting]) useEffect(() => { if (!notificationsEnabled) { // Nur Fehler sichtbar lassen (animiert schließen) setToasts((prev) => prev.map((t) => (t.type === 'error' ? t : { ...t, open: false }))) const ids = toasts.filter((t) => t.type !== 'error').map((t) => t.id) ids.forEach((id) => { clearTimersFor(id) finalizeRemoveTimersRef.current[id] = window.setTimeout(() => { setToasts((prev) => prev.filter((t) => t.id !== id)) delete finalizeRemoveTimersRef.current[id] }, TOAST_LEAVE_MS) }) } // eslint-disable-next-line react-hooks/exhaustive-deps }, [notificationsEnabled]) const remove = useCallback( (id: string) => { const cur = swipeStateRef.current[id] if (cur) { swipeStateRef.current[id] = { ...cur, dismissing: true, dragging: false, } forceSwipeRender((n) => n + 1) } setToasts((prev) => prev.map((t) => (t.id === id ? { ...t, open: false } : t))) clearTimersFor(id) finalizeRemoveTimersRef.current[id] = window.setTimeout(() => { setToasts((prev) => prev.filter((t) => t.id !== id)) delete finalizeRemoveTimersRef.current[id] delete swipeStateRef.current[id] }, TOAST_LEAVE_MS) }, [clearTimersFor] ) const clear = useCallback(() => { setToasts((prev) => prev.map((t) => ({ ...t, open: false }))) Object.keys(autoCloseTimersRef.current).forEach((id) => { window.clearTimeout(autoCloseTimersRef.current[id]) delete autoCloseTimersRef.current[id] }) Object.keys(finalizeRemoveTimersRef.current).forEach((id) => { window.clearTimeout(finalizeRemoveTimersRef.current[id]) delete finalizeRemoveTimersRef.current[id] }) window.setTimeout(() => setToasts([]), TOAST_LEAVE_MS) }, []) const startAutoCloseTimer = useCallback( (id: string, ms: number) => { if (!ms || ms <= 0) return // alten Timer sicher entfernen const old = autoCloseTimersRef.current[id] if (old) { window.clearTimeout(old) delete autoCloseTimersRef.current[id] } toastStartedAtRef.current[id] = Date.now() toastRemainingMsRef.current[id] = ms toastPausedRef.current[id] = false autoCloseTimersRef.current[id] = window.setTimeout(() => { remove(id) delete autoCloseTimersRef.current[id] delete toastStartedAtRef.current[id] delete toastRemainingMsRef.current[id] delete toastPausedRef.current[id] }, ms) }, [remove] ) const push = useCallback( (t: Omit) => { if (!notificationsEnabled && t.type !== 'error') return '' const id = uid() const durationMs = t.durationMs ?? defaultDurationMs setToasts((prev) => { const next: ToastInternal[] = [{ ...t, id, durationMs, open: true }, ...prev] const limit = Math.max(1, maxToasts) const kept = next.slice(0, limit) const dropped = next.slice(limit) dropped.forEach((d) => clearTimersFor(d.id)) return kept }) if (durationMs && durationMs > 0) { startAutoCloseTimer(id, durationMs) } return id }, [defaultDurationMs, maxToasts, notificationsEnabled, clearTimersFor, startAutoCloseTimer] ) const pauseAutoCloseTimer = useCallback((id: string) => { if (toastPausedRef.current[id]) return const timerId = autoCloseTimersRef.current[id] if (!timerId) return window.clearTimeout(timerId) delete autoCloseTimersRef.current[id] const startedAt = toastStartedAtRef.current[id] ?? Date.now() const remaining = toastRemainingMsRef.current[id] ?? 0 const elapsed = Date.now() - startedAt const nextRemaining = Math.max(0, remaining - elapsed) toastRemainingMsRef.current[id] = nextRemaining toastPausedRef.current[id] = true }, []) const resumeAutoCloseTimer = useCallback( (id: string) => { if (!toastPausedRef.current[id]) return const remaining = toastRemainingMsRef.current[id] ?? 0 if (remaining <= 0) { remove(id) return } toastPausedRef.current[id] = false toastStartedAtRef.current[id] = Date.now() autoCloseTimersRef.current[id] = window.setTimeout(() => { remove(id) delete autoCloseTimersRef.current[id] delete toastStartedAtRef.current[id] delete toastRemainingMsRef.current[id] delete toastPausedRef.current[id] }, remaining) }, [remove] ) const isTouchLikePointer = (e: PointerEvent) => { return e.pointerType === 'touch' || e.pointerType === 'pen' } const getSwipeState = (id: string): SwipeState => { return ( swipeStateRef.current[id] ?? { pointerId: null, startX: 0, startY: 0, dx: 0, dy: 0, dragging: false, lock: 'none', dismissing: false, } ) } const handleToastPointerDown = useCallback((id: string, e: PointerEvent) => { if (!isTouchLikePointer(e)) return swipeStateRef.current[id] = { pointerId: e.pointerId, startX: e.clientX, startY: e.clientY, dx: 0, dy: 0, dragging: false, lock: 'none', dismissing: false, } pauseAutoCloseTimer(id) forceSwipeRender((n) => n + 1) }, [pauseAutoCloseTimer]) const handleToastPointerMove = useCallback((id: string, e: PointerEvent) => { const s = swipeStateRef.current[id] if (!s) return if (s.pointerId !== e.pointerId) return if (s.dismissing) return const dx = e.clientX - s.startX const dy = e.clientY - s.startY let lock = s.lock if (lock === 'none') { const absX = Math.abs(dx) const absY = Math.abs(dy) if (absX < TOAST_SWIPE_LOCK_PX && absY < TOAST_SWIPE_LOCK_PX) { swipeStateRef.current[id] = { ...s, dx, dy } forceSwipeRender((n) => n + 1) return } lock = absX > absY ? 'x' : 'y' } if (lock === 'y') { swipeStateRef.current[id] = { ...s, dx, dy, lock, dragging: false } forceSwipeRender((n) => n + 1) return } swipeStateRef.current[id] = { ...s, dx, dy, lock, dragging: true, } forceSwipeRender((n) => n + 1) }, []) const handleToastPointerEnd = useCallback((id: string, e?: PointerEvent) => { const s = swipeStateRef.current[id] if (!s) return if (e && s.pointerId !== e.pointerId) return const shouldDismiss = s.lock === 'x' && s.dx <= -TOAST_SWIPE_DISMISS_PX if (shouldDismiss) { remove(id) return } swipeStateRef.current[id] = { ...s, pointerId: null, dx: 0, dy: 0, dragging: false, lock: 'none', dismissing: false, } resumeAutoCloseTimer(id) forceSwipeRender((n) => n + 1) }, [remove, resumeAutoCloseTimer]) const handleToastPointerCancel = useCallback((id: string, e?: PointerEvent) => { const s = swipeStateRef.current[id] if (!s) return if (e && s.pointerId !== e.pointerId) return swipeStateRef.current[id] = { ...s, pointerId: null, dx: 0, dy: 0, dragging: false, lock: 'none', dismissing: false, } resumeAutoCloseTimer(id) forceSwipeRender((n) => n + 1) }, [resumeAutoCloseTimer]) useEffect(() => { return () => { Object.values(autoCloseTimersRef.current).forEach((n) => window.clearTimeout(n)) Object.values(finalizeRemoveTimersRef.current).forEach((n) => window.clearTimeout(n)) swipeStateRef.current= {} } }, []) const ctx = useMemo(() => ({ push, remove, clear }), [push, remove, clear]) const desktopPosCls = position.startsWith('top') ? 'sm:items-start' : 'sm:items-end' const desktopAlignCls = position.endsWith('left') ? 'sm:items-start' : 'sm:items-end' // Mobile immer oben mittig. // Desktop bleibt wie über `position` gesteuert. const insetCls = position.startsWith('top') ? 'top-0 bottom-auto' : 'top-0 bottom-auto sm:bottom-0 sm:top-auto' return ( {children} {/* Live region */}
{toasts.map((t) => { const { Icon, cls } = iconFor(t.type) const accents = accentFor(t.type) const title = (t.title || '').trim() || titleDefault(t.type) const fullMsg = (t.message || '').trim() const msg = compactToastMessage(fullMsg) const msgWasShortened = fullMsg.length > msg.length const img = (t.imageUrl || '').trim() const imgAlt = (t.imageAlt || title).trim() const isClickable = typeof t.onClick === 'function' const swipe = getSwipeState(t.id) const absDx = Math.abs(swipe.dx) const dragOpacity = swipe.dragging ? Math.max(0.35, 1 - absDx / 220) : 1 const dragScale = swipe.dragging ? Math.max(0.985, 1 - absDx / 1600) : 1 const swipeStyle: CSSProperties = { transform: `translateX(${swipe.dx}px) scale(${dragScale})`, opacity: dragOpacity, transition: swipe.dragging ? 'none' : swipe.dismissing ? 'transform 180ms ease-out, opacity 180ms ease-out' : 'transform 220ms ease, opacity 220ms ease', touchAction: 'pan-y', } return (
pauseAutoCloseTimer(t.id)} onMouseLeave={() => resumeAutoCloseTimer(t.id)} onFocus={() => pauseAutoCloseTimer(t.id)} onBlur={() => resumeAutoCloseTimer(t.id)} onPointerDown={(e) => handleToastPointerDown(t.id, e)} onPointerMove={(e) => handleToastPointerMove(t.id, e)} onPointerUp={(e) => handleToastPointerEnd(t.id, e)} onPointerCancel={(e) => handleToastPointerCancel(t.id, e)} onLostPointerCapture={(e) => handleToastPointerCancel(t.id, e)} onClick={() => { if (swipe.dragging || Math.abs(swipe.dx) > 8) return if (!t.onClick) return t.onClick() if (t.closeOnClick !== false) remove(t.id) }} onKeyDown={(e) => { if (!t.onClick) return if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() t.onClick() if (t.closeOnClick !== false) remove(t.id) } }} tabIndex={isClickable ? 0 : undefined} aria-label={isClickable ? `${title} öffnen` : undefined} > {img ? ( // ===== Layout MIT Bild: Bild links edge-to-edge (oben/unten ohne Padding) =====
{/* Bild links: edge-to-edge */}
{imgAlt}
{/* Textbereich mit Padding */}

{title}

{msg ? (

{msg}

) : null}
{/* Close-Button vertikal mittig rechts */}
) : ( // ===== Layout OHNE Bild: Icon mittig wie vorher =====

{title}

{msg ? (

{msg}

) : null}
{/* Close-Button vertikal mittig rechts */}
)}
) })}
) } export function useToast() { const ctx = useContext(ToastContext) if (!ctx) throw new Error('useToast must be used within ') return ctx }