feat: add public dashboard page with trending, best new releases, and most tested charts

This commit is contained in:
2026-05-09 13:11:27 +08:00
parent 2f4f0250df
commit 2bc6953339
5 changed files with 672 additions and 0 deletions
+183
View File
@@ -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 (
<DashboardClient
trending={serializedTrending}
bestNew={serializedBestNew}
mostTested={serializedMostTested}
mostReported={serializedMostReported}
/>
)
}
@@ -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 `<div style="font-weight:600;margin-bottom:4px">${params.name}</div>
<div>Avg FPS: <b>${v[1]}</b></div>
<div>Benchmarks: <b>${v[2]}</b></div>`
},
},
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 <EChartWrapper option={buildOption(games)} height={360} className="w-full" />
}
+117
View File
@@ -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 (
<main className="w-full max-w-6xl mx-auto px-4 py-6 flex flex-col gap-8">
<header>
<h1 className="text-2xl md:text-3xl font-bold text-text">
Community Dashboard
</h1>
<p className="text-text/60 mt-1 text-sm md:text-base">
Real-time insights from the DeckyVault community trending games, top
performers, and activity highlights.
</p>
</header>
{/* Trending Games */}
<section className="rounded-xl border border-border bg-text/[0.03] p-4 md:p-6 flex flex-col gap-4">
<div className="flex items-center gap-2">
<TrendingUpIcon className="h-5 w-5 text-primary" />
<h2 className="text-lg md:text-xl font-semibold text-text">
Trending Games (7 days)
</h2>
</div>
<p className="text-text/60 text-sm">
Top games by community activity: benchmarks, comments, and upvotes.
</p>
<TrendingGamesChart games={trending} />
</section>
{/* Best New Releases */}
<section className="rounded-xl border border-border bg-text/[0.03] p-4 md:p-6 flex flex-col gap-4">
<div className="flex items-center gap-2">
<SparklesIcon className="h-5 w-5 text-accent" />
<h2 className="text-lg md:text-xl font-semibold text-text">
Best Performing New Releases
</h2>
</div>
<p className="text-text/60 text-sm">
Newly added games with the highest average FPS (minimum 3 benchmarks).
</p>
<BestReleasesChart games={bestNew} />
</section>
{/* Most Tested & Most Reported */}
<section className="rounded-xl border border-border bg-text/[0.03] p-4 md:p-6 flex flex-col gap-4">
<div className="flex items-center gap-2">
<BarChart3Icon className="h-5 w-5 text-info" />
<h2 className="text-lg md:text-xl font-semibold text-text">
Most Tested & Most Reported
</h2>
</div>
<p className="text-text/60 text-sm">
Games with the most benchmarks submitted and the most open reports.
</p>
<MostTestedChart mostTested={mostTested} mostReported={mostReported} />
</section>
</main>
)
}
+159
View File
@@ -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 (
<EChartWrapper
option={buildOption(mostTested, mostReported)}
height={420}
className="w-full"
/>
)
}
@@ -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 `<div style="font-weight:600;margin-bottom:4px">${g.title}</div>
<div>Activity Score: <b>${g.activity_score}</b></div>
<div style="font-size:12px;color:${CHART_THEME.textMuted};margin-top:4px">
Benchmarks: ${g.benchmark_count} · Comments: ${g.comment_count} · Upvotes: ${g.upvote_count}
</div>`
},
},
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 <EChartWrapper option={buildOption(games)} height={360} className="w-full" />
}