refactor: convert to bun workspaces monorepo

- Move web app into apps/web/
- Create packages/shared/ with shared types
- Create plugins/decky-vault/ scaffold
- Root package.json manages workspaces only
This commit is contained in:
2026-06-28 05:20:28 +08:00
parent c4bede20d4
commit cd72b7a948
345 changed files with 488 additions and 126 deletions
+144
View File
@@ -0,0 +1,144 @@
import { db } from "@/lib/db/index"
import { hardware, performanceEntries, gameVersions, games } from "@/lib/db/schema"
import { eq, sql, desc } from "drizzle-orm"
import { buildBreadcrumbList } from "@/lib/utils/seo"
import { DevicesPageClient } from "./page-client"
import type { Metadata } from "next"
// This page needs live data — skip static generation at build time
export const dynamic = "force-dynamic"
export const metadata: Metadata = {
title: "Devices",
description:
"Browse benchmark data for handheld and console gaming devices. Compare FPS, performance stats, and community benchmarks on DeckyVault.",
keywords: ["steam deck", "handheld", "console", "benchmarks", "FPS", "performance", "devices"],
alternates: { canonical: "https://deckyvault.xyz/devices" },
openGraph: {
title: "Devices | DeckyVault",
description:
"Browse benchmark data for handheld and console gaming devices on DeckyVault.",
url: "https://deckyvault.xyz/devices",
siteName: "DeckyVault",
type: "website",
},
}
export default async function DevicesPage() {
const deviceRows = await db
.select({
slug: hardware.slug,
name: hardware.name,
deviceType: hardware.deviceType,
image: hardware.image,
sortOrder: hardware.sortOrder,
wattHours: hardware.wattHours,
tdpMax: hardware.tdpMax,
})
.from(hardware)
.orderBy(hardware.sortOrder)
const statsPerDevice = await db
.select({
hardwareSlug: performanceEntries.hardwareSlug,
totalBenchmarks: sql<number>`count(*)::int`,
avgFps: sql<number>`round(avg(${performanceEntries.fpsAvg})::numeric, 1)`,
verifiedCount: sql<number>`count(*) filter (where ${performanceEntries.verifiedAt} is not null)::int`,
gameCount: sql<number>`count(distinct ${gameVersions.gameId})::int`,
})
.from(performanceEntries)
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
.innerJoin(games, eq(gameVersions.gameId, games.id))
.innerJoin(hardware, eq(performanceEntries.hardwareSlug, hardware.slug))
.where(eq(performanceEntries.isRemoved, false))
.groupBy(performanceEntries.hardwareSlug)
const statsMap = new Map(statsPerDevice.map((s) => [s.hardwareSlug, s]))
const bestGames = await db
.select({
hardwareSlug: performanceEntries.hardwareSlug,
gameId: games.id,
gameTitle: games.title,
gameHeaderImage: games.headerImage,
fpsAvg: sql<number>`round(avg(${performanceEntries.fpsAvg})::numeric, 1)`,
})
.from(performanceEntries)
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
.innerJoin(games, eq(gameVersions.gameId, games.id))
.where(eq(performanceEntries.isRemoved, false))
.groupBy(
performanceEntries.hardwareSlug,
games.id,
games.title,
games.headerImage,
)
.orderBy(desc(sql`avg(${performanceEntries.fpsAvg})`))
const bestGameMap = new Map<
string,
{ id: string; title: string; headerImage: string | null; fpsAvg: number }
>()
for (const bg of bestGames) {
if (!bestGameMap.has(bg.hardwareSlug)) {
bestGameMap.set(bg.hardwareSlug, {
id: bg.gameId,
title: bg.gameTitle,
headerImage: bg.gameHeaderImage,
fpsAvg: Number(bg.fpsAvg),
})
}
}
const devices = deviceRows.map((device, index) => {
const stats = statsMap.get(device.slug)
const bestGame = bestGameMap.get(device.slug)
return {
slug: device.slug,
name: device.name,
deviceType: device.deviceType,
image: device.image,
sortOrder: device.sortOrder,
colorIndex: index,
totalBenchmarks: stats?.totalBenchmarks ?? 0,
avgFps: stats?.avgFps ? Number(stats.avgFps) : null,
gameCount: stats?.gameCount ?? 0,
verifiedCount: stats?.verifiedCount ?? 0,
bestGame: bestGame ?? null,
wattHours: device.wattHours ? Number(device.wattHours) : null,
tdpMax: device.tdpMax ? Number(device.tdpMax) : null,
}
})
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) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(
buildBreadcrumbList([
{ name: "Home", url: "https://deckyvault.xyz" },
{ name: "Devices", url: "https://deckyvault.xyz/devices" },
]),
),
}}
/>
<DevicesPageClient devices={devices} />
</>
)
}