diff --git a/app/api/[[...slugs]]/route.ts b/app/api/[[...slugs]]/route.ts index f31b43c..3b99414 100644 --- a/app/api/[[...slugs]]/route.ts +++ b/app/api/[[...slugs]]/route.ts @@ -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) diff --git a/lib/api/games-listing.ts b/lib/api/games-listing.ts new file mode 100644 index 0000000..5e5a45f --- /dev/null +++ b/lib/api/games-listing.ts @@ -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`( + 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`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() + 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() + 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()), + }), + }, +) diff --git a/lib/api/index.ts b/lib/api/index.ts index 359b60b..738f390 100644 --- a/lib/api/index.ts +++ b/lib/api/index.ts @@ -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"