// frontend\src\components\ui\SwipeCard.tsx 'use client' import { useEffect, useRef, useState, useCallback, type ReactNode, forwardRef, useImperativeHandle } 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' ;(el.style as any).contain = 'layout style paint' document.body.appendChild(el) } return el } export type SwipeAction = { label: ReactNode className?: string } export type SwipeCardProps = { children: ReactNode enabled?: boolean disabled?: boolean onTap?: () => void onSwipeLeft: () => boolean | void | Promise onSwipeRight: () => boolean | void | Promise className?: string leftAction?: SwipeAction rightAction?: SwipeAction thresholdPx?: number thresholdRatio?: number snapMs?: number commitMs?: number ignoreFromBottomPx?: number ignoreSelector?: string tapIgnoreSelector?: string onDoubleTap?: () => boolean | void | Promise hotTargetSelector?: string doubleTapMs?: number doubleTapMaxMovePx?: number pressDelayMs?: number enablePressHold?: boolean onPressStart?: (info: { pointerType: string; button: number }) => void onPressEnd?: () => void } export type SwipeCardHandle = { swipeLeft: (opts?: { runAction?: boolean }) => Promise swipeRight: (opts?: { runAction?: boolean }) => Promise reset: () => void } const HORIZONTAL_START_PX = 10 const LOCK_RATIO = 1.15 const FLICK_VELOCITY = 0.55 const MAX_OVERSWIPE_PX = 64 const ROTATE_MAX_DEG = 5 const SCALE_MIN = 0.992 type CommitDir = 'left' | 'right' function clamp(v: number, min: number, max: number) { return Math.max(min, Math.min(max, v)) } function rubberBand(delta: number, width: number) { if (delta === 0) return 0 const sign = Math.sign(delta) const abs = Math.abs(delta) const limit = width * 0.9 if (abs <= limit) return delta const overflow = abs - limit const resisted = limit + overflow * 0.18 return sign * Math.min(resisted, width + MAX_OVERSWIPE_PX) } const SwipeCard = forwardRef(function SwipeCard( { children, enabled = true, disabled = false, onTap, onSwipeLeft, onSwipeRight, className, leftAction, rightAction, thresholdPx = 140, thresholdRatio = 0.28, ignoreFromBottomPx = 72, ignoreSelector = '[data-swipe-ignore]', snapMs = 220, commitMs = 220, tapIgnoreSelector = 'button,a,input,textarea,select,video[controls],video[controls] *,[data-tap-ignore]', onDoubleTap, hotTargetSelector = '[data-hot-target]', doubleTapMs = 360, doubleTapMaxMovePx = 48, pressDelayMs = 220, enablePressHold = true, onPressStart, onPressEnd, }, ref ) { const cardRef = useRef(null) const outerRef = useRef(null) const doubleTapBusyRef = useRef(false) const pressTimerRef = useRef(null) const longPressActiveRef = useRef(false) const rafRef = useRef(null) const dxRef = useRef(0) const thresholdRef = useRef(0) const widthRef = useRef(360) const tapTimerRef = useRef(null) const lastTapRef = useRef<{ t: number; x: number; y: number } | null>(null) const velocityXRef = useRef(0) const lastMoveXRef = useRef(0) const lastMoveTRef = useRef(0) const pointer = useRef<{ id: number | null startX: number startY: number dragging: boolean captured: boolean tapIgnored: boolean noSwipe: boolean axisLocked: 'x' | 'y' | null }>({ id: null, startX: 0, startY: 0, dragging: false, captured: false, tapIgnored: false, noSwipe: false, axisLocked: null, }) const [dx, setDx] = useState(0) const [armedDir, setArmedDir] = useState(null) const [animMs, setAnimMs] = useState(0) const baseTouchAction = 'pan-y' const clearTapTimer = useCallback(() => { if (tapTimerRef.current != null) { window.clearTimeout(tapTimerRef.current) tapTimerRef.current = null } }, []) const clearPressTimer = useCallback(() => { if (pressTimerRef.current != null) { window.clearTimeout(pressTimerRef.current) pressTimerRef.current = null } }, []) const endLongPress = useCallback(() => { clearPressTimer() if (!longPressActiveRef.current) return longPressActiveRef.current = false onPressEnd?.() }, [clearPressTimer, onPressEnd]) const flushDx = useCallback((nextDx: number) => { dxRef.current = nextDx if (rafRef.current != null) return rafRef.current = requestAnimationFrame(() => { rafRef.current = null setDx(dxRef.current) }) }, []) const reset = useCallback(() => { clearPressTimer() longPressActiveRef.current = false if (rafRef.current != null) { cancelAnimationFrame(rafRef.current) rafRef.current = null } dxRef.current = 0 velocityXRef.current = 0 if (cardRef.current) { cardRef.current.style.touchAction = baseTouchAction } setAnimMs(snapMs) setDx(0) setArmedDir(null) window.setTimeout(() => { setAnimMs(0) }, snapMs) }, [snapMs, clearPressTimer, baseTouchAction]) const commit = useCallback( async (dir: CommitDir, runAction: boolean) => { if (rafRef.current != null) { cancelAnimationFrame(rafRef.current) rafRef.current = null } if (cardRef.current) { cardRef.current.style.touchAction = baseTouchAction } const w = cardRef.current?.offsetWidth || widthRef.current || 360 const out = dir === 'right' ? w + 56 : -(w + 56) setAnimMs(commitMs) setArmedDir(dir) dxRef.current = out setDx(out) const animPromise = new Promise((resolve) => { window.setTimeout(resolve, commitMs) }) await animPromise let ok: boolean | void = true if (runAction) { const action = dir === 'right' ? onSwipeRight : onSwipeLeft ok = await Promise.resolve(action()).catch(() => false) } if (ok === false) { setAnimMs(snapMs) setArmedDir(null) dxRef.current = 0 setDx(0) window.setTimeout(() => setAnimMs(0), snapMs) return false } return true }, [ commitMs, onSwipeLeft, onSwipeRight, snapMs, baseTouchAction, ] ) const softResetForTap = 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 = baseTouchAction } catch { // ignore } }, [baseTouchAction]) const runHotFx = useCallback( (clientX?: number, clientY?: number) => { const outer = outerRef.current const card = cardRef.current if (!outer || !card) return const layer = getGlobalFxLayer() if (!layer) return let startX = typeof clientX === 'number' ? clientX : window.innerWidth / 2 let startY = typeof clientY === 'number' ? clientY : window.innerHeight / 2 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 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))' const inner = document.createElement('div') inner.style.width = '30px' inner.style.height = '30px' inner.style.color = '#f59e0b' inner.style.display = 'block' flame.appendChild(inner) layer.appendChild(flame) const root = createRoot(inner) root.render(