# Community Presets UX + SEO Overhaul 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:** Overhaul community presets UX (view settings, delete/report, horizontal layout, reorder) and robustify SEO (dynamic metadata, sitemaps, JSON-LD, OG images) across DeckyVault. **Architecture:** Two independent tracks — (A) Community Presets UX changes affect the game page client, server component, API routes, and a new reports table. (B) SEO changes are additive page-level metadata, sitemap, and OG image files. Track B is mostly independent and can proceed in parallel after Track A's DB changes land. **Tech Stack:** Next.js 15 App Router, Drizzle ORM, Elysia, Motion (Framer Motion v12), Tailwind CSS, next/og (ImageResponse) --- ## File Structure | File | Responsibility | |---|---| | `lib/db/schema/reports.ts` | Drizzle schema for the reports table | | `lib/db/schema/performanceEntries.ts` | Already exists — no schema changes needed (isRemoved + removedReason already exist) | | `lib/db/schema/index.ts` | Barrel export — add reports | | `lib/api/reports.ts` | Elysia routes for POST /api/performance/:id/report | | `lib/api/performance.ts` | Change user-delete from hard delete to soft delete | | `lib/api/index.ts` | Barrel export — add reports routes | | `app/api/[[...slugs]]/route.ts` | Mount new report routes | | `app/game/[id]/page.tsx` | Add generateMetadata, enrich preset data, add JSON-LD | | `app/game/[id]/game-page-client.tsx` | Horizontal layout, modal, delete/report UI, reorder sections, Preset type update | | `app/game/[id]/opengraph-image.tsx` | Dynamic OG image per game | | `app/game/[id]/preset-detail-modal.tsx` | New: Preset detail modal component with settings view, delete, report | | `app/sitemap.ts` | Expand with games + devices | | `app/search/page.tsx` | Add metadata export | | `app/page.tsx` | Add JSON-LD WebSite schema | | `app/devices/page.tsx` | Add JSON-LD ItemList | | `app/devices/[slug]/page.tsx` | Add JSON-LD Product schema, canonical URL | | `app/globals.css` | Add scrollbar-hiding utility class if needed | --- ## Task 1: Reports Database Schema + Migration **Files:** - Create: `lib/db/schema/reports.ts` - Modify: `lib/db/schema/index.ts` - [ ] **Step 1: Create the reports schema file** Create `lib/db/schema/reports.ts`: ```typescript import { text, pgEnum, pgTable, timestamp, uniqueIndex, } from "drizzle-orm/pg-core" import { performanceEntries } from "./performanceEntries" import { user } from "./auth" export const reportReasonEnum = pgEnum("report_reason", [ "inaccurate", "spam", "inappropriate", "other", ]) export const reportStatusEnum = pgEnum("report_status", [ "open", "reviewed", "dismissed", ]) export const reports = pgTable("reports", { id: text("id") .primaryKey() .$defaultFn(() => crypto.randomUUID()), entryId: text("entry_id") .notNull() .references(() => performanceEntries.id, { onDelete: "cascade" }), reporterId: text("reporter_id") .notNull() .references(() => user.id, { onDelete: "cascade" }), reason: reportReasonEnum("reason").notNull(), details: text("details"), status: reportStatusEnum("status").default("open").notNull(), createdAt: timestamp("created_at").defaultNow().notNull(), }, (table) => [ uniqueIndex("reports_entry_reporter_unique").on(table.entryId, table.reporterId), ]) ``` - [ ] **Step 2: Add reports export to barrel file** In `lib/db/schema/index.ts`, add: ```typescript export * from "./reports" ``` - [ ] **Step 3: Generate and run the migration** ```bash npx drizzle-kit generate npx drizzle-kit migrate ``` Expected: Migration creates the `reports` table with the `reports_entry_reporter_unique` unique index and the two new enums. - [ ] **Step 4: Commit** ```bash git add lib/db/schema/reports.ts lib/db/schema/index.ts drizzle/ git commit -m "feat: add reports table schema for preset reporting" ``` --- ## Task 2: Reports API Route **Files:** - Create: `lib/api/reports.ts` - Modify: `lib/api/index.ts` - Modify: `app/api/[[...slugs]]/route.ts` - [ ] **Step 1: Create the reports API route file** Create `lib/api/reports.ts`: ```typescript import { Elysia, t } from "elysia" import { db } from "@/lib/db/index" import { reports } from "@/lib/db/schema" import { eq, and } from "drizzle-orm" import { requireRole } from "@/lib/auth/guard" export const reportRoutes = new Elysia({ prefix: "/performance" }).post( "/:id/report", async ({ params, body, request, set }) => { const guard = await requireRole(request.headers, [ "user", "contributor", "admin", ]) if (!guard.ok) { set.status = guard.status return { error: guard.error } } // Check if user already reported this entry const [existing] = await db .select() .from(reports) .where( and( eq(reports.entryId, params.id), eq(reports.reporterId, guard.user.id), ), ) .limit(1) if (existing) { set.status = 409 return { error: "You have already reported this entry" } } const [created] = await db .insert(reports) .values({ entryId: params.id, reporterId: guard.user.id, reason: body.reason, details: body.details ?? null, }) .returning() return created }, { params: t.Object({ id: t.String() }), body: t.Object({ reason: t.Union([ t.Literal("inaccurate"), t.Literal("spam"), t.Literal("inappropriate"), t.Literal("other"), ]), details: t.Optional(t.String()), }), }, ) ``` - [ ] **Step 2: Add report routes to barrel export** In `lib/api/index.ts`, add the import and export: ```typescript export { reportRoutes } from "./reports" ``` - [ ] **Step 3: Mount the report routes in the API handler** In `app/api/[[...slugs]]/route.ts`, add `reportRoutes` to the Elysia app. Find the existing line where routes are mounted (e.g., `.use(performanceRoutes)`) and add `.use(reportRoutes)`. - [ ] **Step 4: Test the report endpoint** Start the dev server, then test: ```bash # Should return 401 without auth curl -X POST http://localhost:3000/api/performance/test-id/report \ -H "Content-Type: application/json" \ -d '{"reason":"spam"}' ``` Expected: 401 or auth error response. - [ ] **Step 5: Commit** ```bash git add lib/api/reports.ts lib/api/index.ts app/api/ git commit -m "feat: add POST /api/performance/:id/report endpoint" ``` --- ## Task 3: Change User-Delete to Soft Delete **Files:** - Modify: `lib/api/performance.ts` - [ ] **Step 1: Modify the user-delete endpoint** In `lib/api/performance.ts`, find the `/:id/user-delete` DELETE handler. Replace the hard-delete logic with a soft delete that sets `isRemoved: true` and optionally stores a `removedReason`. Current code does: ```typescript await db .delete(performanceEntries) .where(eq(performanceEntries.id, params.id)) ``` Change to: ```typescript const reason = (body as any)?.reason as string | undefined const [updated] = await db .update(performanceEntries) .set({ isRemoved: true, removedReason: reason ?? "User deleted", updatedAt: new Date(), }) .where(eq(performanceEntries.id, params.id)) .returning() if (!updated) { set.status = 404 return { error: "Performance entry not found" } } return { success: true } ``` Also update the route definition to accept an optional body with `reason`: ```typescript .delete( "/:id/user-delete", async ({ params, body, request, set }) => { // ... same auth check ... // ... same ownership check ... const reason = (body as Record | null)?.reason const [updated] = await db .update(performanceEntries) .set({ isRemoved: true, removedReason: reason ?? "User deleted", updatedAt: new Date(), }) .where(eq(performanceEntries.id, params.id)) .returning()) if (!updated) { set.status = 404 return { error: "Performance entry not found" } } return { success: true } }, { params: t.Object({ id: t.String() }), body: t.Optional(t.Object({ reason: t.Optional(t.String()) })), }, ) ``` - [ ] **Step 2: Verify the change** Start the dev server and confirm the endpoint is accessible. The endpoint should now return `{ success: true }` instead of hard-deleting. - [ ] **Step 3: Commit** ```bash git add lib/api/performance.ts git commit -m "feat: change user-delete to soft delete for performance entries" ``` --- ## Task 4: Enrich Preset Data in Server Component **Files:** - Modify: `app/game/[id]/page.tsx` - [ ] **Step 1: Add generateMetadata function** Replace the static `export const metadata = { title: "Game" }` with a `generateMetadata` function. Add imports for `Metadata` from `next` at the top. Extract the game resolution logic into a helper function that both `generateMetadata` and the page component can use. ```typescript import type { Metadata } from "next" // Helper to resolve a game by ID or Steam AppID async function resolveGame(id: string) { const isNumeric = /^\d+$/.test(id) let game if (isNumeric) { const rows = await db .select() .from(games) .where(eq(games.steamAppId, Number(id))) .limit(1) game = rows[0] } else { const rows = await db .select() .from(games) .where(eq(games.id, id)) .limit(1) game = rows[0] } return game } export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise { const { id } = await params const game = await resolveGame(id) if (!game) { return { title: "Game Not Found | DeckyVault" } } const description = game.description ? game.description.slice(0, 160) : `Find benchmarks, community presets, and performance settings for ${game.title} on Steam Deck.` return { title: `${game.title} - Benchmarks & Settings`, description, alternates: { canonical: `https://deckyvault.xyz/game/${game.id}` }, openGraph: { title: `${game.title} - Benchmarks & Settings | DeckyVault`, description: game.description?.slice(0, 200) ?? `Benchmarks and settings for ${game.title}`, url: `https://deckyvault.xyz/game/${game.id}`, images: [{ url: `/game/${game.id}/opengraph-image`, width: 1200, height: 630 }], type: "website", siteName: "DeckyVault", }, twitter: { card: "summary_large_image", title: `${game.title} - Benchmarks & Settings | DeckyVault`, description: game.description?.slice(0, 200) ?? `Benchmarks and settings for ${game.title}`, images: [`/game/${game.id}/opengraph-image`], }, } } ``` - [ ] **Step 2: Enrich the preset query — add user join and missing fields** In the `presetRows` query, add a join on `user` table and select the additional fields. The current query selects from `performanceEntries`, `gameVersions`, and `hardware`. Add: ```typescript import { user } from "@/lib/db/schema" ``` And in the `presetRows` select, add these fields: ```typescript userName: user.name, userImage: user.image, userId: performanceEntries.userId, downvotes: performanceEntries.downvotes, verifiedAt: performanceEntries.verifiedAt, settingsJson: performanceEntries.settingsJson, launchOptions: performanceEntries.launchOptions, userNotes: performanceEntries.userNotes, ``` Also add `.innerJoin(user, eq(performanceEntries.userId, user.id))` to the query. - [ ] **Step 3: Update serializedPresets to include new fields** In the `serializedPresets` mapping, add: ```typescript settingsJson: p.settingsJson, launchOptions: p.launchOptions, userNotes: p.userNotes, userId: p.userId, userName: p.userName, userImage: p.userImage, downvotes: p.downvotes, verifiedAt: p.verifiedAt ? p.verifiedAt.toISOString() : null, ``` - [ ] **Step 4: Refactor the page component to use resolveGame helper** Replace the inline game resolution logic in the `GamePage` function with a call to `resolveGame(id)`. Keep the `createGameStub` and sync logic in the page component since they have side effects. - [ ] **Step 5: Add JSON-LD structured data** In the `GamePage` return, add a `