diff --git a/app/search/page.tsx b/app/search/page.tsx index 681cb9d..a4b65bb 100644 --- a/app/search/page.tsx +++ b/app/search/page.tsx @@ -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(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 (
@@ -121,13 +191,214 @@ function SearchContent() { {query ? `${results.length} result${results.length !== 1 ? "s" : ""} found` : "Enter a game name or AppID to find benchmarks, settings, and reviews."} + {/* Filter toggle button */} +
+ + {hasActiveFilters && ( + + )} +
+ + {/* Filter panel */} + + {showFilters && ( + +
+ {/* Device filter */} +
+ + Device + +
+ + {DEVICE_OPTIONS.map((device) => ( + + ))} +
+
+ + {/* Performance Filters */} +
+

Performance

+
+ 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]" + /> + 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]" + /> +
+ +
+ + {/* Compatibility Filters */} +
+

Compatibility

+ + + +
+ + {/* Other Filters */} +
+

Other

+ + + setSteamReviewMin(e.target.value)} + className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-2 py-1 text-sm" + /> +
+ + {/* Saved 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("") + }} + /> +
+
+ )} +
+ {!isValidQuery && ( ) -} +} \ No newline at end of file diff --git a/lib/api/search-unified.ts b/lib/api/search-unified.ts index d125403..d4fb410 100644 --- a/lib/api/search-unified.ts +++ b/lib/api/search-unified.ts @@ -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}%` + // ── 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 ──────────────────────────────────── const localGames = await db .select() .from(games) - .where( - or( - ilike(games.title, titleTerm), - ilike(games.developer, term), - ilike(games.publisher, term), - ), - ) - .limit(20) + .where(and(...baseFilterConditions)) + .limit(40) - 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`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() + 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() + 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() + 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`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() const bestFpsMap = new Map() - 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() - 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()), }), }, -) +) \ No newline at end of file