add discover feature: tinder swipe cards, progress, like/skip

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 14:36:30 +01:00
parent 7e71098658
commit c9c69a3265
8 changed files with 317 additions and 1 deletions

View File

@@ -0,0 +1,81 @@
import type { Game } from "@/shared/db/schema"
import { useCallback, useRef, useState } from "react"
import { GameDiscoverCard } from "./game-discover-card"
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 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])
const visibleCards = games.slice(0, 3)
return (
<div className="relative h-80 w-full">
{visibleCards.map((game, i) => {
const isTop = i === 0
const scale = 1 - i * 0.05
const translateY = i * 8
return (
<div
key={game.id}
className="absolute inset-0"
style={{
transform: isTop
? `translateX(${offsetX}px) rotate(${offsetX * 0.05}deg)`
: `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,
}}
onPointerDown={isTop ? handlePointerDown : undefined}
onPointerMove={isTop ? handlePointerMove : undefined}
onPointerUp={isTop ? handlePointerUp : undefined}
>
<GameDiscoverCard game={game} />
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,21 @@
import { Button } from "@/shared/components/ui/button"
import { t } from "@/shared/i18n"
interface DiscoverDoneProps {
seenCount: number
onReset: () => void
}
export function DiscoverDone({ seenCount, onReset }: DiscoverDoneProps) {
return (
<div className="flex flex-col items-center gap-4 py-12 text-center">
<h2 className="text-xl font-bold">{t("discover.done.title")}</h2>
<p className="text-muted-foreground">
{t("discover.done.message")} ({seenCount} reviewed)
</p>
<Button onClick={onReset} variant="secondary">
{t("discover.reset")}
</Button>
</div>
)
}

View File

@@ -0,0 +1,26 @@
import { t } from "@/shared/i18n"
interface DiscoverProgressProps {
progress: number
seenCount: number
totalCount: number
}
export function DiscoverProgress({ progress, seenCount, totalCount }: DiscoverProgressProps) {
return (
<div className="space-y-1">
<div className="flex justify-between text-xs text-muted-foreground">
<span>{t("discover.progress")}</span>
<span>
{seenCount} / {totalCount}
</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: `${progress}%` }}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,36 @@
import { formatPlaytime } from "@/features/games/schema"
import { Badge } from "@/shared/components/ui/badge"
import type { Game } from "@/shared/db/schema"
interface GameDiscoverCardProps {
game: Game
}
function getSteamHeaderImage(sourceId: string): string {
return `https://cdn.akamai.steamstatic.com/steam/apps/${sourceId}/header.jpg`
}
export function GameDiscoverCard({ game }: GameDiscoverCardProps) {
const imageUrl = game.source === "steam" ? getSteamHeaderImage(game.source_id) : null
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" />
)}
<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">
{game.source}
</Badge>
</div>
{game.playtime_hours > 0 && (
<p className="mt-1 text-sm text-muted-foreground">
{formatPlaytime(game.playtime_hours)} played
</p>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,33 @@
import { Button } from "@/shared/components/ui/button"
import { Check, X } from "lucide-react"
interface SwipeButtonsProps {
onSkip: () => void
onLike: () => void
disabled: boolean
}
export function SwipeButtons({ onSkip, onLike, disabled }: SwipeButtonsProps) {
return (
<div className="flex justify-center gap-6">
<Button
variant="outline"
size="icon"
className="h-14 w-14 rounded-full border-2 border-destructive text-destructive"
onClick={onSkip}
disabled={disabled}
>
<X className="h-6 w-6" />
</Button>
<Button
variant="outline"
size="icon"
className="h-14 w-14 rounded-full border-2 border-green-500 text-green-500"
onClick={onLike}
disabled={disabled}
>
<Check className="h-6 w-6" />
</Button>
</div>
)
}

View File

@@ -0,0 +1,66 @@
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"
export function useDiscover() {
const { games: allGames, reload: reloadGames } = 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 [ready, setReady] = useState(false)
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)
return ids
}, [wantToPlayGames, notIntGames])
const unseenGames = useMemo(() => allGames.filter((g) => !seenIds.has(g.id)), [allGames, seenIds])
useEffect(() => {
if (allGames.length > 0) setReady(true)
}, [allGames.length])
const currentGame: Game | null = unseenGames[currentIndex] ?? null
const isDone = ready && unseenGames.length === 0
const progress =
allGames.length > 0 ? ((allGames.length - unseenGames.length) / allGames.length) * 100 : 0
const swipeRight = useCallback(async () => {
if (!currentGame) return
await addGame("want-to-play", currentGame.id)
reloadWtp()
reloadGames()
}, [currentGame, addGame, reloadWtp, reloadGames])
const swipeLeft = useCallback(async () => {
if (!currentGame) return
await addGame("not-interesting", currentGame.id)
reloadNi()
reloadGames()
}, [currentGame, addGame, reloadNi, reloadGames])
const handleReset = useCallback(async () => {
reset()
reloadGames()
reloadWtp()
reloadNi()
}, [reset, reloadGames, reloadWtp, reloadNi])
return {
currentGame,
unseenGames,
currentIndex,
setCurrentIndex,
isDone,
progress,
totalCount: allGames.length,
seenCount: allGames.length - unseenGames.length,
swipeRight,
swipeLeft,
reset: handleReset,
}
}

View File

@@ -0,0 +1,17 @@
import { create } from "zustand"
interface DiscoverState {
currentIndex: number
animatingDirection: "left" | "right" | null
setCurrentIndex: (index: number) => void
setAnimatingDirection: (dir: "left" | "right" | null) => void
reset: () => void
}
export const useDiscoverStore = create<DiscoverState>((set) => ({
currentIndex: 0,
animatingDirection: null,
setCurrentIndex: (currentIndex) => set({ currentIndex }),
setAnimatingDirection: (animatingDirection) => set({ animatingDirection }),
reset: () => set({ currentIndex: 0, animatingDirection: null }),
}))

View File

@@ -1,5 +1,41 @@
import { CardStack } from "@/features/discover/components/card-stack"
import { DiscoverDone } from "@/features/discover/components/discover-done"
import { DiscoverProgress } from "@/features/discover/components/discover-progress"
import { SwipeButtons } from "@/features/discover/components/swipe-buttons"
import { useDiscover } from "@/features/discover/hooks/use-discover"
import { t } from "@/shared/i18n"
import { createFileRoute } from "@tanstack/react-router"
export const Route = createFileRoute("/discover/")({
component: () => <div>Discover</div>,
component: DiscoverPage,
})
function DiscoverPage() {
const { unseenGames, isDone, progress, totalCount, seenCount, swipeRight, swipeLeft, reset } =
useDiscover()
return (
<div className="mx-auto max-w-lg p-4">
<h1 className="mb-4 text-2xl font-bold">{t("discover.title")}</h1>
<DiscoverProgress progress={progress} seenCount={seenCount} totalCount={totalCount} />
<div className="mt-6">
{isDone ? (
<DiscoverDone seenCount={seenCount} onReset={reset} />
) : unseenGames.length === 0 ? (
<p className="py-8 text-center text-muted-foreground">{t("discover.empty")}</p>
) : (
<div className="space-y-6">
<CardStack games={unseenGames} onSwipeLeft={swipeLeft} onSwipeRight={swipeRight} />
<SwipeButtons
onSkip={swipeLeft}
onLike={swipeRight}
disabled={unseenGames.length === 0}
/>
</div>
)}
</div>
</div>
)
}