// frontend\src\components\ui\SwipeCard.tsx 'use client' import * as React from 'react' import { FireIcon as FireSolidIcon } from '@heroicons/react/24/solid' import { createRoot } from 'react-dom/client' function cn(...parts: Array) { return parts.filter(Boolean).join(' ') } function getGlobalFxLayer(): HTMLDivElement | null { if (typeof document === 'undefined') return null const ID = '__swipecard_hot_fx_layer__' let el = document.getElementById(ID) as HTMLDivElement | null if (!el) { el = document.createElement('div') el.id = ID el.style.position = 'fixed' el.style.inset = '0' el.style.pointerEvents = 'none' el.style.zIndex = '2147483647' // optional, aber nice: ;(el.style as any).contain = 'layout style paint' document.body.appendChild(el) } return el } export type SwipeAction = { label: React.ReactNode className?: string } export type SwipeCardProps = { children: React.ReactNode /** Swipe an/aus (z.B. nur mobile view) */ enabled?: boolean /** blockiert Swipe + Tap */ disabled?: boolean /** Tap ohne Swipe (z.B. Player öffnen) */ onTap?: () => void /** * Rückgabe: * - true/void => Aktion erfolgreich, Karte fliegt raus (translate offscreen) * - false => Aktion fehlgeschlagen => Karte snappt zurück */ onSwipeLeft: () => boolean | void | Promise onSwipeRight: () => boolean | void | Promise /** optionales Styling am äußeren Wrapper */ className?: string /** Action-Bereiche */ leftAction?: SwipeAction // standard: Behalten rightAction?: SwipeAction // standard: Löschen /** Ab welcher Strecke wird ausgelöst? */ thresholdPx?: number thresholdRatio?: number // Anteil der Kartenbreite, z.B. 0.35 /** Animation timings */ snapMs?: number commitMs?: number /** * Swipe soll NICHT starten, wenn der Pointer im unteren Bereich startet. * Praktisch für native Video-Controls (Progressbar) beim Inline-Playback. * Beispiel: 72 (px) = unterste 72px sind "swipe-frei". */ ignoreFromBottomPx?: number /** * Optional: CSS-Selector, bei dem Swipe-Start komplett ignoriert wird. * (z.B. setze data-swipe-ignore auf Elemente, die eigene Gesten haben) */ ignoreSelector?: string /** * Optional: CSS-Selector, bei dem ein "Tap" NICHT onTap() auslösen soll. * (z.B. Buttons/Inputs innerhalb der Karte) */ tapIgnoreSelector?: string /** Doppeltippen (z.B. HOT togglen). Rückgabe false => Aktion fehlgeschlagen */ onDoubleTap?: () => boolean | void | Promise /** Wohin soll die Flamme fliegen? (Element innerhalb der Card) */ hotTargetSelector?: string /** Double-Tap Zeitfenster */ doubleTapMs?: number /** Max. Bewegung zwischen taps (px) */ doubleTapMaxMovePx?: number } export type SwipeCardHandle = { swipeLeft: (opts?: { runAction?: boolean }) => Promise swipeRight: (opts?: { runAction?: boolean }) => Promise reset: () => void } const SwipeCard = React.forwardRef(function SwipeCard( { children, enabled = true, disabled = false, onTap, onSwipeLeft, onSwipeRight, className, thresholdPx = 140, thresholdRatio = 0.28, ignoreFromBottomPx = 72, ignoreSelector = '[data-swipe-ignore]', snapMs = 180, commitMs = 180, tapIgnoreSelector = 'button,a,input,textarea,select,video[controls],video[controls] *,[data-tap-ignore]', onDoubleTap, hotTargetSelector = '[data-hot-target]', doubleTapMs = 360, doubleTapMaxMovePx = 48, }, ref ) { const cardRef = React.useRef(null) const doubleTapBusyRef = React.useRef(false) // ✅ Perf: dx pro Frame updaten (statt pro Pointer-Move) const dxRef = React.useRef(0) const rafRef = React.useRef(null) // ✅ Perf: Threshold einmal pro PointerDown berechnen (kein offsetWidth pro Move) const thresholdRef = React.useRef(0) const outerRef = React.useRef(null) const tapTimerRef = React.useRef(null) const lastTapRef = React.useRef<{ t: number; x: number; y: number } | null>(null) const pointer = React.useRef<{ id: number | null x: number y: number dragging: boolean captured: boolean tapIgnored: boolean noSwipe: boolean }>({ id: null, x: 0, y: 0, dragging: false, captured: false, tapIgnored: false, noSwipe: false }) const [dx, setDx] = React.useState(0) const [armedDir, setArmedDir] = React.useState(null) const [animMs, setAnimMs] = React.useState(0) const reset = React.useCallback(() => { // ✅ rAF cleanup if (rafRef.current != null) { cancelAnimationFrame(rafRef.current) rafRef.current = null } dxRef.current = 0 // ✅ TouchAction zurücksetzen (falls während Drag auf none gestellt) if (cardRef.current) cardRef.current.style.touchAction = 'pan-y' setAnimMs(snapMs) setDx(0) setArmedDir(null) window.setTimeout(() => setAnimMs(0), snapMs) }, [snapMs]) const commit = React.useCallback( async (dir: 'left' | 'right', runAction: boolean) => { if (rafRef.current != null) { cancelAnimationFrame(rafRef.current) rafRef.current = null } // ✅ TouchAction zurücksetzen (Sicherheit) if (cardRef.current) cardRef.current.style.touchAction = 'pan-y' const el = cardRef.current const w = el?.offsetWidth || 360 // rausfliegen lassen setAnimMs(commitMs) setArmedDir(dir === 'right' ? 'right' : 'left') const outDx = dir === 'right' ? w + 40 : -(w + 40) dxRef.current = outDx setDx(outDx) let ok: boolean | void = true if (runAction) { try { ok = dir === 'right' ? await onSwipeRight() : await onSwipeLeft() } catch { ok = false } } // wenn Aktion fehlschlägt => zurücksnappen if (ok === false) { setAnimMs(snapMs) setArmedDir(null) setDx(0) window.setTimeout(() => setAnimMs(0), snapMs) return false } return true }, [commitMs, onSwipeLeft, onSwipeRight, snapMs] ) const clearTapTimer = React.useCallback(() => { if (tapTimerRef.current != null) { window.clearTimeout(tapTimerRef.current) tapTimerRef.current = null } }, []) const softResetForTap = React.useCallback(() => { if (rafRef.current != null) { cancelAnimationFrame(rafRef.current) rafRef.current = null } dxRef.current = 0 setAnimMs(0) setDx(0) setArmedDir(null) try { const el = cardRef.current if (el) el.style.touchAction = 'pan-y' } catch {} }, []) const runHotFx = React.useCallback( (clientX?: number, clientY?: number) => { const outer = outerRef.current const card = cardRef.current if (!outer || !card) return const layer = getGlobalFxLayer() if (!layer) return // ✅ Start im Viewport (da wo getippt wurde, fallback: Mitte) let startX = typeof clientX === 'number' ? clientX : window.innerWidth / 2 let startY = typeof clientY === 'number' ? clientY : window.innerHeight / 2 // Ziel: HOT Button (falls gefunden) – ebenfalls im Viewport const targetEl = hotTargetSelector ? ((outerRef.current?.querySelector(hotTargetSelector) as HTMLElement | null) ?? (card.querySelector(hotTargetSelector) as HTMLElement | null)) : null let endX = startX let endY = startY if (targetEl) { const tr = targetEl.getBoundingClientRect() endX = tr.left + tr.width / 2 endY = tr.top + tr.height / 2 } const dx = endX - startX const dy = endY - startY // Flame node const flame = document.createElement('div') flame.style.position = 'absolute' flame.style.left = `${startX}px` flame.style.top = `${startY}px` flame.style.transform = 'translate(-50%, -50%)' flame.style.pointerEvents = 'none' flame.style.willChange = 'transform, opacity' flame.style.zIndex = '2147483647' flame.style.lineHeight = '1' flame.style.userSelect = 'none' flame.style.filter = 'drop-shadow(0 10px 16px rgba(0,0,0,0.22))' // ✅ Heroicon per React-Root rendern (Client-sicher) const inner = document.createElement('div') inner.style.width = '30px' inner.style.height = '30px' inner.style.color = '#f59e0b' // amber // optional: damit SVG nicht “inline” komisch sitzt inner.style.display = 'block' flame.appendChild(inner) layer.appendChild(flame) const root = createRoot(inner) root.render(