diff --git a/app/globals.css b/app/globals.css
index 0a7e559..5eb5700 100644
--- a/app/globals.css
+++ b/app/globals.css
@@ -35,4 +35,23 @@
outline: 2px solid var(--color-primary);
outline-offset: 2px;
border-radius: 4px;
+}
+
+/* Thin scrollbar for horizontal scroll sections */
+.scrollbar-thin {
+ scrollbar-width: thin;
+ scrollbar-color: color-mix(in srgb, var(--color-text) 15%, transparent) transparent;
+}
+
+.scrollbar-thin::-webkit-scrollbar {
+ height: 4px;
+}
+
+.scrollbar-thin::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+.scrollbar-thin::-webkit-scrollbar-thumb {
+ background: color-mix(in srgb, var(--color-text) 15%, transparent);
+ border-radius: 2px;
}
\ No newline at end of file
diff --git a/app/page.tsx b/app/page.tsx
index 259676b..0b83a70 100644
--- a/app/page.tsx
+++ b/app/page.tsx
@@ -1,23 +1,228 @@
"use client"
import { AnimatePresence, motion } from "motion/react"
-import { useState } from "react"
+import { useState, useEffect } from "react"
import Link from "next/link"
-import { Gamepad2Icon, SearchIcon } from "lucide-react"
+import Image from "next/image"
+import { Gamepad2Icon, SearchIcon, TrendingUpIcon, SparklesIcon, GaugeIcon, FlagIcon } from "lucide-react"
import { useRouter } from "next/navigation"
+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
+}
+
+interface SectionData {
+ trending: GameCard[]
+ bestNewReleases: GameCard[]
+ mostTested: GameCard[]
+ mostReported: GameCard[]
+}
+
+function SkeletonSections() {
+ 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: string
+ icon: React.ElementType
+ games: GameCard[]
+ statKey: string
+ statLabel: string
+ statFormatter?: (v: unknown) => string
+ accentColor?: string
+ muted?: boolean
+}) {
+ 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}`
+ }
+
+ return (
+
+
+
+
+ {games.map((game, idx) => (
+
router.push(`/game/${game.id}?sync=1`)}
+ >
+
+ {game.capsule_image ? (
+
+ ) : (
+
+
+
+ )}
+ {game.playability_status && game.playability_status !== "unknown" && (
+
+ )}
+
+
+
+
+ {game.title}
+
+
+ {formatStat((game as unknown as Record)[statKey])}
+
+
+
+ ))}
+
+
+ )
+}
+
export default function Landing() {
const router = useRouter()
const words = ["benchmarks", "settings", "reviews"]
const [currentWord, setCurrentWord] = useState(0)
-
- setTimeout(() => {
- setCurrentWord((currentWord + 1) % words.length)
- }, 2000)
-
const [searchQuery, setSearchQuery] = useState("")
+ // Landing section state
+ const [sections, setSections] = useState({
+ trending: [],
+ bestNewReleases: [],
+ mostTested: [],
+ mostReported: [],
+ })
+ 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)
+ }, [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)
+ }
+ }
+ fetchSections()
+ return () => { cancelled = true }
+ }, [])
+
const handleSearchSubmit = () => {
if (searchQuery.trim()) {
sessionStorage.setItem("focusSearch", "true")
@@ -32,9 +237,11 @@ export default function Landing() {
}
return (
+ <>
+ {/* ── Hero Section ── */}
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 && (
+
+ )}
+ >
+ )}
+
+
+
+ >
)
}