feat(landing): add recently added benchmarks section, reorder sections, add PlayabilityBadge and performance tags to cards

This commit is contained in:
2026-05-15 01:37:06 +08:00
parent 1e546ca007
commit 4ace385a6e
+361 -339
View File
@@ -4,170 +4,196 @@ 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
title: string title: string
capsule_image: string | null capsule_image: string | null
header_image: string | null header_image: string | null
playability_status?: string | null playability_status?: string | null
activity_score?: number activity_score?: number
benchmark_count?: number benchmark_count?: number
comment_count?: number comment_count?: number
upvote_count?: number upvote_count?: number
avg_fps?: number avg_fps?: number
report_count?: number report_count?: number
release_date?: string | null release_date?: string | null
created_at?: string | null created_at?: string | null
} }
interface SectionData { interface SectionData {
trending: GameCard[] recentBenchmarks: GameCard[]
bestNewReleases: GameCard[] trending: 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'
{[1, 2, 3, 4].map((j) => ( >
<div <div className='h-5 w-48 bg-text/5 rounded animate-pulse' />
key={j} <div className='flex gap-3 overflow-x-auto pb-2'>
className="shrink-0 w-36 sm:w-44 rounded-xl bg-text/3 border border-border animate-pulse" {[1, 2, 3, 4].map((j) => (
> <div
<div className="aspect-[2/3] bg-text/5 rounded-t-xl" /> key={j}
<div className="p-3 space-y-2"> className='shrink-0 w-36 sm:w-44 rounded-xl bg-text/3 border border-border animate-pulse'
<div className="h-3 bg-text/5 rounded w-3/4" /> >
<div className="h-2 bg-text/5 rounded w-1/2" /> <div className='aspect-[2/3] bg-text/5 rounded-t-xl' />
<div className='p-3 space-y-2'>
<div className='h-3 bg-text/5 rounded w-3/4' />
<div className='h-2 bg-text/5 rounded w-1/2' />
</div>
</div>
))}
</div>
</div> </div>
</div>
))} ))}
</div> </>
</div> )
))}
</>
)
}
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,
games, games,
statKey, statKey,
statLabel, statLabel,
statFormatter, statFormatter,
accentColor = "text-text/50", accentColor = "text-text/50",
muted = false, muted = false,
}: { }: {
title: string title: string
icon: React.ElementType icon: React.ElementType
games: GameCard[] games: GameCard[]
statKey: string statKey: string
statLabel: string statLabel: string
statFormatter?: (v: unknown) => string statFormatter?: (v: unknown) => string
accentColor?: string accentColor?: string
muted?: boolean muted?: boolean
}) { }) {
const router = useRouter() const router = useRouter()
const formatStat = (v: unknown): string => { const formatStat = (v: unknown): string => {
if (statFormatter) return statFormatter(v) if (statFormatter) return statFormatter(v)
if (typeof v === "number") return `${Math.round(v)} ${statLabel}` if (typeof v === "number") return `${Math.round(v)} ${statLabel}`
return `${v} ${statLabel}` return `${v} ${statLabel}`
} }
return ( return (
<motion.section <motion.section
initial={{ opacity: 0, y: 20 }} initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.4 }}
className={muted ? "opacity-70" : ""}
>
<div className="flex items-center gap-2 mb-4">
<div className="border-l-2 border-primary pl-3">
<div className="flex items-center gap-2">
<Icon className={`h-4 w-4 ${accentColor}`} />
<h2 className="text-sm font-semibold text-text/80">{title}</h2>
</div>
</div>
</div>
<div className="flex gap-3 overflow-x-auto pb-2 -mx-1 px-1 scrollbar-thin">
{games.map((game, idx) => (
<motion.div
key={game.id}
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }} whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }} viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.3, delay: idx * 0.05 }} transition={{ duration: 0.4 }}
whileHover={{ scale: 1.03 }} className={muted ? "opacity-70" : ""}
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" <div className='flex items-center gap-2 mb-4'>
onClick={() => router.push(`/game/${game.id}?sync=1`)} <div className='border-l-2 border-primary pl-3'>
> <div className='flex items-center gap-2'>
<div className="relative aspect-[2/3] bg-text/10 overflow-hidden"> <Icon className={`h-4 w-4 ${accentColor}`} />
{game.capsule_image ? ( <h2 className='text-sm font-semibold text-text/80'>
<Image {title}
src={game.capsule_image} </h2>
alt={game.title} </div>
fill
className="object-cover group-hover:scale-105 transition-transform duration-300"
sizes="(max-width: 640px) 144px, 176px"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Gamepad2Icon className="h-8 w-8 text-text/15" />
</div> </div>
)}
{game.playability_status && game.playability_status !== "unknown" && (
<div className="absolute top-1.5 right-1.5">
<PlayabilityDot status={game.playability_status} />
</div>
)}
</div> </div>
<div className="p-2.5 space-y-1.5"> <div className='flex gap-3 overflow-x-auto pb-2 -mx-1 px-1 scrollbar-thin'>
<h3 className="text-xs font-semibold text-text line-clamp-2 leading-tight group-hover:text-primary transition-colors"> {games.map((game, idx) => (
{game.title} <motion.div
</h3> key={game.id}
<p className={`text-[10px] ${accentColor} font-medium`}> initial={{ opacity: 0, y: 10 }}
{formatStat((game as unknown as Record<string, unknown>)[statKey])} whileInView={{ opacity: 1, y: 0 }}
</p> viewport={{ once: true }}
transition={{ duration: 0.3, delay: idx * 0.05 }}
whileHover={{ scale: 1.03 }}
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/6 transition-colors cursor-pointer overflow-hidden'
onClick={() => router.push(`/game/${game.id}?sync=1`)}
>
<div className='relative aspect-2/3 bg-text/10 overflow-hidden'>
{game.capsule_image ? (
<Image
src={game.capsule_image}
alt={game.title}
fill
className='object-cover group-hover:scale-105 transition-transform duration-300'
sizes='(max-width: 640px) 144px, 176px'
/>
) : (
<div className='w-full h-full flex items-center justify-center'>
<Gamepad2Icon className='h-8 w-8 text-text/15' />
</div>
)}
{/* Playability badge pinned at bottom of image */}
{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 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'>
{game.title}
</h3>
{/* 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>
</div>
</motion.div>
))}
</div> </div>
</motion.div> </motion.section>
))} )
</div>
</motion.section>
)
} }
export default function Landing() { export default function Landing() {
@@ -179,48 +205,54 @@ export default function Landing() {
// Landing section state // Landing section state
const [sections, setSections] = useState<SectionData>({ const [sections, setSections] = useState<SectionData>({
trending: [], recentBenchmarks: [],
bestNewReleases: [], trending: [],
mostTested: [], mostTested: [],
mostReported: [],
}) })
const [sectionsLoading, setSectionsLoading] = useState(true) const [sectionsLoading, setSectionsLoading] = useState(true)
// Animated words cycle — use useEffect with proper cleanup // Animated words cycle — use useEffect with proper cleanup
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { const interval = setInterval(() => {
setCurrentWord((prev) => (prev + 1) % words.length) setCurrentWord((prev) => (prev + 1) % words.length)
}, 2000) }, 2000)
return () => clearInterval(interval) return () => clearInterval(interval)
}, [words.length]) }, [words.length])
// Fetch all 4 sections in parallel on mount // Fetch all 4 sections in parallel on mount
useEffect(() => { useEffect(() => {
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) =>
if (!cancelled) { r.ok ? r.json() : [],
setSections({ ),
trending: Array.isArray(trending) ? trending : [], fetch("/api/dashboard/most-tested").then((r) =>
bestNewReleases: Array.isArray(bestNew) ? bestNew : [], r.ok ? r.json() : [],
mostTested: Array.isArray(mostTested) ? mostTested : [], ),
mostReported: Array.isArray(mostReported) ? mostReported : [], ])
}) if (!cancelled) {
} setSections({
} catch { recentBenchmarks: Array.isArray(recentBenchmarks) ? recentBenchmarks : [],
// Silently fail — sections are best-effort trending: Array.isArray(trending) ? trending : [],
} finally { mostTested: Array.isArray(mostTested) ? mostTested : [],
if (!cancelled) setSectionsLoading(false) })
}
} catch {
// Silently fail — sections are best-effort
} finally {
if (!cancelled) setSectionsLoading(false)
}
}
fetchSections()
return () => {
cancelled = true
} }
}
fetchSections()
return () => { cancelled = true }
}, []) }, [])
const handleSearchSubmit = () => { const handleSearchSubmit = () => {
@@ -237,180 +269,170 @@ export default function Landing() {
} }
return ( return (
<> <>
{/* ── 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
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className='font-bold text-3xl md:text-5xl text-center flex flex-row gap-2 items-center justify-center'
> >
DeckyVault <motion.h1
<span className='border border-border text-xs md:text-base px-2 py-1 rounded-md bg-primary/10 font-medium'>
beta
</span>
</motion.h1>
<motion.h2
layout
initial={{
opacity: 0,
}}
animate={{ opacity: 0.8, transition: { delay: 0.5 } }}
className='mt-4 flex flex-row flex-wrap items-center justify-center gap-x-1 md:gap-x-2 text-base md:text-xl'
>
{"Find your game".split(" ").map((word, index) => (
<motion.span
key={index}
className='text-center'
>
{word}
</motion.span>
))}
<AnimatePresence
mode='wait'
initial={false}
>
<motion.span
key={currentWord}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className='text-primary font-bold'
>
{words[currentWord]}
</motion.span>
</AnimatePresence>
</motion.h2>
<AnimatePresence>
<motion.label
key='search-bar'
layoutId='search-bar'
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
className='mt-8 flex flex-row items-center gap-4 bg-text/5 px-2 py-2 rounded-md border border-border placeholder:text-text/60 group hover:border-border-active transition-colors focus-within:border-primary/80! focus-within:ring-2 focus-within:ring-primary/50! focus-within:ring-offset-2 focus-within:ring-offset-background cursor-text w-full max-w-md' className='font-bold text-3xl md:text-5xl text-center flex flex-row gap-2 items-center justify-center'
> >
<Gamepad2Icon className='h-6 w-6 group-focus-within:stroke-accent transition-colors shrink-0' /> DeckyVault
<input <span className='border border-border text-xs md:text-base px-2 py-1 rounded-md bg-primary/10 font-medium'>
type='text' beta
placeholder='search by game or appid...' </span>
value={searchQuery} </motion.h1>
onChange={(e) => setSearchQuery(e.target.value)} <motion.h2
onKeyDown={handleKeyDown} layout
className='flex-1 outline-none bg-transparent text-xl min-w-0' initial={{
/> opacity: 0,
<motion.button }}
onClick={handleSearchSubmit} animate={{ opacity: 0.8, transition: { delay: 0.5 } }}
whileTap={{ scale: 0.95 }} className='mt-4 flex flex-row flex-wrap items-center justify-center gap-x-1 md:gap-x-2 text-base md:text-xl'
className='text-sm flex flex-row gap-1 items-center bg-text text-background px-2 py-1 rounded-sm cursor-pointer hover:opacity-60 transition-opacity shrink-0' >
{"Find your game".split(" ").map((word, index) => (
<motion.span
key={index}
className='text-center'
>
{word}
</motion.span>
))}
<AnimatePresence
mode='wait'
initial={false}
> >
<SearchIcon className='h-3 w-3' /> <motion.span
search key={currentWord}
</motion.button> initial={{ opacity: 0 }}
</motion.label> animate={{ opacity: 1 }}
</AnimatePresence> exit={{ opacity: 0 }}
<motion.small className='text-primary font-bold'
initial={{ opacity: 0 }} >
animate={{ opacity: 1, transition: { delay: 1.5 } }} {words[currentWord]}
className='mt-8 text-center text-xs flex flex-row gap-1' </motion.span>
> </AnimatePresence>
<Link </motion.h2>
title='Visit our Github Repository' <AnimatePresence>
href='/updates' <motion.label
className='text-accent opacity-60 hover:opacity-100 transition-opacity cursor-pointer' key='search-bar'
layoutId='search-bar'
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className='mt-8 flex flex-row items-center gap-4 bg-text/5 px-2 py-2 rounded-md border border-border placeholder:text-text/60 group hover:border-border-active transition-colors focus-within:border-primary/80! focus-within:ring-2 focus-within:ring-primary/50! focus-within:ring-offset-2 focus-within:ring-offset-background cursor-text w-full max-w-md'
>
<Gamepad2Icon className='h-6 w-6 group-focus-within:stroke-accent transition-colors shrink-0' />
<input
type='text'
placeholder='search by game or appid...'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleKeyDown}
className='flex-1 outline-none bg-transparent text-xl min-w-0'
/>
<motion.button
onClick={handleSearchSubmit}
whileTap={{ scale: 0.95 }}
className='text-sm flex flex-row gap-1 items-center bg-text text-background px-2 py-1 rounded-sm cursor-pointer hover:opacity-60 transition-opacity shrink-0'
>
<SearchIcon className='h-3 w-3' />
search
</motion.button>
</motion.label>
</AnimatePresence>
<motion.small
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: 1.5 } }}
className='mt-8 text-center text-xs flex flex-row gap-1'
> >
See what{"'"}s new. <Link
</Link> title='Visit our Github Repository'
</motion.small> href='/updates'
<motion.small className='text-accent opacity-60 hover:opacity-100 transition-opacity cursor-pointer'
initial={{ opacity: 0 }} >
animate={{ opacity: 1, transition: { delay: 1.5 } }} See what{"'"}s new.
className='mt-2 text-center text-xs flex flex-row gap-1' </Link>
> </motion.small>
<span className='opacity-60'>2026 DeckyVault.</span> <motion.small
<Link initial={{ opacity: 0 }}
title='Visit our Github Repository' animate={{ opacity: 1, transition: { delay: 1.5 } }}
href='https://github.com/AdrianBonpin/deckyvault' className='mt-2 text-center text-xs flex flex-row gap-1'
className='text-accent opacity-60 hover:opacity-100 transition-opacity cursor-pointer'
> >
Github. <span className='opacity-60'>2026 DeckyVault.</span>
</Link> <Link
<span className="opacity-60">v{process.env.NEXT_PUBLIC_APP_VERSION}</span> title='Visit our Github Repository'
</motion.small> href='https://github.com/AdrianBonpin/deckyvault'
</section> className='text-accent opacity-60 hover:opacity-100 transition-opacity cursor-pointer'
>
Github.
</Link>
<span className='opacity-60'>
v{process.env.NEXT_PUBLIC_APP_VERSION}
</span>
</motion.small>
</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 </div>
title="Most Reported Games"
icon={FlagIcon}
games={sections.mostReported}
statKey="report_count"
statLabel="open reports"
accentColor="text-red-400"
muted
/>
)}
</>
)}
</div>
<script <script
type='application/ld+json' type='application/ld+json'
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: JSON.stringify({ __html: JSON.stringify({
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "WebSite", "@type": "WebSite",
name: "DeckyVault", name: "DeckyVault",
url: "https://deckyvault.xyz", url: "https://deckyvault.xyz",
description: description:
"Steam Deck benchmarks, settings, and performance guides", "Steam Deck benchmarks, settings, and performance guides",
potentialAction: { potentialAction: {
"@type": "SearchAction", "@type": "SearchAction",
target: "https://deckyvault.xyz/search?q={search_term_string}", target: "https://deckyvault.xyz/search?q={search_term_string}",
"query-input": "required name=search_term_string", "query-input": "required name=search_term_string",
}, },
}), }),
}} }}
/> />
</> </>
) )
} }