feat: compare page with game selector, stats comparison, and charts

This commit is contained in:
2026-04-29 00:45:16 +08:00
parent 0f0176ae39
commit 2101dfef08
6 changed files with 513 additions and 5 deletions
+158
View File
@@ -0,0 +1,158 @@
"use client"
import { useState, useCallback } from "react"
import { motion } from "motion/react"
import { BarChart3Icon } from "lucide-react"
import { GameSelector } from "@/components/compare/game-selector"
import { StatsComparison } from "@/components/compare/stats-comparison"
import { FpsComparisonChart } from "@/components/compare/fps-comparison-chart"
import { StabilityRadar } from "@/components/compare/stability-radar"
interface SelectedGame {
id: string
appId: number | null
title: string
image: string | null
source: string
}
interface GameComparisonData {
id: string
title: string
stats: {
totalEntries: number
avgFps: number | null
medianFps: number | null
bestFps: number | null
avgOnePercentLow: number | null
avgStability: number | null
bestDevice: string | null
tierBreakdown: { unplayable: number; playable: number; smooth: number; excellent: number } | null
deviceBreakdown: Array<{ hardwareSlug: string; count: number; avgFps: number }>
}
}
export default function ComparePage() {
const [selectedGames, setSelectedGames] = useState<SelectedGame[]>([])
const [comparisonData, setComparisonData] = useState<GameComparisonData[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSelect = useCallback((game: SelectedGame) => {
setSelectedGames(prev => {
if (prev.some(g => g.id === game.id)) return prev
return [...prev, game]
})
}, [])
const handleRemove = useCallback((gameId: string) => {
setSelectedGames(prev => prev.filter(g => g.id !== gameId))
setComparisonData(prev => prev.filter(g => g.id !== gameId))
}, [])
const fetchComparison = useCallback(async () => {
if (selectedGames.length < 2) return
setLoading(true)
setError(null)
try {
const ids = selectedGames.map(g => g.id).join(",")
const res = await fetch(`/api/compare/games?ids=${ids}`)
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || "Failed to fetch comparison")
}
const data = await res.json()
setComparisonData(data.games || [])
} catch (err: any) {
setError(err.message || "Failed to load comparison")
} finally {
setLoading(false)
}
}, [selectedGames])
const canCompare = selectedGames.length >= 2
return (
<section className="w-full min-h-[calc(100vh-3.6rem)] flex flex-col items-center p-4 md:px-[10svw]">
<div className="w-full max-w-7xl">
<motion.h1
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-2xl font-light mb-2"
>
Compare Games
</motion.h1>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: 0.1 } }}
className="text-text/60 text-sm mb-6"
>
Select 2-4 games to compare performance stats side by side.
</motion.p>
{/* Game selector */}
<GameSelector
selectedGames={selectedGames}
onSelect={handleSelect}
onRemove={handleRemove}
maxSelections={4}
/>
{/* Compare button */}
<div className="mt-4">
<button
onClick={fetchComparison}
disabled={!canCompare || loading}
className="px-4 py-2 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors cursor-pointer"
>
{loading ? "Loading..." : "Compare"}
</button>
{!canCompare && selectedGames.length > 0 && (
<span className="ml-3 text-xs text-text/40">Select at least 2 games</span>
)}
</div>
{/* Error */}
{error && (
<p className="mt-4 text-red-400 text-sm">{error}</p>
)}
{/* Results */}
{comparisonData.length >= 2 && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
className="mt-8 flex flex-col gap-8"
>
{/* Stats comparison table */}
<div className="rounded-xl border border-border bg-text/3 p-4">
<h2 className="text-sm font-medium text-text/80 mb-4">Stats Overview</h2>
<StatsComparison games={comparisonData} />
</div>
{/* FPS comparison bar chart */}
<div className="rounded-xl border border-border bg-text/3 p-4">
<h2 className="text-sm font-medium text-text/80 mb-2">FPS by Device</h2>
<FpsComparisonChart games={comparisonData} />
</div>
{/* Stability radar */}
<div className="rounded-xl border border-border bg-text/3 p-4">
<h2 className="text-sm font-medium text-text/80 mb-2">Performance Profile</h2>
<StabilityRadar games={comparisonData} />
</div>
</motion.div>
)}
{/* Empty state */}
{comparisonData.length === 0 && !loading && (
<div className="mt-16 flex flex-col items-center justify-center gap-4">
<BarChart3Icon className="h-12 w-12 text-text/20" />
<p className="text-text/40 text-sm">Select games above to start comparing</p>
</div>
)}
</div>
</section>
)
}
@@ -0,0 +1,60 @@
"use client"
import { EChartWrapper } from "@/components/charts/EChartWrapper"
interface GameWithStats {
id: string
title: string
stats: {
avgFps: number | null
avgOnePercentLow: number | null
deviceBreakdown: Array<{ hardwareSlug: string; count: number; avgFps: number }>
}
}
export function FpsComparisonChart({ games }: { games: GameWithStats[] }) {
if (games.length === 0) return null
// Collect all unique devices across games
const allDevices = [...new Set(games.flatMap(g => g.stats.deviceBreakdown.map(d => d.hardwareSlug)))]
const colors = ["#3b82f6", "#22c55e", "#f59e0b", "#ef4444"]
// Build series: one series per game, data points per device
const series = games.map((game, idx) => ({
name: game.title,
type: "bar" as const,
data: allDevices.map(slug => {
const device = game.stats.deviceBreakdown.find(d => d.hardwareSlug === slug)
return device ? device.avgFps : null
}),
itemStyle: { color: colors[idx % colors.length] },
barGap: "10%",
}))
const option = {
tooltip: { trigger: "axis" as const, axisPointer: { type: "shadow" as const } },
legend: {
data: games.map(g => g.title),
textStyle: { color: "#999" },
top: 0,
},
grid: { left: 60, right: 20, top: 40, bottom: 30 },
xAxis: {
type: "category" as const,
data: allDevices.map(s => s.replace(/-/g, " ")),
axisLine: { lineStyle: { color: "#555" } },
axisLabel: { color: "#999" },
},
yAxis: {
type: "value" as const,
name: "Avg FPS",
nameTextStyle: { color: "#999" },
splitLine: { lineStyle: { color: "#333" } },
axisLabel: { color: "#999" },
},
series,
}
return <EChartWrapper option={option} height={300} />
}
+166
View File
@@ -0,0 +1,166 @@
"use client"
import { useState, useEffect, useRef } from "react"
import Image from "next/image"
import { SearchIcon, XIcon, Gamepad2Icon } from "lucide-react"
interface SearchResult {
id: string
appId: number | null
title: string
image: string | null
source: string
}
interface GameSelectorProps {
selectedGames: SearchResult[]
onSelect: (game: SearchResult) => void
onRemove: (gameId: string) => void
maxSelections?: number
}
export function GameSelector({ selectedGames, onSelect, onRemove, maxSelections = 4 }: GameSelectorProps) {
const [query, setQuery] = useState("")
const [results, setResults] = useState<SearchResult[]>([])
const [loading, setLoading] = useState(false)
const [open, setOpen] = useState(false)
const wrapperRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (query.length < 2) {
setResults([])
return
}
let cancelled = false
const timeout = setTimeout(async () => {
setLoading(true)
try {
const res = await fetch(`/api/search/unified?q=${encodeURIComponent(query)}`)
if (!res.ok) throw new Error("Search failed")
const data = await res.json()
if (!cancelled) {
setResults(
(data.results || [])
.filter((r: any) => !selectedGames.some(sg => sg.id === (r.id || `steam-${r.appId}`)))
.slice(0, 8)
.map((r: any) => ({
id: r.id || `steam-${r.appId}`,
appId: r.appId,
title: r.title,
image: r.image,
source: r.source,
}))
)
}
} catch {
if (!cancelled) setResults([])
} finally {
if (!cancelled) setLoading(false)
}
}, 300)
return () => {
cancelled = true
clearTimeout(timeout)
}
}, [query, selectedGames])
// Close dropdown on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener("mousedown", handleClick)
return () => document.removeEventListener("mousedown", handleClick)
}, [])
const canAdd = selectedGames.length < maxSelections
return (
<div className="flex flex-col gap-3" ref={wrapperRef}>
{/* Selected games chips */}
{selectedGames.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedGames.map(game => (
<div
key={game.id}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg border border-primary/30 bg-primary/5 text-sm"
>
{game.image ? (
<Image src={game.image} alt={game.title} width={20} height={30} className="rounded" />
) : (
<Gamepad2Icon className="h-4 w-4 text-text/30" />
)}
<span className="text-text/80 max-w-40 truncate">{game.title}</span>
<button
onClick={() => onRemove(game.id)}
className="text-text/40 hover:text-text/80 transition-colors cursor-pointer"
>
<XIcon className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
{/* Search input */}
{canAdd && (
<div className="relative">
<div className="flex items-center gap-2 px-3 py-2 rounded-lg border border-border bg-text/5">
<SearchIcon className="h-4 w-4 text-text/40" />
<input
type="text"
value={query}
onChange={e => { setQuery(e.target.value); setOpen(true) }}
onFocus={() => setOpen(true)}
placeholder="Search for games to compare..."
className="flex-1 bg-transparent text-sm text-text outline-none placeholder:text-text/40"
/>
</div>
{/* Dropdown results */}
{open && (query.length >= 2) && (
<div className="absolute z-50 top-full left-0 right-0 mt-1 rounded-lg border border-border bg-background shadow-lg max-h-64 overflow-y-auto">
{loading && (
<div className="px-4 py-3 text-xs text-text/40">Searching...</div>
)}
{!loading && results.length === 0 && (
<div className="px-4 py-3 text-xs text-text/40">No results found</div>
)}
{!loading && results.map(game => (
<button
key={game.id}
onClick={() => {
onSelect(game)
setQuery("")
setOpen(false)
}}
className="w-full flex items-center gap-3 px-4 py-2 hover:bg-text/5 transition-colors cursor-pointer text-left"
>
{game.image ? (
<Image src={game.image} alt={game.title} width={24} height={36} className="rounded" />
) : (
<Gamepad2Icon className="h-4 w-4 text-text/30" />
)}
<span className="text-sm text-text/80 truncate">{game.title}</span>
{game.source !== "steam" && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-400 capitalize ml-auto">
{game.source}
</span>
)}
</button>
))}
</div>
)}
</div>
)}
{!canAdd && (
<p className="text-xs text-text/40">Maximum {maxSelections} games can be compared</p>
)}
</div>
)
}
+66
View File
@@ -0,0 +1,66 @@
"use client"
import { EChartWrapper } from "@/components/charts/EChartWrapper"
interface GameWithStats {
id: string
title: string
stats: {
avgFps: number | null
avgOnePercentLow: number | null
avgStability: number | null
medianFps: number | null
bestFps: number | null
}
}
export function StabilityRadar({ games }: { games: GameWithStats[] }) {
if (games.length === 0) return null
const colors = ["#3b82f6", "#22c55e", "#f59e0b", "#ef4444"]
// Normalize values to 0-100 scale for radar
const maxFps = Math.max(...games.map(g => g.stats.bestFps ?? 0), 60)
const indicators = [
{ name: "Avg FPS", max: maxFps },
{ name: "1% Low", max: maxFps },
{ name: "Stability", max: 100 },
{ name: "Median FPS", max: maxFps },
]
const series = games.map((game, idx) => ({
value: [
game.stats.avgFps ?? 0,
game.stats.avgOnePercentLow ?? 0,
game.stats.avgStability ?? 0,
game.stats.medianFps ?? 0,
],
name: game.title,
lineStyle: { color: colors[idx % colors.length] },
itemStyle: { color: colors[idx % colors.length] },
areaStyle: { color: colors[idx % colors.length], opacity: 0.1 },
}))
const option = {
tooltip: { trigger: "item" as const },
legend: {
data: games.map(g => g.title),
textStyle: { color: "#999" },
bottom: 0,
},
radar: {
indicator: indicators,
splitLine: { lineStyle: { color: "#333" } },
splitArea: { areaStyle: { color: ["transparent"] } },
axisLine: { lineStyle: { color: "#555" } },
axisName: { color: "#999" },
},
series: [{
type: "radar" as const,
data: series,
}],
}
return <EChartWrapper option={option} height={350} />
}
+59
View File
@@ -0,0 +1,59 @@
"use client"
interface GameWithStats {
id: string
title: string
stats: {
totalEntries: number
avgFps: number | null
medianFps: number | null
bestFps: number | null
avgOnePercentLow: number | null
avgStability: number | null
bestDevice: string | null
tierBreakdown: { unplayable: number; playable: number; smooth: number; excellent: number } | null
deviceBreakdown: Array<{ hardwareSlug: string; count: number; avgFps: number }>
}
}
function StatRow({ label, values }: { label: string; values: (string | number | null)[] }) {
return (
<div className="grid gap-4" style={{ gridTemplateColumns: `repeat(${values.length}, 1fr)` }}>
{values.map((val, i) => (
<div key={i} className="flex flex-col gap-1 p-3 rounded-lg border border-border bg-text/3">
<span className="text-[10px] text-text/50 uppercase tracking-wider text-center">{i === 0 ? label : ""}</span>
<span className="text-sm font-semibold tabular-nums text-center">
{val !== null && val !== undefined ? val : "—"}
</span>
</div>
))}
</div>
)
}
export function StatsComparison({ games }: { games: GameWithStats[] }) {
if (games.length === 0) return null
return (
<div className="flex flex-col gap-3">
{/* Header row with game titles */}
<div className="grid gap-4" style={{ gridTemplateColumns: `repeat(${games.length}, 1fr)` }}>
{games.map(game => (
<div key={game.id} className="text-center">
<h3 className="text-sm font-semibold text-text truncate">{game.title}</h3>
<span className="text-xs text-text/50">{game.stats.totalEntries} entries</span>
</div>
))}
</div>
<div className="h-px bg-border" />
<StatRow label="Avg FPS" values={games.map(g => g.stats.avgFps ? `${g.stats.avgFps}` : null)} />
<StatRow label="Median FPS" values={games.map(g => g.stats.medianFps ? `${g.stats.medianFps}` : null)} />
<StatRow label="Best FPS" values={games.map(g => g.stats.bestFps ? `${g.stats.bestFps}` : null)} />
<StatRow label="Avg 1% Low" values={games.map(g => g.stats.avgOnePercentLow ? `${g.stats.avgOnePercentLow}` : null)} />
<StatRow label="Stability" values={games.map(g => g.stats.avgStability != null ? `${g.stats.avgStability}%` : null)} />
<StatRow label="Best Device" values={games.map(g => g.stats.bestDevice ?? null)} />
</div>
)
}
+4 -5
View File
@@ -11,11 +11,10 @@ export const routes = [
title: "Games", title: "Games",
href: "/games", href: "/games",
}, },
// TODO: Add back when compare page is implemented {
// { title: "Compare",
// title: "Compare", href: "/compare",
// href: "/compare", },
// },
{ {
title: "Devices", title: "Devices",
href: "/devices", href: "/devices",