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 { Disc3, X, Heart, ExternalLink, Loader2, RotateCcw } from 'lucide-react'
|
||||||
import { fillCrate, crateSave, type CrateItem } from '../lib/api'
|
import { fillCrate, crateSave, type CrateItem } from '../lib/api'
|
||||||
|
|
||||||
@@ -80,6 +80,52 @@ export default function CrateDigger() {
|
|||||||
advanceCard()
|
advanceCard()
|
||||||
}, [cardState, crate, currentIndex, 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]
|
const currentItem = crate[currentIndex]
|
||||||
|
|
||||||
// Empty state — no crate loaded yet
|
// Empty state — no crate loaded yet
|
||||||
@@ -194,13 +240,23 @@ export default function CrateDigger() {
|
|||||||
{/* Card */}
|
{/* Card */}
|
||||||
{currentItem && (
|
{currentItem && (
|
||||||
<div
|
<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'
|
cardState === 'saving'
|
||||||
? 'opacity-0 translate-x-24'
|
? 'transition-all duration-300 opacity-0 translate-x-48 rotate-6'
|
||||||
: cardState === 'passing'
|
: cardState === 'passing'
|
||||||
? 'opacity-0 -translate-x-24'
|
? 'transition-all duration-300 opacity-0 -translate-x-48 -rotate-6'
|
||||||
: 'opacity-100 translate-x-0'
|
: 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 */}
|
{/* 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">
|
<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>
|
</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 */}
|
{/* Action buttons */}
|
||||||
<div className="flex items-center justify-center gap-8 mt-8">
|
<div className="flex items-center justify-center gap-8 mt-8">
|
||||||
<button
|
<button
|
||||||
@@ -261,7 +329,7 @@ export default function CrateDigger() {
|
|||||||
|
|
||||||
{/* Keyboard hints */}
|
{/* Keyboard hints */}
|
||||||
<p className="text-center text-charcoal-muted text-xs mt-4">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user