445 lines
14 KiB
TypeScript
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 |