diff --git a/app/games/games-page-client.tsx b/app/games/games-page-client.tsx new file mode 100644 index 0000000..9db4037 --- /dev/null +++ b/app/games/games-page-client.tsx @@ -0,0 +1,445 @@ +"use client" + +import { useState, useEffect, useRef, useCallback } from "react" +import Image from "next/image" +import Link from "next/link" +import { motion } from "motion/react" +import { + Gamepad2Icon, + SearchIcon, + TrendingUpIcon, + ChevronDownIcon, + XIcon, + Loader2Icon, +} from "lucide-react" + +interface GamesListItem { + id: string + steamAppId: number | null + title: string + developer: string | null + capsuleImage: string | null + headerImage: string | null + genres: string[] | null + source: string + benchmarkCount: number + deckStatus: string | null +} + +interface DeviceOption { + slug: string + name: string +} + +type SortOption = "recent" | "name" | "benchmarks" + +const DECK_STATUS_CONFIG: Record = { + native: { label: "Native", className: "bg-green-500/10 border-green-500/20 text-green-400" }, + proton: { label: "Proton", className: "bg-blue-500/10 border-blue-500/20 text-blue-400" }, + unsupported: { label: "Unsupported", className: "bg-red-500/10 border-red-500/20 text-red-400" }, + unknown: { label: "Unknown", className: "bg-text/5 border-border text-text/40" }, +} + +const SORT_OPTIONS: { value: SortOption; label: string }[] = [ + { value: "recent", label: "Recently Added" }, + { value: "name", label: "Name A\u2013Z" }, + { value: "benchmarks", label: "Most Benchmarks" }, +] + +export function GamesPageClient({ + initialGames, + totalCount, + allGenres, + allDevices, +}: { + initialGames: GamesListItem[] + totalCount: number + allGenres: string[] + allDevices: DeviceOption[] +}) { + const [games, setGames] = useState(initialGames) + const [total, setTotal] = useState(totalCount) + const [search, setSearch] = useState("") + const [selectedGenres, setSelectedGenres] = useState([]) + const [selectedDevice, setSelectedDevice] = useState("") + const [sort, setSort] = useState("recent") + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [showFilters, setShowFilters] = useState(false) + const observerRef = useRef(null) + const sentinelRef = useRef(null) + + const hasMore = games.length < total + + const buildUrl = useCallback( + (offset: number) => { + const params = new URLSearchParams() + params.set("offset", String(offset)) + params.set("limit", "24") + params.set("sort", sort) + if (search) params.set("search", search) + if (selectedDevice) params.set("device", selectedDevice) + if (selectedGenres.length === 1) params.set("genre", selectedGenres[0]) + return `/api/games/listing?${params.toString()}` + }, + [sort, search, selectedDevice, selectedGenres], + ) + + // Load more function for infinite scroll + const loadMore = useCallback(async () => { + if (loading || !hasMore) return + setLoading(true) + setError(null) + try { + const url = buildUrl(games.length) + const res = await fetch(url) + if (!res.ok) throw new Error("Failed to load games") + const data = await res.json() + setGames((prev) => [...prev, ...data.data]) + setTotal(data.total) + } catch (err) { + setError("Failed to load more games. Please try again.") + console.error(err) + } finally { + setLoading(false) + } + }, [loading, hasMore, games.length, buildUrl]) + + // Full reload when filters/sort change + useEffect(() => { + let cancelled = false + setLoading(true) + setError(null) + + async function fetchGames() { + try { + const url = buildUrl(0) + const res = await fetch(url) + if (!res.ok) throw new Error("Failed to load games") + const data = await res.json() + if (!cancelled) { + setGames(data.data) + setTotal(data.total) + } + } catch (err) { + if (!cancelled) setError("Failed to load games. Please try again.") + console.error(err) + } finally { + if (!cancelled) setLoading(false) + } + } + + fetchGames() + return () => { + cancelled = true + } + }, [buildUrl]) + + // IntersectionObserver for infinite scroll + useEffect(() => { + if (observerRef.current) observerRef.current.disconnect() + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting && hasMore && !loading) { + loadMore() + } + }, + { rootMargin: "200px" }, + ) + + if (sentinelRef.current) { + observer.observe(sentinelRef.current) + } + + observerRef.current = observer + return () => observer.disconnect() + }, [hasMore, loading, loadMore]) + + const toggleGenre = (genre: string) => { + setSelectedGenres((prev) => + prev.includes(genre) ? prev.filter((g) => g !== genre) : [...prev, genre], + ) + } + + return ( +
+ {/* Header */} + +
+

Games

+

+ Browse {total.toLocaleString()} games with benchmarks, settings, and performance data +

+
+
+ + {/* Search & Filter Bar */} + +
+ {/* Search + Sort Row */} +
+ + + +
+ + {/* Filter Panel (collapsible) */} + {showFilters && ( + + {/* Device filter */} +
+ + Device + +
+ + {allDevices.map((device) => ( + + ))} +
+
+ + {/* Genre filter */} +
+ + Genre + +
+ {allGenres.map((genre) => ( + + ))} +
+
+ + {/* Clear filters */} + {(selectedDevice || selectedGenres.length > 0) && ( + + )} +
+ )} +
+
+ + {/* Error State */} + {error && !loading && ( +
+

{error}

+ +
+ )} + + {/* Games Grid */} + +
+ {games.map((game) => ( + + ))} +
+ + {/* Empty state */} + {!loading && games.length === 0 && !error && ( +
+ +

+ {search || selectedDevice || selectedGenres.length > 0 + ? "No games match your filters" + : "No games found"} +

+ {(search || selectedDevice || selectedGenres.length > 0) && ( + + )} +
+ )} +
+ + {/* Loading indicator for infinite scroll */} + {loading && ( +
+ +
+ )} + + {/* End of list */} + {!loading && !hasMore && games.length > 0 && ( +
+

+ Showing all {games.length} of {total.toLocaleString()} games +

+
+ )} + + {/* Infinite scroll sentinel */} + {hasMore && !loading &&
} +
+ ) +} + +function GameCard({ game }: { game: GamesListItem }) { + const [imgError, setImgError] = useState(false) + const imageUrl = game.capsuleImage || game.headerImage + + const deckConfig = game.deckStatus + ? DECK_STATUS_CONFIG[game.deckStatus] ?? DECK_STATUS_CONFIG.unknown + : null + + return ( + +
+ {imageUrl && !imgError ? ( + {game.title} setImgError(true)} + /> + ) : ( +
+ +
+ )} +
+
+

+ {game.title} +

+
+ {game.benchmarkCount > 0 ? ( + + + {game.benchmarkCount} + + ) : ( + No data yet + )} + {deckConfig && ( + + {deckConfig.label} + + )} +
+
+ + ) +} diff --git a/app/games/page.tsx b/app/games/page.tsx new file mode 100644 index 0000000..b8b1532 --- /dev/null +++ b/app/games/page.tsx @@ -0,0 +1,152 @@ +import type { Metadata } from "next" +import { db } from "@/lib/db/index" +import { + games, + gameVersions, + performanceEntries, + gamePlatformSupport, + hardware, +} from "@/lib/db/schema" +import { sql, eq, and, desc, inArray } from "drizzle-orm" +import { GamesPageClient } from "./games-page-client" + +export const metadata: Metadata = { + title: "Games — DeckyVault", + description: + "Browse the full catalog of Steam Deck games with benchmarks, community settings, and performance data. Filter by genre, device, and more.", + keywords: [ + "Steam Deck games", + "game benchmarks", + "Steam Deck settings", + "game catalog", + "performance data", + ], + alternates: { canonical: "https://deckyvault.xyz/games" }, + openGraph: { + title: "Games — DeckyVault", + description: + "Browse the full catalog of Steam Deck games with benchmarks and performance data.", + url: "https://deckyvault.xyz/games", + siteName: "DeckyVault", + type: "website", + }, +} + +export default async function GamesPage() { + // Fetch initial 24 games with benchmark counts + const benchmarkCountSql = sql`( + SELECT count(*)::int FROM ${performanceEntries} + INNER JOIN ${gameVersions} ON ${performanceEntries.versionId} = ${gameVersions.id} + WHERE ${gameVersions.gameId} = ${games.id} + AND ${performanceEntries.isRemoved} = false + )` + + const gamesData = await db + .select({ + id: games.id, + steamAppId: games.steamAppId, + title: games.title, + developer: games.developer, + capsuleImage: games.capsuleImage, + headerImage: games.headerImage, + genres: games.genres, + source: games.source, + createdAt: games.createdAt, + benchmarkCount: benchmarkCountSql, + }) + .from(games) + .orderBy(desc(games.createdAt)) + .limit(24) + + // Get total count + const [{ count: totalCount }] = await db + .select({ count: sql`count(*)::int` }) + .from(games) + + // Fetch platform support for initial games (prioritise Steam Deck) + const gameIds = gamesData.map((g) => g.id) + const platformRows = gameIds.length > 0 + ? await db + .select({ + gameId: gamePlatformSupport.gameId, + hardwareSlug: gamePlatformSupport.hardwareSlug, + protonStatus: gamePlatformSupport.protonStatus, + }) + .from(gamePlatformSupport) + .where(inArray(gamePlatformSupport.gameId, gameIds)) + : [] + + const platformMap = new Map() + for (const row of platformRows) { + const isSteamDeck = row.hardwareSlug.startsWith("steamdeck") + const existing = platformMap.get(row.gameId) + if (!existing || (!existing.startsWith("steamdeck") && isSteamDeck)) { + platformMap.set(row.gameId, row.protonStatus) + } + } + + // Fetch all genres + const genreRows = await db + .select({ genres: games.genres }) + .from(games) + .where(sql`${games.genres} IS NOT NULL`) + + const genreSet = new Set() + for (const row of genreRows) { + if (Array.isArray(row.genres)) { + for (const g of row.genres) { + if (typeof g === "string") genreSet.add(g) + } + } + } + + // Fetch all hardware devices + const deviceRows = await db + .select({ slug: hardware.slug, name: hardware.name }) + .from(hardware) + .orderBy(hardware.sortOrder) + + // Serialize for client + const initialGames = gamesData.map((g) => ({ + id: g.id, + steamAppId: g.steamAppId, + title: g.title, + developer: g.developer, + capsuleImage: g.capsuleImage, + headerImage: g.headerImage, + genres: g.genres, + source: g.source, + benchmarkCount: g.benchmarkCount, + deckStatus: platformMap.get(g.id) ?? null, + })) + + const allGenres = Array.from(genreSet).sort() + const allDevices = deviceRows + + // JSON-LD ItemList + const jsonLd = { + "@context": "https://schema.org", + "@type": "ItemList", + itemListElement: initialGames.map((game, i) => ({ + "@type": "ListItem", + position: i + 1, + name: game.title, + url: `https://deckyvault.xyz/game/${game.id}`, + })), + } + + return ( + <> +