diff --git a/app/compare/page.tsx b/app/compare/page.tsx new file mode 100644 index 0000000..d4c8279 --- /dev/null +++ b/app/compare/page.tsx @@ -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([]) + const [comparisonData, setComparisonData] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(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 ( +
+
+ + Compare Games + + + Select 2-4 games to compare performance stats side by side. + + + {/* Game selector */} + + + {/* Compare button */} +
+ + {!canCompare && selectedGames.length > 0 && ( + Select at least 2 games + )} +
+ + {/* Error */} + {error && ( +

{error}

+ )} + + {/* Results */} + {comparisonData.length >= 2 && ( + + {/* Stats comparison table */} +
+

Stats Overview

+ +
+ + {/* FPS comparison bar chart */} +
+

FPS by Device

+ +
+ + {/* Stability radar */} +
+

Performance Profile

+ +
+
+ )} + + {/* Empty state */} + {comparisonData.length === 0 && !loading && ( +
+ +

Select games above to start comparing

+
+ )} +
+
+ ) +} diff --git a/components/compare/fps-comparison-chart.tsx b/components/compare/fps-comparison-chart.tsx new file mode 100644 index 0000000..bf950c8 --- /dev/null +++ b/components/compare/fps-comparison-chart.tsx @@ -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 +} diff --git a/components/compare/game-selector.tsx b/components/compare/game-selector.tsx new file mode 100644 index 0000000..c0f0a7e --- /dev/null +++ b/components/compare/game-selector.tsx @@ -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([]) + const [loading, setLoading] = useState(false) + const [open, setOpen] = useState(false) + const wrapperRef = useRef(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 ( +
+ {/* Selected games chips */} + {selectedGames.length > 0 && ( +
+ {selectedGames.map(game => ( +
+ {game.image ? ( + {game.title} + ) : ( + + )} + {game.title} + +
+ ))} +
+ )} + + {/* Search input */} + {canAdd && ( +
+
+ + { 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" + /> +
+ + {/* Dropdown results */} + {open && (query.length >= 2) && ( +
+ {loading && ( +
Searching...
+ )} + {!loading && results.length === 0 && ( +
No results found
+ )} + {!loading && results.map(game => ( + + ))} +
+ )} +
+ )} + + {!canAdd && ( +

Maximum {maxSelections} games can be compared

+ )} +
+ ) +} diff --git a/components/compare/stability-radar.tsx b/components/compare/stability-radar.tsx new file mode 100644 index 0000000..4542444 --- /dev/null +++ b/components/compare/stability-radar.tsx @@ -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 +} diff --git a/components/compare/stats-comparison.tsx b/components/compare/stats-comparison.tsx new file mode 100644 index 0000000..33ec18d --- /dev/null +++ b/components/compare/stats-comparison.tsx @@ -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 ( +
+ {values.map((val, i) => ( +
+ {i === 0 ? label : ""} + + {val !== null && val !== undefined ? val : "—"} + +
+ ))} +
+ ) +} + +export function StatsComparison({ games }: { games: GameWithStats[] }) { + if (games.length === 0) return null + + return ( +
+ {/* Header row with game titles */} +
+ {games.map(game => ( +
+

{game.title}

+ {game.stats.totalEntries} entries +
+ ))} +
+ +
+ + g.stats.avgFps ? `${g.stats.avgFps}` : null)} /> + g.stats.medianFps ? `${g.stats.medianFps}` : null)} /> + g.stats.bestFps ? `${g.stats.bestFps}` : null)} /> + g.stats.avgOnePercentLow ? `${g.stats.avgOnePercentLow}` : null)} /> + g.stats.avgStability != null ? `${g.stats.avgStability}%` : null)} /> + g.stats.bestDevice ?? null)} /> +
+ ) +} diff --git a/lib/routes.ts b/lib/routes.ts index a20e8b0..6c887c5 100644 --- a/lib/routes.ts +++ b/lib/routes.ts @@ -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",