Task 17: Revamp Games List with New Filters & Badges

- Add new filter state variables (min/max FPS, FSR, Proton/Native, anti-cheat,
  playability, Steam review score, free-to-play, multiplayer)
- Expand sort options to include performance, popularity, release_date, steam_reviews
- Add Performance, Compatibility, and Other filter UI sections with inputs
- Integrate SavedFilters component for saving/loading filter presets
- Add PlayabilityBadge and AntiCheatBadge to game cards
- Update API listing to return antiCheatRelevant/antiCheatStatus per game
- Update server-side games page to include new fields in initialGames
- Update clear-filters logic across filter panel and empty state
This commit is contained in:
2026-04-30 20:25:57 +08:00
parent 0bb8c205f9
commit 32d52cf3ba
3 changed files with 301 additions and 9 deletions
+265 -9
View File
@@ -11,6 +11,9 @@ import {
XIcon, XIcon,
Loader2Icon, Loader2Icon,
} from "lucide-react" } from "lucide-react"
import { AntiCheatBadge } from "@/components/anti-cheat-badge"
import { PlayabilityBadge } from "@/components/playability-badge"
import { SavedFilters } from "@/components/saved-filters"
interface GamesListItem { interface GamesListItem {
id: string id: string
@@ -21,8 +24,13 @@ interface GamesListItem {
headerImage: string | null headerImage: string | null
genres: string[] | null genres: string[] | null
source: string source: string
steamReviewScore: number | null
playabilityStatus: "great" | "playable" | "needs_tweaks" | "unplayable" | "unknown" | null
onlineMultiplayerStatus: "none" | "supported" | "unknown" | null
benchmarkCount: number benchmarkCount: number
deckStatus: string | null deckStatus: string | null
antiCheatRelevant: boolean
antiCheatStatus: "none" | "supported" | "unsupported" | "unknown" | null
} }
interface DeviceOption { interface DeviceOption {
@@ -30,7 +38,7 @@ interface DeviceOption {
name: string name: string
} }
type SortOption = "recent" | "name" | "benchmarks" type SortOption = "recent" | "name" | "benchmarks" | "performance" | "popularity" | "release_date" | "steam_reviews"
type SortDirection = "asc" | "desc" type SortDirection = "asc" | "desc"
const DECK_STATUS_CONFIG: Record<string, { label: string; className: string }> = { const DECK_STATUS_CONFIG: Record<string, { label: string; className: string }> = {
@@ -42,8 +50,12 @@ const DECK_STATUS_CONFIG: Record<string, { label: string; className: string }> =
const SORT_OPTIONS: { value: SortOption; label: string }[] = [ const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: "recent", label: "Recently Added" }, { value: "recent", label: "Recently Added" },
{ value: "name", label: "Name A\u2013Z" }, { value: "name", label: "Name AZ" },
{ value: "benchmarks", label: "Most Benchmarks" }, { 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({ export function GamesPageClient({
@@ -67,6 +79,15 @@ export function GamesPageClient({
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [showFilters, setShowFilters] = useState(false) 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 observerRef = useRef<IntersectionObserver | null>(null)
const sentinelRef = useRef<HTMLDivElement>(null) const sentinelRef = useRef<HTMLDivElement>(null)
@@ -82,9 +103,22 @@ export function GamesPageClient({
if (search) params.set("search", search) if (search) params.set("search", search)
if (selectedDevice) params.set("device", selectedDevice) if (selectedDevice) params.set("device", selectedDevice)
if (selectedGenres.length === 1) params.set("genre", selectedGenres[0]) if (selectedGenres.length === 1) params.set("genre", selectedGenres[0])
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()}` return `/api/games/listing?${params.toString()}`
}, },
[sort, sortDirection, search, selectedDevice, selectedGenres], [
sort, sortDirection, search, selectedDevice, selectedGenres,
minFps, maxFps, fsrSupport, protonNative, antiCheatStatus,
playabilityStatus, steamReviewMin, isFree, hasMultiplayer,
],
) )
// Load more function for infinite scroll // Load more function for infinite scroll
@@ -230,15 +264,46 @@ export function GamesPageClient({
<button <button
onClick={() => setShowFilters(!showFilters)} onClick={() => setShowFilters(!showFilters)}
className={`px-3 py-2 rounded-md text-sm transition-colors cursor-pointer border ${ className={`px-3 py-2 rounded-md text-sm transition-colors cursor-pointer border ${
showFilters || selectedDevice || selectedGenres.length > 0 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-primary/10 text-primary border-primary/30"
: "bg-text/5 text-text/60 hover:text-text/80 border-border hover:border-border-active" : "bg-text/5 text-text/60 hover:text-text/80 border-border hover:border-border-active"
}`} }`}
> >
Filters Filters
{(selectedDevice || selectedGenres.length > 0) && ( {(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"> <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)} {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> </span>
)} )}
</button> </button>
@@ -308,12 +373,160 @@ export function GamesPageClient({
</div> </div>
</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-1 text-sm"
/>
<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-1 text-sm"
/>
</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-1 text-sm"
>
<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-1 text-sm"
>
<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-1 text-sm"
>
<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">
<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">
<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">
<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 || "")
setMaxFps(filters.maxFps || "")
setFsrSupport(filters.fsrSupport || false)
setProtonNative(filters.protonNative || "any")
setAntiCheatStatus(filters.antiCheatStatus || "any")
setPlayabilityStatus(filters.playabilityStatus || "")
setSteamReviewMin(filters.steamReviewMin || "")
setIsFree(filters.isFree || false)
setHasMultiplayer(filters.hasMultiplayer || false)
if (filters.genre) setSelectedGenres([filters.genre])
else setSelectedGenres([])
if (filters.device) setSelectedDevice(filters.device)
else setSelectedDevice("")
if (filters.sortBy) setSort(filters.sortBy as SortOption)
}}
/>
{/* Clear filters */} {/* Clear filters */}
{(selectedDevice || selectedGenres.length > 0) && ( {(selectedDevice ||
selectedGenres.length > 0 ||
minFps ||
maxFps ||
fsrSupport ||
protonNative !== "any" ||
antiCheatStatus !== "any" ||
playabilityStatus ||
steamReviewMin ||
isFree ||
hasMultiplayer) && (
<button <button
onClick={() => { onClick={() => {
setSelectedDevice("") setSelectedDevice("")
setSelectedGenres([]) 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" className="text-xs text-text/50 hover:text-primary transition-colors cursor-pointer self-start"
> >
@@ -356,16 +569,47 @@ export function GamesPageClient({
<div className="flex flex-col items-center justify-center py-20 gap-4"> <div className="flex flex-col items-center justify-center py-20 gap-4">
<Gamepad2Icon className="h-12 w-12 text-text/20" /> <Gamepad2Icon className="h-12 w-12 text-text/20" />
<p className="text-text/40 text-sm"> <p className="text-text/40 text-sm">
{search || selectedDevice || selectedGenres.length > 0 {search ||
selectedDevice ||
selectedGenres.length > 0 ||
minFps ||
maxFps ||
fsrSupport ||
protonNative !== "any" ||
antiCheatStatus !== "any" ||
playabilityStatus ||
steamReviewMin ||
isFree ||
hasMultiplayer
? "No games match your filters" ? "No games match your filters"
: "No games found"} : "No games found"}
</p> </p>
{(search || selectedDevice || selectedGenres.length > 0) && ( {(search ||
selectedDevice ||
selectedGenres.length > 0 ||
minFps ||
maxFps ||
fsrSupport ||
protonNative !== "any" ||
antiCheatStatus !== "any" ||
playabilityStatus ||
steamReviewMin ||
isFree ||
hasMultiplayer) && (
<button <button
onClick={() => { onClick={() => {
setSearch("") setSearch("")
setSelectedDevice("") setSelectedDevice("")
setSelectedGenres([]) 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" className="text-xs text-primary hover:text-primary/80 transition-colors cursor-pointer"
> >
@@ -431,6 +675,18 @@ function GameCard({ game }: { game: GamesListItem }) {
<h3 className="text-xs sm:text-sm font-semibold text-text group-hover:text-primary transition-colors line-clamp-2 leading-tight"> <h3 className="text-xs sm:text-sm font-semibold text-text group-hover:text-primary transition-colors line-clamp-2 leading-tight">
{game.title} {game.title}
</h3> </h3>
<div className="flex flex-wrap gap-1 mt-1">
{game.playabilityStatus && (
<PlayabilityBadge status={game.playabilityStatus} compact showLabel={false} />
)}
{game.antiCheatRelevant && game.antiCheatStatus === "unsupported" && (
<AntiCheatBadge
antiCheatRelevant={true}
antiCheatStatus={game.antiCheatStatus}
compact
/>
)}
</div>
<div className="mt-1.5 flex items-center gap-2 flex-wrap"> <div className="mt-1.5 flex items-center gap-2 flex-wrap">
{game.benchmarkCount > 0 ? ( {game.benchmarkCount > 0 ? (
<span className="inline-flex items-center gap-1 text-[10px] text-text/50"> <span className="inline-flex items-center gap-1 text-[10px] text-text/50">
+21
View File
@@ -48,6 +48,9 @@ export default async function GamesPage() {
genres: games.genres, genres: games.genres,
source: games.source, source: games.source,
createdAt: games.createdAt, createdAt: games.createdAt,
steamReviewScore: games.steamReviewScore,
playabilityStatus: games.playabilityStatus,
onlineMultiplayerStatus: games.onlineMultiplayerStatus,
}) })
.from(games) .from(games)
.orderBy(desc(games.createdAt)) .orderBy(desc(games.createdAt))
@@ -90,18 +93,31 @@ export default async function GamesPage() {
gameId: gamePlatformSupport.gameId, gameId: gamePlatformSupport.gameId,
hardwareSlug: gamePlatformSupport.hardwareSlug, hardwareSlug: gamePlatformSupport.hardwareSlug,
protonStatus: gamePlatformSupport.protonStatus, protonStatus: gamePlatformSupport.protonStatus,
antiCheatRelevant: gamePlatformSupport.antiCheatRelevant,
antiCheatStatus: gamePlatformSupport.antiCheatStatus,
}) })
.from(gamePlatformSupport) .from(gamePlatformSupport)
.where(inArray(gamePlatformSupport.gameId, gameIds)) .where(inArray(gamePlatformSupport.gameId, gameIds))
: [] : []
const platformMap = new Map<string, string>() const platformMap = new Map<string, string>()
const antiCheatMap = new Map<string, { antiCheatRelevant: boolean; antiCheatStatus: "none" | "supported" | "unsupported" | "unknown" | null }>()
for (const row of platformRows) { for (const row of platformRows) {
const isSteamDeck = row.hardwareSlug.startsWith("steamdeck") const isSteamDeck = row.hardwareSlug.startsWith("steamdeck")
const existing = platformMap.get(row.gameId) const existing = platformMap.get(row.gameId)
if (!existing || (!existing.startsWith("steamdeck") && isSteamDeck)) { if (!existing || (!existing.startsWith("steamdeck") && isSteamDeck)) {
platformMap.set(row.gameId, row.protonStatus) platformMap.set(row.gameId, row.protonStatus)
} }
const existingAc = antiCheatMap.get(row.gameId)
if (row.antiCheatRelevant) {
if (!existingAc || (!existingAc.antiCheatRelevant && isSteamDeck) || (!existingAc.antiCheatRelevant)) {
antiCheatMap.set(row.gameId, {
antiCheatRelevant: row.antiCheatRelevant,
antiCheatStatus: row.antiCheatStatus,
})
}
}
} }
// Fetch all genres // Fetch all genres
@@ -135,8 +151,13 @@ export default async function GamesPage() {
headerImage: g.headerImage, headerImage: g.headerImage,
genres: g.genres, genres: g.genres,
source: g.source, source: g.source,
steamReviewScore: g.steamReviewScore,
playabilityStatus: g.playabilityStatus,
onlineMultiplayerStatus: g.onlineMultiplayerStatus,
benchmarkCount: benchmarkMap.get(g.id) ?? 0, benchmarkCount: benchmarkMap.get(g.id) ?? 0,
deckStatus: platformMap.get(g.id) ?? null, deckStatus: platformMap.get(g.id) ?? null,
antiCheatRelevant: antiCheatMap.get(g.id)?.antiCheatRelevant ?? false,
antiCheatStatus: antiCheatMap.get(g.id)?.antiCheatStatus ?? null,
})) }))
const allGenres = Array.from(genreSet).sort() const allGenres = Array.from(genreSet).sort()
+15
View File
@@ -313,12 +313,15 @@ export const gamesListingRoutes = new Elysia({ prefix: "/games/listing" }).get(
// Fetch platform support for the returned games (prioritise Steam Deck) // Fetch platform support for the returned games (prioritise Steam Deck)
const platformMap = new Map<string, string>() const platformMap = new Map<string, string>()
const antiCheatMap = new Map<string, { antiCheatRelevant: boolean; antiCheatStatus: string | null }>()
if (gameIds.length > 0) { if (gameIds.length > 0) {
const platformRows = await db const platformRows = await db
.select({ .select({
gameId: gamePlatformSupport.gameId, gameId: gamePlatformSupport.gameId,
hardwareSlug: gamePlatformSupport.hardwareSlug, hardwareSlug: gamePlatformSupport.hardwareSlug,
protonStatus: gamePlatformSupport.protonStatus, protonStatus: gamePlatformSupport.protonStatus,
antiCheatRelevant: gamePlatformSupport.antiCheatRelevant,
antiCheatStatus: gamePlatformSupport.antiCheatStatus,
}) })
.from(gamePlatformSupport) .from(gamePlatformSupport)
.where(inArray(gamePlatformSupport.gameId, gameIds)) .where(inArray(gamePlatformSupport.gameId, gameIds))
@@ -329,6 +332,16 @@ export const gamesListingRoutes = new Elysia({ prefix: "/games/listing" }).get(
if (!existing || (!existing.startsWith("steamdeck") && isSteamDeck)) { if (!existing || (!existing.startsWith("steamdeck") && isSteamDeck)) {
platformMap.set(row.gameId, row.protonStatus) platformMap.set(row.gameId, row.protonStatus)
} }
const existingAc = antiCheatMap.get(row.gameId)
if (row.antiCheatRelevant) {
if (!existingAc || (!existingAc.antiCheatRelevant && isSteamDeck) || (!existingAc.antiCheatRelevant)) {
antiCheatMap.set(row.gameId, {
antiCheatRelevant: row.antiCheatRelevant,
antiCheatStatus: row.antiCheatStatus,
})
}
}
} }
} }
@@ -349,6 +362,8 @@ export const gamesListingRoutes = new Elysia({ prefix: "/games/listing" }).get(
onlineMultiplayerStatus: g.onlineMultiplayerStatus, onlineMultiplayerStatus: g.onlineMultiplayerStatus,
benchmarkCount: benchmarkMap.get(g.id) ?? 0, benchmarkCount: benchmarkMap.get(g.id) ?? 0,
deckStatus: platformMap.get(g.id) ?? null, deckStatus: platformMap.get(g.id) ?? null,
antiCheatRelevant: antiCheatMap.get(g.id)?.antiCheatRelevant ?? false,
antiCheatStatus: antiCheatMap.get(g.id)?.antiCheatStatus ?? null,
})) }))
// If sorting by benchmarks, re-sort the enriched data // If sorting by benchmarks, re-sort the enriched data