feat: add advanced filters and saved filter presets to search page

This commit is contained in:
2026-05-09 13:27:59 +08:00
parent 1e59224622
commit c6a9b448d3
2 changed files with 439 additions and 31 deletions
+277 -6
View File
@@ -11,6 +11,8 @@ import {
TrendingUpIcon, TrendingUpIcon,
DatabaseIcon, DatabaseIcon,
SparklesIcon, SparklesIcon,
SlidersHorizontalIcon,
XIcon,
} from "lucide-react" } from "lucide-react"
import { FaSteam } from "react-icons/fa" import { FaSteam } from "react-icons/fa"
import Image from "next/image" import Image from "next/image"
@@ -19,6 +21,7 @@ import { useSession } from "@/lib/auth-client"
import { WindowsIcon, MacIcon, LinuxIcon } from "@/app/components/PlatformIcons" import { WindowsIcon, MacIcon, LinuxIcon } from "@/app/components/PlatformIcons"
import { AntiCheatBadge } from "@/components/anti-cheat-badge" import { AntiCheatBadge } from "@/components/anti-cheat-badge"
import { PlayabilityBadge } from "@/components/playability-badge" import { PlayabilityBadge } from "@/components/playability-badge"
import { SavedFilters } from "@/components/saved-filters"
interface UnifiedResult { interface UnifiedResult {
kind: "local" | "steam" kind: "local" | "steam"
@@ -57,6 +60,12 @@ interface UnifiedResult {
antiCheatName?: string | null antiCheatName?: string | null
} }
const DEVICE_OPTIONS = [
{ slug: "steam-deck-oled", name: "Steam Deck OLED" },
{ slug: "steam-deck-lcd", name: 'Steam Deck LCD' },
{ slug: "rog-ally", name: "ROG Ally" },
]
function SearchContent() { function SearchContent() {
const searchParams = useSearchParams() const searchParams = useSearchParams()
const router = useRouter() const router = useRouter()
@@ -68,6 +77,43 @@ function SearchContent() {
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const isValidQuery = query && query.length >= 2 const isValidQuery = query && query.length >= 2
// Filter state
const [showFilters, setShowFilters] = useState(false)
const [selectedDevice, setSelectedDevice] = useState("")
const [minFps, setMinFps] = useState("")
const [maxFps, setMaxFps] = useState("")
const [fsrSupport, setFsrSupport] = useState(false)
const [protonNative, setProtonNative] = useState("any")
const [antiCheatStatus, setAntiCheatStatus] = useState("any")
const [playabilityStatus, setPlayabilityStatus] = useState("")
const [steamReviewMin, setSteamReviewMin] = useState("")
const [isFree, setIsFree] = useState(false)
const [hasMultiplayer, setHasMultiplayer] = useState(false)
const hasActiveFilters =
selectedDevice ||
minFps ||
maxFps ||
fsrSupport ||
protonNative !== "any" ||
antiCheatStatus !== "any" ||
playabilityStatus ||
steamReviewMin ||
isFree ||
hasMultiplayer
const activeFilterCount =
(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)
// Handle direct navigation / browser back-forward // Handle direct navigation / browser back-forward
useEffect(() => { useEffect(() => {
if (!isValidQuery) return if (!isValidQuery) return
@@ -79,9 +125,20 @@ function SearchContent() {
setError(null) setError(null)
try { try {
const res = await fetch( const params = new URLSearchParams()
`/api/search/unified?q=${encodeURIComponent(query)}`, params.set("q", query)
) if (selectedDevice) params.set("device", selectedDevice)
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")
const res = await fetch(`/api/search/unified?${params.toString()}`)
if (!res.ok) throw new Error(await res.text()) if (!res.ok) throw new Error(await res.text())
const data = await res.json() const data = await res.json()
if (!cancelled) setResults(data.results || []) if (!cancelled) setResults(data.results || [])
@@ -99,7 +156,7 @@ function SearchContent() {
return () => { return () => {
cancelled = true cancelled = true
} }
}, [isValidQuery, query]) }, [isValidQuery, query, selectedDevice, minFps, maxFps, fsrSupport, protonNative, antiCheatStatus, playabilityStatus, steamReviewMin, isFree, hasMultiplayer])
function handleClick(result: UnifiedResult) { function handleClick(result: UnifiedResult) {
const path = result.appId const path = result.appId
@@ -108,6 +165,19 @@ function SearchContent() {
router.push(path) router.push(path)
} }
function clearAllFilters() {
setSelectedDevice("")
setMinFps("")
setMaxFps("")
setFsrSupport(false)
setProtonNative("any")
setAntiCheatStatus("any")
setPlayabilityStatus("")
setSteamReviewMin("")
setIsFree(false)
setHasMultiplayer(false)
}
return ( return (
<section className="w-full min-h-[calc(100vh-3.6rem)] flex flex-col items-center p-4 md:px-[10svw]"> <section className="w-full min-h-[calc(100vh-3.6rem)] flex flex-col items-center p-4 md:px-[10svw]">
<div className="w-full max-w-7xl"> <div className="w-full max-w-7xl">
@@ -121,13 +191,214 @@ function SearchContent() {
<motion.p <motion.p
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: 0.1 } }} animate={{ opacity: 1, transition: { delay: 0.1 } }}
className="text-text/60 text-sm mb-8" className="text-text/60 text-sm mb-4"
> >
{query {query
? `${results.length} result${results.length !== 1 ? "s" : ""} found` ? `${results.length} result${results.length !== 1 ? "s" : ""} found`
: "Enter a game name or AppID to find benchmarks, settings, and reviews."} : "Enter a game name or AppID to find benchmarks, settings, and reviews."}
</motion.p> </motion.p>
{/* Filter toggle button */}
<div className="flex items-center gap-3 mb-4">
<button
onClick={() => setShowFilters(!showFilters)}
className={`flex items-center gap-1.5 px-3 py-2 rounded-md text-sm transition-colors cursor-pointer border min-h-[44px] ${
showFilters || hasActiveFilters
? "bg-primary/10 text-primary border-primary/30"
: "bg-text/5 text-text/60 hover:text-text/80 border-border hover:border-border-active"
}`}
>
<SlidersHorizontalIcon className="h-4 w-4" />
Filters
{activeFilterCount > 0 && (
<span className="ml-0.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-primary text-background text-[10px] font-bold">
{activeFilterCount}
</span>
)}
</button>
{hasActiveFilters && (
<button
onClick={clearAllFilters}
className="text-xs text-text/50 hover:text-primary transition-colors cursor-pointer min-h-[44px] flex items-center gap-1"
>
<XIcon className="h-3 w-3" />
Clear all filters
</button>
)}
</div>
{/* Filter panel */}
<AnimatePresence>
{showFilters && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="overflow-hidden mb-4"
>
<div className="flex flex-col gap-4 p-4 rounded-xl border border-border bg-text/[0.03]">
{/* 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>
{DEVICE_OPTIONS.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>
{/* 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,
device: selectedDevice,
}}
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.device) setSelectedDevice(filters.device as string)
else setSelectedDevice("")
}}
/>
</div>
</motion.div>
)}
</AnimatePresence>
{!isValidQuery && ( {!isValidQuery && (
<motion.div <motion.div
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
@@ -681,4 +952,4 @@ export default function SearchPage() {
<SearchContent /> <SearchContent />
</Suspense> </Suspense>
) )
} }
+162 -25
View File
@@ -6,7 +6,7 @@ import {
performanceEntries, performanceEntries,
gameComments, gameComments,
} from "@/lib/db/schema" } from "@/lib/db/schema"
import { ilike, or, sql, eq, inArray, and } from "drizzle-orm" import { ilike, or, sql, eq, inArray, and, gte } from "drizzle-orm"
import { fuzzySearchTerm } from "@/lib/db/search" import { fuzzySearchTerm } from "@/lib/db/search"
interface SteamSearchItem { interface SteamSearchItem {
@@ -35,22 +35,108 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
const titleTerm = fuzzySearchTerm(query.q) const titleTerm = fuzzySearchTerm(query.q)
const term = `%${query.q}%` const term = `%${query.q}%`
// ── Build filter conditions for columns on the games table ────
const baseFilterConditions = [
or(
ilike(games.title, titleTerm),
ilike(games.developer, term),
ilike(games.publisher, term),
),
]
if (query.playabilityStatus) {
baseFilterConditions.push(sql`${games.playabilityStatus} = ${query.playabilityStatus}`)
}
if (query.steamReviewScore) {
const minScore = parseInt(query.steamReviewScore, 10)
if (!isNaN(minScore)) {
baseFilterConditions.push(gte(games.steamReviewScore, minScore))
}
}
if (query.isFree === "true") {
baseFilterConditions.push(eq(games.isFree, true))
}
if (query.hasMultiplayer === "true") {
baseFilterConditions.push(sql`${games.onlineMultiplayerStatus} = 'supported'`)
}
// ── 1. Search local database ──────────────────────────────────── // ── 1. Search local database ────────────────────────────────────
const localGames = await db const localGames = await db
.select() .select()
.from(games) .from(games)
.where( .where(and(...baseFilterConditions))
or( .limit(40)
ilike(games.title, titleTerm),
ilike(games.developer, term),
ilike(games.publisher, term),
),
)
.limit(20)
const localGameIds = localGames.map((g) => g.id) // ── 1b. Post-process filters requiring joins ───────────────────
let filteredGameIds = new Set(localGames.map((g) => g.id))
// Device filter: keep only games that have at least one benchmark for the device
if (query.device && filteredGameIds.size > 0) {
const matchingGameIds = await db
.select({ gameId: gameVersions.gameId })
.from(performanceEntries)
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
.where(
and(
inArray(gameVersions.gameId, [...filteredGameIds]),
eq(performanceEntries.hardwareSlug, query.device),
eq(performanceEntries.isRemoved, false),
),
)
.groupBy(gameVersions.gameId)
filteredGameIds = new Set(matchingGameIds.map((r) => r.gameId))
}
// FSR support filter: keep games with at least one benchmark using upscaler !== 'none'
if (query.fsrSupport === "true" && filteredGameIds.size > 0) {
const matchingGameIds = await db
.select({ gameId: gameVersions.gameId })
.from(performanceEntries)
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
.where(
and(
inArray(gameVersions.gameId, [...filteredGameIds]),
sql`${performanceEntries.upscalerType} != 'none'`,
eq(performanceEntries.isRemoved, false),
),
)
.groupBy(gameVersions.gameId)
filteredGameIds = new Set(matchingGameIds.map((r) => r.gameId))
}
// FPS range filter: keep games whose bestFps falls within [minFps, maxFps]
const minFps = query.minFps ? parseInt(query.minFps, 10) : undefined
const maxFps = query.maxFps ? parseInt(query.maxFps, 10) : undefined
if ((minFps !== undefined || maxFps !== undefined) && filteredGameIds.size > 0) {
const fpsStats = await db
.select({
gameId: gameVersions.gameId,
bestFps: sql<number>`MAX(${performanceEntries.fpsAvg})::real`,
})
.from(performanceEntries)
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
.where(
and(
inArray(gameVersions.gameId, [...filteredGameIds]),
eq(performanceEntries.isRemoved, false),
),
)
.groupBy(gameVersions.gameId)
const fpsMatchIds = new Set<string>()
for (const row of fpsStats) {
if (minFps !== undefined && row.bestFps < minFps) continue
if (maxFps !== undefined && row.bestFps > maxFps) continue
fpsMatchIds.add(row.gameId)
}
filteredGameIds = fpsMatchIds
}
// ── Filter local games to only those that passed all filters so far ──
const filteredLocalGames = localGames.filter((g) => filteredGameIds.has(g.id))
const filteredIds = filteredLocalGames.map((g) => g.id)
const localSteamAppIds = new Set( const localSteamAppIds = new Set(
localGames.map((g) => g.steamAppId).filter(Boolean), filteredLocalGames.map((g) => g.steamAppId).filter(Boolean),
) )
// Fetch platform support + anti-cheat for local games // Fetch platform support + anti-cheat for local games
@@ -64,7 +150,7 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
antiCheatStatus: string antiCheatStatus: string
} }
>() >()
if (localGameIds.length > 0) { if (filteredIds.length > 0) {
const { gamePlatformSupport } = await import("@/lib/db/schema") const { gamePlatformSupport } = await import("@/lib/db/schema")
const supportRows = await db const supportRows = await db
.select({ .select({
@@ -76,7 +162,7 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
antiCheatStatus: gamePlatformSupport.antiCheatStatus, antiCheatStatus: gamePlatformSupport.antiCheatStatus,
}) })
.from(gamePlatformSupport) .from(gamePlatformSupport)
.where(inArray(gamePlatformSupport.gameId, localGameIds)) .where(inArray(gamePlatformSupport.gameId, filteredIds))
for (const row of supportRows) { for (const row of supportRows) {
platformSupportMap.set(row.gameId, { platformSupportMap.set(row.gameId, {
@@ -89,12 +175,53 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
} }
} }
// ── 1c. Proton/Native and Anti-cheat post-filters ─────────────
if (query.protonNative && query.protonNative !== "any" && filteredIds.length > 0) {
const protonMatchIds = new Set<string>()
for (const g of filteredLocalGames) {
const platform = platformSupportMap.get(g.id)
const protonStatus = platform
? platform.protonStatus
: g.platforms?.linux
? "native"
: g.platforms?.windows
? "proton"
: "unknown"
if (
(query.protonNative === "native" && protonStatus === "native") ||
(query.protonNative === "proton" && protonStatus === "proton")
) {
protonMatchIds.add(g.id)
}
}
filteredGameIds = protonMatchIds
}
if (query.antiCheatStatus && query.antiCheatStatus !== "any" && filteredIds.length > 0) {
const acMatchIds = new Set<string>()
for (const g of filteredLocalGames) {
const platform = platformSupportMap.get(g.id)
const acStatus = platform ? platform.antiCheatStatus : "unknown"
if (acStatus === query.antiCheatStatus) {
acMatchIds.add(g.id)
}
}
filteredGameIds = acMatchIds
}
// Final local games after all filters
const finalLocalGames = filteredLocalGames.filter((g) => filteredGameIds.has(g.id))
const finalIds = finalLocalGames.map((g) => g.id)
const finalSteamAppIds = new Set(
finalLocalGames.map((g) => g.steamAppId).filter(Boolean),
)
// ── 2. Count related data for local games ─────────────────────── // ── 2. Count related data for local games ───────────────────────
let benchmarkCounts: { gameId: string; count: number }[] = [] let benchmarkCounts: { gameId: string; count: number }[] = []
let presetCounts: { gameId: string; count: number }[] = [] let presetCounts: { gameId: string; count: number }[] = []
let commentCounts: { gameId: string; count: number }[] = [] let commentCounts: { gameId: string; count: number }[] = []
if (localGameIds.length > 0) { if (finalIds.length > 0) {
const [bCounts, pCounts, cCounts] = await Promise.all([ const [bCounts, pCounts, cCounts] = await Promise.all([
db db
.select({ .select({
@@ -108,7 +235,7 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
) )
.where( .where(
and( and(
inArray(gameVersions.gameId, localGameIds), inArray(gameVersions.gameId, finalIds),
eq(performanceEntries.isRemoved, false), eq(performanceEntries.isRemoved, false),
), ),
) )
@@ -125,7 +252,7 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
) )
.where( .where(
and( and(
inArray(gameVersions.gameId, localGameIds), inArray(gameVersions.gameId, finalIds),
eq(performanceEntries.isRemoved, false), eq(performanceEntries.isRemoved, false),
sql`${performanceEntries.settingsJson} IS NOT NULL`, sql`${performanceEntries.settingsJson} IS NOT NULL`,
), ),
@@ -137,7 +264,7 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
count: sql<number>`count(*)::int`, count: sql<number>`count(*)::int`,
}) })
.from(gameComments) .from(gameComments)
.where(inArray(gameComments.gameId, localGameIds)) .where(inArray(gameComments.gameId, finalIds))
.groupBy(gameComments.gameId), .groupBy(gameComments.gameId),
]) ])
benchmarkCounts = bCounts benchmarkCounts = bCounts
@@ -149,7 +276,7 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
string, string,
{ benchmarks: number; presets: number; comments: number } { benchmarks: number; presets: number; comments: number }
>() >()
for (const g of localGames) { for (const g of finalLocalGames) {
countMap.set(g.id, { benchmarks: 0, presets: 0, comments: 0 }) countMap.set(g.id, { benchmarks: 0, presets: 0, comments: 0 })
} }
for (const c of benchmarkCounts) { for (const c of benchmarkCounts) {
@@ -167,7 +294,7 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
const poorPerformerMap = new Map<string, boolean>() const poorPerformerMap = new Map<string, boolean>()
const bestFpsMap = new Map<string, number>() const bestFpsMap = new Map<string, number>()
if (localGameIds.length > 0) { if (finalIds.length > 0) {
const perfStats = await db const perfStats = await db
.select({ .select({
gameId: gameVersions.gameId, gameId: gameVersions.gameId,
@@ -186,7 +313,7 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
) )
.where( .where(
and( and(
inArray(gameVersions.gameId, localGameIds), inArray(gameVersions.gameId, finalIds),
eq(performanceEntries.isRemoved, false), eq(performanceEntries.isRemoved, false),
), ),
) )
@@ -202,7 +329,7 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
// ── 2c. Latest version ────────────────────────────────────────── // ── 2c. Latest version ──────────────────────────────────────────
const latestVersionMap = new Map<string, string>() const latestVersionMap = new Map<string, string>()
if (localGameIds.length > 0) { if (finalIds.length > 0) {
const versionRows = await db const versionRows = await db
.select({ .select({
gameId: gameVersions.gameId, gameId: gameVersions.gameId,
@@ -211,7 +338,7 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
.from(gameVersions) .from(gameVersions)
.where( .where(
and( and(
inArray(gameVersions.gameId, localGameIds), inArray(gameVersions.gameId, finalIds),
eq(gameVersions.isLatest, true), eq(gameVersions.isLatest, true),
), ),
) )
@@ -267,7 +394,7 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
const results = [] const results = []
// Add local games // Add local games
for (const g of localGames) { for (const g of finalLocalGames) {
const counts = countMap.get(g.id)! const counts = countMap.get(g.id)!
const platform = platformSupportMap.get(g.id) const platform = platformSupportMap.get(g.id)
results.push({ results.push({
@@ -320,7 +447,7 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
// Add Steam-only games // Add Steam-only games
for (const item of steamItems) { for (const item of steamItems) {
if (localSteamAppIds.has(item.id)) continue if (finalSteamAppIds.has(item.id)) continue
results.push({ results.push({
kind: "steam" as const, kind: "steam" as const,
appId: item.id, appId: item.id,
@@ -355,6 +482,16 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
{ {
query: t.Object({ query: t.Object({
q: t.String(), q: t.String(),
device: t.Optional(t.String()),
minFps: t.Optional(t.String()),
maxFps: t.Optional(t.String()),
fsrSupport: t.Optional(t.String()),
protonNative: t.Optional(t.String()),
antiCheatStatus: t.Optional(t.String()),
playabilityStatus: t.Optional(t.String()),
steamReviewScore: t.Optional(t.String()),
isFree: t.Optional(t.String()),
hasMultiplayer: t.Optional(t.String()),
}), }),
}, },
) )