Files
deckyvault/app/games/games-page-client.tsx
T
adrianbonpin dcf6066b2b feat: sitemap remediation, PWA activation, search refinements, Steam Deck UX
Sitemap:
- Switch from ISR (revalidate=3600) to force-dynamic for per-request generation
- Flatten into single app/sitemap.ts with inlined static entries and DB queries
- NULL-safe syncStatus filter: ne(games.syncStatus, 'failed') OR isNull(games.syncStatus)
- Structured JSON logging for generated URLs and DB errors
- Delete lib/sitemap/* helpers and app/api/revalidate-sitemap route
- Add basic vitest coverage for sitemap exports

PWA & Offline:
- Add @serwist/next service worker (webpack build) with runtime caching
- Cache strategies: stale-while-revalidate for game pages, network-first for listings/API,
  cache-first for Steam CDN images
- Offline fallback page (public/offline.html)
- Manifest icons: 192px maskable + 512px any
- Viewport meta with viewport-fit=cover, user-scalable=no
- Apple mobile web app meta tags

Search / Filter Refinements:
- Multi-genre OR support in listing API (comma-separated genres)
- Device-scoped FPS filter (min/max FPS constrained to selected device)
- Client-side URL state sync via router.replace for shareable filtered views
- Initialize filter state from URL params on mount
- Auto-collapse filter panel on saved-filter load
- Fix multi-genre saved filter parsing (comma-separated)

Steam Deck / Touch / Gamepad UX:
- WCAG 2.1 AA touch targets (44x44px) on all filter controls
- .gamepad-focus CSS focus ring for controller navigation
- useGamepadNavigation hook: D-pad/left-stick roving tabindex, A/B/X/Y actions
- Integrate gamepad hook into games page (X=search, Y=toggle filters)

Chore:
- Bump version to 2026.0.97
2026-05-05 16:20:28 +08:00

790 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useState, useEffect, useRef, useCallback } from "react"
import { useRouter } from "next/navigation"
import Image from "next/image"
import Link from "next/link"
import { motion } from "motion/react"
import {
Gamepad2Icon,
SearchIcon,
TrendingUpIcon,
XIcon,
Loader2Icon,
} from "lucide-react"
import { useGamepadNavigation } from "@/lib/hooks/use-gamepad-navigation"
import { AntiCheatBadge } from "@/components/anti-cheat-badge"
import { PlayabilityBadge } from "@/components/playability-badge"
import { SavedFilters } from "@/components/saved-filters"
interface GamesListItem {
id: string
steamAppId: number | null
title: string
developer: string | null
capsuleImage: string | null
headerImage: string | null
genres: string[] | null
source: string
steamReviewScore: number | null
playabilityStatus: "great" | "playable" | "needs_tweaks" | "unplayable" | "unknown" | null
onlineMultiplayerStatus: "none" | "supported" | "unknown" | null
benchmarkCount: number
deckStatus: string | null
antiCheatRelevant: boolean
antiCheatStatus: "none" | "supported" | "unsupported" | "unknown" | null
}
interface DeviceOption {
slug: string
name: string
}
type SortOption = "recent" | "name" | "benchmarks" | "performance" | "popularity" | "release_date" | "steam_reviews"
type SortDirection = "asc" | "desc"
const DECK_STATUS_CONFIG: Record<string, { label: string; className: string }> = {
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 AZ" },
{ value: "benchmarks", label: "Most Benchmarks" },
{ value: "performance", label: "Best Performance" },
{ value: "popularity", label: "Most Popular" },
{ value: "release_date", label: "Release Date" },
{ value: "steam_reviews", label: "Steam Reviews" },
]
export function GamesPageClient({
initialGames,
totalCount,
allGenres,
allDevices,
}: {
initialGames: GamesListItem[]
totalCount: number
allGenres: string[]
allDevices: DeviceOption[]
}) {
const [games, setGames] = useState<GamesListItem[]>(initialGames)
const [total, setTotal] = useState(totalCount)
const [search, setSearch] = useState("")
const [selectedGenres, setSelectedGenres] = useState<string[]>([])
const [selectedDevice, setSelectedDevice] = useState("")
const [sort, setSort] = useState<SortOption>("recent")
const [sortDirection, setSortDirection] = useState<SortDirection>("desc")
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showFilters, setShowFilters] = useState(false)
const [minFps, setMinFps] = useState<string>("")
const [maxFps, setMaxFps] = useState<string>("")
const [fsrSupport, setFsrSupport] = useState<boolean>(false)
const [protonNative, setProtonNative] = useState<string>("any")
const [antiCheatStatus, setAntiCheatStatus] = useState<string>("any")
const [playabilityStatus, setPlayabilityStatus] = useState<string>("")
const [steamReviewMin, setSteamReviewMin] = useState<string>("")
const [isFree, setIsFree] = useState<boolean>(false)
const [hasMultiplayer, setHasMultiplayer] = useState<boolean>(false)
const observerRef = useRef<IntersectionObserver | null>(null)
const sentinelRef = useRef<HTMLDivElement>(null)
const pageRef = useRef<HTMLElement>(null)
const { isGamepadActive } = useGamepadNavigation(pageRef, {
onXButton: () => {
// Navigate to search page
window.location.href = "/search"
},
onYButton: () => {
// Toggle filter panel
setShowFilters((prev) => !prev)
},
})
const router = useRouter()
const hasMore = games.length < total
// ── Initialize filters from URL params on mount ────────────────
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
const params = new URLSearchParams(window.location.search)
if (params.get("search")) setSearch(params.get("search")!)
if (params.get("device")) setSelectedDevice(params.get("device")!)
if (params.get("genre")) {
const genres = params.get("genre")!.split(",").filter(Boolean)
setSelectedGenres(genres)
}
if (params.get("minFps")) setMinFps(params.get("minFps")!)
if (params.get("maxFps")) setMaxFps(params.get("maxFps")!)
if (params.get("fsrSupport") === "true") setFsrSupport(true)
if (params.get("protonNative") && params.get("protonNative") !== "any")
setProtonNative(params.get("protonNative")!)
if (params.get("antiCheatStatus") && params.get("antiCheatStatus") !== "any")
setAntiCheatStatus(params.get("antiCheatStatus")!)
if (params.get("playabilityStatus")) setPlayabilityStatus(params.get("playabilityStatus")!)
if (params.get("steamReviewScore")) setSteamReviewMin(params.get("steamReviewScore")!)
if (params.get("isFree") === "true") setIsFree(true)
if (params.get("hasMultiplayer") === "true") setHasMultiplayer(true)
if (params.get("sort")) {
const s = params.get("sort")!
if (["recent", "name", "benchmarks", "performance", "popularity", "release_date", "steam_reviews"].includes(s)) {
setSort(s as SortOption)
}
}
if (params.get("order")) setSortDirection(params.get("order") as SortDirection)
}, [])
/* eslint-enable react-hooks/set-state-in-effect */
const buildUrl = useCallback(
(offset: number) => {
const params = new URLSearchParams()
params.set("offset", String(offset))
params.set("limit", "24")
params.set("sort", sort)
params.set("order", sortDirection)
if (search) params.set("search", search)
if (selectedDevice) params.set("device", selectedDevice)
if (selectedGenres.length > 0) params.set("genre", selectedGenres.join(","))
if (minFps) params.set("minFps", minFps)
if (maxFps) params.set("maxFps", maxFps)
if (fsrSupport) params.set("fsrSupport", "true")
if (protonNative !== "any") params.set("protonNative", protonNative)
if (antiCheatStatus !== "any") params.set("antiCheatStatus", antiCheatStatus)
if (playabilityStatus) params.set("playabilityStatus", playabilityStatus)
if (steamReviewMin) params.set("steamReviewScore", steamReviewMin)
if (isFree) params.set("isFree", "true")
if (hasMultiplayer) params.set("hasMultiplayer", "true")
return `/api/games/listing?${params.toString()}`
},
[
sort, sortDirection, search, selectedDevice, selectedGenres,
minFps, maxFps, fsrSupport, protonNative, antiCheatStatus,
playabilityStatus, steamReviewMin, isFree, hasMultiplayer,
],
)
// 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])
// ── Sync filter state to URL (replace, not push) ───────────────
useEffect(() => {
const params = new URLSearchParams()
if (search) params.set("search", search)
if (selectedDevice) params.set("device", selectedDevice)
if (selectedGenres.length > 0) params.set("genre", selectedGenres.join(","))
if (minFps) params.set("minFps", minFps)
if (maxFps) params.set("maxFps", maxFps)
if (fsrSupport) params.set("fsrSupport", "true")
if (protonNative !== "any") params.set("protonNative", protonNative)
if (antiCheatStatus !== "any") params.set("antiCheatStatus", antiCheatStatus)
if (playabilityStatus) params.set("playabilityStatus", playabilityStatus)
if (steamReviewMin) params.set("steamReviewScore", steamReviewMin)
if (isFree) params.set("isFree", "true")
if (hasMultiplayer) params.set("hasMultiplayer", "true")
if (sort !== "recent") params.set("sort", sort)
if (sortDirection !== "desc") params.set("order", sortDirection)
const qs = params.toString()
const url = qs ? `/games?${qs}` : "/games"
router.replace(url, { scroll: false })
}, [search, selectedDevice, selectedGenres, minFps, maxFps, fsrSupport, protonNative, antiCheatStatus, playabilityStatus, steamReviewMin, isFree, hasMultiplayer, sort, sortDirection, router])
// Full reload when filters/sort change
useEffect(() => {
let cancelled = false
async function fetchGames() {
setLoading(true)
setError(null)
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 (
<section ref={pageRef} className={`w-full flex flex-col gap-8 py-8 ${isGamepadActive ? "gamepad-focus" : ""}`}>
{/* Header */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className="px-4 md:px-[10svw]"
>
<div className="max-w-7xl mx-auto">
<h1 className="text-2xl sm:text-3xl font-bold">Games</h1>
<p className="text-sm text-text/60 mt-1">
Browse {total.toLocaleString()} games with benchmarks, settings, and performance data
</p>
</div>
</motion.div>
{/* Search & Filter Bar */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.05 }}
className="px-4 md:px-[10svw]"
>
<div className="max-w-7xl mx-auto flex flex-col gap-3">
{/* Search + Sort Row */}
<div className="flex flex-row items-center gap-3">
<label className="flex-1 flex flex-row items-center gap-2 bg-text/5 px-3 py-2.5 rounded-md border border-border hover:border-border-active focus-within:border-primary/80 focus-within:ring-2 focus-within:ring-primary/50 focus-within:ring-offset-2 focus-within:ring-offset-background transition-colors cursor-text min-h-[44px]">
<SearchIcon className="h-4 w-4 text-text/40 shrink-0" />
<input
type="text"
placeholder="Search games..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="flex-1 outline-none bg-transparent text-sm min-w-0"
/>
{search && (
<button
onClick={() => setSearch("")}
className="text-text/40 hover:text-text/70 transition-colors cursor-pointer"
>
<XIcon className="h-3.5 w-3.5" />
</button>
)}
</label>
<select
value={sort}
onChange={(e) => setSort(e.target.value as SortOption)}
className="bg-text/5 border border-border rounded-md px-3 py-2 text-sm outline-none focus:border-primary/80 focus:ring-2 focus:ring-primary/50 cursor-pointer"
>
{SORT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
<button
onClick={() => setSortDirection(prev => prev === "asc" ? "desc" : "asc")}
className="px-2 py-2 rounded-md text-sm bg-text/5 border border-border hover:bg-text/10 transition-colors cursor-pointer min-h-[44px]"
title={sortDirection === "asc" ? "Sort ascending" : "Sort descending"}
>
{sortDirection === "asc" ? "↑" : "↓"}
</button>
<button
onClick={() => setShowFilters(!showFilters)}
className={`px-3 py-2 rounded-md text-sm transition-colors cursor-pointer border min-h-[44px] ${
showFilters ||
selectedDevice ||
selectedGenres.length > 0 ||
minFps ||
maxFps ||
fsrSupport ||
protonNative !== "any" ||
antiCheatStatus !== "any" ||
playabilityStatus ||
steamReviewMin ||
isFree ||
hasMultiplayer
? "bg-primary/10 text-primary border-primary/30"
: "bg-text/5 text-text/60 hover:text-text/80 border-border hover:border-border-active"
}`}
>
Filters
{(selectedDevice ||
selectedGenres.length > 0 ||
minFps ||
maxFps ||
fsrSupport ||
protonNative !== "any" ||
antiCheatStatus !== "any" ||
playabilityStatus ||
steamReviewMin ||
isFree ||
hasMultiplayer) && (
<span className="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-primary text-background text-[10px] font-bold">
{selectedGenres.length +
(selectedDevice ? 1 : 0) +
(minFps ? 1 : 0) +
(maxFps ? 1 : 0) +
(fsrSupport ? 1 : 0) +
(protonNative !== "any" ? 1 : 0) +
(antiCheatStatus !== "any" ? 1 : 0) +
(playabilityStatus ? 1 : 0) +
(steamReviewMin ? 1 : 0) +
(isFree ? 1 : 0) +
(hasMultiplayer ? 1 : 0)}
</span>
)}
</button>
</div>
{/* Filter Panel (collapsible) */}
{showFilters && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="flex flex-col gap-3 pt-1"
>
{/* Device filter */}
<div>
<span className="text-xs text-text/50 uppercase tracking-wider mb-1.5 block">
Device
</span>
<div className="flex flex-wrap gap-1.5">
<button
onClick={() => setSelectedDevice("")}
className={`px-3 py-2.5 rounded-full text-xs font-medium transition-colors cursor-pointer min-h-[44px] ${
selectedDevice === ""
? "bg-primary/10 text-primary border border-primary/30"
: "text-text/50 hover:text-text/70 hover:bg-text/5 border border-transparent"
}`}
>
All Devices
</button>
{allDevices.map((device) => (
<button
key={device.slug}
onClick={() =>
setSelectedDevice(selectedDevice === device.slug ? "" : device.slug)
}
className={`px-3 py-2.5 rounded-full text-xs font-medium transition-colors cursor-pointer min-h-[44px] ${
selectedDevice === device.slug
? "bg-primary/10 text-primary border border-primary/30"
: "text-text/50 hover:text-text/70 hover:bg-text/5 border border-transparent"
}`}
>
{device.name}
</button>
))}
</div>
</div>
{/* Genre filter */}
<div>
<span className="text-xs text-text/50 uppercase tracking-wider mb-1.5 block">
Genre
</span>
<div className="flex flex-wrap gap-1.5 max-h-32 overflow-y-auto scrollbar-hide">
{allGenres.map((genre) => (
<button
key={genre}
onClick={() => toggleGenre(genre)}
className={`px-3 py-2.5 rounded-full text-xs font-medium transition-colors cursor-pointer min-h-[44px] ${
selectedGenres.includes(genre)
? "bg-primary/10 text-primary border border-primary/30"
: "text-text/50 hover:text-text/70 hover:bg-text/5 border border-transparent"
}`}
>
{genre}
</button>
))}
</div>
</div>
{/* Performance Filters */}
<div className="space-y-2">
<h4 className="text-sm font-medium text-zinc-300">Performance</h4>
<div className="flex gap-2">
<input
type="number"
placeholder="Min FPS"
value={minFps}
onChange={(e) => setMinFps(e.target.value)}
className="w-24 rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-[44px]"
/>
<input
type="number"
placeholder="Max FPS"
value={maxFps}
onChange={(e) => setMaxFps(e.target.value)}
className="w-24 rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-[44px]"
/>
</div>
<select
value={playabilityStatus}
onChange={(e) => setPlayabilityStatus(e.target.value)}
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-[44px]"
>
<option value="">Any Playability</option>
<option value="great">Plays Great</option>
<option value="playable">Playable</option>
<option value="needs_tweaks">Needs Tweaks</option>
<option value="unplayable">Unplayable</option>
</select>
</div>
{/* Compatibility Filters */}
<div className="space-y-2">
<h4 className="text-sm font-medium text-zinc-300">Compatibility</h4>
<select
value={protonNative}
onChange={(e) => setProtonNative(e.target.value)}
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-[44px]"
>
<option value="any">Any Runtime</option>
<option value="native">Native</option>
<option value="proton">Proton</option>
</select>
<select
value={antiCheatStatus}
onChange={(e) => setAntiCheatStatus(e.target.value)}
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-[44px]"
>
<option value="any">Any Anti-Cheat</option>
<option value="supported">AC Supported</option>
<option value="unsupported">AC Unsupported</option>
<option value="unknown">AC Unknown</option>
</select>
<label className="flex items-center gap-2 text-sm py-2 min-h-[44px]">
<input
type="checkbox"
checked={fsrSupport}
onChange={(e) => setFsrSupport(e.target.checked)}
className="rounded border-zinc-600"
/>
FSR Support
</label>
</div>
{/* Other Filters */}
<div className="space-y-2">
<h4 className="text-sm font-medium text-zinc-300">Other</h4>
<label className="flex items-center gap-2 text-sm py-2 min-h-[44px]">
<input
type="checkbox"
checked={isFree}
onChange={(e) => setIsFree(e.target.checked)}
className="rounded border-zinc-600"
/>
Free to Play
</label>
<label className="flex items-center gap-2 text-sm py-2 min-h-[44px]">
<input
type="checkbox"
checked={hasMultiplayer}
onChange={(e) => setHasMultiplayer(e.target.checked)}
className="rounded border-zinc-600"
/>
Has Multiplayer
</label>
<input
type="number"
placeholder="Min Steam Review %"
value={steamReviewMin}
onChange={(e) => setSteamReviewMin(e.target.value)}
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-2 py-1 text-sm"
/>
</div>
{/* Saved Filters */}
<SavedFilters
currentFilters={{
minFps,
maxFps,
fsrSupport,
protonNative,
antiCheatStatus,
playabilityStatus,
steamReviewMin,
isFree,
hasMultiplayer,
genre: selectedGenres[0] || "",
device: selectedDevice,
sortBy: sort,
}}
onLoad={(filters) => {
setMinFps((filters.minFps as string) || "")
setMaxFps((filters.maxFps as string) || "")
setFsrSupport((filters.fsrSupport as boolean) || false)
setProtonNative((filters.protonNative as string) || "any")
setAntiCheatStatus((filters.antiCheatStatus as string) || "any")
setPlayabilityStatus((filters.playabilityStatus as string) || "")
setSteamReviewMin((filters.steamReviewMin as string) || "")
setIsFree((filters.isFree as boolean) || false)
setHasMultiplayer((filters.hasMultiplayer as boolean) || false)
if (filters.genre) setSelectedGenres((filters.genre as string).split(",").filter(Boolean))
else setSelectedGenres([])
if (filters.device) setSelectedDevice(filters.device as string)
else setSelectedDevice("")
if (filters.sortBy) setSort(filters.sortBy as SortOption)
setShowFilters(false)
}}
/>
{/* Clear filters */}
{(selectedDevice ||
selectedGenres.length > 0 ||
minFps ||
maxFps ||
fsrSupport ||
protonNative !== "any" ||
antiCheatStatus !== "any" ||
playabilityStatus ||
steamReviewMin ||
isFree ||
hasMultiplayer) && (
<button
onClick={() => {
setSelectedDevice("")
setSelectedGenres([])
setMinFps("")
setMaxFps("")
setFsrSupport(false)
setProtonNative("any")
setAntiCheatStatus("any")
setPlayabilityStatus("")
setSteamReviewMin("")
setIsFree(false)
setHasMultiplayer(false)
}}
className="text-xs text-text/50 hover:text-primary transition-colors cursor-pointer self-start min-h-[44px] py-2 flex items-center"
>
Clear all filters
</button>
)}
</motion.div>
)}
</div>
</motion.div>
{/* Error State */}
{error && !loading && (
<div className="max-w-7xl mx-auto px-4 md:px-0 text-center py-12">
<p className="text-red-400 text-sm">{error}</p>
<button
onClick={() => window.location.reload()}
className="mt-2 text-xs text-text/50 hover:text-primary transition-colors cursor-pointer min-h-[44px] py-2 flex items-center"
>
Try again
</button>
</div>
)}
{/* Games Grid */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
className="px-4 md:px-[10svw]"
>
<div className="max-w-7xl mx-auto grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3 sm:gap-4">
{games.map((game) => (
<GameCard key={game.id} game={game} />
))}
</div>
{/* Empty state */}
{!loading && games.length === 0 && !error && (
<div className="flex flex-col items-center justify-center py-20 gap-4">
<Gamepad2Icon className="h-12 w-12 text-text/20" />
<p className="text-text/40 text-sm">
{search ||
selectedDevice ||
selectedGenres.length > 0 ||
minFps ||
maxFps ||
fsrSupport ||
protonNative !== "any" ||
antiCheatStatus !== "any" ||
playabilityStatus ||
steamReviewMin ||
isFree ||
hasMultiplayer
? "No games match your filters"
: "No games found"}
</p>
{(search ||
selectedDevice ||
selectedGenres.length > 0 ||
minFps ||
maxFps ||
fsrSupport ||
protonNative !== "any" ||
antiCheatStatus !== "any" ||
playabilityStatus ||
steamReviewMin ||
isFree ||
hasMultiplayer) && (
<button
onClick={() => {
setSearch("")
setSelectedDevice("")
setSelectedGenres([])
setMinFps("")
setMaxFps("")
setFsrSupport(false)
setProtonNative("any")
setAntiCheatStatus("any")
setPlayabilityStatus("")
setSteamReviewMin("")
setIsFree(false)
setHasMultiplayer(false)
}}
className="text-xs text-primary hover:text-primary/80 transition-colors cursor-pointer min-h-[44px]"
>
Clear filters
</button>
)}
</div>
)}
</motion.div>
{/* Loading indicator for infinite scroll */}
{loading && (
<div className="flex items-center justify-center py-8">
<Loader2Icon className="h-6 w-6 text-primary animate-spin" />
</div>
)}
{/* End of list */}
{!loading && !hasMore && games.length > 0 && (
<div className="text-center py-6">
<p className="text-text/30 text-xs">
Showing all {games.length} of {total.toLocaleString()} games
</p>
</div>
)}
{/* Infinite scroll sentinel */}
{hasMore && !loading && <div ref={sentinelRef} className="h-1" />}
</section>
)
}
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 (
<Link
href={`/game/${game.id}`}
className="group rounded-xl border border-border bg-text/3 hover:border-primary/30 transition-all duration-200 overflow-hidden"
>
<div className="relative w-full aspect-[2/3] bg-text/10 overflow-hidden">
{imageUrl && !imgError ? (
<Image
src={imageUrl}
alt={game.title}
fill
className="object-cover group-hover:scale-105 transition-transform duration-300"
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 20vw"
onError={() => setImgError(true)}
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Gamepad2Icon className="h-8 w-8 text-text/20" />
</div>
)}
</div>
<div className="p-2.5 sm:p-3">
<h3 className="text-xs sm:text-sm font-semibold text-text group-hover:text-primary transition-colors line-clamp-2 leading-tight">
{game.title}
</h3>
<div className="flex flex-wrap gap-1 mt-1">
{game.playabilityStatus && (
<PlayabilityBadge status={game.playabilityStatus} compact />
)}
{game.antiCheatRelevant && game.antiCheatStatus === "unsupported" && (
<AntiCheatBadge
antiCheatRelevant={true}
antiCheatStatus={game.antiCheatStatus}
compact
/>
)}
{game.steamReviewScore != null && (
<span className="inline-flex items-center gap-1 text-[10px] text-blue-400">
<svg className="h-2.5 w-2.5" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
{game.steamReviewScore}%
</span>
)}
</div>
<div className="mt-1.5 flex items-center gap-2 flex-wrap">
{game.benchmarkCount > 0 ? (
<span className="inline-flex items-center gap-1 text-[10px] text-text/50">
<TrendingUpIcon className="h-3 w-3 text-primary/60" />
<span className="tabular-nums">{game.benchmarkCount}</span>
</span>
) : (
<span className="text-[10px] text-text/25 italic">No data yet</span>
)}
{deckConfig && (
<span
className={`text-[9px] px-1.5 py-0.5 rounded-full border ${deckConfig.className}`}
>
{deckConfig.label}
</span>
)}
</div>
</div>
</Link>
)
}