feat: add games listing API endpoint with search, genre/device filter, and sort
This commit is contained in:
@@ -19,6 +19,7 @@ import { steamSearchRoutes } from "@/lib/api/steam-search"
|
||||
import { searchUnifiedRoutes } from "@/lib/api/search-unified"
|
||||
import { gameStubRoutes } from "@/lib/api/game-stub"
|
||||
import { gameStatsRoutes } from "@/lib/api/game-stats"
|
||||
import { gamesListingRoutes } from "@/lib/api/games-listing"
|
||||
|
||||
const betterAuth = new Elysia({ name: "better-auth" })
|
||||
.mount(auth.handler)
|
||||
@@ -59,6 +60,7 @@ export const app = new Elysia({ prefix: "/api" })
|
||||
// Games + Versions
|
||||
.use(gamesRoutes)
|
||||
.use(gameVersionsRoutes)
|
||||
.use(gamesListingRoutes)
|
||||
// Hardware
|
||||
.use(hardwareRoutes)
|
||||
.use(hardwareStatsRoutes)
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { Elysia, t } from "elysia"
|
||||
import { db } from "@/lib/db/index"
|
||||
import {
|
||||
games,
|
||||
gameVersions,
|
||||
performanceEntries,
|
||||
gamePlatformSupport,
|
||||
hardware,
|
||||
} from "@/lib/db/schema"
|
||||
import { ilike, or, sql, eq, and, desc, asc, inArray } from "drizzle-orm"
|
||||
|
||||
const MAX_OFFSET = 10000
|
||||
const PAGE_SIZE = 24
|
||||
|
||||
export const gamesListingRoutes = new Elysia({ prefix: "/games/listing" }).get(
|
||||
"/",
|
||||
async ({ query, set }) => {
|
||||
const offset = Math.min(Number(query.offset) || 0, MAX_OFFSET)
|
||||
const limit = Math.min(Number(query.limit) || PAGE_SIZE, 100)
|
||||
const search = query.search || ""
|
||||
const genre = query.genre || ""
|
||||
const device = query.device || ""
|
||||
const sort = query.sort || "recent"
|
||||
|
||||
// Build where conditions
|
||||
const conditions = []
|
||||
|
||||
if (search) {
|
||||
const term = `%${search}%`
|
||||
conditions.push(
|
||||
or(
|
||||
ilike(games.title, term),
|
||||
ilike(games.developer, term),
|
||||
ilike(games.publisher, term),
|
||||
)!,
|
||||
)
|
||||
}
|
||||
|
||||
if (genre) {
|
||||
conditions.push(sql`${games.genres} @> ${JSON.stringify([genre])}::jsonb`)
|
||||
}
|
||||
|
||||
if (device) {
|
||||
const supportedIds = await db
|
||||
.select({ gameId: gamePlatformSupport.gameId })
|
||||
.from(gamePlatformSupport)
|
||||
.where(
|
||||
and(
|
||||
eq(gamePlatformSupport.hardwareSlug, device),
|
||||
eq(gamePlatformSupport.isSupported, true),
|
||||
),
|
||||
)
|
||||
if (supportedIds.length > 0) {
|
||||
conditions.push(inArray(games.id, supportedIds.map((s) => s.gameId)))
|
||||
} else {
|
||||
return { data: [], total: 0, limit, offset, genres: [], devices: [] }
|
||||
}
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined
|
||||
|
||||
// Determine sort order
|
||||
let orderBy
|
||||
switch (sort) {
|
||||
case "name":
|
||||
orderBy = asc(games.title)
|
||||
break
|
||||
case "benchmarks":
|
||||
orderBy = desc(sql`benchmark_count`)
|
||||
break
|
||||
case "recent":
|
||||
default:
|
||||
orderBy = desc(games.createdAt)
|
||||
break
|
||||
}
|
||||
|
||||
// Fetch games with benchmark counts
|
||||
const gamesQuery = db
|
||||
.select({
|
||||
id: games.id,
|
||||
steamAppId: games.steamAppId,
|
||||
title: games.title,
|
||||
developer: games.developer,
|
||||
capsuleImage: games.capsuleImage,
|
||||
headerImage: games.headerImage,
|
||||
genres: games.genres,
|
||||
source: games.source,
|
||||
createdAt: games.createdAt,
|
||||
benchmarkCount: sql<number>`(
|
||||
SELECT count(*)::int FROM ${performanceEntries}
|
||||
INNER JOIN ${gameVersions} ON ${performanceEntries.versionId} = ${gameVersions.id}
|
||||
WHERE ${gameVersions.gameId} = ${games.id}
|
||||
AND ${performanceEntries.isRemoved} = false
|
||||
)`,
|
||||
})
|
||||
.from(games)
|
||||
.where(where)
|
||||
.orderBy(sort === "benchmarks" ? desc(sql`benchmark_count`) : orderBy)
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
|
||||
// Count total
|
||||
const countQuery = db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(games)
|
||||
.where(where)
|
||||
|
||||
// Fetch all genres (for filter options)
|
||||
const genreRows = await db
|
||||
.select({ genres: games.genres })
|
||||
.from(games)
|
||||
.where(sql`${games.genres} IS NOT NULL`)
|
||||
|
||||
const genreSet = new Set<string>()
|
||||
for (const row of genreRows) {
|
||||
if (Array.isArray(row.genres)) {
|
||||
for (const g of row.genres) {
|
||||
if (typeof g === "string") genreSet.add(g)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch all hardware devices (for filter options)
|
||||
const deviceRows = await db
|
||||
.select({ slug: hardware.slug, name: hardware.name })
|
||||
.from(hardware)
|
||||
.orderBy(hardware.sortOrder)
|
||||
|
||||
const [data, countResult] = await Promise.all([gamesQuery, countQuery])
|
||||
|
||||
// Fetch platform support for the returned games
|
||||
const gameIds = data.map((g) => g.id)
|
||||
let platformMap = new Map<string, string>()
|
||||
if (gameIds.length > 0) {
|
||||
const platformRows = await db
|
||||
.select({
|
||||
gameId: gamePlatformSupport.gameId,
|
||||
protonStatus: gamePlatformSupport.protonStatus,
|
||||
})
|
||||
.from(gamePlatformSupport)
|
||||
.where(inArray(gamePlatformSupport.gameId, gameIds))
|
||||
|
||||
for (const row of platformRows) {
|
||||
if (!platformMap.has(row.gameId)) {
|
||||
platformMap.set(row.gameId, row.protonStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const enrichedData = data.map((g) => ({
|
||||
id: g.id,
|
||||
steamAppId: g.steamAppId,
|
||||
title: g.title,
|
||||
developer: g.developer,
|
||||
capsuleImage: g.capsuleImage,
|
||||
headerImage: g.headerImage,
|
||||
genres: g.genres,
|
||||
source: g.source,
|
||||
benchmarkCount: g.benchmarkCount,
|
||||
deckStatus: platformMap.get(g.id) ?? null,
|
||||
}))
|
||||
|
||||
return {
|
||||
data: enrichedData,
|
||||
total: countResult[0]?.count ?? 0,
|
||||
limit,
|
||||
offset,
|
||||
genres: Array.from(genreSet).sort(),
|
||||
devices: deviceRows,
|
||||
}
|
||||
},
|
||||
{
|
||||
query: t.Object({
|
||||
offset: t.Optional(t.String()),
|
||||
limit: t.Optional(t.String()),
|
||||
search: t.Optional(t.String()),
|
||||
genre: t.Optional(t.String()),
|
||||
device: t.Optional(t.String()),
|
||||
sort: t.Optional(t.String()),
|
||||
}),
|
||||
},
|
||||
)
|
||||
@@ -5,6 +5,7 @@ export { hardwareRoutes } from "./hardware"
|
||||
export { performanceRoutes, performanceVerifyRoutes } from "./performance"
|
||||
export { performanceSubmitRoutes } from "./performance-submit"
|
||||
export { commentsRoutes } from "./comments"
|
||||
export { gamesListingRoutes } from "./games-listing"
|
||||
export { gameStatsRoutes } from "./game-stats"
|
||||
export { hardwareStatsRoutes } from "./hardware-stats"
|
||||
export { savedGamesRoutes } from "./saved-games"
|
||||
|
||||
Reference in New Issue
Block a user