diff --git a/app/page.tsx b/app/page.tsx
index 0b83a70..a7e67c3 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -4,170 +4,196 @@ import { AnimatePresence, motion } from "motion/react"
import { useState, useEffect } from "react"
import Link from "next/link"
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 { PlayabilityBadge } from "@/components/playability-badge"
+import { cn } from "@/lib/utils"
interface GameCard {
- id: string
- title: string
- capsule_image: string | null
- header_image: string | null
- playability_status?: string | null
- activity_score?: number
- benchmark_count?: number
- comment_count?: number
- upvote_count?: number
- avg_fps?: number
- report_count?: number
- release_date?: string | null
- created_at?: string | null
+ id: string
+ title: string
+ capsule_image: string | null
+ header_image: string | null
+ playability_status?: string | null
+ activity_score?: number
+ benchmark_count?: number
+ comment_count?: number
+ upvote_count?: number
+ avg_fps?: number
+ report_count?: number
+ release_date?: string | null
+ created_at?: string | null
}
interface SectionData {
- trending: GameCard[]
- bestNewReleases: GameCard[]
- mostTested: GameCard[]
- mostReported: GameCard[]
+ recentBenchmarks: GameCard[]
+ trending: GameCard[]
+ mostTested: GameCard[]
}
function SkeletonSections() {
- return (
- <>
- {[1, 2, 3].map((i) => (
-
-
-
- {[1, 2, 3, 4].map((j) => (
-
-
-
-
-
+ return (
+ <>
+ {[1, 2, 3].map((i) => (
+
+
+
+ {[1, 2, 3, 4].map((j) => (
+
+ ))}
+
-
))}
-
-
- ))}
- >
- )
-}
-
-function PlayabilityDot({ status }: { status: string }) {
- const colors: Record
= {
- great: "bg-green-500",
- playable: "bg-blue-500",
- needs_tweaks: "bg-yellow-500",
- unplayable: "bg-red-500",
- }
- const labels: Record = {
- great: "Plays Great",
- playable: "Playable",
- needs_tweaks: "Needs Tweaks",
- unplayable: "Unplayable",
- }
- return (
-
- )
+ >
+ )
}
function GameSection({
- title,
- icon: Icon,
- games,
- statKey,
- statLabel,
- statFormatter,
- accentColor = "text-text/50",
- muted = false,
+ title,
+ icon: Icon,
+ games,
+ statKey,
+ statLabel,
+ statFormatter,
+ accentColor = "text-text/50",
+ muted = false,
}: {
- title: string
- icon: React.ElementType
- games: GameCard[]
- statKey: string
- statLabel: string
- statFormatter?: (v: unknown) => string
- accentColor?: string
- muted?: boolean
+ title: string
+ icon: React.ElementType
+ games: GameCard[]
+ statKey: string
+ statLabel: string
+ statFormatter?: (v: unknown) => string
+ accentColor?: string
+ muted?: boolean
}) {
- const router = useRouter()
+ const router = useRouter()
- const formatStat = (v: unknown): string => {
- if (statFormatter) return statFormatter(v)
- if (typeof v === "number") return `${Math.round(v)} ${statLabel}`
- return `${v} ${statLabel}`
- }
+ const formatStat = (v: unknown): string => {
+ if (statFormatter) return statFormatter(v)
+ if (typeof v === "number") return `${Math.round(v)} ${statLabel}`
+ return `${v} ${statLabel}`
+ }
- return (
-
-
-
-
- {games.map((game, idx) => (
-
router.push(`/game/${game.id}?sync=1`)}
- >
-
- {game.capsule_image ? (
-
- ) : (
-
-
+ viewport={{ once: true, margin: "-50px" }}
+ transition={{ duration: 0.4 }}
+ className={muted ? "opacity-70" : ""}
+ >
+
+
- )}
- {game.playability_status && game.playability_status !== "unknown" && (
-
- )}
-
-
- {game.title}
-
-
- {formatStat((game as unknown as Record)[statKey])}
-
+
+ {games.map((game, idx) => (
+
router.push(`/game/${game.id}?sync=1`)}
+ >
+
+ {game.capsule_image ? (
+
+ ) : (
+
+
+
+ )}
+ {/* Playability badge pinned at bottom of image */}
+ {game.playability_status &&
+ game.playability_status !== "unknown" && (
+
+ )}
+
+
+
+
+ {game.title}
+
+
+ {/* Performance badges */}
+ {game.avg_fps !== undefined && game.avg_fps !== null && (
+
+ {game.avg_fps >= 60 ? (
+
+ ⚡ RAW PERFORMER
+
+ ) : game.avg_fps < 30 ? (
+
+ ⚠ POOR PERFORMANCE
+
+ ) : null}
+
+ {Math.round(game.avg_fps)}fps
+
+
+ )}
+
+
+ {formatStat(
+ (
+ game as unknown as Record<
+ string,
+ unknown
+ >
+ )[statKey],
+ )}
+
+
+
+ ))}
-
- ))}
-
-
- )
+
+ )
}
export default function Landing() {
@@ -179,48 +205,54 @@ export default function Landing() {
// Landing section state
const [sections, setSections] = useState
({
- trending: [],
- bestNewReleases: [],
- mostTested: [],
- mostReported: [],
+ recentBenchmarks: [],
+ trending: [],
+ mostTested: [],
})
const [sectionsLoading, setSectionsLoading] = useState(true)
// Animated words cycle — use useEffect with proper cleanup
useEffect(() => {
- const interval = setInterval(() => {
- setCurrentWord((prev) => (prev + 1) % words.length)
- }, 2000)
- return () => clearInterval(interval)
+ const interval = setInterval(() => {
+ setCurrentWord((prev) => (prev + 1) % words.length)
+ }, 2000)
+ return () => clearInterval(interval)
}, [words.length])
// Fetch all 4 sections in parallel on mount
useEffect(() => {
- let cancelled = false
- async function fetchSections() {
- try {
- const [trending, bestNew, mostTested, mostReported] = await Promise.all([
- fetch("/api/dashboard/trending").then(r => r.ok ? r.json() : []),
- fetch("/api/dashboard/best-new-releases").then(r => r.ok ? r.json() : []),
- fetch("/api/dashboard/most-tested").then(r => r.ok ? r.json() : []),
- fetch("/api/dashboard/most-reported").then(r => r.ok ? r.json() : []),
- ])
- if (!cancelled) {
- setSections({
- trending: Array.isArray(trending) ? trending : [],
- bestNewReleases: Array.isArray(bestNew) ? bestNew : [],
- mostTested: Array.isArray(mostTested) ? mostTested : [],
- mostReported: Array.isArray(mostReported) ? mostReported : [],
- })
- }
- } catch {
- // Silently fail — sections are best-effort
- } finally {
- if (!cancelled) setSectionsLoading(false)
+ let cancelled = false
+ async function fetchSections() {
+ try {
+ const [recentBenchmarks, trending, mostTested] =
+ await Promise.all([
+ fetch("/api/dashboard/recent-benchmarks").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) {
+ setSections({
+ recentBenchmarks: Array.isArray(recentBenchmarks) ? recentBenchmarks : [],
+ trending: Array.isArray(trending) ? trending : [],
+ mostTested: Array.isArray(mostTested) ? mostTested : [],
+ })
+ }
+ } catch {
+ // Silently fail — sections are best-effort
+ } finally {
+ if (!cancelled) setSectionsLoading(false)
+ }
+ }
+ fetchSections()
+ return () => {
+ cancelled = true
}
- }
- fetchSections()
- return () => { cancelled = true }
}, [])
const handleSearchSubmit = () => {
@@ -237,180 +269,170 @@ export default function Landing() {
}
return (
- <>
- {/* ── Hero Section ── */}
-
+ 2026 DeckyVault.
+
+ Github.
+
+
+ v{process.env.NEXT_PUBLIC_APP_VERSION}
+
+
+
- {/* ── Landing Sections ── */}
-
- {sectionsLoading ? (
-
- ) : (
- <>
- {sections.trending.length > 0 && (
-
- )}
- {sections.bestNewReleases.length > 0 && (
- `${Math.round(Number(v))} FPS`}
- accentColor="text-green-400"
- />
- )}
- {sections.mostTested.length > 0 && (
-
- )}
- {sections.mostReported.length > 0 && (
-
- )}
- >
- )}
-
+ {/* ── Landing Sections ── */}
+
+ {sectionsLoading ? (
+
+ ) : (
+ <>
+ {sections.recentBenchmarks.length > 0 && (
+
+ )}
+ {sections.trending.length > 0 && (
+
+ )}
+ {sections.mostTested.length > 0 && (
+
+ )}
+ >
+ )}
+
-
- >
+
+ >
)
}