Add touch swipe gestures + keyboard arrows to Crate Digger

This commit is contained in:
root
2026-03-31 19:26:50 -05:00
parent 2b56d0c06b
commit 34eabf0fae

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect } from 'react'
import { useState, useCallback, useEffect, useRef } from 'react'
import { Disc3, X, Heart, ExternalLink, Loader2, RotateCcw } from 'lucide-react'
import { fillCrate, crateSave, type CrateItem } from '../lib/api'
@@ -80,6 +80,52 @@ export default function CrateDigger() {
advanceCard()
}, [cardState, crate, currentIndex, advanceCard])
// Touch swipe handling
const touchStartX = useRef<number | null>(null)
const touchStartY = useRef<number | null>(null)
const [swipeOffset, setSwipeOffset] = useState(0)
const cardRef = useRef<HTMLDivElement>(null)
const handleTouchStart = useCallback((e: React.TouchEvent) => {
touchStartX.current = e.touches[0].clientX
touchStartY.current = e.touches[0].clientY
}, [])
const handleTouchMove = useCallback((e: React.TouchEvent) => {
if (touchStartX.current === null || cardState !== 'visible') return
const dx = e.touches[0].clientX - touchStartX.current
const dy = e.touches[0].clientY - (touchStartY.current || 0)
// Only swipe horizontally if horizontal movement > vertical
if (Math.abs(dx) > Math.abs(dy)) {
e.preventDefault()
setSwipeOffset(dx)
}
}, [cardState])
const handleTouchEnd = useCallback(() => {
if (touchStartX.current === null) return
const threshold = 80
if (swipeOffset > threshold) {
handleSave()
} else if (swipeOffset < -threshold) {
handlePass()
}
setSwipeOffset(0)
touchStartX.current = null
touchStartY.current = null
}, [swipeOffset, handleSave, handlePass])
// Keyboard support
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (cardState !== 'visible' || finished || crate.length === 0) return
if (e.key === 'ArrowLeft') handlePass()
if (e.key === 'ArrowRight') handleSave()
}
window.addEventListener('keydown', handleKey)
return () => window.removeEventListener('keydown', handleKey)
}, [cardState, finished, crate.length, handlePass, handleSave])
const currentItem = crate[currentIndex]
// Empty state — no crate loaded yet
@@ -194,13 +240,23 @@ export default function CrateDigger() {
{/* Card */}
{currentItem && (
<div
className={`bg-white rounded-2xl shadow-lg border border-purple-100 overflow-hidden transition-all duration-300 ${
ref={cardRef}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
className={`bg-white rounded-2xl shadow-lg border border-purple-100 overflow-hidden select-none ${
cardState === 'saving'
? 'opacity-0 translate-x-24'
? 'transition-all duration-300 opacity-0 translate-x-48 rotate-6'
: cardState === 'passing'
? 'opacity-0 -translate-x-24'
: 'opacity-100 translate-x-0'
? 'transition-all duration-300 opacity-0 -translate-x-48 -rotate-6'
: swipeOffset === 0
? 'transition-all duration-300 opacity-100 translate-x-0 rotate-0'
: ''
}`}
style={swipeOffset !== 0 ? {
transform: `translateX(${swipeOffset}px) rotate(${swipeOffset * 0.05}deg)`,
opacity: Math.max(0.3, 1 - Math.abs(swipeOffset) / 300),
} : undefined}
>
{/* Album art placeholder */}
<div className="h-48 bg-gradient-to-br from-purple-600 via-purple-500 to-purple-800 flex items-center justify-center relative">
@@ -239,6 +295,18 @@ export default function CrateDigger() {
</div>
)}
{/* Swipe indicators */}
{swipeOffset !== 0 && (
<div className="flex justify-between px-4 mt-3">
<span className={`text-sm font-bold transition-opacity ${swipeOffset < -40 ? 'opacity-100 text-red-500' : 'opacity-0'}`}>
PASS
</span>
<span className={`text-sm font-bold transition-opacity ${swipeOffset > 40 ? 'opacity-100 text-green-500' : 'opacity-0'}`}>
SAVE
</span>
</div>
)}
{/* Action buttons */}
<div className="flex items-center justify-center gap-8 mt-8">
<button
@@ -261,7 +329,7 @@ export default function CrateDigger() {
{/* Keyboard hints */}
<p className="text-center text-charcoal-muted text-xs mt-4">
Tap the buttons to pass or save
Swipe or use buttons · Arrow keys on desktop
</p>
</div>
)