From 2bc6953339d7484750f425a69df022eaf526646c Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Sat, 9 May 2026 13:11:27 +0800 Subject: [PATCH] feat: add public dashboard page with trending, best new releases, and most tested charts --- app/dashboard/page.tsx | 183 ++++++++++++++++++ components/dashboard/best-releases-chart.tsx | 105 ++++++++++ components/dashboard/dashboard-client.tsx | 117 +++++++++++ components/dashboard/most-tested-chart.tsx | 159 +++++++++++++++ components/dashboard/trending-games-chart.tsx | 108 +++++++++++ 5 files changed, 672 insertions(+) create mode 100644 app/dashboard/page.tsx create mode 100644 components/dashboard/best-releases-chart.tsx create mode 100644 components/dashboard/dashboard-client.tsx create mode 100644 components/dashboard/most-tested-chart.tsx create mode 100644 components/dashboard/trending-games-chart.tsx diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx new file mode 100644 index 0000000..31b5ce5 --- /dev/null +++ b/app/dashboard/page.tsx @@ -0,0 +1,183 @@ +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: any) => ({ + 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: any) => ({ + 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(row.release_date).toISOString() : null, + created_at: row.created_at ? new Date(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: any) => ({ + 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: any) => ({ + 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/components/dashboard/best-releases-chart.tsx b/components/dashboard/best-releases-chart.tsx new file mode 100644 index 0000000..e7b3fd0 --- /dev/null +++ b/components/dashboard/best-releases-chart.tsx @@ -0,0 +1,105 @@ +"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: any) => { + const v = params.value + return `
${params.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: any) => p.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 new file mode 100644 index 0000000..f7a3ad2 --- /dev/null +++ b/components/dashboard/dashboard-client.tsx @@ -0,0 +1,117 @@ +"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 new file mode 100644 index 0000000..db7d828 --- /dev/null +++ b/components/dashboard/most-tested-chart.tsx @@ -0,0 +1,159 @@ +"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 new file mode 100644 index 0000000..98a5f3c --- /dev/null +++ b/components/dashboard/trending-games-chart.tsx @@ -0,0 +1,108 @@ +"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) + const comments = sorted.map((g) => g.comment_count) + const upvotes = sorted.map((g) => g.upvote_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: any) => { + const idx = params[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 +}