Files
deckyvault/docs/superpowers/plans/2026-04-27-community-presets-seo.md

49 KiB
Raw Permalink Blame History

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:

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:

export * from "./reports"
  • Step 3: Generate and run the migration
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
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:

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:

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:

# 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
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:

await db
    .delete(performanceEntries)
    .where(eq(performanceEntries.id, params.id))

Change to:

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:

.delete(
    "/:id/user-delete",
    async ({ params, body, request, set }) => {
        // ... same auth check ...
        // ... same ownership check ...

        const reason = (body as Record<string, string> | 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
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.

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<Metadata> {
    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:

import { user } from "@/lib/db/schema"

And in the presetRows select, add these fields:

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:

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 <script> tag for JSON-LD before <GamePageClient>:

<script
    type="application/ld+json"
    dangerouslySetInnerHTML={{
        __html: JSON.stringify({
            "@context": "https://schema.org",
            "@type": "VideoGame",
            name: game.title,
            ...(game.developer && { developer: { "@type": "Organization", name: game.developer } }),
            ...(game.genres && { genre: game.genres }),
            ...(game.headerImage && { image: game.headerImage }),
            url: `https://deckyvault.xyz/game/${game.id}`,
            applicationCategory: "Game",
            operatingSystem: "SteamOS",
            ...(game.storeUrl && { offers: { "@type": "Offer", url: game.storeUrl } }),
        }),
    }}
/>
  • Step 6: Verify the page loads

Start dev server, navigate to a game page, verify:

  • The <title> tag shows the game title

  • The <meta name="description"> shows game description

  • The JSON-LD script is present in the page source

  • Preset data includes the new fields (userId, userName, etc.)

  • Step 7: Commit

git add app/game/[id]/page.tsx
git commit -m "feat: add generateMetadata, enriched preset data, and JSON-LD to game page"

Task 5: Preset Detail Modal Component

Files:

  • Create: app/game/[id]/preset-detail-modal.tsx

  • Step 1: Create the preset detail modal component

Create app/game/[id]/preset-detail-modal.tsx:

"use client"

import { useState } from "react"
import { AnimatePresence, motion } from "motion/react"
import {
    TrendingUpIcon,
    TrendingDownIcon,
    FlagIcon,
    TrashIcon,
    ChevronDownIcon,
    ShieldCheckIcon,
    XIcon,
    UserIcon,
} from "lucide-react"
import { useSession } from "@/lib/auth-client"
import type { GameSettingCategory } from "@/lib/db/schema/performanceEntries"

interface Preset {
    id: string
    hardwareSlug: string
    hardwareName: string
    upvotes: number
    downvotes: number
    settingsJson: GameSettingCategory[] | null
    settingsCount: number
    fpsAvg: number | null
    fpsLow: number | null
    fpsHigh: number | null
    upscalerType: string | null
    upscalerVersion: string | null
    frameGenMethod: string | null
    protonVersion: string | null
    osVersion: string | null
    launchOptions: string | null
    userNotes: string | null
    userId: string
    userName: string | null
    userImage: string | null
    verifiedAt: string | null
    createdAt: string
}

const REPORT_REASONS = [
    { value: "inaccurate" as const, label: "Inaccurate data" },
    { value: "spam" as const, label: "Spam" },
    { value: "inappropriate" as const, label: "Inappropriate" },
    { value: "other" as const, label: "Other" },
]

export function PresetDetailModal({
    preset,
    onClose,
    onDelete,
    onReport,
    hasReported,
}: {
    preset: Preset
    onClose: () => void
    onDelete: (presetId: string) => void
    onReport: (presetId: string, reason: "inaccurate" | "spam" | "inappropriate" | "other", details?: string) => void
    hasReported: boolean
}) {
    const { data: session } = useSession()
    const isOwner = session?.user?.id === preset.userId
    const isAdmin = session?.user?.role === "admin"
    const canDelete = isOwner || isAdmin
    const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
    const [showReport, setShowReport] = useState(false)
    const [reportReason, setReportReason] = useState<"inaccurate" | "spam" | "inappropriate" | "other">("inaccurate")
    const [reportDetails, setReportDetails] = useState("")
    const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set())

    const toggleCategory = (cat: string) => {
        setExpandedCategories((prev) => {
            const next = new Set(prev)
            if (next.has(cat)) next.delete(cat)
            else next.add(cat)
            return next
        })
    }

    return (
        <motion.div
            layoutId={preset.id}
            className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"
            onClick={onClose}
        >
            <motion.div
                className="w-full max-w-2xl max-h-[85vh] overflow-y-auto bg-background border border-border rounded-2xl shadow-2xl"
                onClick={(e) => e.stopPropagation()}
                initial={{ scale: 0.95, opacity: 0 }}
                animate={{ scale: 1, opacity: 1 }}
                exit={{ scale: 0.95, opacity: 0 }}
            >
                {/* Header */}
                <div className="flex items-center justify-between p-4 border-b border-border">
                    <div className="flex items-center gap-3">
                        {preset.userImage ? (
                            <img src={preset.userImage} alt="" className="h-8 w-8 rounded-full" />
                        ) : (
                            <div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
                                <UserIcon className="h-4 w-4 text-primary" />
                            </div>
                        )}
                        <div>
                            <p className="text-sm font-medium">{preset.userName ?? "Anonymous"}</p>
                            <p className="text-xs text-text/50">{new Date(preset.createdAt).toLocaleDateString()}</p>
                        </div>
                        {preset.verifiedAt && (
                            <ShieldCheckIcon className="h-4 w-4 text-blue-400" title="Verified" />
                        )}
                    </div>
                    <button onClick={onClose} className="p-1 rounded-lg hover:bg-text/5 transition-colors">
                        <XIcon className="h-5 w-5 text-text/60" />
                    </button>
                </div>

                {/* Stats Row */}
                <div className="flex items-center gap-4 px-4 py-3 border-b border-border text-sm">
                    <span className="flex items-center gap-1">
                        <TrendingUpIcon className="h-3.5 w-3.5 text-green-400" />
                        {preset.upvotes}
                    </span>
                    <span className="flex items-center gap-1">
                        <TrendingDownIcon className="h-3.5 w-3.5 text-red-400" />
                        {preset.downvotes}
                    </span>
                    {preset.fpsAvg !== null && (
                        <span className="ml-auto font-semibold">
                            {preset.fpsAvg} avg FPS
                            {preset.fpsLow !== null && ` (${preset.fpsLow}${preset.fpsHigh})`}
                        </span>
                    )}
                </div>

                {/* Metadata */}
                <div className="px-4 py-3 border-b border-border grid grid-cols-2 gap-2 text-xs text-text/70">
                    {preset.protonVersion && <span>Proton {preset.protonVersion}</span>}
                    {preset.osVersion && <span>SteamOS {preset.osVersion}</span>}
                    {preset.upscalerType && preset.upscalerType !== "none" && (
                        <span>Upscaler: {preset.upscalerType.toUpperCase()}</span>
                    )}
                    {preset.frameGenMethod && preset.frameGenMethod !== "none" && (
                        <span>Frame Gen: {preset.frameGenMethod}</span>
                    )}
                    {preset.launchOptions && <span className="col-span-2">Launch: {preset.launchOptions}</span>}
                </div>

                {/* Settings Table */}
                {preset.settingsJson && preset.settingsJson.length > 0 && (
                    <div className="px-4 py-3 border-b border-border">
                        <h3 className="text-sm font-semibold mb-2">Settings ({preset.settingsCount})</h3>
                        <div className="flex flex-col gap-2">
                            {preset.settingsJson.map((cat) => (
                                <div key={cat.category} className="border border-border rounded-lg overflow-hidden">
                                    <button
                                        onClick={() => toggleCategory(cat.category)}
                                        className="w-full flex items-center justify-between px-3 py-2 text-xs font-medium bg-text/3 hover:bg-text/5 transition-colors"
                                    >
                                        {cat.category}
                                        <ChevronDownIcon
                                            className={`h-3.5 w-3.5 transition-transform ${
                                                expandedCategories.has(cat.category) ? "rotate-180" : ""
                                            }`}
                                        />
                                    </button>
                                    {expandedCategories.has(cat.category) && (
                                        <div className="divide-y divide-border">
                                            {cat.settings.map((s, i) => (
                                                <div key={i} className="flex items-center justify-between px-3 py-1.5 text-xs">
                                                    <span className="text-text/60">{s.title ?? s.title}</span>
                                                    <span className="font-medium">
                                                        {typeof s.value === "boolean" ? (s.value ? "On" : "Off") : String(s.value)}
                                                    </span>
                                                </div>
                                            ))}
                                        </div>
                                    )}
                                </div>
                            ))}
                        </div>
                    </div>
                )}

                {/* User Notes */}
                {preset.userNotes && (
                    <div className="px-4 py-3 border-b border-border text-sm text-text/70">
                        <h3 className="text-xs font-semibold mb-1">Notes</h3>
                        <p>{preset.userNotes}</p>
                    </div>
                )}

                {/* Actions */}
                <div className="flex items-center justify-between p-4">
                    <div className="flex items-center gap-2">
                        {canDelete && !showDeleteConfirm && (
                            <button
                                onClick={() => setShowDeleteConfirm(true)}
                                className="flex items-center gap-1 px-3 py-1.5 text-xs text-red-400 border border-red-500/20 rounded-lg hover:bg-red-500/10 transition-colors"
                            >
                                <TrashIcon className="h-3 w-3" />
                                Delete
                            </button>
                        )}
                        {canDelete && showDeleteConfirm && (
                            <div className="flex items-center gap-2">
                                <span className="text-xs text-text/60">Remove this preset?</span>
                                <button
                                    onClick={() => onDelete(preset.id)}
                                    className="px-2 py-1 text-xs bg-red-500/20 text-red-400 rounded hover:bg-red-500/30 transition-colors"
                                >
                                    Confirm
                                </button>
                                <button
                                    onClick={() => setShowDeleteConfirm(false)}
                                    className="px-2 py-1 text-xs bg-text/5 text-text/60 rounded hover:bg-text/10 transition-colors"
                                >
                                    Cancel
                                </button>
                            </div>
                        )}
                    </div>

                    <div className="relative">
                        {!hasReported ? (
                            <>
                                <button
                                    onClick={() => setShowReport(!showReport)}
                                    className="flex items-center gap-1 px-3 py-1.5 text-xs text-text/50 border border-border rounded-lg hover:bg-text/5 transition-colors"
                                >
                                    <FlagIcon className="h-3 w-3" />
                                    Report
                                </button>
                                {showReport && (
                                    <div className="absolute right-0 bottom-full mb-2 w-56 bg-background border border-border rounded-xl shadow-xl p-3 z-10">
                                        <p className="text-xs font-medium mb-2">Report reason:</p>
                                        <div className="flex flex-col gap-1">
                                            {REPORT_REASONS.map((r) => (
                                                <button
                                                    key={r.value}
                                                    onClick={() => setReportReason(r.value)}
                                                    className={`text-xs px-2 py-1 rounded text-left ${
                                                        reportReason === r.value
                                                            ? "bg-primary/10 text-primary"
                                                            : "hover:bg-text/5"
                                                    }`}
                                                >
                                                    {r.label}
                                                </button>
                                            ))}
                                        </div>
                                        <textarea
                                            value={reportDetails}
                                            onChange={(e) => setReportDetails(e.target.value)}
                                            placeholder="Details (optional)"
                                            className="mt-2 w-full text-xs border border-border rounded-lg p-2 bg-transparent resize-none"
                                            rows={2}
                                        />
                                        <button
                                            onClick={() => {
                                                onReport(preset.id, reportReason, reportDetails || undefined)
                                                setShowReport(false)
                                            }}
                                            className="mt-2 w-full text-xs bg-primary text-primary-foreground py-1.5 rounded-lg hover:opacity-90 transition-opacity"
                                        >
                                            Submit Report
                                        </button>
                                    </div>
                                )}
                            </>
                        ) : (
                            <span className="flex items-center gap-1 text-xs text-text/30">
                                <FlagIcon className="h-3 w-3" />
                                Reported
                            </span>
                        )}
                    </div>
                </div>
            </motion.div>
        </motion.div>
    )
}
  • Step 2: Commit
git add app/game/[id]/preset-detail-modal.tsx
git commit -m "feat: add PresetDetailModal component with settings view, delete, and report"

Task 6: Game Page Client — Layout, Preset Section Overhaul

Files:

  • Modify: app/game/[id]/game-page-client.tsx

This is the largest task. It involves:

  1. Updating the Preset interface with new fields
  2. Reordering the Community Presets section above Statistics Dashboard
  3. Changing the presets layout from a 3-column grid to a horizontal scroll row
  4. Adding selectedPresetId state and click handler to open the modal
  5. Importing and rendering PresetDetailModal with AnimatePresence
  6. Adding delete and report API call handlers
  7. Sorting presets by createdAt descending (newest first, leftmost)
  • Step 1: Update the Preset interface

In game-page-client.tsx, update the Preset interface to include all new fields:

interface Preset {
    id: string
    hardwareSlug: string
    hardwareName: string
    upvotes: number
    downvotes: number
    settingsJson: GameSettingCategory[] | null
    settingsCount: number
    fpsAvg: number | null
    fpsLow: number | null
    fpsHigh: number | null
    upscalerType: string | null
    upscalerVersion: string | null
    frameGenMethod: string | null
    protonVersion: string | null
    osVersion: string | null
    launchOptions: string | null
    userNotes: string | null
    userId: string
    userName: string | null
    userImage: string | null
    verifiedAt: string | null
    createdAt: string
}

Add the import for GameSettingCategory:

import type { GameSettingCategory } from "@/lib/db/schema/performanceEntries"
  • Step 2: Add state and handlers for modal, delete, and report

Add new state variables inside the GamePageClient component:

const [selectedPresetId, setSelectedPresetId] = useState<string | null>(null)
const [reportedPresets, setReportedPresets] = useState<Set<string>>(new Set())
const [deletingPresetId, setDeletingPresetId] = useState<string | null>(null)

Add handler functions:

const handleDeletePreset = async (presetId: string) => {
    try {
        const res = await fetch(`/api/performance/${presetId}/user-delete`, {
            method: "DELETE",
            headers: { "Content-Type": "application/json" },
        })
        if (res.ok) {
            // Remove from local state
            setDeletingPresetId(null)
            setSelectedPresetId(null)
            // Ideally refresh presets from server, but for now just filter locally
        }
    } catch (err) {
        console.error("Failed to delete preset:", err)
    }
}

const handleReportPreset = async (presetId: string, reason: "inaccurate" | "spam" | "inappropriate" | "other", details?: string) => {
    try {
        const res = await fetch(`/api/performance/${presetId}/report`, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ reason, details }),
        })
        if (res.ok) {
            setReportedPresets((prev) => new Set(prev).add(presetId))
        }
    } catch (err) {
        console.error("Failed to report preset:", err)
    }
}
  • Step 3: Reorder sections — presets above statistics

