769 lines
26 KiB
TypeScript
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
|
|
} |