feat: compare page with game selector, stats comparison, and charts
This commit is contained in:
@@ -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} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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
@@ -11,11 +11,10 @@ export const routes = [
|
||||
title: "Games",
|
||||
href: "/games",
|
||||
},
|
||||
// TODO: Add back when compare page is implemented
|
||||
// {
|
||||
// title: "Compare",
|
||||
// href: "/compare",
|
||||
// },
|
||||
{
|
||||
title: "Compare",
|
||||
href: "/compare",
|
||||
},
|
||||
{
|
||||
title: "Devices",
|
||||
href: "/devices",
|
||||
|
||||
Reference in New Issue
Block a user