nsfwapp/frontend/src/components/ui/ToastProvider.tsx
2026-06-06 14:58:55 +02:00

769 lines
26 KiB
TypeScript

// 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<Toast, 'id'>) => string
remove: (id: string) => void
clear: () => void
}
const ToastContext = createContext<ToastContextValue | null>(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<ToastInternal[]>([])
const [notificationsEnabled, setNotificationsEnabled] = useState(true)
const autoCloseTimersRef = useRef<Record<string, number>>({})
const finalizeRemoveTimersRef = useRef<Record<string, number>>({})
const toastStartedAtRef = useRef<Record<string, number>>({})
const toastRemainingMsRef = useRef<Record<string, number>>({})
const toastPausedRef = useRef<Record<string, boolean>>({})
const swipeStateRef = useRef<Record<string, SwipeState>>({})
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<Toast, 'id'>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<ToastContextValue>(() => ({ 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 (
<ToastContext.Provider value={ctx}>
{children}
<style>{`
@keyframes toast-progress {
from { transform: scaleX(1); }
to { transform: scaleX(0); }
}
.toast-progress-bar {
animation-name: toast-progress;
animation-timing-function: linear;
animation-fill-mode: forwards;
transform-origin: left center;
will-change: transform;
}
`}</style>
{/* Live region */}
<div
aria-live="assertive"
className={['pointer-events-none fixed z-[80] inset-x-0', insetCls].join(' ')}
>
<div
className={[
'flex w-full justify-center px-3 py-4 sm:px-6 sm:py-6',
desktopPosCls,
].join(' ')}
>
<div
className={[
'flex w-full max-w-[22rem] flex-col items-center gap-2.5 sm:max-w-none sm:gap-3',
desktopAlignCls,
].join(' ')}
>
{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 (
<Transition
key={t.id}
appear
show={t.open}
enter="transform transition ease-out duration-250"
enterFrom="opacity-0 -translate-y-3 sm:-translate-y-2"
enterTo="opacity-100 translate-y-0"
leave="transform transition ease-in duration-200"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 -translate-y-2 sm:-translate-y-3"
>
<div
role={isClickable ? 'button' : 'status'}
className={[
'pointer-events-auto relative w-[22rem] max-w-[calc(100vw-1.5rem)] overflow-hidden rounded-xl',
'border bg-white dark:bg-slate-900',
'shadow-sm',
'ring-1 ring-black/5 dark:ring-white/5',
isClickable
? 'cursor-pointer transition-[box-shadow,background-color] duration-150 hover:bg-gray-50 dark:hover:bg-slate-800/90 hover:shadow-lg hover:ring-black/10 dark:hover:ring-white/10'
: '',
borderFor(t.type),
].join(' ')}
style={swipeStyle}
onMouseEnter={() => 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) =====
<div className="relative pr-12 sm:pr-14">
<div className="flex items-stretch">
{/* Bild links: edge-to-edge */}
<div className="shrink-0">
<img
src={img}
alt={imgAlt}
loading="lazy"
referrerPolicy="no-referrer"
className={[
'h-full min-h-[72px] w-20 sm:w-24',
'object-cover object-center',
'rounded-l-xl',
'border-r border-black/5 dark:border-white/10',
'bg-gray-100 dark:bg-white/5',
].join(' ')}
/>
</div>
{/* Textbereich mit Padding */}
<div className="min-w-0 flex-1 py-3 pl-3 pr-2 sm:py-3.5 sm:pl-3.5 sm:pr-3">
<p className="truncate text-sm font-semibold leading-5 text-gray-900 dark:text-white">
{title}
</p>
{msg ? (
<p
className="mt-0.5 text-sm leading-5 text-gray-600 dark:text-gray-300 break-words"
style={toastMessageClampStyle}
title={msgWasShortened ? fullMsg : undefined}
>
{msg}
</p>
) : null}
</div>
</div>
{/* Close-Button vertikal mittig rechts */}
<button
type="button"
onClick={(e) => {
e.stopPropagation()
remove(t.id)
}}
title="Schließen"
className={[
'absolute right-2 top-1/2 -translate-y-1/2',
'inline-flex h-9 w-9 sm:h-10 sm:w-10 items-center justify-center rounded-full',
'text-gray-400 hover:text-gray-700 dark:text-gray-300 dark:hover:text-white',
'hover:bg-gray-100 dark:hover:bg-white/10',
'active:scale-[0.98] active:bg-gray-200/80 dark:active:bg-white/15',
'transition',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2',
'dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-900',
].join(' ')}
>
<span className="sr-only">Schließen</span>
<XMarkIcon aria-hidden="true" className="size-5.5 sm:size-6" />
</button>
</div>
) : (
// ===== Layout OHNE Bild: Icon mittig wie vorher =====
<div className="relative pr-12 py-2.5 sm:pr-14">
<div className="flex items-center gap-3 px-3 sm:px-3.5">
<div className="shrink-0">
<div
className={[
'inline-flex h-12 w-12 sm:h-14 sm:w-14 items-center justify-center rounded-full',
accents.iconBg,
'ring-1 ring-black/5 dark:ring-white/10',
].join(' ')}
>
<Icon className={['size-5.5 sm:size-6', cls].join(' ')} aria-hidden="true" />
</div>
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold leading-5 text-gray-900 dark:text-white">
{title}
</p>
{msg ? (
<p
className="mt-0.5 text-sm leading-5 text-gray-600 dark:text-gray-300 break-words"
style={toastMessageClampStyle}
title={msgWasShortened ? fullMsg : undefined}
>
{msg}
</p>
) : null}
</div>
</div>
{/* Close-Button vertikal mittig rechts */}
<button
type="button"
onClick={(e) => {
e.stopPropagation()
remove(t.id)
}}
title="Schließen"
className={[
'absolute right-2 top-1/2 -translate-y-1/2',
'inline-flex h-9 w-9 sm:h-10 sm:w-10 items-center justify-center rounded-full',
'text-gray-400 hover:text-gray-700 dark:text-gray-300 dark:hover:text-white',
'hover:bg-gray-100 dark:hover:bg-white/10',
'active:scale-[0.98] active:bg-gray-200/80 dark:active:bg-white/15',
'transition',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2',
'dark:focus-visible:ring-indigo-400 dark:focus-visible:ring-offset-slate-900',
].join(' ')}
>
<span className="sr-only">Schließen</span>
<XMarkIcon aria-hidden="true" className="size-5.5 sm:size-6" />
</button>
</div>
)}
</div>
</Transition>
)
})}
</div>
</div>
</div>
</ToastContext.Provider>
)
}
export function useToast() {
const ctx = useContext(ToastContext)
if (!ctx) throw new Error('useToast must be used within <ToastProvider>')
return ctx
}