Find the two <motion.div> sections in the JSX:

  • Section 4: "Statistics Dashboard" (contains charts)
  • Section 5: "Community Presets" (contains preset grid)

Move Section 5 (Community Presets) above Section 4 (Statistics Dashboard).

  • Step 4: Change presets layout to horizontal scroll row

Replace the grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 container with a horizontal scroll container. Add left/right scroll chevron buttons.

The Community Presets section should look like:

{/* Section 4: Community Presets */}
<motion.div ...>
    <div className="max-w-7xl mx-auto flex flex-col gap-4">
        <div className="flex items-center justify-between">
            <h2 className="text-lg font-semibold">Community Presets</h2>
            <span className="text-xs text-text/50">
                {filteredPresets.length} preset{filteredPresets.length !== 1 ? "s" : ""}
            </span>
        </div>

        {filteredPresets.length === 0 ? (
            <div className="flex flex-col items-center justify-center py-16 gap-3 rounded-xl border border-border bg-text/2">
                <SettingsIcon className="h-10 w-10 text-text/20" />
                <p className="text-sm text-text/40">No presets match the selected filters</p>
            </div>
        ) : (
            <div className="relative group/presets">
                {/* Left scroll button */}
                <button
                    onClick={() => presetsRef.current?.scrollBy({ left: -300, behavior: "smooth" })}
                    className="absolute left-0 top-1/2 -translate-y-1/2 z-10 hidden md:flex items-center justify-center h-10 w-10 rounded-full bg-background/80 border border-border hover:bg-text/5 opacity-0 group-hover/presets:opacity-100 transition-opacity"
                >
                    <ChevronLeftIcon className="h-5 w-5" />
                </button>
                {/* Right scroll button */}
                <button
                    onClick={() => presetsRef.current?.scrollBy({ left: 300, behavior: "smooth" })}
                    className="absolute right-0 top-1/2 -translate-y-1/2 z-10 hidden md:flex items-center justify-center h-10 w-10 rounded-full bg-background/80 border border-border hover:bg-text/5 opacity-0 group-hover/presets:opacity-100 transition-opacity"
                >
                    <ChevronRightIcon className="h-5 w-5" />
                </button>
                <div
                    ref={presetsRef}
                    className="flex gap-4 overflow-x-auto scrollbar-hide pb-4"
                >
                    {/* Sort newest first */}
                    {[...filteredPresets]
                        .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
                        .map((preset) => {
                            const raw = isRawPerformerPreset(preset)
                            return (
                                <motion.div
                                    key={preset.id}
                                    layoutId={preset.id}
                                    onClick={() => setSelectedPresetId(preset.id)}
                                    className={`flex-shrink-0 w-72 flex flex-col gap-3 p-4 rounded-xl border transition-colors cursor-pointer hover:border-primary/30 ${
                                        raw
                                            ? "border-green-500/30 bg-green-500/5"
                                            : "border-border bg-text/3"
                                    }`}
                                >
                                    {/* Card content stays the same — FPS, tags, etc */}
                                    {/* ... existing card content ... */}
                                </motion.div>
                            )
                        })}
                </div>
            </div>
        )}
    </div>
