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.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")}

+ ) : ( +
+ + +
+ )} +
+
+ ) +}