diff --git a/src/features/discover/components/card-stack.tsx b/src/features/discover/components/card-stack.tsx
new file mode 100644
index 0000000..5f7a6ad
--- /dev/null
+++ b/src/features/discover/components/card-stack.tsx
@@ -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 (
+
+ {visibleCards.map((game, i) => {
+ const isTop = i === 0
+ const scale = 1 - i * 0.05
+ const translateY = i * 8
+
+ return (
+
+
+
+ )
+ })}
+
+ )
+}
diff --git a/src/features/discover/components/discover-done.tsx b/src/features/discover/components/discover-done.tsx
new file mode 100644
index 0000000..280985e
--- /dev/null
+++ b/src/features/discover/components/discover-done.tsx
@@ -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 (
+
+
{t("discover.done.title")}
+
+ {t("discover.done.message")} ({seenCount} reviewed)
+
+
+
+ )
+}
diff --git a/src/features/discover/components/discover-progress.tsx b/src/features/discover/components/discover-progress.tsx
new file mode 100644
index 0000000..53ed69a
--- /dev/null
+++ b/src/features/discover/components/discover-progress.tsx
@@ -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 (
+
+
+ {t("discover.progress")}
+
+ {seenCount} / {totalCount}
+
+
+
+
+ )
+}
diff --git a/src/features/discover/components/game-discover-card.tsx b/src/features/discover/components/game-discover-card.tsx
new file mode 100644
index 0000000..4d23b7e
--- /dev/null
+++ b/src/features/discover/components/game-discover-card.tsx
@@ -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 (
+
+ {imageUrl && (
+

+ )}
+
+
+
{game.title}
+
+ {game.source}
+
+
+ {game.playtime_hours > 0 && (
+
+ {formatPlaytime(game.playtime_hours)} played
+
+ )}
+
+
+ )
+}
diff --git a/src/features/discover/components/swipe-buttons.tsx b/src/features/discover/components/swipe-buttons.tsx
new file mode 100644
index 0000000..5441af5
--- /dev/null
+++ b/src/features/discover/components/swipe-buttons.tsx
@@ -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 (
+
+
+
+
+ )
+}
diff --git a/src/features/discover/hooks/use-discover.ts b/src/features/discover/hooks/use-discover.ts
new file mode 100644
index 0000000..5aa5bc3
--- /dev/null
+++ b/src/features/discover/hooks/use-discover.ts
@@ -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()
+ 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,
+ }
+}
diff --git a/src/features/discover/store.ts b/src/features/discover/store.ts
new file mode 100644
index 0000000..0d2b6bb
--- /dev/null
+++ b/src/features/discover/store.ts
@@ -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((set) => ({
+ currentIndex: 0,
+ animatingDirection: null,
+ setCurrentIndex: (currentIndex) => set({ currentIndex }),
+ setAnimatingDirection: (animatingDirection) => set({ animatingDirection }),
+ reset: () => set({ currentIndex: 0, animatingDirection: null }),
+}))
diff --git a/src/routes/discover/index.tsx b/src/routes/discover/index.tsx
index f602ef0..dcc3597 100644
--- a/src/routes/discover/index.tsx
+++ b/src/routes/discover/index.tsx
@@ -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: () => Discover
,
+ component: DiscoverPage,
})
+
+function DiscoverPage() {
+ const { unseenGames, isDone, progress, totalCount, seenCount, swipeRight, swipeLeft, reset } =
+ useDiscover()
+
+ return (
+
+
{t("discover.title")}
+
+
+
+
+ {isDone ? (
+
+ ) : unseenGames.length === 0 ? (
+
{t("discover.empty")}
+ ) : (
+
+
+
+
+ )}
+
+
+ )
+}