716 lines
22 KiB
TypeScript
716 lines
22 KiB
TypeScript
// 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<string | false | null | undefined>) {
|
||
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<boolean | void>
|
||
onSwipeRight: () => boolean | void | Promise<boolean | void>
|
||
|
||
/** 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<boolean | void>
|
||
|
||
/** 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<boolean>
|
||
swipeRight: (opts?: { runAction?: boolean }) => Promise<boolean>
|
||
reset: () => void
|
||
}
|
||
|
||
const SwipeCard = React.forwardRef<SwipeCardHandle, SwipeCardProps>(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<HTMLDivElement | null>(null)
|
||
|
||
const doubleTapBusyRef = React.useRef(false)
|
||
|
||
// ✅ Perf: dx pro Frame updaten (statt pro Pointer-Move)
|
||
const dxRef = React.useRef(0)
|
||
const rafRef = React.useRef<number | null>(null)
|
||
|
||
// ✅ Perf: Threshold einmal pro PointerDown berechnen (kein offsetWidth pro Move)
|
||
const thresholdRef = React.useRef(0)
|
||
|
||
const outerRef = React.useRef<HTMLDivElement | null>(null)
|
||
|
||
const tapTimerRef = React.useRef<number | null>(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 | 'left' | 'right'>(null)
|
||
const [animMs, setAnimMs] = React.useState<number>(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(<FireSolidIcon className="w-full h-full" aria-hidden="true" />)
|
||
|
||
void flame.getBoundingClientRect()
|
||
|
||
// ✅ Timing: Pop (200ms) + Hold (500ms) + Fly (400ms) = 1100ms
|
||
const popMs = 200
|
||
const holdMs = 500
|
||
const flyMs = 400
|
||
const duration = popMs + holdMs + flyMs // 1100
|
||
|
||
const tPopEnd = popMs / duration
|
||
const tHoldEnd = (popMs + holdMs) / duration
|
||
|
||
const anim = flame.animate(
|
||
[
|
||
// --- POP am Tap-Punkt ---
|
||
{ transform: 'translate(-50%, -50%) scale(0.15)', opacity: 0, offset: 0.0 },
|
||
{ transform: 'translate(-50%, -50%) scale(1.25)', opacity: 1, offset: tPopEnd * 0.55 },
|
||
{ transform: 'translate(-50%, -50%) scale(1.00)', opacity: 1, offset: tPopEnd },
|
||
|
||
// --- HOLD (0.5s stehen bleiben) ---
|
||
{ transform: 'translate(-50%, -50%) scale(1.00)', opacity: 1, offset: tHoldEnd },
|
||
|
||
// --- FLY zum HOT-Button ---
|
||
{
|
||
transform: `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px)) scale(0.85)`,
|
||
opacity: 0.95,
|
||
offset: tHoldEnd + (1 - tHoldEnd) * 0.75,
|
||
},
|
||
{
|
||
transform: `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px)) scale(0.55)`,
|
||
opacity: 0,
|
||
offset: 1.0,
|
||
},
|
||
],
|
||
{
|
||
duration,
|
||
easing: 'cubic-bezier(0.2, 0.9, 0.2, 1)',
|
||
fill: 'forwards',
|
||
}
|
||
)
|
||
|
||
// Flash beim Ankommen (am Ende der Fly-Phase)
|
||
if (targetEl) {
|
||
window.setTimeout(() => {
|
||
try {
|
||
targetEl.animate(
|
||
[
|
||
{ transform: 'translateZ(0) scale(1)', filter: 'brightness(1)' },
|
||
{ transform: 'translateZ(0) scale(1.10)', filter: 'brightness(1.25)', offset: 0.35 },
|
||
{ transform: 'translateZ(0) scale(1)', filter: 'brightness(1)' },
|
||
],
|
||
{ duration: 260, easing: 'cubic-bezier(0.2, 0.9, 0.2, 1)' }
|
||
)
|
||
} catch {}
|
||
}, popMs + holdMs + Math.round(flyMs * 0.75))
|
||
}
|
||
|
||
anim.onfinish = () => {
|
||
try {
|
||
root.unmount()
|
||
} catch {}
|
||
flame.remove()
|
||
}
|
||
},
|
||
[hotTargetSelector]
|
||
)
|
||
|
||
React.useEffect(() => {
|
||
return () => {
|
||
if (tapTimerRef.current != null) window.clearTimeout(tapTimerRef.current)
|
||
}
|
||
}, [])
|
||
|
||
React.useImperativeHandle(
|
||
ref,
|
||
() => ({
|
||
swipeLeft: (opts) => commit('left', opts?.runAction ?? true),
|
||
swipeRight: (opts) => commit('right', opts?.runAction ?? true),
|
||
reset: () => reset(),
|
||
}),
|
||
[commit, reset]
|
||
)
|
||
|
||
const absDx = Math.abs(dx)
|
||
const swipeDir: 'left' | 'right' | null = dx === 0 ? null : dx > 0 ? 'right' : 'left'
|
||
|
||
const activeThreshold =
|
||
thresholdRef.current || Math.min(thresholdPx, (cardRef.current?.offsetWidth || 360) * thresholdRatio)
|
||
|
||
const reveal = Math.max(0, Math.min(1, absDx / Math.max(1, activeThreshold)))
|
||
const revealSoft = Math.max(0, Math.min(1, absDx / Math.max(1, activeThreshold * 1.35)))
|
||
|
||
// leichte Tinder-ähnliche Kippung beim Drag
|
||
const tiltDeg = Math.max(-6, Math.min(6, dx / 28))
|
||
const dragScale = dx === 0 ? 1 : 0.995
|
||
|
||
return (
|
||
<div
|
||
ref={outerRef}
|
||
className={cn('relative isolate overflow-visible rounded-lg', className)}
|
||
>
|
||
{/* Foreground (moves) */}
|
||
<div
|
||
ref={cardRef}
|
||
className="relative"
|
||
style={{
|
||
// ✅ iOS Fix: im Idle kein transform
|
||
transform:
|
||
dx !== 0
|
||
? `translate3d(${dx}px,0,0) rotate(${tiltDeg}deg) scale(${dragScale})`
|
||
: undefined,
|
||
transition: animMs ? `transform ${animMs}ms ease` : undefined,
|
||
touchAction: 'pan-y',
|
||
willChange: dx !== 0 ? 'transform' : undefined,
|
||
boxShadow:
|
||
dx !== 0
|
||
? swipeDir === 'right'
|
||
? `0 16px 34px rgba(0,0,0,0.28), 0 0 0 1px rgba(16,185,129,${0.08 + reveal * 0.12})`
|
||
: `0 16px 34px rgba(0,0,0,0.28), 0 0 0 1px rgba(244,63,94,${0.08 + reveal * 0.12})`
|
||
: undefined,
|
||
borderRadius: dx !== 0 ? '12px' : undefined,
|
||
filter:
|
||
dx !== 0
|
||
? `saturate(${1 + reveal * 0.08}) brightness(${1 + reveal * 0.02})`
|
||
: undefined,
|
||
}}
|
||
onPointerDown={(e) => {
|
||
if (!enabled || disabled) return
|
||
|
||
const target = e.target as HTMLElement | null
|
||
|
||
// Tap ignorieren (SingleTap nicht auslösen), DoubleTap soll aber weiter gehen
|
||
let tapIgnored = Boolean(tapIgnoreSelector && target?.closest?.(tapIgnoreSelector))
|
||
|
||
// Harte Ignore-Zone: da wollen wir wirklich gar nichts
|
||
if (ignoreSelector && target?.closest?.(ignoreSelector)) return
|
||
|
||
// Tap/DoubleTap erlauben, aber niemals swipe starten
|
||
let noSwipe = false
|
||
|
||
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) {
|
||
const fromBottomVideo = vr.bottom - e.clientY
|
||
const scrubZonePx = 72
|
||
if (fromBottomVideo <= scrubZonePx) {
|
||
noSwipe = true
|
||
tapIgnored = true
|
||
} else {
|
||
const edgeZonePx = 64
|
||
const xFromLeft = e.clientX - vr.left
|
||
const xFromRight = vr.right - e.clientX
|
||
const inEdge = xFromLeft <= edgeZonePx || xFromRight <= edgeZonePx
|
||
|
||
if (!inEdge) {
|
||
noSwipe = true
|
||
tapIgnored = true
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||
const fromBottom = rect.bottom - e.clientY
|
||
if (ignoreFromBottomPx && fromBottom <= ignoreFromBottomPx) {
|
||
noSwipe = true
|
||
}
|
||
|
||
pointer.current = {
|
||
id: e.pointerId,
|
||
x: e.clientX,
|
||
y: e.clientY,
|
||
dragging: false,
|
||
captured: false,
|
||
tapIgnored,
|
||
noSwipe,
|
||
}
|
||
|
||
const el = cardRef.current
|
||
const w = el?.offsetWidth || 360
|
||
thresholdRef.current = Math.min(thresholdPx, w * thresholdRatio)
|
||
dxRef.current = 0
|
||
}}
|
||
onPointerMove={(e) => {
|
||
if (!enabled || disabled) return
|
||
if (pointer.current.id !== e.pointerId) return
|
||
|
||
if (pointer.current.noSwipe) return
|
||
|
||
const ddx = e.clientX - pointer.current.x
|
||
const ddy = e.clientY - pointer.current.y
|
||
|
||
if (!pointer.current.dragging) {
|
||
if (Math.abs(ddy) > Math.abs(ddx) && Math.abs(ddy) > 8) {
|
||
pointer.current.id = null
|
||
return
|
||
}
|
||
|
||
if (Math.abs(ddx) < 12) return
|
||
|
||
pointer.current.dragging = true
|
||
;(e.currentTarget as HTMLElement).style.touchAction = 'none'
|
||
setAnimMs(0)
|
||
|
||
try {
|
||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||
pointer.current.captured = true
|
||
} catch {
|
||
pointer.current.captured = false
|
||
}
|
||
}
|
||
|
||
const applyResistance = (x: number, w: number) => {
|
||
const limit = w * 0.9
|
||
const ax = Math.abs(x)
|
||
if (ax <= limit) return x
|
||
const extra = ax - limit
|
||
const resisted = limit + extra * 0.25
|
||
return Math.sign(x) * resisted
|
||
}
|
||
|
||
const w = cardRef.current?.offsetWidth || 360
|
||
dxRef.current = applyResistance(ddx, w)
|
||
|
||
if (rafRef.current == null) {
|
||
rafRef.current = requestAnimationFrame(() => {
|
||
rafRef.current = null
|
||
setDx(dxRef.current)
|
||
})
|
||
}
|
||
|
||
const threshold = thresholdRef.current
|
||
const nextDir = ddx > threshold ? 'right' : ddx < -threshold ? 'left' : null
|
||
setArmedDir((prev) => {
|
||
if (prev === nextDir) return prev
|
||
if (nextDir) {
|
||
try {
|
||
navigator.vibrate?.(10)
|
||
} catch {}
|
||
}
|
||
return 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
|
||
|
||
if (wasCaptured) {
|
||
try {
|
||
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
|
||
} catch {}
|
||
}
|
||
|
||
;(e.currentTarget as HTMLElement).style.touchAction = 'pan-y'
|
||
|
||
if (!wasDragging) {
|
||
const now = Date.now()
|
||
const last = lastTapRef.current
|
||
|
||
const isNear =
|
||
last &&
|
||
Math.hypot(e.clientX - last.x, e.clientY - last.y) <= doubleTapMaxMovePx
|
||
|
||
const isDouble =
|
||
Boolean(onDoubleTap) &&
|
||
last &&
|
||
now - last.t <= doubleTapMs &&
|
||
isNear
|
||
|
||
if (isDouble) {
|
||
lastTapRef.current = null
|
||
clearTapTimer()
|
||
|
||
if (doubleTapBusyRef.current) return
|
||
doubleTapBusyRef.current = true
|
||
|
||
try {
|
||
runHotFx(e.clientX, e.clientY)
|
||
} catch {}
|
||
|
||
requestAnimationFrame(() => {
|
||
;(async () => {
|
||
try {
|
||
await onDoubleTap?.()
|
||
} finally {
|
||
doubleTapBusyRef.current = false
|
||
}
|
||
})()
|
||
})
|
||
|
||
return
|
||
}
|
||
|
||
if (wasTapIgnored) {
|
||
lastTapRef.current = { t: now, x: e.clientX, y: e.clientY }
|
||
clearTapTimer()
|
||
tapTimerRef.current = window.setTimeout(() => {
|
||
tapTimerRef.current = null
|
||
lastTapRef.current = null
|
||
}, onDoubleTap ? doubleTapMs : 0)
|
||
return
|
||
}
|
||
|
||
softResetForTap()
|
||
|
||
lastTapRef.current = { t: now, x: e.clientX, y: e.clientY }
|
||
clearTapTimer()
|
||
|
||
tapTimerRef.current = window.setTimeout(() => {
|
||
tapTimerRef.current = null
|
||
lastTapRef.current = null
|
||
onTap?.()
|
||
}, onDoubleTap ? doubleTapMs : 0)
|
||
|
||
return
|
||
}
|
||
|
||
const finalDx = dxRef.current
|
||
|
||
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, noSwipe: false }
|
||
if (rafRef.current != null) {
|
||
cancelAnimationFrame(rafRef.current)
|
||
rafRef.current = null
|
||
}
|
||
dxRef.current = 0
|
||
|
||
try {
|
||
;(e.currentTarget as HTMLElement).style.touchAction = 'pan-y'
|
||
} catch {}
|
||
|
||
reset()
|
||
}}
|
||
>
|
||
<div className="relative">
|
||
<div className="relative z-10">{children}</div>
|
||
|
||
{/* Overlay über dem Inhalt (subtiler, progressiv) */}
|
||
<div
|
||
className="absolute inset-0 z-20 pointer-events-none rounded-lg transition-opacity duration-100"
|
||
style={{
|
||
opacity: dx === 0 ? 0 : 0.12 + revealSoft * 0.18,
|
||
background:
|
||
swipeDir === 'right'
|
||
? 'linear-gradient(90deg, rgba(16,185,129,0.16) 0%, rgba(16,185,129,0.04) 45%, rgba(0,0,0,0) 100%)'
|
||
: swipeDir === 'left'
|
||
? 'linear-gradient(270deg, rgba(244,63,94,0.16) 0%, rgba(244,63,94,0.04) 45%, rgba(0,0,0,0) 100%)'
|
||
: 'transparent',
|
||
}}
|
||
/>
|
||
|
||
{/* Armed border glow */}
|
||
<div
|
||
className="absolute inset-0 z-20 pointer-events-none rounded-lg transition-opacity duration-100"
|
||
style={{
|
||
opacity: armedDir ? 1 : 0,
|
||
boxShadow:
|
||
armedDir === 'right'
|
||
? 'inset 0 0 0 1px rgba(16,185,129,0.45), inset 0 0 32px rgba(16,185,129,0.10)'
|
||
: armedDir === 'left'
|
||
? 'inset 0 0 0 1px rgba(244,63,94,0.45), inset 0 0 32px rgba(244,63,94,0.10)'
|
||
: 'none',
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})
|
||
|
||
export default SwipeCard
|