nsfwapp/frontend/src/components/ui/SwipeCard.tsx
2025-12-27 00:54:17 +01:00

268 lines
7.8 KiB
TypeScript

'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
}
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,
thresholdRatio = 0.35,
snapMs = 180,
commitMs = 180,
},
ref
) {
const cardRef = React.useRef<HTMLDivElement | null>(null)
const pointer = React.useRef<{
id: number | null
x: number
y: number
dragging: boolean
}>({ id: null, x: 0, y: 0, dragging: 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(() => {
setAnimMs(snapMs)
setDx(0)
setArmedDir(null)
window.setTimeout(() => setAnimMs(0), snapMs)
}, [snapMs])
const commit = React.useCallback(
async (dir: 'left' | 'right', runAction: boolean) => {
const el = cardRef.current
const w = el?.offsetWidth || 360
// rausfliegen lassen
setAnimMs(commitMs)
setArmedDir(dir === 'right' ? 'right' : 'left')
setDx(dir === 'right' ? w + 40 : -(w + 40))
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={{
transform: `translateX(${dx}px)`,
transition: animMs ? `transform ${animMs}ms ease` : undefined,
touchAction: 'pan-y', // wichtig: vertikales Scrollen zulassen
}}
onPointerDown={(e) => {
if (!enabled || disabled) return
pointer.current = { id: e.pointerId, x: e.clientX, y: e.clientY, dragging: false }
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
}}
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, nicht hijacken
if (Math.abs(ddy) > Math.abs(ddx) && Math.abs(ddy) > 8) return
if (Math.abs(ddx) < 6) return
pointer.current.dragging = true
}
setAnimMs(0)
setDx(ddx)
const el = cardRef.current
const w = el?.offsetWidth || 360
const threshold = Math.min(thresholdPx, w * thresholdRatio)
setArmedDir(ddx > threshold ? 'right' : ddx < -threshold ? 'left' : null)
}}
onPointerUp={(e) => {
if (!enabled || disabled) return
if (pointer.current.id !== e.pointerId) return
const el = cardRef.current
const w = el?.offsetWidth || 360
const threshold = Math.min(thresholdPx, w * thresholdRatio)
const wasDragging = pointer.current.dragging
pointer.current.id = null
if (!wasDragging) {
reset()
onTap?.()
return
}
if (dx > threshold) {
void commit('right', true) // keep
} else if (dx < -threshold) {
void commit('left', true) // delete
} else {
reset()
}
}}
onPointerCancel={() => {
if (!enabled || disabled) return
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