nsfwapp/frontend/src/components/ui/SwipeCard.tsx
2026-01-13 14:00:05 +01:00

445 lines
14 KiB
TypeScript

// 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<string | false | null | undefined>) {
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<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
}
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,
leftAction = {
label: (
<span className="inline-flex flex-col items-center gap-1 font-semibold leading-tight">
<BookmarkSquareIcon className="h-6 w-6" aria-hidden="true" />
<span>Behalten</span>
</span>
),
className: 'bg-emerald-500/20 text-emerald-800 dark:bg-emerald-500/15 dark:text-emerald-300',
},
rightAction = {
label: (
<span className="inline-flex flex-col items-center gap-1 font-semibold leading-tight">
<TrashIcon className="h-6 w-6" aria-hidden="true" />
<span>Löschen</span>
</span>
),
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<HTMLDivElement | null>(null)
// ✅ 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 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 | '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
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 (
<div className={cn('relative overflow-hidden rounded-lg', className)}>
{/* Background actions (100% je Richtung, animiert) */}
<div className="absolute inset-0 pointer-events-none overflow-hidden rounded-lg">
<div
className={cn(
'absolute inset-0 transition-opacity duration-200 ease-out',
dx === 0 ? 'opacity-0' : 'opacity-100',
dx > 0 ? leftAction.className : rightAction.className
)}
/>
<div
className={cn(
'absolute inset-0 flex items-center transition-all duration-200 ease-out'
)}
style={{
transform: `translateX(${Math.max(-24, Math.min(24, dx / 8))}px)`,
opacity: dx === 0 ? 0 : 1,
justifyContent: dx > 0 ? 'flex-start' : 'flex-end',
paddingLeft: dx > 0 ? 16 : 0,
paddingRight: dx > 0 ? 0 : 16,
}}
>
{dx > 0 ? leftAction.label : rightAction.label}
</div>
</div>
{/* Foreground (moves) */}
<div
ref={cardRef}
className="relative"
style={{
// ✅ iOS Fix: kein transform im Idle-Zustand, sonst sind Video-Controls oft nicht tappbar
transform: dx !== 0 ? `translate3d(${dx}px,0,0)` : undefined,
transition: animMs ? `transform ${animMs}ms ease` : undefined,
touchAction: 'pan-y',
willChange: dx !== 0 ? 'transform' : undefined,
}}
onPointerDown={(e) => {
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()
}}
>
<div className="relative">
<div className="relative z-10">{children}</div>
{/* ✅ Overlay liegt ÜBER dem Inhalt */}
<div
className={cn(
'absolute inset-0 z-20 pointer-events-none transition-opacity duration-150 rounded-lg',
armedDir === 'right' && 'bg-emerald-500/20 opacity-100',
armedDir === 'left' && 'bg-red-500/20 opacity-100',
armedDir === null && 'opacity-0'
)}
/>
</div>
</div>
</div>
)
})
export default SwipeCard