diff --git a/docs/superpowers/plans/2026-04-28-games-list-contact-seo.md b/docs/superpowers/plans/2026-04-28-games-list-contact-seo.md new file mode 100644 index 0000000..3429183 --- /dev/null +++ b/docs/superpowers/plans/2026-04-28-games-list-contact-seo.md @@ -0,0 +1,1772 @@ +# Games List, Contact Page & SEO — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a browsable games list page with search/filter/infinite scroll, a contact/report page with Discord webhook, and update SEO/sitemap coverage. + +**Architecture:** Server components for initial data + SEO, client components for interactivity. New Elysia route for contact submission. Existing CRUD endpoint enhanced with a dedicated listing route for paginated games with stats. Discord webhook for contact form. Sitemap and robots.txt updated for new pages. + +**Tech Stack:** Next.js 16 (App Router, Server Components), Elysia API, Drizzle ORM, Tailwind v4, Lucide React icons, motion/react (framer-motion) + +--- + +## Phase 1: Games Listing API + +### Task 1: Create games listing API endpoint + +**Files:** +- Create: `lib/api/games-listing.ts` +- Modify: `lib/api/index.ts` +- Modify: `app/api/[[...slugs]]/route.ts` + +- [ ] **Step 1: Create `lib/api/games-listing.ts`** + +This endpoint returns paginated games enriched with benchmark count and platform support status, supporting search, genre filter, device filter, and sort. + +```typescript +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) { + // Sub-select game IDs that have platform support for this 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 { + // No games support this device + 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) { + // Use the first platform support entry found + 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()), + }), + }, +) +``` + +- [ ] **Step 2: Export from `lib/api/index.ts`** + +Add this line to the existing barrel export: + +```typescript +export { gamesListingRoutes } from "./games-listing" +``` + +- [ ] **Step 3: Register route in `app/api/[[...slugs]]/route.ts`** + +Add import: + +```typescript +import { gamesListingRoutes } from "@/lib/api/games-listing" +``` + +Add `.use(gamesListingRoutes)` after `.use(gamesRoutes)`: + +```typescript + // Games + Versions + Listing + .use(gamesRoutes) + .use(gameVersionsRoutes) + .use(gamesListingRoutes) +``` + +- [ ] **Step 4: Verify the API starts** + +Run: `bun run dev` and confirm no compilation or startup errors. Hit `http://localhost:3000/api/games/listing` and verify it returns a JSON response with `data`, `total`, `genres`, `devices` fields. + +- [ ] **Step 5: Commit** + +```bash +git add lib/api/games-listing.ts lib/api/index.ts app/api/\[\[...slugs\]\]/route.ts +git commit -m "feat: add games listing API endpoint with search, genre/device filter, and sort" +``` + +--- + +## Phase 2: Games List Page + +### Task 2: Create games list server component (`page.tsx`) + +**Files:** +- Create: `app/games/page.tsx` + +- [ ] **Step 1: Create `app/games/page.tsx`** + +Server component that fetches initial games data, all genres, all devices, and renders metadata + JSON-LD. Passes serialized data to client component. + +```typescript +import type { Metadata } from "next" +import { db } from "@/lib/db/index" +import { + games, + gameVersions, + performanceEntries, + gamePlatformSupport, + hardware, +} from "@/lib/db/schema" +import { sql, eq, and, desc, inArray } from "drizzle-orm" +import { GamesPageClient } from "./games-page-client" + +export const metadata: Metadata = { + title: "Games — DeckyVault", + description: + "Browse the full catalog of Steam Deck games with benchmarks, community settings, and performance data. Filter by genre, device, and more.", + keywords: [ + "Steam Deck games", + "game benchmarks", + "Steam Deck settings", + "game catalog", + "performance data", + ], + alternates: { canonical: "https://deckyvault.xyz/games" }, + openGraph: { + title: "Games — DeckyVault", + description: + "Browse the full catalog of Steam Deck games with benchmarks and performance data.", + url: "https://deckyvault.xyz/games", + siteName: "DeckyVault", + type: "website", + }, +} + +export default async function GamesPage() { + // Fetch initial 24 games with benchmark counts + const gamesData = await 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) + .orderBy(desc(games.createdAt)) + .limit(24) + + // Get total count + const [{ count: totalCount }] = await db + .select({ count: sql`count(*)::int` }) + .from(games) + + // Fetch platform support for initial games + const gameIds = gamesData.map((g) => g.id) + const platformRows = gameIds.length > 0 + ? await db + .select({ + gameId: gamePlatformSupport.gameId, + protonStatus: gamePlatformSupport.protonStatus, + }) + .from(gamePlatformSupport) + .where(inArray(gamePlatformSupport.gameId, gameIds)) + : [] + + const platformMap = new Map() + for (const row of platformRows) { + if (!platformMap.has(row.gameId)) { + platformMap.set(row.gameId, row.protonStatus) + } + } + + // Fetch all genres + 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 + const deviceRows = await db + .select({ slug: hardware.slug, name: hardware.name }) + .from(hardware) + .orderBy(hardware.sortOrder) + + // Serialize for client + const initialGames = gamesData.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, + })) + + const allGenres = Array.from(genreSet).sort() + const allDevices = deviceRows + + // JSON-LD ItemList + const jsonLd = { + "@context": "https://schema.org", + "@type": "ItemList", + itemListElement: initialGames.map((game, i) => ({ + "@type": "ListItem", + position: i + 1, + name: game.title, + url: `https://deckyvault.xyz/game/${game.id}`, + })), + } + + return ( + <> +