feat: add advanced filters and saved filter presets to search page
This commit is contained in:
+276
-5
@@ -11,6 +11,8 @@ import {
|
||||
TrendingUpIcon,
|
||||
DatabaseIcon,
|
||||
SparklesIcon,
|
||||
SlidersHorizontalIcon,
|
||||
XIcon,
|
||||
} from "lucide-react"
|
||||
import { FaSteam } from "react-icons/fa"
|
||||
import Image from "next/image"
|
||||
@@ -19,6 +21,7 @@ import { useSession } from "@/lib/auth-client"
|
||||
import { WindowsIcon, MacIcon, LinuxIcon } from "@/app/components/PlatformIcons"
|
||||
import { AntiCheatBadge } from "@/components/anti-cheat-badge"
|
||||
import { PlayabilityBadge } from "@/components/playability-badge"
|
||||
import { SavedFilters } from "@/components/saved-filters"
|
||||
|
||||
interface UnifiedResult {
|
||||
kind: "local" | "steam"
|
||||
@@ -57,6 +60,12 @@ interface UnifiedResult {
|
||||
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() {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
@@ -68,6 +77,43 @@ function SearchContent() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
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
|
||||
useEffect(() => {
|
||||
if (!isValidQuery) return
|
||||
@@ -79,9 +125,20 @@ function SearchContent() {
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/search/unified?q=${encodeURIComponent(query)}`,
|
||||
)
|
||||
const params = new URLSearchParams()
|
||||
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())
|
||||
const data = await res.json()
|
||||
if (!cancelled) setResults(data.results || [])
|
||||
@@ -99,7 +156,7 @@ function SearchContent() {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [isValidQuery, query])
|
||||
}, [isValidQuery, query, selectedDevice, minFps, maxFps, fsrSupport, protonNative, antiCheatStatus, playabilityStatus, steamReviewMin, isFree, hasMultiplayer])
|
||||
|
||||
function handleClick(result: UnifiedResult) {
|
||||
const path = result.appId
|
||||
@@ -108,6 +165,19 @@ function SearchContent() {
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
function clearAllFilters() {
|
||||
setSelectedDevice("")
|
||||
setMinFps("")
|
||||
setMaxFps("")
|
||||
setFsrSupport(false)
|
||||
setProtonNative("any")
|
||||
setAntiCheatStatus("any")
|
||||
setPlayabilityStatus("")
|
||||
setSteamReviewMin("")
|
||||
setIsFree(false)
|
||||
setHasMultiplayer(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
@@ -121,13 +191,214 @@ function SearchContent() {
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, transition: { delay: 0.1 } }}
|
||||
className="text-text/60 text-sm mb-8"
|
||||
className="text-text/60 text-sm mb-4"
|
||||
>
|
||||
{query
|
||||
? `${results.length} result${results.length !== 1 ? "s" : ""} found`
|
||||
: "Enter a game name or AppID to find benchmarks, settings, and reviews."}
|
||||
</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 && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
|
||||
+160
-23
@@ -6,7 +6,7 @@ import {
|
||||
performanceEntries,
|
||||
gameComments,
|
||||
} 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"
|
||||
|
||||
interface SteamSearchItem {
|
||||
@@ -35,22 +35,108 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
|
||||
const titleTerm = fuzzySearchTerm(query.q)
|
||||
const term = `%${query.q}%`
|
||||
|
||||
// ── 1. Search local database ────────────────────────────────────
|
||||
const localGames = await db
|
||||
.select()
|
||||
.from(games)
|
||||
.where(
|
||||
// ── Build filter conditions for columns on the games table ────
|
||||
const baseFilterConditions = [
|
||||
or(
|
||||
ilike(games.title, titleTerm),
|
||||
ilike(games.developer, term),
|
||||
ilike(games.publisher, term),
|
||||
),
|
||||
)
|
||||
.limit(20)
|
||||
]
|
||||
|
||||
const localGameIds = localGames.map((g) => g.id)
|
||||
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 ────────────────────────────────────
|
||||
const localGames = await db
|
||||
.select()
|
||||
.from(games)
|
||||
.where(and(...baseFilterConditions))
|
||||
.limit(40)
|
||||
|
||||
// ── 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(
|
||||
localGames.map((g) => g.steamAppId).filter(Boolean),
|
||||
filteredLocalGames.map((g) => g.steamAppId).filter(Boolean),
|
||||
)
|
||||
|
||||
// Fetch platform support + anti-cheat for local games
|
||||
@@ -64,7 +150,7 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
|
||||
antiCheatStatus: string
|
||||
}
|
||||
>()
|
||||
if (localGameIds.length > 0) {
|
||||
if (filteredIds.length > 0) {
|
||||
const { gamePlatformSupport } = await import("@/lib/db/schema")
|
||||
const supportRows = await db
|
||||
.select({
|
||||
@@ -76,7 +162,7 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
|
||||
antiCheatStatus: gamePlatformSupport.antiCheatStatus,
|
||||
})
|
||||
.from(gamePlatformSupport)
|
||||
.where(inArray(gamePlatformSupport.gameId, localGameIds))
|
||||
.where(inArray(gamePlatformSupport.gameId, filteredIds))
|
||||
|
||||
for (const row of supportRows) {
|
||||
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 ───────────────────────
|
||||
let benchmarkCounts: { gameId: string; count: number }[] = []
|
||||
let presetCounts: { 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([
|
||||
db
|
||||
.select({
|
||||
@@ -108,7 +235,7 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(gameVersions.gameId, localGameIds),
|
||||
inArray(gameVersions.gameId, finalIds),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
),
|
||||
)
|
||||
@@ -125,7 +252,7 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(gameVersions.gameId, localGameIds),
|
||||
inArray(gameVersions.gameId, finalIds),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
sql`${performanceEntries.settingsJson} IS NOT NULL`,
|
||||
),
|
||||
@@ -137,7 +264,7 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(gameComments)
|
||||
.where(inArray(gameComments.gameId, localGameIds))
|
||||
.where(inArray(gameComments.gameId, finalIds))
|
||||
.groupBy(gameComments.gameId),
|
||||
])
|
||||
benchmarkCounts = bCounts
|
||||
@@ -149,7 +276,7 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
|
||||
string,
|
||||
{ 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 })
|
||||
}
|
||||
for (const c of benchmarkCounts) {
|
||||
@@ -167,7 +294,7 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
|
||||
const poorPerformerMap = new Map<string, boolean>()
|
||||
const bestFpsMap = new Map<string, number>()
|
||||
|
||||
if (localGameIds.length > 0) {
|
||||
if (finalIds.length > 0) {
|
||||
const perfStats = await db
|
||||
.select({
|
||||
gameId: gameVersions.gameId,
|
||||
@@ -186,7 +313,7 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
inArray(gameVersions.gameId, localGameIds),
|
||||
inArray(gameVersions.gameId, finalIds),
|
||||
eq(performanceEntries.isRemoved, false),
|
||||
),
|
||||
)
|
||||
@@ -202,7 +329,7 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
|
||||
// ── 2c. Latest version ──────────────────────────────────────────
|
||||
const latestVersionMap = new Map<string, string>()
|
||||
|
||||
if (localGameIds.length > 0) {
|
||||
if (finalIds.length > 0) {
|
||||
const versionRows = await db
|
||||
.select({
|
||||
gameId: gameVersions.gameId,
|
||||
@@ -211,7 +338,7 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
|
||||
.from(gameVersions)
|
||||
.where(
|
||||
and(
|
||||
inArray(gameVersions.gameId, localGameIds),
|
||||
inArray(gameVersions.gameId, finalIds),
|
||||
eq(gameVersions.isLatest, true),
|
||||
),
|
||||
)
|
||||
@@ -267,7 +394,7 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
|
||||
const results = []
|
||||
|
||||
// Add local games
|
||||
for (const g of localGames) {
|
||||
for (const g of finalLocalGames) {
|
||||
const counts = countMap.get(g.id)!
|
||||
const platform = platformSupportMap.get(g.id)
|
||||
results.push({
|
||||
@@ -320,7 +447,7 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
|
||||
|
||||
// Add Steam-only games
|
||||
for (const item of steamItems) {
|
||||
if (localSteamAppIds.has(item.id)) continue
|
||||
if (finalSteamAppIds.has(item.id)) continue
|
||||
results.push({
|
||||
kind: "steam" as const,
|
||||
appId: item.id,
|
||||
@@ -355,6 +482,16 @@ export const searchUnifiedRoutes = new Elysia({ prefix: "/search" }).get(
|
||||
{
|
||||
query: t.Object({
|
||||
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()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user