From 23700b00aecedcfa7a8258093c335d397c4c4841 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Fri, 15 May 2026 01:05:55 +0800 Subject: [PATCH] feat(landing): trending, new releases, most tested/reported sections with peek hero layout --- app/globals.css | 19 +++ app/page.tsx | 313 ++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 306 insertions(+), 26 deletions(-) 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 ( + +
+
+
+ +

{title}

+
+
+
+ +
+ {games.map((game, idx) => ( + router.push(`/game/${game.id}?sync=1`)} + > +
+ {game.capsule_image ? ( + {game.title} + ) : ( +
+ +
+ )} + {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} -