</motion.div>

{/* Modal */}
<AnimatePresence>
    {selectedPresetId && (
        <PresetDetailModal
            preset={presets.find((p) => p.id === selectedPresetId)!}
            onClose={() => setSelectedPresetId(null)}
            onDelete={handleDeletePreset}
            onReport={handleReportPreset}
            hasReported={reportedPresets.has(selectedPresetId)}
        />
    )}
</AnimatePresence>

Add the presetsRef at the top of the component:

const presetsRef = useRef<HTMLDivElement>(null)

Add necessary icon imports:

import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"
  • Step 5: Add scrollbar-hide CSS

In app/globals.css, add:

.scrollbar-hide {
    scrollbar-width: none;
    -ms-overflow-style: none;
}
.scrollbar-hide::-webkit-scrollbar {
    display: none;
}
  • Step 6: Verify the page loads and presets render correctly

Start dev server, navigate to a game page with presets, verify:

  • Presets render in a horizontal row (not grid)

  • Newest preset is leftmost

  • Clicking a preset opens the modal with animation

  • Modal shows settings table with collapsible categories

  • Delete button appears for owner/admin

  • Report button appears for all authenticated users

  • Presets section is above the statistics dashboard

  • Step 7: Commit

git add app/game/[id]/game-page-client.tsx app/globals.css
git commit -m "feat: horizontal preset layout, detail modal with AnimatePresence, delete/report, reorder above stats"

