diff --git a/.env.example b/.env.example
index dd76ff6..bb05520 100644
--- a/.env.example
+++ b/.env.example
@@ -74,6 +74,13 @@ RP_ID="localhost"
# RP Name: Human-readable name shown in passkey prompts
RP_NAME="DeckyVault"
+# Android APK key hash for native passkey support on Android
+# Generate with:
+# keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android | grep 'SHA256:'
+# Then convert to base64url format (remove colons, lowercase, base64url encode)
+# Leave empty for web-only passkey support
+ANDROID_APK_KEY_HASH=
+
# -----------------------------------------------------------------------------
# EMAIL
# -----------------------------------------------------------------------------
diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx
deleted file mode 100644
index 32f4647..0000000
--- a/app/dashboard/page.tsx
+++ /dev/null
@@ -1,183 +0,0 @@
-import type { Metadata } from "next"
-import { db } from "@/lib/db/index"
-import { sql } from "drizzle-orm"
-import { DashboardClient } from "@/components/dashboard/dashboard-client"
-
-export const revalidate = 3600
-
-export const metadata: Metadata = {
- title: "Dashboard — DeckyVault",
- description:
- "Community dashboard with trending games, best new releases, and most tested games on DeckyVault.",
- keywords: [
- "Steam Deck dashboard",
- "trending games",
- "best new releases",
- "game benchmarks",
- "community insights",
- ],
- alternates: { canonical: "https://deckyvault.xyz/dashboard" },
- openGraph: {
- title: "Dashboard — DeckyVault",
- description:
- "Community dashboard with trending games, best new releases, and most tested games.",
- url: "https://deckyvault.xyz/dashboard",
- siteName: "DeckyVault",
- type: "website",
- },
-}
-
-const SEVEN_DAYS_AGO = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)
-
-export default async function DashboardPage() {
- const trending = await db.execute(sql`
- WITH recent_benchmarks AS (
- SELECT gv.game_id, COUNT(*) AS cnt
- FROM performance_entries pe
- JOIN game_versions gv ON pe.version_id = gv.id
- WHERE pe.is_removed = false
- AND pe.created_at >= ${SEVEN_DAYS_AGO}
- GROUP BY gv.game_id
- ),
- recent_comments AS (
- SELECT gc.game_id, COUNT(*) AS cnt
- FROM game_comments gc
- WHERE gc.is_removed = false
- AND gc.created_at >= ${SEVEN_DAYS_AGO}
- GROUP BY gc.game_id
- ),
- recent_upvotes AS (
- SELECT gv.game_id, SUM(pe.upvotes) AS total_upvotes
- FROM performance_entries pe
- JOIN game_versions gv ON pe.version_id = gv.id
- WHERE pe.is_removed = false
- AND pe.updated_at >= ${SEVEN_DAYS_AGO}
- GROUP BY gv.game_id
- )
- SELECT
- g.id,
- g.title,
- g.capsule_image,
- g.header_image,
- g.playability_status,
- COALESCE(rb.cnt, 0) AS benchmark_count,
- COALESCE(rc.cnt, 0) AS comment_count,
- COALESCE(ru.total_upvotes, 0) AS upvote_count,
- (COALESCE(rb.cnt, 0) * 3 + COALESCE(rc.cnt, 0) * 2 + COALESCE(ru.total_upvotes, 0) * 1) AS activity_score
- FROM games g
- LEFT JOIN recent_benchmarks rb ON rb.game_id = g.id
- LEFT JOIN recent_comments rc ON rc.game_id = g.id
- LEFT JOIN recent_upvotes ru ON ru.game_id = g.id
- WHERE (rb.cnt IS NOT NULL OR rc.cnt IS NOT NULL OR ru.total_upvotes IS NOT NULL)
- ORDER BY activity_score DESC
- LIMIT 10
- `)
-
- const bestNew = await db.execute(sql`
- SELECT
- g.id,
- g.title,
- g.capsule_image,
- g.header_image,
- g.release_date,
- g.created_at,
- g.playability_status,
- AVG(pe.fps_avg) AS avg_fps,
- COUNT(pe.id) AS benchmark_count
- FROM games g
- JOIN game_versions gv ON gv.game_id = g.id
- JOIN performance_entries pe ON pe.version_id = gv.id
- WHERE pe.is_removed = false
- AND (g.created_at >= ${new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)}
- OR g.release_date IS NOT NULL)
- GROUP BY g.id, g.title, g.capsule_image, g.header_image, g.release_date, g.created_at, g.playability_status
- HAVING COUNT(pe.id) >= 3
- ORDER BY avg_fps DESC
- LIMIT 10
- `)
-
- const mostTested = await db.execute(sql`
- SELECT
- g.id,
- g.title,
- g.capsule_image,
- g.header_image,
- g.playability_status,
- COUNT(pe.id) AS benchmark_count
- FROM games g
- JOIN game_versions gv ON gv.game_id = g.id
- JOIN performance_entries pe ON pe.version_id = gv.id
- WHERE pe.is_removed = false
- GROUP BY g.id, g.title, g.capsule_image, g.header_image, g.playability_status
- ORDER BY benchmark_count DESC
- LIMIT 10
- `)
-
- const mostReported = await db.execute(sql`
- SELECT
- g.id,
- g.title,
- g.capsule_image,
- g.header_image,
- COUNT(DISTINCT r.id) AS report_count
- FROM games g
- JOIN game_versions gv ON gv.game_id = g.id
- JOIN performance_entries pe ON pe.version_id = gv.id
- JOIN reports r ON r.entry_id = pe.id
- WHERE r.status = 'open'
- GROUP BY g.id, g.title, g.capsule_image, g.header_image
- ORDER BY report_count DESC
- LIMIT 10
- `)
-
- // Serialize rows for the client component
- const serializedTrending = trending.rows.map((row) => ({
- id: String(row.id),
- title: String(row.title),
- capsule_image: row.capsule_image ? String(row.capsule_image) : null,
- header_image: row.header_image ? String(row.header_image) : null,
- playability_status: row.playability_status ? String(row.playability_status) : null,
- benchmark_count: Number(row.benchmark_count ?? 0),
- comment_count: Number(row.comment_count ?? 0),
- upvote_count: Number(row.upvote_count ?? 0),
- activity_score: Number(row.activity_score ?? 0),
- }))
-
- const serializedBestNew = bestNew.rows.map((row) => ({
- id: String(row.id),
- title: String(row.title),
- capsule_image: row.capsule_image ? String(row.capsule_image) : null,
- header_image: row.header_image ? String(row.header_image) : null,
- release_date: row.release_date ? new Date(String(row.release_date)).toISOString() : null,
- created_at: row.created_at ? new Date(String(row.created_at)).toISOString() : null,
- playability_status: row.playability_status ? String(row.playability_status) : null,
- avg_fps: row.avg_fps ? Number(row.avg_fps) : null,
- benchmark_count: Number(row.benchmark_count ?? 0),
- }))
-
- const serializedMostTested = mostTested.rows.map((row) => ({
- id: String(row.id),
- title: String(row.title),
- capsule_image: row.capsule_image ? String(row.capsule_image) : null,
- header_image: row.header_image ? String(row.header_image) : null,
- playability_status: row.playability_status ? String(row.playability_status) : null,
- benchmark_count: Number(row.benchmark_count ?? 0),
- }))
-
- const serializedMostReported = mostReported.rows.map((row) => ({
- id: String(row.id),
- title: String(row.title),
- capsule_image: row.capsule_image ? String(row.capsule_image) : null,
- header_image: row.header_image ? String(row.header_image) : null,
- report_count: Number(row.report_count ?? 0),
- }))
-
- return (
-
- )
-}
diff --git a/app/sitemap.ts b/app/sitemap.ts
index 00f164e..b7f48bf 100644
--- a/app/sitemap.ts
+++ b/app/sitemap.ts
@@ -25,7 +25,6 @@ const STATIC_ENTRIES: Array<{
}> = [
{ urlPath: "", changeFrequency: "weekly", priority: 1 },
{ urlPath: "/games", changeFrequency: "daily", priority: 0.8 },
- { urlPath: "/dashboard", changeFrequency: "daily", priority: 0.7 },
{ urlPath: "/devices", changeFrequency: "monthly", priority: 0.6 },
{ urlPath: "/updates", changeFrequency: "weekly", priority: 0.5 },
{ urlPath: "/contact", changeFrequency: "yearly", priority: 0.3 },
diff --git a/bun.lock b/bun.lock
index af5d8c8..dd13644 100644
--- a/bun.lock
+++ b/bun.lock
@@ -7,6 +7,7 @@
"dependencies": {
"@aws-sdk/client-s3": "^3.1036.0",
"@better-auth/drizzle-adapter": "^1.6.9",
+ "@better-auth/expo": "^1.6.11",
"@better-auth/passkey": "^1.6.9",
"@elysia/cron": "^1.4.2",
"@elysia/eden": "^1.4.10",
@@ -192,6 +193,8 @@
"@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.6.9", "", { "peerDependencies": { "@better-auth/core": "^1.6.9", "@better-auth/utils": "0.4.0", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["drizzle-orm"] }, "sha512-Lcco5hOGrMgc4XKAkvB6x72eQm4wCcya8IevMg4wBHY9W9GVg8pu23rpRX6VsVQSO4Ux13S7lFwUWtF7/r9aKw=="],
+ "@better-auth/expo": ["@better-auth/expo@1.6.11", "", { "dependencies": { "@better-fetch/fetch": "1.1.21", "better-call": "1.3.5", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.6.11", "better-auth": "^1.6.11", "expo-constants": ">=17.0.0", "expo-linking": ">=7.0.0", "expo-network": ">=8.0.7", "expo-web-browser": ">=14.0.0" }, "optionalPeers": ["expo-constants", "expo-linking", "expo-network", "expo-web-browser"] }, "sha512-ahqtpj5DRF4Tu8+PZuLPkR10Q6b8AntQNsn4LPcOIp6za5IJDAsaD/go1I5qCYIRi+8YKiIXk9vh4qQM54u+hA=="],
+
"@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.6.9", "", { "peerDependencies": { "@better-auth/core": "^1.6.9", "@better-auth/utils": "0.4.0", "kysely": "^0.28.14" }, "optionalPeers": ["kysely"] }, "sha512-gyjuuxJtZ4o9G9z9q4kqn24X2kvMSp7F+KHogYxF03SnXY/2WleAcuj57iC4wP3e9mGDbjPOrnM5K6Kr3Ktdpw=="],
"@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.6.9", "", { "peerDependencies": { "@better-auth/core": "^1.6.9", "@better-auth/utils": "0.4.0" } }, "sha512-XmIG4tUnOXZ+KEcWjHUjOI9Z5donD09dC2t/AQTXifAUIqx7cySg86w0KTM09ArzAxRx1fCqO36Wkt5nULnrkQ=="],
@@ -1752,7 +1755,7 @@
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
- "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
+ "zod": ["zod@4.4.1", "", {}, "sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q=="],
"zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
@@ -1774,6 +1777,10 @@
"@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+ "@better-auth/core/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
+
+ "@better-auth/passkey/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
+
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
@@ -1784,12 +1791,6 @@
"@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
- "@serwist/build/zod": ["zod@4.4.1", "", {}, "sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q=="],
-
- "@serwist/next/zod": ["zod@4.4.1", "", {}, "sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q=="],
-
- "@serwist/webpack-plugin/zod": ["zod@4.4.1", "", {}, "sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q=="],
-
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
@@ -1808,6 +1809,8 @@
"@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
+ "better-auth/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
+
"echarts/tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="],
"eslint-import-resolver-node/debug": ["debug@3.2.7", "", { "dependencies": { "ms": "^2.1.1" } }, "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ=="],
@@ -1820,6 +1823,8 @@
"eslint-plugin-react/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+ "eslint-plugin-react-hooks/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
+
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
diff --git a/components/dashboard/best-releases-chart.tsx b/components/dashboard/best-releases-chart.tsx
deleted file mode 100644
index bb8d440..0000000
--- a/components/dashboard/best-releases-chart.tsx
+++ /dev/null
@@ -1,106 +0,0 @@
-"use client"
-
-import { EChartWrapper, CHART_THEME } from "@/components/charts/EChartWrapper"
-import type { EChartsOption } from "echarts"
-
-interface BestRelease {
- id: string
- title: string
- avg_fps: number | null
- benchmark_count: number
-}
-
-function buildOption(games: BestRelease[]): EChartsOption {
- const data = games.map((g) => ({
- name: g.title,
- value: [g.benchmark_count, Math.round((g.avg_fps ?? 0) * 10) / 10, g.benchmark_count],
- itemStyle: {
- color: {
- type: "radial" as const,
- x: 0.5,
- y: 0.5,
- r: 0.5,
- colorStops: [
- { offset: 0, color: CHART_THEME.accent },
- { offset: 1, color: CHART_THEME.primary },
- ],
- },
- shadowBlur: 10,
- shadowColor: CHART_THEME.primary + "40",
- },
- }))
-
- return {
- backgroundColor: CHART_THEME.bg,
- tooltip: {
- trigger: "item",
- backgroundColor: "#1a1225",
- borderColor: CHART_THEME.border,
- textStyle: { color: CHART_THEME.text },
- formatter: (params: unknown) => {
- const p = params as { name: string; value: number[] }
- const v = p.value
- return `
${p.name}
-Avg FPS: ${v[1]}
-Benchmarks: ${v[2]}
`
- },
- },
- grid: {
- left: "3%",
- right: "6%",
- bottom: "10%",
- top: "10%",
- containLabel: true,
- },
- xAxis: {
- name: "Benchmarks",
- nameLocation: "middle",
- nameGap: 24,
- nameTextStyle: { color: CHART_THEME.textMuted, fontSize: 12 },
- type: "value",
- splitLine: { lineStyle: { color: CHART_THEME.border, type: "dashed" } },
- axisLabel: { color: CHART_THEME.textMuted, fontSize: 11 },
- axisLine: { lineStyle: { color: CHART_THEME.border } },
- axisTick: { show: false },
- },
- yAxis: {
- name: "Avg FPS",
- nameTextStyle: { color: CHART_THEME.textMuted, fontSize: 12 },
- type: "value",
- splitLine: { lineStyle: { color: CHART_THEME.border, type: "dashed" } },
- axisLabel: { color: CHART_THEME.textMuted, fontSize: 11 },
- axisLine: { lineStyle: { color: CHART_THEME.border } },
- axisTick: { show: false },
- },
- series: [
- {
- type: "scatter",
- data,
- symbolSize: (val: number[]) => Math.max(12, Math.min(40, val[2] * 3)),
- label: {
- show: true,
- formatter: (p: unknown) => (p as { name: string }).name,
- position: "top",
- color: CHART_THEME.text,
- fontSize: 10,
- overflow: "truncate",
- width: 100,
- },
- emphasis: {
- scale: 1.5,
- itemStyle: {
- shadowBlur: 20,
- shadowColor: CHART_THEME.accent + "60",
- },
- },
- },
- ],
- animationDuration: 800,
- animationEasing: "cubicOut",
- }
-}
-
-export function BestReleasesChart({ games }: { games: BestRelease[] }) {
- if (!games.length) return null
- return
-}
diff --git a/components/dashboard/dashboard-client.tsx b/components/dashboard/dashboard-client.tsx
deleted file mode 100644
index 6be1074..0000000
--- a/components/dashboard/dashboard-client.tsx
+++ /dev/null
@@ -1,117 +0,0 @@
-"use client"
-
-import { TrendingUpIcon, SparklesIcon, BarChart3Icon } from "lucide-react"
-import { TrendingGamesChart } from "./trending-games-chart"
-import { BestReleasesChart } from "./best-releases-chart"
-import { MostTestedChart } from "./most-tested-chart"
-
-interface TrendingGame {
- id: string
- title: string
- capsule_image: string | null
- header_image: string | null
- playability_status: string | null
- benchmark_count: number
- comment_count: number
- upvote_count: number
- activity_score: number
-}
-
-interface BestRelease {
- id: string
- title: string
- capsule_image: string | null
- header_image: string | null
- release_date: string | null
- created_at: string | null
- playability_status: string | null
- avg_fps: number | null
- benchmark_count: number
-}
-
-interface MostTestedGame {
- id: string
- title: string
- capsule_image: string | null
- header_image: string | null
- playability_status: string | null
- benchmark_count: number
-}
-
-interface MostReportedGame {
- id: string
- title: string
- capsule_image: string | null
- header_image: string | null
- report_count: number
-}
-
-interface DashboardClientProps {
- trending: TrendingGame[]
- bestNew: BestRelease[]
- mostTested: MostTestedGame[]
- mostReported: MostReportedGame[]
-}
-
-export function DashboardClient({
- trending,
- bestNew,
- mostTested,
- mostReported,
-}: DashboardClientProps) {
- return (
-
-
-
- Community Dashboard
-
-
- Real-time insights from the DeckyVault community — trending games, top
- performers, and activity highlights.
-
-
-
- {/* Trending Games */}
-
-
-
-
- Trending Games (7 days)
-
-
-
- Top games by community activity: benchmarks, comments, and upvotes.
-
-
-
-
- {/* Best New Releases */}
-
-
-
-
- Best Performing New Releases
-
-
-
- Newly added games with the highest average FPS (minimum 3 benchmarks).
-
-
-
-
- {/* Most Tested & Most Reported */}
-
-
-
-
- Most Tested & Most Reported
-
-
-
- Games with the most benchmarks submitted and the most open reports.
-
-
-
-
- )
-}
diff --git a/components/dashboard/most-tested-chart.tsx b/components/dashboard/most-tested-chart.tsx
deleted file mode 100644
index db7d828..0000000
--- a/components/dashboard/most-tested-chart.tsx
+++ /dev/null
@@ -1,159 +0,0 @@
-"use client"
-
-import { EChartWrapper, CHART_THEME } from "@/components/charts/EChartWrapper"
-import type { EChartsOption } from "echarts"
-
-interface MostTestedGame {
- id: string
- title: string
- benchmark_count: number
-}
-
-interface MostReportedGame {
- id: string
- title: string
- report_count: number
-}
-
-function buildOption(
- tested: MostTestedGame[],
- reported: MostReportedGame[],
-): EChartsOption {
- const testedTitles = tested.map((g) => g.title)
- const testedCounts = tested.map((g) => g.benchmark_count)
- const reportedTitles = reported.map((g) => g.title)
- const reportedCounts = reported.map((g) => g.report_count)
-
- return {
- backgroundColor: CHART_THEME.bg,
- tooltip: {
- trigger: "axis",
- axisPointer: { type: "shadow" },
- backgroundColor: "#1a1225",
- borderColor: CHART_THEME.border,
- textStyle: { color: CHART_THEME.text },
- },
- legend: {
- data: ["Most Tested", "Most Reported"],
- textStyle: { color: CHART_THEME.textMuted },
- bottom: 0,
- },
- grid: [
- {
- left: "3%",
- right: "52%",
- bottom: "14%",
- top: "8%",
- containLabel: true,
- },
- {
- left: "52%",
- right: "3%",
- bottom: "14%",
- top: "8%",
- containLabel: true,
- },
- ],
- xAxis: [
- {
- type: "value",
- gridIndex: 0,
- splitLine: { lineStyle: { color: CHART_THEME.border, type: "dashed" } },
- axisLabel: { color: CHART_THEME.textMuted, fontSize: 10 },
- axisLine: { show: false },
- axisTick: { show: false },
- },
- {
- type: "value",
- gridIndex: 1,
- splitLine: { lineStyle: { color: CHART_THEME.border, type: "dashed" } },
- axisLabel: { color: CHART_THEME.textMuted, fontSize: 10 },
- axisLine: { show: false },
- axisTick: { show: false },
- },
- ],
- yAxis: [
- {
- type: "category",
- gridIndex: 0,
- data: testedTitles,
- axisLabel: { color: CHART_THEME.text, fontSize: 11, width: 120, overflow: "truncate" },
- axisLine: { show: false },
- axisTick: { show: false },
- },
- {
- type: "category",
- gridIndex: 1,
- data: reportedTitles,
- axisLabel: { color: CHART_THEME.text, fontSize: 11, width: 120, overflow: "truncate" },
- axisLine: { show: false },
- axisTick: { show: false },
- },
- ],
- series: [
- {
- name: "Most Tested",
- type: "bar",
- xAxisIndex: 0,
- yAxisIndex: 0,
- data: testedCounts,
- barWidth: "55%",
- itemStyle: {
- borderRadius: [0, 4, 4, 0],
- color: {
- type: "linear",
- x: 0,
- y: 0,
- x2: 1,
- y2: 0,
- colorStops: [
- { offset: 0, color: CHART_THEME.info },
- { offset: 1, color: CHART_THEME.success },
- ],
- },
- },
- },
- {
- name: "Most Reported",
- type: "bar",
- xAxisIndex: 1,
- yAxisIndex: 1,
- data: reportedCounts,
- barWidth: "55%",
- itemStyle: {
- borderRadius: [0, 4, 4, 0],
- color: {
- type: "linear",
- x: 0,
- y: 0,
- x2: 1,
- y2: 0,
- colorStops: [
- { offset: 0, color: CHART_THEME.warning },
- { offset: 1, color: CHART_THEME.accent },
- ],
- },
- },
- },
- ],
- animationDuration: 800,
- animationEasing: "cubicOut",
- }
-}
-
-export function MostTestedChart({
- mostTested,
- mostReported,
-}: {
- mostTested: MostTestedGame[]
- mostReported: MostReportedGame[]
-}) {
- if (!mostTested.length && !mostReported.length) return null
- return (
-
- )
-}
diff --git a/components/dashboard/trending-games-chart.tsx b/components/dashboard/trending-games-chart.tsx
deleted file mode 100644
index bd5d2a0..0000000
--- a/components/dashboard/trending-games-chart.tsx
+++ /dev/null
@@ -1,107 +0,0 @@
-"use client"
-
-import { EChartWrapper, CHART_THEME } from "@/components/charts/EChartWrapper"
-import type { EChartsOption } from "echarts"
-
-interface TrendingGame {
- id: string
- title: string
- activity_score: number
- benchmark_count: number
- comment_count: number
- upvote_count: number
-}
-
-function buildOption(games: TrendingGame[]): EChartsOption {
- const sorted = [...games].reverse()
- const titles = sorted.map((g) => g.title)
- const scores = sorted.map((g) => g.activity_score)
- const benchmarks = sorted.map((g) => g.benchmark_count)
-
- return {
- backgroundColor: CHART_THEME.bg,
- tooltip: {
- trigger: "axis",
- axisPointer: { type: "shadow" },
- backgroundColor: "#1a1225",
- borderColor: CHART_THEME.border,
- textStyle: { color: CHART_THEME.text },
- formatter: (params: unknown) => {
- const p = params as { dataIndex: number }[]
- const idx = p[0].dataIndex
- const g = sorted[idx]
- return `${g.title}
-Activity Score: ${g.activity_score}
-
-Benchmarks: ${g.benchmark_count} · Comments: ${g.comment_count} · Upvotes: ${g.upvote_count}
-
`
- },
- },
- grid: {
- left: "3%",
- right: "4%",
- bottom: "3%",
- top: "3%",
- containLabel: true,
- },
- xAxis: {
- type: "value",
- splitLine: { lineStyle: { color: CHART_THEME.border, type: "dashed" } },
- axisLabel: { color: CHART_THEME.textMuted, fontSize: 11 },
- axisLine: { show: false },
- axisTick: { show: false },
- },
- yAxis: {
- type: "category",
- data: titles,
- axisLabel: { color: CHART_THEME.text, fontSize: 12, width: 160, overflow: "truncate" },
- axisLine: { show: false },
- axisTick: { show: false },
- },
- series: [
- {
- name: "Activity Score",
- type: "bar",
- data: scores,
- barWidth: "60%",
- itemStyle: {
- borderRadius: [0, 4, 4, 0],
- color: {
- type: "linear",
- x: 0,
- y: 0,
- x2: 1,
- y2: 0,
- colorStops: [
- { offset: 0, color: CHART_THEME.secondary },
- { offset: 1, color: CHART_THEME.primary },
- ],
- },
- },
- },
- {
- name: "Benchmarks",
- type: "bar",
- data: benchmarks,
- barWidth: "60%",
- barGap: "-100%",
- itemStyle: {
- borderRadius: [0, 4, 4, 0],
- color: "transparent",
- borderWidth: 1,
- borderColor: CHART_THEME.accent,
- },
- emphasis: { disabled: true },
- tooltip: { show: false },
- silent: true,
- },
- ],
- animationDuration: 800,
- animationEasing: "cubicOut",
- }
-}
-
-export function TrendingGamesChart({ games }: { games: TrendingGame[] }) {
- if (!games.length) return null
- return
-}
diff --git a/lib/steamdb/scrape.ts b/lib/steamdb/scrape.ts
index 2ff6353..6cc3c22 100644
--- a/lib/steamdb/scrape.ts
+++ b/lib/steamdb/scrape.ts
@@ -7,6 +7,18 @@ interface ScrapeResult {
buildId: string | null
}
+/**
+ * Scrape version/build info from SteamDB for a given Steam App ID.
+ *
+ * SteamDB is a client-side rendered app — the raw HTML from a fetch() is a
+ * skeleton that gets populated by JavaScript. To work around this we:
+ * 1. Search ALL