feat: add public dashboard page with trending, best new releases, and most tested charts
This commit is contained in:
@@ -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" />
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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" />
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user