Task 7: Dynamic OG Image for Game Pages

Files:

  • Create: app/game/[id]/opengraph-image.tsx

  • Step 1: Create the dynamic OG image route

Create app/game/[id]/opengraph-image.tsx:

import { ImageResponse } from "next/og"
import { db } from "@/lib/db/index"
import { games, gameVersions, performanceEntries, hardware } from "@/lib/db/schema"
import { eq, and, sql, desc } from "drizzle-orm"
import { readFile } from "node:fs/promises"
import { join } from "node:path"

export const alt = "DeckyVault - Game Benchmarks"
export const size = { width: 1200, height: 630 }
export const contentType = "image/png"

export default async function Image({ params }: { params: Promise<{ id: string }> }) {
    const { id } = await params
    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]
    }

    if (!game) {
        return new ImageResponse(
            (
                <div style={{ width: "100%", height: "100%", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", background: "#100b14", color: "#ebe4f1", fontFamily: "sans-serif", gap: "16px" }}>
                    <div style={{ fontSize: 48, fontWeight: 700 }}>Game Not Found</div>
                    <div style={{ fontSize: 24, opacity: 0.7 }}>DeckyVault</div>
                </div>
            ),
            { ...size }
        )
    }

    // Get best FPS stat
    const [bestStat] = await db
        .select({ avgFps: sql<number>`round(avg(${performanceEntries.fpsAvg})::numeric, 1)` })
        .from(performanceEntries)
        .innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
        .where(and(eq(gameVersions.gameId, game.id), eq(performanceEntries.isRemoved, false)))
        .limit(1)

    const logoData = await readFile(join(process.cwd(), "app/icon.png"), "base64")
    const logoSrc = `data:image/png;base64,${logoData}`

    return new ImageResponse(
        (
            <div style={{ width: "100%", height: "100%", display: "flex", flexDirection: "column", justifyContent: "center", padding: "60px", background: "#100b14", color: "#ebe4f1", fontFamily: "sans-serif" }}>
                <div style={{ display: "flex", alignItems: "center", gap: "16px", marginBottom: "24px" }}>
                    <img src={logoSrc} alt="" height={48} style={{ borderRadius: "8px" }} />
                    <span style={{ fontSize: 24, fontWeight: 600, opacity: 0.8 }}>DeckyVault</span>
                </div>
                <div style={{ fontSize: 56, fontWeight: 700, lineHeight: 1.1, marginBottom: "16px", maxWidth: "900px" }}>
                    {game.title}
                </div>
                <div style={{ display: "flex", alignItems: "center", gap: "24px", fontSize: 24, opacity: 0.8 }}>
                    {game.developer && <span>by {game.developer}</span>}
                    {bestStat?.avgFps && <span style={{ color: "#22c55e" }}>~{bestStat.avgFps} avg FPS</span>}
                </div>
                {game.genres && game.genres.length > 0 && (
                    <div style={{ display: "flex", gap: "8px", marginTop: "16px" }}>
                        {game.genres.slice(0, 4).map((genre: string) => (
                            <span key={genre} style={{ padding: "4px 12px", borderRadius: "9999px", background: "rgba(139,92,246,0.15)", border: "1px solid rgba(139,92,246,0.3)", fontSize: 16 }}>
                                {genre}
                            </span>
                        ))}
                    </div>
                )}
            </div>
        ),
        { ...size }
    )
}
  • Step 2: Verify OG image loads

Start dev server, navigate to http://localhost:3000/game/<game-id>/opengraph-image and verify a PNG image renders with the game title, developer, avg FPS, and genres.

  • Step 3: Commit
git add app/game/[id]/opengraph-image.tsx
git commit -m "feat: add dynamic OG image generation for game pages"

Task 8: SEO — Sitemap + Search/Landing/Devices Metadata + Canonical URLs

Files:

  • Modify: app/sitemap.ts

  • Modify: app/search/page.tsx

  • Modify: app/page.tsx

  • Modify: app/devices/page.tsx

  • Modify: app/devices/[slug]/page.tsx

  • Step 1: Expand sitemap with games and devices

Modify app/sitemap.ts to query the database for all games and devices:

import type { MetadataRoute } from "next"
import { db } from "@/lib/db/index"
import { games, hardware } from "@/lib/db/schema"

const BASE_URL = "https://deckyvault.xyz"

export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
    const [allGames, allDevices] = await Promise.all([
        db.select({ id: games.id, updatedAt: games.updatedAt }).from(games),
        db.select({ slug: hardware.slug, updatedAt: hardware.updatedAt }).from(hardware),
    ])

    const gameEntries: MetadataRoute.Sitemap = allGames.map((game) => ({
        url: `${BASE_URL}/game/${game.id}`,
        lastModified: game.updatedAt,
        changeFrequency: "weekly",
        priority: 0.8,
    }))

    const deviceEntries: MetadataRoute.Sitemap = allDevices.map((device) => ({
        url: `${BASE_URL}/devices/${device.slug}`,
        lastModified: device.updatedAt,
        changeFrequency: "monthly",
        priority: 0.6,
    }))

    return [
        { url: BASE_URL, lastModified: new Date(), changeFrequency: "weekly", priority: 1 },
        { url: `${BASE_URL}/search`, lastModified: new Date(), changeFrequency: "weekly", priority: 0.7 },
        { url: `${BASE_URL}/devices`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.6 },
        ...gameEntries,
        ...deviceEntries,
    ]
}
  • Step 2: Add metadata to search page

