improve discover: seeded shuffle, reusable swipe gesture hook, optimistic swipes
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,51 +1,32 @@
|
||||
import type { Game } from "@/shared/db/schema"
|
||||
import { useCallback, useRef, useState } from "react"
|
||||
import { useSwipeGesture } from "@/shared/hooks/use-swipe-gesture"
|
||||
import { useEffect } from "react"
|
||||
import { GameDiscoverCard } from "./game-discover-card"
|
||||
|
||||
function getSteamHeaderImage(sourceId: string): string {
|
||||
return `https://cdn.akamai.steamstatic.com/steam/apps/${sourceId}/header.jpg`
|
||||
}
|
||||
|
||||
interface CardStackProps {
|
||||
games: Game[]
|
||||
onSwipeLeft: () => void
|
||||
onSwipeRight: () => void
|
||||
}
|
||||
|
||||
const SWIPE_THRESHOLD = 100
|
||||
|
||||
export function CardStack({ games, onSwipeLeft, onSwipeRight }: CardStackProps) {
|
||||
const [offsetX, setOffsetX] = useState(0)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [isExiting, setIsExiting] = useState(false)
|
||||
const startXRef = useRef(0)
|
||||
const { offsetX, isDragging, handlers } = useSwipeGesture({
|
||||
threshold: 80,
|
||||
onSwipeLeft,
|
||||
onSwipeRight,
|
||||
})
|
||||
|
||||
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
||||
startXRef.current = e.clientX
|
||||
setIsDragging(true)
|
||||
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
|
||||
}, [])
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!isDragging) return
|
||||
setOffsetX(e.clientX - startXRef.current)
|
||||
},
|
||||
[isDragging],
|
||||
)
|
||||
|
||||
const handlePointerUp = useCallback(() => {
|
||||
setIsDragging(false)
|
||||
if (Math.abs(offsetX) > SWIPE_THRESHOLD) {
|
||||
const direction = offsetX > 0 ? "right" : "left"
|
||||
setIsExiting(true)
|
||||
setOffsetX(direction === "right" ? 400 : -400)
|
||||
setTimeout(() => {
|
||||
if (direction === "right") onSwipeRight()
|
||||
else onSwipeLeft()
|
||||
setOffsetX(0)
|
||||
setIsExiting(false)
|
||||
}, 200)
|
||||
} else {
|
||||
setOffsetX(0)
|
||||
}
|
||||
}, [offsetX, onSwipeLeft, onSwipeRight])
|
||||
// Preload 4th card's image so it's cached before entering the visible stack
|
||||
const preloadGame = games[3]
|
||||
useEffect(() => {
|
||||
if (preloadGame?.source !== "steam") return
|
||||
const img = new Image()
|
||||
img.src = getSteamHeaderImage(preloadGame.source_id)
|
||||
}, [preloadGame?.source, preloadGame?.source_id])
|
||||
|
||||
const visibleCards = games.slice(0, 3)
|
||||
|
||||
@@ -66,11 +47,9 @@ export function CardStack({ games, onSwipeLeft, onSwipeRight }: CardStackProps)
|
||||
: `scale(${scale}) translateY(${translateY}px)`,
|
||||
transition: isDragging && isTop ? "none" : "transform 0.2s ease",
|
||||
zIndex: 3 - i,
|
||||
opacity: isExiting && isTop ? 1 - Math.abs(offsetX) / 500 : 1,
|
||||
touchAction: isTop ? "none" : undefined,
|
||||
}}
|
||||
onPointerDown={isTop ? handlePointerDown : undefined}
|
||||
onPointerMove={isTop ? handlePointerMove : undefined}
|
||||
onPointerUp={isTop ? handlePointerUp : undefined}
|
||||
{...(isTop ? handlers : {})}
|
||||
>
|
||||
<GameDiscoverCard game={game} />
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ export function DiscoverDone({ seenCount, onReset }: DiscoverDoneProps) {
|
||||
<p className="text-muted-foreground">
|
||||
{t("discover.done.message")} ({seenCount} reviewed)
|
||||
</p>
|
||||
<Button onClick={onReset} variant="secondary">
|
||||
<Button variant="outline" onClick={onReset}>
|
||||
{t("discover.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -15,13 +15,11 @@ export function GameDiscoverCard({ game }: GameDiscoverCardProps) {
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border bg-card shadow-lg">
|
||||
{imageUrl && (
|
||||
<img src={imageUrl} alt={game.title} className="h-44 w-full object-cover" loading="lazy" />
|
||||
)}
|
||||
{imageUrl && <img src={imageUrl} alt={game.title} className="h-44 w-full object-cover" />}
|
||||
<div className="p-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="truncate text-lg font-bold">{game.title}</h3>
|
||||
<Badge variant="outline" className="shrink-0">
|
||||
<Badge variant="secondary" className="shrink-0">
|
||||
{game.source}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@ export function SwipeButtons({ onSkip, onLike, disabled }: SwipeButtonsProps) {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-14 w-14 rounded-full border-2 border-destructive text-destructive"
|
||||
className="size-14 rounded-full border-2 border-red-500 text-red-500"
|
||||
onClick={onSkip}
|
||||
disabled={disabled}
|
||||
>
|
||||
@@ -22,7 +22,7 @@ export function SwipeButtons({ onSkip, onLike, disabled }: SwipeButtonsProps) {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-14 w-14 rounded-full border-2 border-green-500 text-green-500"
|
||||
className="size-14 rounded-full border-2 border-green-500 text-green-500"
|
||||
onClick={onLike}
|
||||
disabled={disabled}
|
||||
>
|
||||
|
||||
@@ -1,24 +1,33 @@
|
||||
import { useGames, usePlaylist, usePlaylistMutations } from "@/shared/db/hooks"
|
||||
import type { Game } from "@/shared/db/schema"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import { useDiscoverStore } from "../store"
|
||||
import { seededShuffle, useDiscoverStore } from "../store"
|
||||
|
||||
export function useDiscover() {
|
||||
const { games: allGames, reload: reloadGames } = useGames()
|
||||
const { games: allGames } = useGames()
|
||||
const { games: wantToPlayGames, reload: reloadWtp } = usePlaylist("want-to-play")
|
||||
const { games: notIntGames, reload: reloadNi } = usePlaylist("not-interesting")
|
||||
const { addGame } = usePlaylistMutations()
|
||||
const { currentIndex, setCurrentIndex, reset } = useDiscoverStore()
|
||||
const { currentIndex, setCurrentIndex, seed, reset } = useDiscoverStore()
|
||||
const [ready, setReady] = useState(false)
|
||||
const [localSeenIds, setLocalSeenIds] = useState<Set<string>>(new Set())
|
||||
|
||||
const seenIds = useMemo(() => {
|
||||
const ids = new Set<string>()
|
||||
for (const g of wantToPlayGames) ids.add(g.id)
|
||||
for (const g of notIntGames) ids.add(g.id)
|
||||
for (const id of localSeenIds) ids.add(id)
|
||||
return ids
|
||||
}, [wantToPlayGames, notIntGames])
|
||||
}, [wantToPlayGames, notIntGames, localSeenIds])
|
||||
|
||||
const unseenGames = useMemo(() => allGames.filter((g) => !seenIds.has(g.id)), [allGames, seenIds])
|
||||
const unseenGames = useMemo(
|
||||
() =>
|
||||
seededShuffle(
|
||||
allGames.filter((g) => !seenIds.has(g.id)),
|
||||
seed,
|
||||
),
|
||||
[allGames, seenIds, seed],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (allGames.length > 0) setReady(true)
|
||||
@@ -29,26 +38,24 @@ export function useDiscover() {
|
||||
const progress =
|
||||
allGames.length > 0 ? ((allGames.length - unseenGames.length) / allGames.length) * 100 : 0
|
||||
|
||||
const swipeRight = useCallback(async () => {
|
||||
const swipeRight = useCallback(() => {
|
||||
if (!currentGame) return
|
||||
await addGame("want-to-play", currentGame.id)
|
||||
reloadWtp()
|
||||
reloadGames()
|
||||
}, [currentGame, addGame, reloadWtp, reloadGames])
|
||||
setLocalSeenIds((prev) => new Set(prev).add(currentGame.id))
|
||||
addGame("want-to-play", currentGame.id)
|
||||
}, [currentGame, addGame])
|
||||
|
||||
const swipeLeft = useCallback(async () => {
|
||||
const swipeLeft = useCallback(() => {
|
||||
if (!currentGame) return
|
||||
await addGame("not-interesting", currentGame.id)
|
||||
reloadNi()
|
||||
reloadGames()
|
||||
}, [currentGame, addGame, reloadNi, reloadGames])
|
||||
setLocalSeenIds((prev) => new Set(prev).add(currentGame.id))
|
||||
addGame("not-interesting", currentGame.id)
|
||||
}, [currentGame, addGame])
|
||||
|
||||
const handleReset = useCallback(async () => {
|
||||
const handleReset = useCallback(() => {
|
||||
reset()
|
||||
reloadGames()
|
||||
setLocalSeenIds(new Set())
|
||||
reloadWtp()
|
||||
reloadNi()
|
||||
}, [reset, reloadGames, reloadWtp, reloadNi])
|
||||
}, [reset, reloadWtp, reloadNi])
|
||||
|
||||
return {
|
||||
currentGame,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { create } from "zustand"
|
||||
|
||||
interface DiscoverState {
|
||||
currentIndex: number
|
||||
seed: number
|
||||
animatingDirection: "left" | "right" | null
|
||||
setCurrentIndex: (index: number) => void
|
||||
setAnimatingDirection: (dir: "left" | "right" | null) => void
|
||||
@@ -10,8 +11,31 @@ interface DiscoverState {
|
||||
|
||||
export const useDiscoverStore = create<DiscoverState>((set) => ({
|
||||
currentIndex: 0,
|
||||
seed: Math.random(),
|
||||
animatingDirection: null,
|
||||
setCurrentIndex: (currentIndex) => set({ currentIndex }),
|
||||
setAnimatingDirection: (animatingDirection) => set({ animatingDirection }),
|
||||
reset: () => set({ currentIndex: 0, animatingDirection: null }),
|
||||
reset: () => set({ currentIndex: 0, seed: Math.random(), animatingDirection: null }),
|
||||
}))
|
||||
|
||||
/** Mulberry32 — fast seeded 32-bit PRNG */
|
||||
export function mulberry32(seed: number): () => number {
|
||||
let s = seed | 0 || 1
|
||||
return () => {
|
||||
s = (s + 0x6d2b79f5) | 0
|
||||
let t = Math.imul(s ^ (s >>> 15), 1 | s)
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
/** Fisher-Yates shuffle using a seeded PRNG */
|
||||
export function seededShuffle<T>(arr: readonly T[], seed: number): T[] {
|
||||
const result = arr.slice()
|
||||
const rng = mulberry32(Math.floor(seed * 2147483647))
|
||||
for (let i = result.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rng() * (i + 1))
|
||||
;[result[i], result[j]] = [result[j], result[i]]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
75
src/shared/hooks/use-swipe-gesture.ts
Normal file
75
src/shared/hooks/use-swipe-gesture.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useCallback, useRef, useState } from "react"
|
||||
|
||||
interface SwipeGestureOptions {
|
||||
/** Minimum px to travel before triggering a swipe (default: 80) */
|
||||
threshold?: number
|
||||
/** Called when swiped past threshold to the left */
|
||||
onSwipeLeft?: () => void
|
||||
/** Called when swiped past threshold to the right */
|
||||
onSwipeRight?: () => void
|
||||
}
|
||||
|
||||
interface SwipeGestureResult {
|
||||
offsetX: number
|
||||
isDragging: boolean
|
||||
handlers: {
|
||||
onPointerDown: (e: React.PointerEvent) => void
|
||||
onPointerMove: (e: React.PointerEvent) => void
|
||||
onPointerUp: () => void
|
||||
}
|
||||
}
|
||||
|
||||
export function useSwipeGesture({
|
||||
threshold = 80,
|
||||
onSwipeLeft,
|
||||
onSwipeRight,
|
||||
}: SwipeGestureOptions): SwipeGestureResult {
|
||||
const [offsetX, setOffsetX] = useState(0)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const startXRef = useRef(0)
|
||||
const offsetRef = useRef(0)
|
||||
const onSwipeLeftRef = useRef(onSwipeLeft)
|
||||
const onSwipeRightRef = useRef(onSwipeRight)
|
||||
onSwipeLeftRef.current = onSwipeLeft
|
||||
onSwipeRightRef.current = onSwipeRight
|
||||
|
||||
const handlePointerDown = useCallback((e: React.PointerEvent) => {
|
||||
startXRef.current = e.clientX
|
||||
offsetRef.current = 0
|
||||
setIsDragging(true)
|
||||
setOffsetX(0)
|
||||
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||
}, [])
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
if (!isDragging) return
|
||||
const dx = e.clientX - startXRef.current
|
||||
offsetRef.current = dx
|
||||
setOffsetX(dx)
|
||||
},
|
||||
[isDragging],
|
||||
)
|
||||
|
||||
const handlePointerUp = useCallback(() => {
|
||||
if (!isDragging) return
|
||||
setIsDragging(false)
|
||||
const dx = offsetRef.current
|
||||
if (Math.abs(dx) > threshold) {
|
||||
if (dx > 0) onSwipeRightRef.current?.()
|
||||
else onSwipeLeftRef.current?.()
|
||||
}
|
||||
setOffsetX(0)
|
||||
offsetRef.current = 0
|
||||
}, [isDragging, threshold])
|
||||
|
||||
return {
|
||||
offsetX,
|
||||
isDragging,
|
||||
handlers: {
|
||||
onPointerDown: handlePointerDown,
|
||||
onPointerMove: handlePointerMove,
|
||||
onPointerUp: handlePointerUp,
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user