UX overhaul: cohesive navigation + sticky header, new sidebar menu with smooth anchor scrolling, home layout restructured into cards (About/Writing/Contact), blog list affordance improved, background dot contrast reduced, Lenis tuned for softer feel, footer aligned to page container, Next/Image sizing warning fixed, providers (Lenis/Motion) wired.
This commit is contained in:
parent
4b1b6ec6cb
commit
ef24c27085
@ -32,6 +32,8 @@ export function AvatarMotion({
|
||||
alt={alt}
|
||||
width={size}
|
||||
height={size}
|
||||
sizes={`${size}px`}
|
||||
style={{ width: size, height: size }}
|
||||
className={cn(
|
||||
baseSizeClasses,
|
||||
"rounded-full object-cover ring-2 ring-neutral-200 shadow-lg transition-shadow duration-300 group-hover:shadow-xl dark:shadow-none dark:ring-neutral-800",
|
||||
|
||||
@ -12,6 +12,7 @@ export function DotBackground({ className }: { className?: string }) {
|
||||
"[background-size:28px_28px]",
|
||||
"[background-image:radial-gradient(var(--dot-color)_1px,transparent_1px)]",
|
||||
"dark:[background-image:radial-gradient(#404040_1px,transparent_1px)]",
|
||||
"opacity-40 dark:opacity-20",
|
||||
)}
|
||||
/>
|
||||
{/* Radial gradient for the container to give a faded look */}
|
||||
|
||||
@ -17,8 +17,9 @@ function ArrowIcon() {
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="mb-16">
|
||||
<ul className="font-sm mt-8 flex flex-col space-x-0 space-y-2 text-neutral-600 md:flex-row md:space-x-4 md:space-y-0 dark:text-neutral-300">
|
||||
<footer className="mt-16 border-t border-black/5 dark:border-white/10">
|
||||
<div className="mx-auto max-w-5xl px-4 md:px-6">
|
||||
<ul className="font-sm mt-6 flex flex-row flex-wrap gap-4 text-neutral-600 dark:text-neutral-300">
|
||||
<li>
|
||||
<a
|
||||
className="flex items-center transition-all hover:text-neutral-800 dark:hover:text-neutral-100"
|
||||
@ -42,9 +43,10 @@ export default function Footer() {
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<p className="mt-8 text-neutral-600 dark:text-neutral-300">
|
||||
<p className="mt-6 text-neutral-600 dark:text-neutral-300">
|
||||
© {new Date().getFullYear()} MIT Licensed
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,40 +1,77 @@
|
||||
import Link from 'next/link'
|
||||
'use client'
|
||||
|
||||
const navItems = {
|
||||
'/': {
|
||||
name: 'home',
|
||||
},
|
||||
'/blog': {
|
||||
name: 'blog',
|
||||
},
|
||||
'https://vercel.com/templates/next.js/portfolio-starter-kit': {
|
||||
name: 'deploy',
|
||||
},
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
type NavItem = { href: string; label: string }
|
||||
|
||||
const mainNav: NavItem[] = [
|
||||
{ href: '/', label: 'Home' },
|
||||
{ href: '/blog', label: 'Blog' },
|
||||
{ href: '/#about', label: 'About' },
|
||||
{ href: '/#contact', label: 'Contact' },
|
||||
]
|
||||
|
||||
function NavLink({ href, label }: NavItem) {
|
||||
const pathname = usePathname()
|
||||
const isActive =
|
||||
href === '/'
|
||||
? pathname === '/'
|
||||
: pathname.startsWith(href.replace('/#', '/'))
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
'px-2 py-1 text-sm rounded-md transition-colors',
|
||||
'hover:bg-black/[0.04] dark:hover:bg-white/5',
|
||||
isActive && 'text-foreground underline underline-offset-4'
|
||||
)}
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export function Navbar() {
|
||||
return (
|
||||
<aside className="-ml-[8px] mb-16 tracking-tight">
|
||||
<div className="lg:sticky lg:top-20">
|
||||
<nav
|
||||
className="flex flex-row items-start relative px-0 pb-0 fade md:overflow-auto scroll-pr-6 md:relative"
|
||||
id="nav"
|
||||
>
|
||||
<div className="flex flex-row space-x-0 pr-10">
|
||||
{Object.entries(navItems).map(([path, { name }]) => {
|
||||
return (
|
||||
<Link
|
||||
key={path}
|
||||
href={path}
|
||||
className="transition-all hover:text-neutral-800 dark:hover:text-neutral-200 flex align-middle relative py-1 px-2 m-1"
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<header className="sticky top-0 z-40 w-full border-b border-black/5 dark:border-white/10 bg-background/70 backdrop-blur">
|
||||
<div className="mx-auto max-w-5xl px-4 md:px-6 h-14 flex items-center justify-between">
|
||||
<div className="font-semibold tracking-tight">
|
||||
<Link
|
||||
href="/"
|
||||
className="px-2 py-1 rounded-md hover:bg-black/[0.04] dark:hover:bg-white/5"
|
||||
aria-label="Home"
|
||||
>
|
||||
N
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<nav className="flex items-center gap-1" aria-label="Primary">
|
||||
{mainNav.map((item) => (
|
||||
<NavLink key={item.href} {...item} />
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="flex items-center gap-2" aria-label="Utilities">
|
||||
<Link
|
||||
href="/rss"
|
||||
className="px-2 py-1 text-sm rounded-md hover:bg-black/[0.04] dark:hover:bg-white/5"
|
||||
>
|
||||
RSS
|
||||
</Link>
|
||||
<a
|
||||
href="https://github.com/vercel/next.js"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="px-2 py-1 text-sm rounded-md hover:bg-black/[0.04] dark:hover:bg-white/5"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
82
app/components/sidebar-menu.tsx
Normal file
82
app/components/sidebar-menu.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useScrollContext } from '@/app/providers/LenisProvider'
|
||||
|
||||
type Item = { href: string; label: string }
|
||||
|
||||
const items: Item[] = [
|
||||
{ href: '#about', label: 'About' },
|
||||
{ href: '#writing', label: 'Writing' },
|
||||
{ href: '#contact', label: 'Contact' },
|
||||
]
|
||||
|
||||
export default function SidebarMenu() {
|
||||
const [active, setActive] = useState<string>('')
|
||||
const { lenis } = useScrollContext()
|
||||
|
||||
useEffect(() => {
|
||||
const observers: IntersectionObserver[] = []
|
||||
|
||||
const onIntersect: IntersectionObserverCallback = (entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
setActive(`#${entry.target.id}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const opts: IntersectionObserverInit = { rootMargin: '-30% 0px -60% 0px', threshold: 0.1 }
|
||||
const observer = new IntersectionObserver(onIntersect, opts)
|
||||
|
||||
items.forEach((it) => {
|
||||
const id = it.href.replace('#', '')
|
||||
const el = document.getElementById(id)
|
||||
if (el) observer.observe(el)
|
||||
})
|
||||
|
||||
observers.push(observer)
|
||||
|
||||
return () => {
|
||||
observers.forEach((o) => o.disconnect())
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<aside className="sticky top-24 h-fit">
|
||||
<p className="mb-2 text-xs font-medium uppercase tracking-wider text-neutral-600 dark:text-neutral-500">
|
||||
Menu
|
||||
</p>
|
||||
<nav aria-label="Section menu" className="flex flex-col gap-1">
|
||||
{items.map((it) => {
|
||||
const isActive = active === it.href
|
||||
return (
|
||||
<Link
|
||||
key={it.href}
|
||||
href={`/${it.href}`}
|
||||
onClick={(e) => {
|
||||
const id = it.href.replace('#', '')
|
||||
const el = document.getElementById(id)
|
||||
if (el && lenis) {
|
||||
e.preventDefault()
|
||||
lenis.scrollTo(el, { offset: -80 })
|
||||
try { history.replaceState(null, '', `/${it.href}`) } catch {}
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'px-3 py-2 text-sm rounded-md transition-colors',
|
||||
'hover:bg-black/[0.04] dark:hover:bg-white/5',
|
||||
isActive && 'bg-black/[0.06] dark:bg-white/10'
|
||||
)}
|
||||
aria-current={isActive ? 'true' : undefined}
|
||||
>
|
||||
{it.label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@ -4,6 +4,8 @@ import "./globals.css";
|
||||
import { DotBackground } from "@/app/components/dotbackground";
|
||||
import { Navbar } from './components/nav'
|
||||
import Footer from './components/footer'
|
||||
import { LenisProvider } from "./providers/LenisProvider";
|
||||
import { MotionConfigProvider } from "./providers/MotionConfigProvider";
|
||||
import { baseUrl } from './sitemap';
|
||||
|
||||
const geistSans = Geist({
|
||||
@ -59,12 +61,16 @@ export default function RootLayout({
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<Navbar />
|
||||
<DotBackground />
|
||||
<main className="flex-auto min-w-0 mt-6 flex flex-col px-2 md:px-0">
|
||||
{children}
|
||||
<Footer />
|
||||
</main>
|
||||
<MotionConfigProvider>
|
||||
<LenisProvider>
|
||||
<Navbar />
|
||||
<DotBackground />
|
||||
<main className="flex-auto min-w-0 mt-6 flex flex-col px-2 md:px-0">
|
||||
{children}
|
||||
<Footer />
|
||||
</main>
|
||||
</LenisProvider>
|
||||
</MotionConfigProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
215
app/page.tsx
215
app/page.tsx
@ -1,4 +1,7 @@
|
||||
import React from "react"
|
||||
import Link from "next/link"
|
||||
import SidebarMenu from "@/app/components/sidebar-menu"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { FlipWords } from "@/components/ui/flip-words"
|
||||
import { AvatarMotion } from "@/app/components/avatar-motion"
|
||||
import { BlogPosts } from "@/components/posts"
|
||||
@ -21,100 +24,132 @@ export default function Home() {
|
||||
const year = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center antialiased">
|
||||
<div className="w-full max-w-xl mx-auto flex flex-col items-center text-center gap-8 px-4 py-12">
|
||||
<section aria-label="Profile photo">
|
||||
<AvatarMotion
|
||||
src="/images/profile.jpg"
|
||||
alt="Hand drawn portrait of Nicholai"
|
||||
size={160}
|
||||
/>
|
||||
</section>
|
||||
<main className="min-h-screen">
|
||||
<div className="mx-auto grid w-full max-w-5xl grid-cols-1 gap-6 px-4 pt-10 pb-16 md:grid-cols-12 md:gap-8">
|
||||
{/* Sidebar */}
|
||||
<div className="md:col-span-3">
|
||||
<SidebarMenu />
|
||||
</div>
|
||||
|
||||
<section aria-labelledby="intro-title" className="space-y-2">
|
||||
<h1 id="intro-title" className="text-2xl font-semibold">
|
||||
Nicholai
|
||||
</h1>
|
||||
<p className="text-xs text-neutral-600 dark:text-neutral-400 font-normal">
|
||||
I wanted to justify the $6.39 I spent on the domain.
|
||||
</p>
|
||||
<div className="text-sm text-neutral-700 dark:text-neutral-400">
|
||||
<FlipWords words={["VFX Artist", "Developer"]} className="text-neutral-900 dark:text-neutral-200" />
|
||||
</div>
|
||||
</section>
|
||||
{/* Content column */}
|
||||
<div className="md:col-span-9 flex flex-col gap-6">
|
||||
{/* About */}
|
||||
<section id="about" aria-label="About">
|
||||
<Card>
|
||||
<CardHeader className="flex items-center gap-4 sm:flex-row">
|
||||
<AvatarMotion
|
||||
src="/images/profile.jpg"
|
||||
alt="Hand drawn portrait of Nicholai"
|
||||
size={96}
|
||||
/>
|
||||
<div>
|
||||
<CardTitle className="tracking-tight !normal-case">About</CardTitle>
|
||||
<CardDescription>Nicholai • VFX Artist & Developer</CardDescription>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
I wanted to justify the $6.39 I spent on the domain. Building cinematic, accessible web experiences.
|
||||
</p>
|
||||
|
||||
<nav aria-label="Hyperlinks" className="w-full space-y-1">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-neutral-600 dark:text-neutral-500">
|
||||
Hyperlinks
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
<li className="text-neutral-900 dark:text-neutral-200">
|
||||
VFX Supervisor at{" "}
|
||||
<a
|
||||
href="https://biohazardvfx.com"
|
||||
className=""
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Biohazard VFX
|
||||
</a>
|
||||
</li>
|
||||
<li className="text-neutral-900 dark:text-neutral-200">
|
||||
Developer{" "}
|
||||
<a
|
||||
href="https://fortura.cc"
|
||||
className=""
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Fortura Data Solutions
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="mailto:nicholai@biohazardvfx.com"
|
||||
className="text-xs"
|
||||
>
|
||||
Email me
|
||||
</a>
|
||||
</li>
|
||||
<li className="text-neutral-900 dark:text-neutral-200">
|
||||
<a
|
||||
href="https://www.instagram.com/nicholai.exe/"
|
||||
className=""
|
||||
target="_blank"
|
||||
rel="me noopener noreferrer"
|
||||
>
|
||||
Instagram
|
||||
</a>
|
||||
<span className="text-xs text-neutral-500"> - I hate Instagram</span>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<div className="my-8">
|
||||
<BlogPosts />
|
||||
</div>
|
||||
<div className="text-sm text-neutral-800 dark:text-neutral-200">
|
||||
<FlipWords words={["VFX Artist", "Developer"]} className="font-medium" />
|
||||
</div>
|
||||
|
||||
<section aria-labelledby="listening-title" className="w-full space-y-4">
|
||||
<h3 id="listening-title" className="text-sm font-medium uppercase tracking-wider text-neutral-600 dark:text-neutral-500 mb-4">
|
||||
Listening
|
||||
</h3>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<iframe
|
||||
title="Spotify playlist"
|
||||
style={{ borderRadius: 12 }}
|
||||
src="https://open.spotify.com/embed/playlist/1kV9JPnhvpfk0UtcpslRpa?utm_source=generator&theme=0"
|
||||
width="290"
|
||||
height="220"
|
||||
loading="lazy"
|
||||
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<ul className="flex flex-wrap gap-3 pt-1 text-sm">
|
||||
<li className="text-neutral-900 dark:text-neutral-200">
|
||||
VFX Supervisor at{" "}
|
||||
<a href="https://biohazardvfx.com" target="_blank" rel="noopener noreferrer">
|
||||
Biohazard VFX
|
||||
</a>
|
||||
</li>
|
||||
<li className="text-neutral-900 dark:text-neutral-200">
|
||||
Developer{" "}
|
||||
<a href="https://fortura.cc" target="_blank" rel="noopener noreferrer">
|
||||
Fortura Data Solutions
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="mailto:nicholai@biohazardvfx.com" className="underline underline-offset-4">
|
||||
Email me
|
||||
</a>
|
||||
</li>
|
||||
<li className="text-neutral-900 dark:text-neutral-200">
|
||||
<a
|
||||
href="https://www.instagram.com/nicholai.exe/"
|
||||
target="_blank"
|
||||
rel="me noopener noreferrer"
|
||||
>
|
||||
Instagram
|
||||
</a>
|
||||
<span className="ml-1 text-xs text-neutral-500">– I hate Instagram</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<footer className="pt-6 text-center text-xs text-neutral-600 dark:text-neutral-500">
|
||||
© {year} Nicholai · $6.39 well spent.
|
||||
</footer>
|
||||
{/* Listening */}
|
||||
<div className="pt-2">
|
||||
<h3 className="mb-2 text-xs font-medium uppercase tracking-wider text-neutral-600 dark:text-neutral-500">
|
||||
Listening
|
||||
</h3>
|
||||
<div className="spotify-card">
|
||||
<iframe
|
||||
title="Spotify playlist"
|
||||
style={{ borderRadius: 12 }}
|
||||
src="https://open.spotify.com/embed/playlist/1kV9JPnhvpfk0UtcpslRpa?utm_source=generator&theme=0"
|
||||
width="290"
|
||||
height="220"
|
||||
loading="lazy"
|
||||
allow="autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Writing */}
|
||||
<section id="writing" aria-label="Writing">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="!normal-case">Writing</CardTitle>
|
||||
<CardDescription>Recent posts</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="my-2">
|
||||
<BlogPosts />
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<Link
|
||||
href="/blog"
|
||||
className="inline-flex items-center rounded-md border border-black/10 dark:border-white/10 px-3 py-2 text-sm hover:bg-black/[0.04] dark:hover:bg-white/5"
|
||||
>
|
||||
View all posts
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Contact */}
|
||||
<section id="contact" aria-label="Contact">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="!normal-case">Contact</CardTitle>
|
||||
<CardDescription>Let's build something</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm">
|
||||
Prefer email:
|
||||
{" "}
|
||||
<a href="mailto:nicholai@biohazardvfx.com" className="underline underline-offset-4">
|
||||
nicholai@biohazardvfx.com
|
||||
</a>
|
||||
</p>
|
||||
<p className="footer-small mt-4">© {year} Nicholai · $6.39 well spent.</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
|
||||
@ -39,16 +39,17 @@ export function LenisProvider({ children }: { children: React.ReactNode }) {
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
|
||||
const lenis = new Lenis({
|
||||
// Good defaults for premium-feel scroll
|
||||
// Softer, less "floaty" feel
|
||||
smoothWheel: !prefersReducedMotion,
|
||||
syncTouch: true,
|
||||
duration: 1.2, // seconds to ease to target position
|
||||
easing: (t: number) => 1 - Math.pow(1 - t, 3), // easeOutCubic
|
||||
duration: 0.7, // was 1.2
|
||||
easing: (t: number) => 1 - Math.pow(1 - t, 2.4), // gentler ease-out
|
||||
wheelMultiplier: 0.9, // slightly reduce wheel amplitude
|
||||
// Use native on reduced motion
|
||||
wrapper: undefined,
|
||||
content: undefined,
|
||||
// If reduced motion, disable smoothing entirely
|
||||
lerp: prefersReducedMotion ? 1 : 0.1,
|
||||
lerp: prefersReducedMotion ? 1 : 0.22, // was 0.1 (heavier smoothing)
|
||||
});
|
||||
|
||||
lenisRef.current = lenis;
|
||||
|
||||
@ -5,32 +5,33 @@ export function BlogPosts() {
|
||||
const allBlogs = getBlogPosts()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ul className="divide-y divide-black/5 dark:divide-white/10">
|
||||
{allBlogs
|
||||
.sort((a, b) => {
|
||||
if (
|
||||
new Date(a.metadata.publishedAt) > new Date(b.metadata.publishedAt)
|
||||
) {
|
||||
if (new Date(a.metadata.publishedAt) > new Date(b.metadata.publishedAt)) {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
})
|
||||
.map((post) => (
|
||||
<Link
|
||||
key={post.slug}
|
||||
className="flex flex-col space-y-1 mb-4"
|
||||
href={`/blog/${post.slug}`}
|
||||
>
|
||||
<div className="w-full flex flex-col md:flex-row space-x-0 md:space-x-2">
|
||||
<p className="text-neutral-600 dark:text-neutral-400 w-[100px] tabular-nums">
|
||||
<li key={post.slug}>
|
||||
<Link
|
||||
className="group flex items-baseline justify-between gap-4 rounded-md px-2 py-2 transition-colors hover:bg-black/[0.04] dark:hover:bg-white/5"
|
||||
href={`/blog/${post.slug}`}
|
||||
aria-label={`Read: ${post.metadata.title}`}
|
||||
>
|
||||
<span className="w-32 flex-none text-xs text-neutral-600 dark:text-neutral-400 tabular-nums">
|
||||
{formatDate(post.metadata.publishedAt, false)}
|
||||
</p>
|
||||
<p className="text-neutral-900 dark:text-neutral-100 tracking-tight">
|
||||
</span>
|
||||
<span className="flex-1 text-neutral-900 dark:text-neutral-100 tracking-tight underline-offset-4 group-hover:underline">
|
||||
{post.metadata.title}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</span>
|
||||
<span className="hidden sm:inline text-neutral-400 transition-colors group-hover:text-neutral-600 dark:group-hover:text-neutral-300">
|
||||
→
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</div>
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
61
components/ui/card.tsx
Normal file
61
components/ui/card.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function Card({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border border-black/10 dark:border-white/10',
|
||||
'bg-white/60 dark:bg-white/[0.03] shadow-sm backdrop-blur-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('px-5 py-4', className)} {...props} />
|
||||
}
|
||||
|
||||
export function CardTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLHeadingElement>) {
|
||||
return (
|
||||
<h3
|
||||
className={cn(
|
||||
'text-sm font-medium uppercase tracking-wider text-neutral-600 dark:text-neutral-400',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLParagraphElement>) {
|
||||
return (
|
||||
<p
|
||||
className={cn('text-sm text-neutral-600 dark:text-neutral-400', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function CardContent({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('px-5 pb-5', className)} {...props} />
|
||||
}
|
||||
36
package-lock.json
generated
36
package-lock.json
generated
@ -9,6 +9,7 @@
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"lenis": "^1.3.11",
|
||||
"mdx": "^0.3.1",
|
||||
"motion": "^12.23.12",
|
||||
"next": "15.5.2",
|
||||
@ -1425,6 +1426,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz",
|
||||
"integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
@ -1491,6 +1493,7 @@
|
||||
"integrity": "sha512-B7RIQiTsCBBmY+yW4+ILd6mF5h1FUwJsVvpqkrgpszYifetQ2Ke+Z4u6aZh0CblkUGIdR59iYVyXqqZGkZ3aBw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.43.0",
|
||||
"@typescript-eslint/types": "8.43.0",
|
||||
@ -2013,6 +2016,7 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@ -3108,6 +3112,7 @@
|
||||
"integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@ -3282,6 +3287,7 @@
|
||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.9",
|
||||
@ -4840,6 +4846,32 @@
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/lenis": {
|
||||
"version": "1.3.11",
|
||||
"resolved": "https://registry.npmjs.org/lenis/-/lenis-1.3.11.tgz",
|
||||
"integrity": "sha512-lkyBnNTVwJzlupp+VL6LTn62WeT8WponuLpmTU0Z20cMwMsLLjqbSqwuA7I1yKSVWCBj/awo4jnFzOMOVCB8OQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/darkroomengineering"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@nuxt/kit": ">=3.0.0",
|
||||
"react": ">=17.0.0",
|
||||
"vue": ">=3.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@nuxt/kit": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"vue": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||
@ -6773,6 +6805,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
|
||||
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@ -6782,6 +6815,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.26.0"
|
||||
},
|
||||
@ -7816,6 +7850,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@ -7994,6 +8029,7 @@
|
||||
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"clsx": "^2.1.1",
|
||||
"lenis": "^1.3.11",
|
||||
"mdx": "^0.3.1",
|
||||
"motion": "^12.23.12",
|
||||
"next": "15.5.2",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user