nsfwapp/frontend/src/components/ui/SwipeCard.tsx
2026-02-23 17:00:22 +01:00

716 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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