feat: initial landing and search pages
This commit is contained in:
@@ -8,6 +8,8 @@
|
|||||||
--color-secondary: #571b8b;
|
--color-secondary: #571b8b;
|
||||||
--color-accent: #fb793c;
|
--color-accent: #fb793c;
|
||||||
--color-text: #ebe4f1;
|
--color-text: #ebe4f1;
|
||||||
|
--color-border: color-mix(in srgb, var(--color-text) 20%, transparent);
|
||||||
|
--color-border-active: color-mix(in srgb, var(--color-text) 60%, transparent);
|
||||||
|
|
||||||
/* Defaults */
|
/* Defaults */
|
||||||
--default-transition-duration: 0.3s;
|
--default-transition-duration: 0.3s;
|
||||||
|
|||||||
+6
-1
@@ -2,6 +2,8 @@ import type { Metadata } from "next"
|
|||||||
import { Lexend } from "next/font/google"
|
import { Lexend } from "next/font/google"
|
||||||
import "./globals.css"
|
import "./globals.css"
|
||||||
import Script from "next/script"
|
import Script from "next/script"
|
||||||
|
import Navbar from "@/components/navbar"
|
||||||
|
import { Suspense } from "react"
|
||||||
|
|
||||||
const font = Lexend({
|
const font = Lexend({
|
||||||
variable: "--font-lexend",
|
variable: "--font-lexend",
|
||||||
@@ -80,7 +82,10 @@ export default function RootLayout({
|
|||||||
lang='en'
|
lang='en'
|
||||||
className={`${font.variable} bg-background text-text antialiased overscroll-none`}
|
className={`${font.variable} bg-background text-text antialiased overscroll-none`}
|
||||||
>
|
>
|
||||||
<body className='min-h-full flex flex-col relative'>
|
<body className='min-h-full w-dvw flex flex-col relative'>
|
||||||
|
<Suspense>
|
||||||
|
<Navbar />
|
||||||
|
</Suspense>
|
||||||
{children}
|
{children}
|
||||||
<Script
|
<Script
|
||||||
src='https://analytics.ranlabs.space/api/script.js'
|
src='https://analytics.ranlabs.space/api/script.js'
|
||||||
|
|||||||
+62
-29
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
import { AnimatePresence, motion } from "motion/react"
|
import { AnimatePresence, motion } from "motion/react"
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
import logo from "@/app/icon.png"
|
|
||||||
import Image from "next/image"
|
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
import { Gamepad2Icon, SearchIcon } from "lucide-react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
|
||||||
export default function Landing() {
|
export default function Landing() {
|
||||||
const words = ["benchmarks", "settings", "guides", "reviews"]
|
const router = useRouter()
|
||||||
|
const words = ["benchmarks", "settings", "reviews"]
|
||||||
|
|
||||||
const [currentWord, setCurrentWord] = useState(0)
|
const [currentWord, setCurrentWord] = useState(0)
|
||||||
|
|
||||||
@@ -15,39 +16,51 @@ export default function Landing() {
|
|||||||
setCurrentWord((currentWord + 1) % words.length)
|
setCurrentWord((currentWord + 1) % words.length)
|
||||||
}, 2000)
|
}, 2000)
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
|
|
||||||
|
const handleSearchSubmit = () => {
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
sessionStorage.setItem("focusSearch", "true")
|
||||||
|
router.push(`/search?q=${encodeURIComponent(searchQuery.trim())}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
handleSearchSubmit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
id='hero'
|
id="hero"
|
||||||
className='w-dvw h-dvh flex flex-col items-center justify-center relative p-4'
|
className="w-full h-[calc(100vh-3.6rem)] flex flex-col items-center justify-center relative p-4"
|
||||||
>
|
>
|
||||||
<Image
|
|
||||||
src={logo}
|
|
||||||
alt=''
|
|
||||||
className='h-24 md:h-30 w-auto'
|
|
||||||
loading='eager'
|
|
||||||
/>
|
|
||||||
<motion.h1
|
<motion.h1
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
className='font-bold text-3xl md:text-5xl text-center'
|
className="font-bold text-3xl md:text-5xl text-center"
|
||||||
>
|
>
|
||||||
DeckyVault
|
DeckyVault
|
||||||
</motion.h1>
|
</motion.h1>
|
||||||
<motion.h2
|
<motion.h2
|
||||||
|
layout
|
||||||
initial={{
|
initial={{
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
}}
|
}}
|
||||||
animate={{ opacity: 1, transition: { delay: 0.5 } }}
|
animate={{ opacity: 0.8, transition: { delay: 0.5 } }}
|
||||||
className='mt-4 flex flex-col items-center font-semibold text-lg md:text-2xl'
|
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
|
<motion.span
|
||||||
key='intro'
|
key={index}
|
||||||
className='text-center'
|
className="text-center"
|
||||||
>
|
>
|
||||||
A fast, modern browser for finding game
|
{word}
|
||||||
</motion.span>
|
</motion.span>
|
||||||
|
))}
|
||||||
<AnimatePresence
|
<AnimatePresence
|
||||||
mode='wait'
|
mode="wait"
|
||||||
initial={false}
|
initial={false}
|
||||||
>
|
>
|
||||||
<motion.span
|
<motion.span
|
||||||
@@ -55,29 +68,49 @@ export default function Landing() {
|
|||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
className='text-primary font-bold'
|
className="text-primary font-bold"
|
||||||
>
|
>
|
||||||
{words[currentWord]}
|
{words[currentWord]}
|
||||||
</motion.span>
|
</motion.span>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</motion.h2>
|
</motion.h2>
|
||||||
<motion.p
|
<AnimatePresence>
|
||||||
|
<motion.label
|
||||||
|
key="search-bar"
|
||||||
|
layoutId="search-bar"
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 0.6, transition: { delay: 1 } }}
|
animate={{ opacity: 1 }}
|
||||||
className='mt-4 text-center md:text-lg max-w-xl'
|
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"
|
||||||
>
|
>
|
||||||
Stay tuned for the launch of DeckyVault
|
<Gamepad2Icon className="h-6 w-6 group-focus-within:stroke-accent transition-colors shrink-0" />
|
||||||
</motion.p>
|
<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
|
<motion.small
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1, transition: { delay: 1.5 } }}
|
animate={{ opacity: 1, transition: { delay: 1.5 } }}
|
||||||
className='absolute bottom-4 text-center text-xs flex flex-row gap-1'
|
className="mt-8 text-center text-xs flex flex-row gap-1"
|
||||||
>
|
>
|
||||||
<span className='opacity-60'>2026 DeckyVault.</span>
|
<span className="opacity-60">2026 DeckyVault.</span>
|
||||||
<Link
|
<Link
|
||||||
title='Visit our Github Repository'
|
title="Visit our Github Repository"
|
||||||
href='https://github.com/AdrianBonpin/deckyvault'
|
href="https://github.com/AdrianBonpin/deckyvault"
|
||||||
className='text-accent opacity-60 hover:opacity-100 transition-opacity'
|
className="text-accent opacity-60 hover:opacity-100 transition-opacity"
|
||||||
>
|
>
|
||||||
Github.
|
Github.
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Suspense } from "react"
|
||||||
|
import { useSearchParams } from "next/navigation"
|
||||||
|
import { motion } from "motion/react"
|
||||||
|
import { Gamepad2Icon } from "lucide-react"
|
||||||
|
|
||||||
|
function SearchContent() {
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
const query = searchParams.get("q") || ""
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="w-full min-h-[calc(100vh-3.6rem)] flex flex-col items-center p-4 md:px-[10svw]">
|
||||||
|
<div className="w-full max-w-4xl mt-8">
|
||||||
|
<h1 className="text-2xl font-bold mb-2">
|
||||||
|
{query ? `Search results for "${query}"` : "Search"}
|
||||||
|
</h1>
|
||||||
|
<p className="text-text/60 text-sm mb-8">
|
||||||
|
{query
|
||||||
|
? "Showing results from the database..."
|
||||||
|
: "Enter a game name or AppID to find benchmarks, settings, and reviews."}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!query && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
||||||
|
<Gamepad2Icon className="h-12 w-12 text-text/20" />
|
||||||
|
<p className="text-text/40 text-sm">
|
||||||
|
Start typing to search for games
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{query && (
|
||||||
|
<div className="flex flex-col items-center justify-center py-20 gap-4">
|
||||||
|
<div className="animate-spin h-8 w-8 border-2 border-primary border-t-transparent rounded-full" />
|
||||||
|
<p className="text-text/60 text-sm">
|
||||||
|
Searching for "{query}"...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SearchPage() {
|
||||||
|
return (
|
||||||
|
<Suspense>
|
||||||
|
<SearchContent />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { AnimatePresence, motion } from "motion/react"
|
||||||
|
import logo from "@/app/icon.png"
|
||||||
|
import Image from "next/image"
|
||||||
|
import Link from "next/link"
|
||||||
|
import { useEffect, useRef, useState } from "react"
|
||||||
|
import { CircleXIcon, Gamepad2Icon, MenuIcon, XIcon } from "lucide-react"
|
||||||
|
import { routes } from "@/lib/routes"
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation"
|
||||||
|
|
||||||
|
export default function Navbar() {
|
||||||
|
const pathname = usePathname()
|
||||||
|
const router = useRouter()
|
||||||
|
const searchParams = useSearchParams()
|
||||||
|
|
||||||
|
const isLanding = pathname === "/"
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
|
||||||
|
const [isFocused, setIsFocused] = useState(false)
|
||||||
|
const [forceFocusStyles, setForceFocusStyles] = useState(false)
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
// Sync search query with URL ?q= param
|
||||||
|
useEffect(() => {
|
||||||
|
const q = searchParams.get("q")
|
||||||
|
if (q) setSearchQuery(q)
|
||||||
|
}, [searchParams])
|
||||||
|
|
||||||
|
// Maintain focus & styles when flying from landing page search
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
!isLanding &&
|
||||||
|
searchQuery &&
|
||||||
|
sessionStorage.getItem("focusSearch") === "true"
|
||||||
|
) {
|
||||||
|
sessionStorage.removeItem("focusSearch")
|
||||||
|
setForceFocusStyles(true)
|
||||||
|
// Focus the input after the layout animation element mounts
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
inputRef.current?.focus()
|
||||||
|
})
|
||||||
|
const timer = setTimeout(() => setForceFocusStyles(false), 450)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [isLanding, searchQuery])
|
||||||
|
|
||||||
|
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = e.target.value
|
||||||
|
setSearchQuery(value)
|
||||||
|
if (!isLanding) {
|
||||||
|
const params = new URLSearchParams(searchParams.toString())
|
||||||
|
if (value) {
|
||||||
|
params.set("q", value)
|
||||||
|
} else {
|
||||||
|
params.delete("q")
|
||||||
|
}
|
||||||
|
router.replace(`/search?${params.toString()}`, { scroll: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSearchSubmit = () => {
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
router.push(`/search?q=${encodeURIComponent(searchQuery.trim())}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
handleSearchSubmit()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const navbarRoutes = routes.filter(
|
||||||
|
(route) => route.href !== "/" && route.href !== "/search",
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<motion.nav
|
||||||
|
initial={{ opacity: 0, y: "-100%" }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
className='w-full flex flex-row px-4 md:px-[10svw] py-2 items-center gap-4 md:gap-8 justify-between border-b border-border sticky top-0 z-50 backdrop-blur-sm bg-background/80'
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
href='/'
|
||||||
|
className='flex flex-row gap-2 items-center font-bold text-lg py-1 shrink-0'
|
||||||
|
>
|
||||||
|
<Image
|
||||||
|
src={logo}
|
||||||
|
alt='DeckyVault Logo'
|
||||||
|
className='h-6 my-1 w-auto'
|
||||||
|
/>
|
||||||
|
{!isLanding && (
|
||||||
|
<motion.span className='hidden md:inline-block'>
|
||||||
|
DeckyVault
|
||||||
|
</motion.span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<AnimatePresence>
|
||||||
|
{!isLanding && (
|
||||||
|
<motion.label
|
||||||
|
key='search-bar'
|
||||||
|
layoutId='search-bar'
|
||||||
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
|
exit={{ opacity: 0, scale: 0.95 }}
|
||||||
|
transition={{ duration: 0.3 }}
|
||||||
|
className={`flex flex-row items-center gap-2 flex-1 bg-text/5 px-2 py-1 rounded-md border placeholder:text-text/60 group transition-colors cursor-text ${
|
||||||
|
isFocused || forceFocusStyles
|
||||||
|
? "border-primary/80 ring-2 ring-primary/50 ring-offset-2 ring-offset-background"
|
||||||
|
: "border-border hover:border-border-active"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Gamepad2Icon
|
||||||
|
className={`h-4 w-4 transition-colors shrink-0 ${
|
||||||
|
isFocused || forceFocusStyles
|
||||||
|
? "stroke-accent"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type='text'
|
||||||
|
placeholder='search by game or appid...'
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={() => setIsFocused(true)}
|
||||||
|
onBlur={() => setIsFocused(false)}
|
||||||
|
className='flex-1 outline-none bg-transparent text-sm min-w-0'
|
||||||
|
/>
|
||||||
|
<motion.button>
|
||||||
|
<CircleXIcon
|
||||||
|
onClick={() => setSearchQuery("")}
|
||||||
|
className={`h-3 w-3 transition-color cursor-default hover:stroke-accent transition-all ${
|
||||||
|
isFocused || forceFocusStyles
|
||||||
|
? "opacity-100"
|
||||||
|
: "opacity-0"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</motion.button>
|
||||||
|
</motion.label>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* Desktop Navigation Links */}
|
||||||
|
<ul className='hidden md:flex flex-row items-center gap-6 shrink-0'>
|
||||||
|
{navbarRoutes.map((route) => (
|
||||||
|
<Link
|
||||||
|
key={route.href}
|
||||||
|
href={route.href}
|
||||||
|
title={`Navigate to ${route.title}`}
|
||||||
|
className='text-sm font-medium hover:text-primary transition-colors uppercase'
|
||||||
|
>
|
||||||
|
{route.title}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{/* Mobile Hamburger Button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileMenuOpen(true)}
|
||||||
|
className='flex md:hidden flex-row items-center justify-center p-2 -mr-2 rounded-md hover:bg-text/5 transition-colors'
|
||||||
|
aria-label='Open menu'
|
||||||
|
>
|
||||||
|
<MenuIcon className='h-5 w-5' />
|
||||||
|
</button>
|
||||||
|
</motion.nav>
|
||||||
|
|
||||||
|
{/* Mobile Sidebar Overlay */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{mobileMenuOpen && (
|
||||||
|
<>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2 }}
|
||||||
|
className='fixed inset-0 bg-black/50 z-60 md:hidden'
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
/>
|
||||||
|
<motion.aside
|
||||||
|
initial={{ x: "100%" }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
exit={{ x: "100%" }}
|
||||||
|
transition={{
|
||||||
|
type: "spring",
|
||||||
|
damping: 25,
|
||||||
|
stiffness: 250,
|
||||||
|
}}
|
||||||
|
className='fixed top-0 right-0 bottom-0 w-64 bg-background border-l border-border z-70 md:hidden flex flex-col'
|
||||||
|
>
|
||||||
|
<div className='flex flex-row items-center justify-between px-4 py-2 border-b border-border'>
|
||||||
|
<span className='font-bold text-lg'>Menu</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
className='p-2 -mr-2 rounded-md hover:bg-text/5 transition-colors'
|
||||||
|
aria-label='Close menu'
|
||||||
|
>
|
||||||
|
<XIcon className='h-5 w-5' />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<nav className='flex flex-col p-4 gap-2'>
|
||||||
|
{routes.map((route) => (
|
||||||
|
<Link
|
||||||
|
key={route.href}
|
||||||
|
href={route.href}
|
||||||
|
onClick={() => setMobileMenuOpen(false)}
|
||||||
|
className={`text-sm font-medium hover:text-primary transition-colors uppercase px-3 py-2 rounded-md hover:bg-text/5 ${
|
||||||
|
pathname === route.href
|
||||||
|
? "text-primary bg-text/5"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{route.title}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</motion.aside>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export const routes = [
|
||||||
|
{
|
||||||
|
title: "Home",
|
||||||
|
href: "/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Search",
|
||||||
|
href: "/search",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Games",
|
||||||
|
href: "/games",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Compare",
|
||||||
|
href: "/compare",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Devices",
|
||||||
|
href: "/devices",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "About",
|
||||||
|
href: "/about",
|
||||||
|
}
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user