feat(landing): add recently added benchmarks section, reorder sections, add PlayabilityBadge and performance tags to cards
This commit is contained in:
+115
-93
@@ -4,8 +4,16 @@ import { AnimatePresence, motion } from "motion/react"
|
|||||||
import { useState, useEffect } from "react"
|
import { useState, useEffect } from "react"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import Image from "next/image"
|
import Image from "next/image"
|
||||||
import { Gamepad2Icon, SearchIcon, TrendingUpIcon, SparklesIcon, GaugeIcon, FlagIcon } from "lucide-react"
|
import {
|
||||||
|
Gamepad2Icon,
|
||||||
|
SearchIcon,
|
||||||
|
TrendingUpIcon,
|
||||||
|
GaugeIcon,
|
||||||
|
ClockIcon,
|
||||||
|
} from "lucide-react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
|
import { PlayabilityBadge } from "@/components/playability-badge"
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
interface GameCard {
|
interface GameCard {
|
||||||
id: string
|
id: string
|
||||||
@@ -24,28 +32,30 @@ interface GameCard {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface SectionData {
|
interface SectionData {
|
||||||
|
recentBenchmarks: GameCard[]
|
||||||
trending: GameCard[]
|
trending: GameCard[]
|
||||||
bestNewReleases: GameCard[]
|
|
||||||
mostTested: GameCard[]
|
mostTested: GameCard[]
|
||||||
mostReported: GameCard[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function SkeletonSections() {
|
function SkeletonSections() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{[1, 2, 3].map((i) => (
|
{[1, 2, 3].map((i) => (
|
||||||
<div key={i} className="space-y-3">
|
<div
|
||||||
<div className="h-5 w-48 bg-text/5 rounded animate-pulse" />
|
key={i}
|
||||||
<div className="flex gap-3 overflow-x-auto pb-2">
|
className='space-y-3'
|
||||||
|
>
|
||||||
|
<div className='h-5 w-48 bg-text/5 rounded animate-pulse' />
|
||||||
|
<div className='flex gap-3 overflow-x-auto pb-2'>
|
||||||
{[1, 2, 3, 4].map((j) => (
|
{[1, 2, 3, 4].map((j) => (
|
||||||
<div
|
<div
|
||||||
key={j}
|
key={j}
|
||||||
className="shrink-0 w-36 sm:w-44 rounded-xl bg-text/3 border border-border animate-pulse"
|
className='shrink-0 w-36 sm:w-44 rounded-xl bg-text/3 border border-border animate-pulse'
|
||||||
>
|
>
|
||||||
<div className="aspect-[2/3] bg-text/5 rounded-t-xl" />
|
<div className='aspect-[2/3] bg-text/5 rounded-t-xl' />
|
||||||
<div className="p-3 space-y-2">
|
<div className='p-3 space-y-2'>
|
||||||
<div className="h-3 bg-text/5 rounded w-3/4" />
|
<div className='h-3 bg-text/5 rounded w-3/4' />
|
||||||
<div className="h-2 bg-text/5 rounded w-1/2" />
|
<div className='h-2 bg-text/5 rounded w-1/2' />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -56,27 +66,6 @@ function SkeletonSections() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function PlayabilityDot({ status }: { status: string }) {
|
|
||||||
const colors: Record<string, string> = {
|
|
||||||
great: "bg-green-500",
|
|
||||||
playable: "bg-blue-500",
|
|
||||||
needs_tweaks: "bg-yellow-500",
|
|
||||||
unplayable: "bg-red-500",
|
|
||||||
}
|
|
||||||
const labels: Record<string, string> = {
|
|
||||||
great: "Plays Great",
|
|
||||||
playable: "Playable",
|
|
||||||
needs_tweaks: "Needs Tweaks",
|
|
||||||
unplayable: "Unplayable",
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={`inline-block w-2.5 h-2.5 rounded-full ${colors[status] || "bg-text/20"}`}
|
|
||||||
title={labels[status] || status}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function GameSection({
|
function GameSection({
|
||||||
title,
|
title,
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
@@ -112,16 +101,18 @@ function GameSection({
|
|||||||
transition={{ duration: 0.4 }}
|
transition={{ duration: 0.4 }}
|
||||||
className={muted ? "opacity-70" : ""}
|
className={muted ? "opacity-70" : ""}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 mb-4">
|
<div className='flex items-center gap-2 mb-4'>
|
||||||
<div className="border-l-2 border-primary pl-3">
|
<div className='border-l-2 border-primary pl-3'>
|
||||||
<div className="flex items-center gap-2">
|
<div className='flex items-center gap-2'>
|
||||||
<Icon className={`h-4 w-4 ${accentColor}`} />
|
<Icon className={`h-4 w-4 ${accentColor}`} />
|
||||||
<h2 className="text-sm font-semibold text-text/80">{title}</h2>
|
<h2 className='text-sm font-semibold text-text/80'>
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-3 overflow-x-auto pb-2 -mx-1 px-1 scrollbar-thin">
|
<div className='flex gap-3 overflow-x-auto pb-2 -mx-1 px-1 scrollbar-thin'>
|
||||||
{games.map((game, idx) => (
|
{games.map((game, idx) => (
|
||||||
<motion.div
|
<motion.div
|
||||||
key={game.id}
|
key={game.id}
|
||||||
@@ -131,36 +122,71 @@ function GameSection({
|
|||||||
transition={{ duration: 0.3, delay: idx * 0.05 }}
|
transition={{ duration: 0.3, delay: idx * 0.05 }}
|
||||||
whileHover={{ scale: 1.03 }}
|
whileHover={{ scale: 1.03 }}
|
||||||
whileTap={{ scale: 0.98 }}
|
whileTap={{ scale: 0.98 }}
|
||||||
className="group shrink-0 w-36 sm:w-44 rounded-xl bg-text/3 border border-border hover:border-text/30 hover:bg-text/[0.06] transition-colors cursor-pointer overflow-hidden"
|
className='group shrink-0 w-36 sm:w-44 rounded-xl bg-text/3 border border-border hover:border-text/30 hover:bg-text/6 transition-colors cursor-pointer overflow-hidden'
|
||||||
onClick={() => router.push(`/game/${game.id}?sync=1`)}
|
onClick={() => router.push(`/game/${game.id}?sync=1`)}
|
||||||
>
|
>
|
||||||
<div className="relative aspect-[2/3] bg-text/10 overflow-hidden">
|
<div className='relative aspect-2/3 bg-text/10 overflow-hidden'>
|
||||||
{game.capsule_image ? (
|
{game.capsule_image ? (
|
||||||
<Image
|
<Image
|
||||||
src={game.capsule_image}
|
src={game.capsule_image}
|
||||||
alt={game.title}
|
alt={game.title}
|
||||||
fill
|
fill
|
||||||
className="object-cover group-hover:scale-105 transition-transform duration-300"
|
className='object-cover group-hover:scale-105 transition-transform duration-300'
|
||||||
sizes="(max-width: 640px) 144px, 176px"
|
sizes='(max-width: 640px) 144px, 176px'
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full flex items-center justify-center">
|
<div className='w-full h-full flex items-center justify-center'>
|
||||||
<Gamepad2Icon className="h-8 w-8 text-text/15" />
|
<Gamepad2Icon className='h-8 w-8 text-text/15' />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{game.playability_status && game.playability_status !== "unknown" && (
|
{/* Playability badge pinned at bottom of image */}
|
||||||
<div className="absolute top-1.5 right-1.5">
|
{game.playability_status &&
|
||||||
<PlayabilityDot status={game.playability_status} />
|
game.playability_status !== "unknown" && (
|
||||||
|
<div className='absolute bottom-1.5 left-1.5 right-1.5'>
|
||||||
|
<PlayabilityBadge
|
||||||
|
status={game.playability_status as "great" | "playable" | "needs_tweaks" | "unplayable" | "unknown" | null}
|
||||||
|
compact
|
||||||
|
showLabel
|
||||||
|
className='text-[10px] px-1.5 py-0.5 w-full justify-center'
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-2.5 space-y-1.5">
|
<div className='p-2.5 space-y-1.5'>
|
||||||
<h3 className="text-xs font-semibold text-text line-clamp-2 leading-tight group-hover:text-primary transition-colors">
|
<h3 className='text-xs font-semibold text-text line-clamp-2 leading-tight group-hover:text-primary transition-colors'>
|
||||||
{game.title}
|
{game.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className={`text-[10px] ${accentColor} font-medium`}>
|
|
||||||
{formatStat((game as unknown as Record<string, unknown>)[statKey])}
|
{/* Performance badges */}
|
||||||
|
{game.avg_fps !== undefined && game.avg_fps !== null && (
|
||||||
|
<div className='flex flex-wrap gap-1'>
|
||||||
|
{game.avg_fps >= 60 ? (
|
||||||
|
<span className='inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-green-500/10 border border-green-500/20 text-green-400 text-[9px] font-semibold leading-tight'>
|
||||||
|
⚡ RAW PERFORMER
|
||||||
|
</span>
|
||||||
|
) : game.avg_fps < 30 ? (
|
||||||
|
<span className='inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-red-500/10 border border-red-500/20 text-red-400 text-[9px] font-semibold leading-tight'>
|
||||||
|
⚠ POOR PERFORMANCE
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<span className='inline-flex items-center gap-0.5 text-[9px] text-text/50 font-medium'>
|
||||||
|
{Math.round(game.avg_fps)}fps
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p
|
||||||
|
className={`text-[10px] ${accentColor} font-medium`}
|
||||||
|
>
|
||||||
|
{formatStat(
|
||||||
|
(
|
||||||
|
game as unknown as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>
|
||||||
|
)[statKey],
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
@@ -179,10 +205,9 @@ export default function Landing() {
|
|||||||
|
|
||||||
// Landing section state
|
// Landing section state
|
||||||
const [sections, setSections] = useState<SectionData>({
|
const [sections, setSections] = useState<SectionData>({
|
||||||
|
recentBenchmarks: [],
|
||||||
trending: [],
|
trending: [],
|
||||||
bestNewReleases: [],
|
|
||||||
mostTested: [],
|
mostTested: [],
|
||||||
mostReported: [],
|
|
||||||
})
|
})
|
||||||
const [sectionsLoading, setSectionsLoading] = useState(true)
|
const [sectionsLoading, setSectionsLoading] = useState(true)
|
||||||
|
|
||||||
@@ -199,18 +224,23 @@ export default function Landing() {
|
|||||||
let cancelled = false
|
let cancelled = false
|
||||||
async function fetchSections() {
|
async function fetchSections() {
|
||||||
try {
|
try {
|
||||||
const [trending, bestNew, mostTested, mostReported] = await Promise.all([
|
const [recentBenchmarks, trending, mostTested] =
|
||||||
fetch("/api/dashboard/trending").then(r => r.ok ? r.json() : []),
|
await Promise.all([
|
||||||
fetch("/api/dashboard/best-new-releases").then(r => r.ok ? r.json() : []),
|
fetch("/api/dashboard/recent-benchmarks").then((r) =>
|
||||||
fetch("/api/dashboard/most-tested").then(r => r.ok ? r.json() : []),
|
r.ok ? r.json() : [],
|
||||||
fetch("/api/dashboard/most-reported").then(r => r.ok ? r.json() : []),
|
),
|
||||||
|
fetch("/api/dashboard/trending").then((r) =>
|
||||||
|
r.ok ? r.json() : [],
|
||||||
|
),
|
||||||
|
fetch("/api/dashboard/most-tested").then((r) =>
|
||||||
|
r.ok ? r.json() : [],
|
||||||
|
),
|
||||||
])
|
])
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setSections({
|
setSections({
|
||||||
|
recentBenchmarks: Array.isArray(recentBenchmarks) ? recentBenchmarks : [],
|
||||||
trending: Array.isArray(trending) ? trending : [],
|
trending: Array.isArray(trending) ? trending : [],
|
||||||
bestNewReleases: Array.isArray(bestNew) ? bestNew : [],
|
|
||||||
mostTested: Array.isArray(mostTested) ? mostTested : [],
|
mostTested: Array.isArray(mostTested) ? mostTested : [],
|
||||||
mostReported: Array.isArray(mostReported) ? mostReported : [],
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -220,7 +250,9 @@ export default function Landing() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fetchSections()
|
fetchSections()
|
||||||
return () => { cancelled = true }
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleSearchSubmit = () => {
|
const handleSearchSubmit = () => {
|
||||||
@@ -241,7 +273,7 @@ export default function Landing() {
|
|||||||
{/* ── Hero Section ── */}
|
{/* ── Hero Section ── */}
|
||||||
<section
|
<section
|
||||||
id='hero'
|
id='hero'
|
||||||
className='w-full min-h-[calc(100svh-10svh)] flex flex-col items-center justify-center relative p-4'
|
className='w-full min-h-[70svh] mt-[10svh] flex flex-col items-center justify-center relative p-4'
|
||||||
>
|
>
|
||||||
<motion.h1
|
<motion.h1
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
@@ -337,56 +369,46 @@ export default function Landing() {
|
|||||||
>
|
>
|
||||||
Github.
|
Github.
|
||||||
</Link>
|
</Link>
|
||||||
<span className="opacity-60">v{process.env.NEXT_PUBLIC_APP_VERSION}</span>
|
<span className='opacity-60'>
|
||||||
|
v{process.env.NEXT_PUBLIC_APP_VERSION}
|
||||||
|
</span>
|
||||||
</motion.small>
|
</motion.small>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Landing Sections ── */}
|
{/* ── Landing Sections ── */}
|
||||||
<div className="w-full max-w-7xl mx-auto px-4 pb-12 space-y-10">
|
<div className='w-full max-w-7xl mx-auto px-4 pb-12 space-y-10'>
|
||||||
{sectionsLoading ? (
|
{sectionsLoading ? (
|
||||||
<SkeletonSections />
|
<SkeletonSections />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{sections.trending.length > 0 && (
|
{sections.recentBenchmarks.length > 0 && (
|
||||||
<GameSection
|
<GameSection
|
||||||
title="Trending This Week"
|
title='Recently Added Benchmarks'
|
||||||
icon={TrendingUpIcon}
|
icon={ClockIcon}
|
||||||
games={sections.trending}
|
games={sections.recentBenchmarks}
|
||||||
statKey="benchmark_count"
|
statKey='benchmark_count'
|
||||||
statLabel="benchmarks this week"
|
statLabel='benchmarks'
|
||||||
accentColor="text-orange-400"
|
accentColor='text-violet-400'
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{sections.bestNewReleases.length > 0 && (
|
{sections.trending.length > 0 && (
|
||||||
<GameSection
|
<GameSection
|
||||||
title="Best Performing New Releases"
|
title='Trending This Week'
|
||||||
icon={SparklesIcon}
|
icon={TrendingUpIcon}
|
||||||
games={sections.bestNewReleases}
|
games={sections.trending}
|
||||||
statKey="avg_fps"
|
statKey='benchmark_count'
|
||||||
statLabel="avg FPS"
|
statLabel='benchmarks this week'
|
||||||
statFormatter={(v) => `${Math.round(Number(v))} FPS`}
|
accentColor='text-orange-400'
|
||||||
accentColor="text-green-400"
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{sections.mostTested.length > 0 && (
|
{sections.mostTested.length > 0 && (
|
||||||
<GameSection
|
<GameSection
|
||||||
title="Most Tested Games"
|
title='Most Tested Games'
|
||||||
icon={GaugeIcon}
|
icon={GaugeIcon}
|
||||||
games={sections.mostTested}
|
games={sections.mostTested}
|
||||||
statKey="benchmark_count"
|
statKey='benchmark_count'
|
||||||
statLabel="benchmarks"
|
statLabel='benchmarks'
|
||||||
accentColor="text-blue-400"
|
accentColor='text-blue-400'
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{sections.mostReported.length > 0 && (
|
|
||||||
<GameSection
|
|
||||||
title="Most Reported Games"
|
|
||||||
icon={FlagIcon}
|
|
||||||
games={sections.mostReported}
|
|
||||||
statKey="report_count"
|
|
||||||
statLabel="open reports"
|
|
||||||
accentColor="text-red-400"
|
|
||||||
muted
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
Reference in New Issue
Block a user