Add touch swipe gestures + keyboard arrows to Crate Digger
This commit is contained in:
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user