From 5087f3370dc94183771a1588e2ef00ca430c7e42 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Mon, 27 Apr 2026 15:05:35 +0800 Subject: [PATCH] docs: add implementation plan for community presets UX + SEO overhaul --- .../plans/2026-04-27-community-presets-seo.md | 1348 +++++++++++++++++ 1 file changed, 1348 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-27-community-presets-seo.md diff --git a/docs/superpowers/plans/2026-04-27-community-presets-seo.md b/docs/superpowers/plans/2026-04-27-community-presets-seo.md new file mode 100644 index 0000000..e7416fd --- /dev/null +++ b/docs/superpowers/plans/2026-04-27-community-presets-seo.md @@ -0,0 +1,1348 @@ +# 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 `