Add a metadata export to app/search/page.tsx. Since this is a client component, create a separate layout.tsx or use a wrapper approach. If the search page file starts with "use client", we need to move metadata to a parent layout or a wrapper server component.

Create a server component wrapper app/search/layout.tsx:

import type { Metadata } from "next"

export const metadata: Metadata = {
    title: "Search Games",
    description: "Search for games and find benchmarks, settings, and performance data on DeckyVault.",
    alternates: { canonical: "https://deckyvault.xyz/search" },
    robots: { index: false },
}

export default function SearchLayout({ children }: { children: React.ReactNode }) {
    return children
}
  • Step 3: Add JSON-LD WebSite schema to landing page

In app/page.tsx, add a JSON-LD script tag before the returning JSX. Since this is a client component, we need to handle this differently — create a small server component for the JSON-LD and import it, or embed it in a <script> tag.

Since the landing page is a client component ("use client"), add the JSON-LD as a <script> tag inside the component's return:

<script
    type="application/ld+json"
    dangerouslySetInnerHTML={{
        __html: JSON.stringify({
            "@context": "https://schema.org",
            "@type": "WebSite",
            name: "DeckyVault",
            url: "https://deckyvault.xyz",
            description: "Steam Deck benchmarks, settings, and performance guides",
            potentialAction: {
                "@type": "SearchAction",
                target: "https://deckyvault.xyz/search?q={search_term_string}",
                "query-input": "required name=search_term_string",
            },
        }),
    }}
