nsfwapp/frontend/src/components/ui/SwipeCard.tsx
2026-06-10 09:39:07 +02:00

882 lines
24 KiB
TypeScript

// 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<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'
;(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<boolean | void>
onSwipeRight: () => boolean | void | Promise<boolean | void>
className?: string
leftAction?: SwipeAction
rightAction?: SwipeAction
thresholdPx?: number
thresholdRatio?: number
snapMs?: number
commitMs?: number
ignoreFromBottomPx?: number
ignoreSelector?: string
tapIgnoreSelector?: string
onDoubleTap?: () => boolean | void | Promise<boolean | void>
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<boolean>
swipeRight: (opts?: { runAction?: boolean }) => Promise<boolean>
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<SwipeCardHandle, SwipeCardProps>(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<HTMLDivElement | null>(null)
const outerRef = useRef<HTMLDivElement | null>(null)
const doubleTapBusyRef = useRef(false)
const pressTimerRef = useRef<number | null>(null)
const longPressActiveRef = useRef(false)
const rafRef = useRef<number | null>(null)
const dxRef = useRef(0)
const thresholdRef = useRef(0)
const widthRef = useRef(360)
const tapTimerRef = useRef<number | null>(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 | CommitDir>(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<void>((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(<FireSolidIcon className="w-full h-full" aria-hidden="true" />)
void flame.getBoundingClientRect()
const popMs = 200
const holdMs = 500
const flyMs = 400
const duration = popMs + holdMs + flyMs
const tPopEnd = popMs / duration
const tHoldEnd = (popMs + holdMs) / duration
const anim = flame.animate(
[
{ 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 },
{ transform: 'translate(-50%, -50%) scale(1.00)', opacity: 1, offset: tHoldEnd },
{
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',
}
)
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 {
// ignore
}
}, popMs + holdMs + Math.round(flyMs * 0.75))
}
anim.onfinish = () => {
try {
root.unmount()
} catch {
// ignore
}
flame.remove()
}
},
[hotTargetSelector]
)
useEffect(() => {
return () => {
clearTapTimer()
clearPressTimer()
}
}, [clearTapTimer, clearPressTimer])
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: CommitDir | null =
dx === 0 ? null : dx > 0 ? 'right' : 'left'
const activeThreshold =
thresholdRef.current ||
Math.min(thresholdPx, (cardRef.current?.offsetWidth || 360) * thresholdRatio)
const reveal = clamp(absDx / Math.max(1, activeThreshold), 0, 1)
const revealSoft = clamp(absDx / Math.max(1, activeThreshold * 1.35), 0, 1)
const tiltDeg = clamp(dx / 34, -ROTATE_MAX_DEG, ROTATE_MAX_DEG)
const dragScale =
dx === 0
? 1
: Math.max(SCALE_MIN, 1 - revealSoft * 0.008)
const activeAction =
armedDir === 'left'
? leftAction ?? { label: 'Löschen' }
: armedDir === 'right'
? rightAction ?? { label: 'Behalten' }
: null
return (
<div ref={outerRef} className={cn('relative isolate overflow-visible rounded-lg', className)}>
<div
ref={cardRef}
className="relative"
style={{
transform:
dx !== 0
? `translate3d(${dx}px,0,0) rotate(${tiltDeg}deg) scale(${dragScale})`
: undefined,
transition: animMs
? `transform ${animMs}ms cubic-bezier(0.22, 1, 0.36, 1)`
: undefined,
touchAction: baseTouchAction,
willChange: dx !== 0 ? 'transform' : undefined,
borderRadius: dx !== 0 ? '12px' : undefined,
filter:
dx !== 0
? `saturate(${1 + reveal * 0.06}) brightness(${1 + reveal * 0.015})`
: undefined,
}}
onPointerDown={(e) => {
if (!enabled || disabled) return
const target = e.target as HTMLElement | null
let tapIgnored = Boolean(tapIgnoreSelector && target?.closest?.(tapIgnoreSelector))
if (ignoreSelector && target?.closest?.(ignoreSelector)) return
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
}
endLongPress()
// Long-Press nur im Teaser-Modus, nicht im Inline-Playback / Controls / gesperrten Zonen
if (enablePressHold && !noSwipe && !tapIgnored && onPressStart) {
const pressInfo = {
pointerType: e.pointerType,
button: e.button,
}
const pointerId = e.pointerId
pressTimerRef.current = window.setTimeout(() => {
pressTimerRef.current = null
if (pointer.current.id !== pointerId) return
if (pointer.current.dragging) return
if (pointer.current.noSwipe) return
if (pointer.current.tapIgnored) return
longPressActiveRef.current = true
onPressStart(pressInfo)
}, pressDelayMs)
}
const w = cardRef.current?.offsetWidth || 360
widthRef.current = w
thresholdRef.current = Math.min(thresholdPx, w * thresholdRatio)
pointer.current = {
id: e.pointerId,
startX: e.clientX,
startY: e.clientY,
dragging: false,
captured: false,
tapIgnored,
noSwipe,
axisLocked: null,
}
lastMoveXRef.current = e.clientX
lastMoveTRef.current = performance.now()
velocityXRef.current = 0
dxRef.current = 0
}}
onPointerMove={(e) => {
if (!enabled || disabled) return
if (pointer.current.id !== e.pointerId) return
if (pointer.current.noSwipe) return
if (longPressActiveRef.current) return
const ddx = e.clientX - pointer.current.startX
const ddy = e.clientY - pointer.current.startY
const absX = Math.abs(ddx)
const absY = Math.abs(ddy)
if (!pointer.current.axisLocked) {
if (absX < HORIZONTAL_START_PX && absY < HORIZONTAL_START_PX) return
if (absY > absX * LOCK_RATIO) {
clearPressTimer()
pointer.current.axisLocked = 'y'
pointer.current.id = null
return
}
if (absX > absY * LOCK_RATIO) {
pointer.current.axisLocked = 'x'
} else {
return
}
}
if (pointer.current.axisLocked !== 'x') return
if (!pointer.current.dragging) {
clearPressTimer()
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 now = performance.now()
const dt = Math.max(1, now - lastMoveTRef.current)
velocityXRef.current = (e.clientX - lastMoveXRef.current) / dt
lastMoveXRef.current = e.clientX
lastMoveTRef.current = now
const next = rubberBand(ddx, widthRef.current)
flushDx(next)
const threshold = thresholdRef.current
const nextDir =
next > threshold
? 'right'
: next < -threshold
? 'left'
: null
setArmedDir((prev) => {
if (prev === nextDir) return prev
if (nextDir) {
try {
navigator.vibrate?.(10)
} catch {
// ignore
}
}
return nextDir
})
}}
onPointerUp={(e) => {
if (!enabled || disabled) return
if (pointer.current.id !== e.pointerId) return
const threshold = thresholdRef.current || Math.min(thresholdPx, widthRef.current * thresholdRatio)
const wasDragging = pointer.current.dragging
const wasCaptured = pointer.current.captured
const wasTapIgnored = pointer.current.tapIgnored
const wasLongPress = longPressActiveRef.current
clearPressTimer()
pointer.current.id = null
pointer.current.dragging = false
pointer.current.captured = false
pointer.current.axisLocked = null
if (wasCaptured) {
try {
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
} catch {
// ignore
}
}
;(e.currentTarget as HTMLElement).style.touchAction = 'pan-y'
if (wasLongPress) {
longPressActiveRef.current = false
onPressEnd?.()
return
}
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 {
// ignore
}
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
}
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
const finalDx = dxRef.current
const abs = Math.abs(finalDx)
const vx = velocityXRef.current
const fastRight = vx > FLICK_VELOCITY
const fastLeft = vx < -FLICK_VELOCITY
dxRef.current = 0
if (finalDx > threshold || (fastRight && abs >= threshold * 0.5)) {
void commit('right', true)
return
}
if (finalDx < -threshold || (fastLeft && abs >= threshold * 0.5)) {
void commit('left', true)
return
}
reset()
}}
onPointerCancel={(e) => {
if (!enabled || disabled) return
endLongPress()
clearPressTimer()
if (pointer.current.captured && pointer.current.id != null) {
try {
;(e.currentTarget as HTMLElement).releasePointerCapture(pointer.current.id)
} catch {
// ignore
}
}
pointer.current = {
id: null,
startX: 0,
startY: 0,
dragging: false,
captured: false,
tapIgnored: false,
noSwipe: false,
axisLocked: null,
}
if (rafRef.current != null) {
cancelAnimationFrame(rafRef.current)
rafRef.current = null
}
dxRef.current = 0
velocityXRef.current = 0
try {
;(e.currentTarget as HTMLElement).style.touchAction = baseTouchAction
} catch {
// ignore
}
reset()
}}
>
<div className="relative">
<div className="relative z-10">{children}</div>
<div
className="absolute inset-0 z-20 pointer-events-none rounded-lg transition-opacity duration-100"
style={{
opacity: dx === 0 ? 0 : 0.08 + revealSoft * 0.16,
background:
swipeDir === 'right'
? 'linear-gradient(90deg, rgba(16,185,129,0.18) 0%, rgba(16,185,129,0.06) 42%, rgba(0,0,0,0) 100%)'
: swipeDir === 'left'
? 'linear-gradient(270deg, rgba(244,63,94,0.18) 0%, rgba(244,63,94,0.06) 42%, rgba(0,0,0,0) 100%)'
: 'transparent',
}}
/>
<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.42), inset 0 0 28px rgba(16,185,129,0.12)'
: armedDir === 'left'
? 'inset 0 0 0 1px rgba(244,63,94,0.42), inset 0 0 28px rgba(244,63,94,0.12)'
: 'none',
}}
/>
{activeAction ? (
<div
className={cn(
'pointer-events-none absolute inset-0 z-30 grid place-items-center rounded-lg transition-opacity duration-100',
activeAction.className
)}
style={{
opacity: armedDir ? 1 : 0,
}}
>
<div className="rounded-full bg-black/55 px-4 py-2 text-sm font-black uppercase tracking-wide text-white shadow-lg ring-1 ring-white/20 backdrop-blur-sm">
{activeAction.label}
</div>
</div>
) : null}
</div>
</div>
</div>
)
})
export default SwipeCard