add discover feature: tinder swipe cards, progress, like/skip
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
81
src/features/discover/components/card-stack.tsx
Normal file
81
src/features/discover/components/card-stack.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
21
src/features/discover/components/discover-done.tsx
Normal file
21
src/features/discover/components/discover-done.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
26
src/features/discover/components/discover-progress.tsx
Normal file
26
src/features/discover/components/discover-progress.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
src/features/discover/components/game-discover-card.tsx
Normal file
36
src/features/discover/components/game-discover-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
src/features/discover/components/swipe-buttons.tsx
Normal file
33
src/features/discover/components/swipe-buttons.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
66
src/features/discover/hooks/use-discover.ts
Normal file
66
src/features/discover/hooks/use-discover.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
17
src/features/discover/store.ts
Normal file
17
src/features/discover/store.ts
Normal 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 }),
|
||||
}))
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user