/>
  • Step 4: Add JSON-LD ItemList to devices page

In app/devices/page.tsx, this is a server component. Add metadata expansion and JSON-LD:

export const metadata = {
    title: "Devices — DeckyVault",
    description: "Browse handheld and console devices with benchmark data on DeckyVault",
    alternates: { canonical: "https://deckyvault.xyz/devices" },
}

But the JSON-LD needs the device list — this data is fetched in the client component. Since we need device data for JSON-LD, we should create a separate server component for the JSON-LD that fetches device slugs.

In app/devices/page.tsx, add:

import { db } from "@/lib/db/index"
import { hardware } from "@/lib/db/schema"

export default async function DevicesPage() {
    const devices = await db.select({ slug: hardware.slug, name: hardware.name }).from(hardware)

    const jsonLd = {
        "@context": "https://schema.org",
        "@type": "ItemList",
        itemListElement: devices.map((d, i) => ({
            "@type": "ListItem",
            position: i + 1,
            name: d.name,
            url: `https://deckyvault.xyz/devices/${d.slug}`,
        })),
    }

    return (
        <>
            <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
            <DevicesPageClient />
        </>
    )
}

Note: This means the server component now fetches devices for JSON-LD, but the client component still fetches them for display. We could pass the data down, but for now this is acceptable since the device count is small.

  • Step 5: Add JSON-LD Product schema to device detail page

