// frontend\src\components\ui\SwipeCard.tsx 'use client' import * as React from 'react' import { TrashIcon, BookmarkSquareIcon } from '@heroicons/react/24/outline' function cn(...parts: Array) { return parts.filter(Boolean).join(' ') } 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 } 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, leftAction = { label: ( ), className: 'bg-emerald-500/20 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-300', }, rightAction = { label: ( ), className: 'bg-red-500/20 text-red-800 dark:bg-red-500/15 dark:text-red-300', }, //thresholdPx = 120, thresholdPx = 180, //thresholdRatio = 0.35, thresholdRatio = 0.1, ignoreFromBottomPx = 72, ignoreSelector = '[data-swipe-ignore]', snapMs = 180, commitMs = 180, tapIgnoreSelector = 'button,a,input,textarea,select,video[controls],video[controls] *,[data-tap-ignore]', }, ref ) { const cardRef = React.useRef(null) // ✅ 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 pointer = React.useRef<{ id: number | null x: number y: number dragging: boolean captured: boolean tapIgnored: boolean }>({ id: null, x: 0, y: 0, dragging: false, captured: false, tapIgnored: 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 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 } 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] ) React.useImperativeHandle( ref, () => ({ swipeLeft: (opts) => commit('left', opts?.runAction ?? true), swipeRight: (opts) => commit('right', opts?.runAction ?? true), reset: () => reset(), }), [commit, reset] ) return (
{/* Background actions (100% je Richtung, animiert) */}
0 ? leftAction.className : rightAction.className )} />
0 ? 'flex-start' : 'flex-end', paddingLeft: dx > 0 ? 16 : 0, paddingRight: dx > 0 ? 0 : 16, }} > {dx > 0 ? leftAction.label : rightAction.label}
{/* Foreground (moves) */}
{ if (!enabled || disabled) return // ✅ 1) Ignoriere Start auf "No-swipe"-Elementen const target = e.target as HTMLElement | null const tapIgnored = Boolean(tapIgnoreSelector && target?.closest?.(tapIgnoreSelector)) if (ignoreSelector && target?.closest?.(ignoreSelector)) return const root = e.currentTarget as HTMLElement const videos = Array.from(root.querySelectorAll('video')) as HTMLVideoElement[] const ctlVideo = videos.find((v) => v.controls) if (ctlVideo) { const vr = ctlVideo.getBoundingClientRect() const inVideo = e.clientX >= vr.left && e.clientX <= vr.right && e.clientY >= vr.top && e.clientY <= vr.bottom if (inVideo) { // unten frei für Timeline/Scrub (iPhone braucht meist etwas mehr) const fromBottomVideo = vr.bottom - e.clientY const scrubZonePx = 72 if (fromBottomVideo <= scrubZonePx) return // Swipe nur aus den Seitenrändern const edgeZonePx = 64 const xFromLeft = e.clientX - vr.left const xFromRight = vr.right - e.clientX const inEdge = xFromLeft <= edgeZonePx || xFromRight <= edgeZonePx if (!inEdge) return } } // ✅ 3) Optional: generelle Card-Bottom-Sperre (bei dir in CardsView auf 0 lassen) const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() const fromBottom = rect.bottom - e.clientY if (ignoreFromBottomPx && fromBottom <= ignoreFromBottomPx) return pointer.current = { id: e.pointerId, x: e.clientX, y: e.clientY, dragging: false, captured: false, tapIgnored, // ✅ WICHTIG: nicht "false" } // ✅ Perf: pro Gesture einmal Threshold berechnen const el = cardRef.current const w = el?.offsetWidth || 360 thresholdRef.current = Math.min(thresholdPx, w * thresholdRatio) // ✅ dxRef reset (neue Gesture) dxRef.current = 0 }} onPointerMove={(e) => { if (!enabled || disabled) return if (pointer.current.id !== e.pointerId) return const ddx = e.clientX - pointer.current.x const ddy = e.clientY - pointer.current.y // Erst entscheiden ob wir überhaupt draggen if (!pointer.current.dragging) { // wenn Nutzer vertikal scrollt -> abbrechen, NICHT hijacken if (Math.abs(ddy) > Math.abs(ddx) && Math.abs(ddy) > 8) { pointer.current.id = null return } // "Dead zone" bis wirklich horizontal gedrückt wird if (Math.abs(ddx) < 12) return // ✅ jetzt erst beginnen wir zu swipen pointer.current.dragging = true // ✅ Anim nur 1x beim Drag-Start deaktivieren setAnimMs(0) // ✅ Pointer-Capture erst JETZT (nicht bei pointerdown) try { ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId) pointer.current.captured = true } catch { pointer.current.captured = false } } // ✅ dx nur pro Frame in React-State schreiben dxRef.current = ddx if (rafRef.current == null) { rafRef.current = requestAnimationFrame(() => { rafRef.current = null setDx(dxRef.current) }) } // ✅ armedDir nur updaten wenn geändert const threshold = thresholdRef.current const nextDir = ddx > threshold ? 'right' : ddx < -threshold ? 'left' : null setArmedDir((prev) => (prev === nextDir ? prev : nextDir)) }} onPointerUp={(e) => { if (!enabled || disabled) return if (pointer.current.id !== e.pointerId) return const threshold = thresholdRef.current || Math.min(thresholdPx, (cardRef.current?.offsetWidth || 360) * thresholdRatio) const wasDragging = pointer.current.dragging const wasCaptured = pointer.current.captured const wasTapIgnored = pointer.current.tapIgnored pointer.current.id = null pointer.current.dragging = false pointer.current.captured = false // Capture sauber lösen (falls gesetzt) if (wasCaptured) { try { ;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId) } catch {} } if (!wasDragging) { // ✅ Wichtig: Wenn Tap auf Video/Controls (tapIgnored), NICHT resetten // sonst “stiehlt” SwipeCard den Tap (iOS besonders empfindlich). if (wasTapIgnored) { setAnimMs(0) setDx(0) setArmedDir(null) return } reset() onTap?.() return } const finalDx = dxRef.current // rAF cleanup if (rafRef.current != null) { cancelAnimationFrame(rafRef.current) rafRef.current = null } if (finalDx > threshold) { void commit('right', true) } else if (finalDx < -threshold) { void commit('left', true) } else { reset() } dxRef.current = 0 }} onPointerCancel={(e) => { if (!enabled || disabled) return if (pointer.current.captured && pointer.current.id != null) { try { ;(e.currentTarget as HTMLElement).releasePointerCapture(pointer.current.id) } catch {} } pointer.current = { id: null, x: 0, y: 0, dragging: false, captured: false, tapIgnored: false } if (rafRef.current != null) { cancelAnimationFrame(rafRef.current) rafRef.current = null } dxRef.current = 0 reset() }} >
{children}
{/* ✅ Overlay liegt ÜBER dem Inhalt */}
) }) export default SwipeCard