From b50fde1af55dc2621e9a00c5caad32cff1b80852 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felix=20F=C3=B6rtsch?= Date: Mon, 2 Mar 2026 21:30:50 +0100 Subject: [PATCH] improve discover: seeded shuffle, reusable swipe gesture hook, optimistic swipes Co-Authored-By: Claude Opus 4.6 --- .../discover/components/card-stack.tsx | 61 +++++---------- .../discover/components/discover-done.tsx | 2 +- .../components/game-discover-card.tsx | 6 +- .../discover/components/swipe-buttons.tsx | 4 +- src/features/discover/hooks/use-discover.ts | 43 ++++++----- src/features/discover/store.ts | 26 ++++++- src/shared/hooks/use-swipe-gesture.ts | 75 +++++++++++++++++++ 7 files changed, 150 insertions(+), 67 deletions(-) create mode 100644 src/shared/hooks/use-swipe-gesture.ts diff --git a/src/features/discover/components/card-stack.tsx b/src/features/discover/components/card-stack.tsx index 5f7a6ad..7e0da0d 100644 --- a/src/features/discover/components/card-stack.tsx +++ b/src/features/discover/components/card-stack.tsx @@ -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 : {})} > diff --git a/src/features/discover/components/discover-done.tsx b/src/features/discover/components/discover-done.tsx index 280985e..eecb86e 100644 --- a/src/features/discover/components/discover-done.tsx +++ b/src/features/discover/components/discover-done.tsx @@ -13,7 +13,7 @@ export function DiscoverDone({ seenCount, onReset }: DiscoverDoneProps) {

{t("discover.done.message")} ({seenCount} reviewed)

- diff --git a/src/features/discover/components/game-discover-card.tsx b/src/features/discover/components/game-discover-card.tsx index 4d23b7e..a6e48ad 100644 --- a/src/features/discover/components/game-discover-card.tsx +++ b/src/features/discover/components/game-discover-card.tsx @@ -15,13 +15,11 @@ export function GameDiscoverCard({ game }: GameDiscoverCardProps) { return (
- {imageUrl && ( - {game.title} - )} + {imageUrl && {game.title}}

{game.title}

- + {game.source}
diff --git a/src/features/discover/components/swipe-buttons.tsx b/src/features/discover/components/swipe-buttons.tsx index 5441af5..ca09f1e 100644 --- a/src/features/discover/components/swipe-buttons.tsx +++ b/src/features/discover/components/swipe-buttons.tsx @@ -13,7 +13,7 @@ export function SwipeButtons({ onSkip, onLike, disabled }: SwipeButtonsProps) {