In app/devices/[slug]/page.tsx, add JSON-LD in the return and expand metadata:

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
    // ... existing metadata generation ...
    return {
        title: `${device.name} — DeckyVault`,
        description: `Benchmark data and performance stats for ${device.name} on DeckyVault`,
        alternates: { canonical: `https://deckyvault.xyz/devices/${slug}` },
    }
}

export default async function DevicePage({ params }: { params: Promise<{ slug: string }> }) {
    // ... existing logic ...
    const jsonLd = {
        "@context": "https://schema.org",
        "@type": "Product",
        name: device.name,
        category: device.deviceType,
        url: `https://deckyvault.xyz/devices/${device.slug}`,
    }

    return (
        <>
            <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
            <DeviceDetailClient device={device} />
        </>
    )
}
  • Step 6: Commit
git add app/sitemap.ts app/search/layout.tsx app/page.tsx app/devices/page.tsx app/devices/[slug]/page.tsx
git commit -m "feat: SEO overhaul — expanded sitemap, JSON-LD, canonical URLs, search metadata"

Task 9: Build & Lint Cleanup

Files:

  • Various (fix any build/lint errors from above changes)

  • Step 1: Run the build and fix all errors

cd /Users/adrianbonpin/Documents/Code/personal/deckyvault && npm run build

Fix any TypeScript errors, import issues, or build failures.

  • Step 2: Run linting and fix all warnings
cd /Users/adrianbonpin/Documents/Code/personal/deckyvault && npm run lint

Fix all lint warnings and errors.

  • Step 3: Verify all pages render correctly

Start the dev server and manually verify:

  • Landing page loads and JSON-LD is present in source

  • Search page has correct metadata

  • Devices page has correct metadata and JSON-LD

  • Device detail page has correct metadata and JSON-LD

  • Game detail page has dynamic metadata (check <title> tag)

  • Game detail page has JSON-LD

  • Game OG image loads at /game/[id]/opengraph-image

  • Sitemap includes games and devices

  • Presets render horizontally newest-first

  • Clicking preset opens modal with settings view

  • Delete and report buttons work correctly

  • Step 4: Final commit

git add -A
git commit -m "chore: fix build errors and lint issues from presets + SEO overhaul"

Self-Review Checklist

1. Spec Coverage:

  • 1.1 Horizontal scroll layout → Task 6
  • 1.2 Presets above statistics → Task 6
  • 1.3 AnimatePresence modal for settings view → Task 5 + Task 6
  • 1.4 Soft delete for owners + admins → Task 3
  • 1.5 Report flow (table, API, UI) → Task 1, Task 2, Task 5
  • 1.6 Preset data enrichment → Task 4
  • 1.7 Preset type update → Task 6
  • 2.1 Game generateMetadata → Task 4
  • 2.2 Game JSON-LD → Task 4
  • 2.3 Dynamic OG image → Task 7
  • 2.4 Search page metadata → Task 8
  • 2.5 Landing page JSON-LD → Task 8
  • 2.6 Devices page JSON-LD → Task 8
  • 2.7 Device detail JSON-LD → Task 8
  • 2.8 Sitemap expansion → Task 8
  • 2.9 robots.ts — No changes needed (confirmed)
  • 2.10 Canonical URLs → Task 8 + Task 4

2. Placeholder Scan: No TBD/TODO/placeholders found.

3. Type Consistency: All Preset fields are consistently used across Tasks 4, 5, and 6. API paths match between Task 2 and Task 6.