Integrate blog functionality from EXAMPLE/blog

This commit is contained in:
nicholai 2025-09-21 12:50:40 -06:00
parent 0500465ad2
commit 4b1b6ec6cb
45 changed files with 4750 additions and 20 deletions

100
app/blog/[slug]/page.tsx Normal file
View File

@ -0,0 +1,100 @@
import { notFound } from 'next/navigation'
import { CustomMDX } from '@/components/mdx'
import { formatDate, getBlogPosts } from '../utils'
import { baseUrl } from '../../sitemap'
export async function generateStaticParams() {
let posts = getBlogPosts()
return posts.map((post) => ({
slug: post.slug,
}))
}
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = getBlogPosts().find((post) => post.slug === slug)
if (!post) {
return
}
const {
title,
publishedAt: publishedTime,
summary: description,
image,
} = post.metadata
const ogImage = image
? image
: `${baseUrl}/og?title=${encodeURIComponent(title)}`
return {
title,
description,
openGraph: {
title,
description,
type: 'article',
publishedTime,
url: `${baseUrl}/blog/${post.slug}`,
images: [
{
url: ogImage,
},
],
},
twitter: {
card: 'summary_large_image',
title,
description,
images: [ogImage],
},
}
}
export default async function Blog({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const post = getBlogPosts().find((post) => post.slug === slug)
if (!post) {
notFound()
}
return (
<section>
<script
type="application/ld+json"
suppressHydrationWarning
dangerouslySetInnerHTML={{
__html: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: post.metadata.title,
datePublished: post.metadata.publishedAt,
dateModified: post.metadata.publishedAt,
description: post.metadata.summary,
image: post.metadata.image
? `${baseUrl}${post.metadata.image}`
: `/og?title=${encodeURIComponent(post.metadata.title)}`,
url: `${baseUrl}/blog/${post.slug}`,
author: {
'@type': 'Person',
name: 'My Portfolio',
},
}),
}}
/>
<h1 className="title font-semibold text-2xl tracking-tighter">
{post.metadata.title}
</h1>
<div className="flex justify-between items-center mt-2 mb-8 text-sm">
<p className="text-sm text-neutral-600 dark:text-neutral-400">
{formatDate(post.metadata.publishedAt)}
</p>
</div>
<article className="prose">
<CustomMDX source={post.content} />
</article>
</section>
)
}

15
app/blog/page.tsx Normal file
View File

@ -0,0 +1,15 @@
import { BlogPosts } from '../../components/posts'
export const metadata = {
title: 'Blog',
description: 'Read my blog.',
}
export default function Page() {
return (
<section>
<h1 className="font-semibold text-2xl mb-8 tracking-tighter">My Blog</h1>
<BlogPosts />
</section>
)
}

View File

@ -0,0 +1,31 @@
---
title: 'Spaces vs. Tabs: The Indentation Debate Continues'
publishedAt: '2024-04-08'
summary: 'Explore the enduring debate between using spaces and tabs for code indentation, and why this choice matters more than you might think.'
---
The debate between using spaces and tabs for indentation in coding may seem trivial to the uninitiated, but it is a topic that continues to inspire passionate discussions among developers. This seemingly minor choice can affect code readability, maintenance, and even team dynamics.
Let's delve into the arguments for both sides and consider why this debate remains relevant in the software development world.
## The Case for Spaces
Advocates for using spaces argue that it ensures consistent code appearance across different editors, tools, and platforms. Because a space is a universally recognized character with a consistent width, code indented with spaces will look the same no matter where it's viewed. This consistency is crucial for maintaining readability and avoiding formatting issues when code is shared between team members or published online.
Additionally, some programming languages and style guides explicitly recommend spaces for indentation, suggesting a certain number of spaces (often two or four) per indentation level. Adhering to these recommendations can be essential for projects that aim for best practices in code quality and readability.
## The Case for Tabs
On the other side of the debate, proponents of tabs highlight the flexibility that tabs offer. Because the width of a tab can be adjusted in most text editors, individual developers can choose how much indentation they prefer to see, making the code more accessible and comfortable to read on a personal level. This adaptability can be particularly beneficial in teams with diverse preferences regarding code layout.
Tabs also have the advantage of semantic meaning. A tab is explicitly meant to represent indentation, whereas a space is used for many purposes within code. This distinction can make automated parsing and manipulation of code simpler, as tools can more easily recognize and adjust indentation levels without confusing them with spaces used for alignment.
## Hybrid Approaches and Team Dynamics
The debate often extends into discussions about hybrid approaches, where teams might use tabs for indentation and spaces for alignment within lines, attempting to combine the best of both worlds. However, such strategies require clear team agreements and disciplined adherence to coding standards to prevent formatting chaos.
Ultimately, the choice between spaces and tabs often comes down to team consensus and project guidelines. In environments where collaboration and code sharing are common, agreeing on a standard that everyone follows is more important than the individual preferences of spaces versus tabs. Modern development tools and linters can help enforce these standards, making the choice less about technical limitations and more about team dynamics and coding philosophy.
## Conclusion
While the spaces vs. tabs debate might not have a one-size-fits-all answer, it underscores the importance of consistency, readability, and team collaboration in software development. Whether a team chooses spaces, tabs, or a hybrid approach, the key is to make a conscious choice that serves the project's needs and to adhere to it throughout the codebase. As with many aspects of coding, communication and agreement among team members are paramount to navigating this classic programming debate.

View File

@ -0,0 +1,52 @@
---
title: 'The Power of Static Typing in Programming'
publishedAt: '2024-04-07'
summary: 'In the ever-evolving landscape of software development, the debate between dynamic and static typing continues to be a hot topic.'
---
In the ever-evolving landscape of software development, the debate between dynamic and static typing continues to be a hot topic. While dynamic typing offers flexibility and rapid development, static typing brings its own set of powerful advantages that can significantly improve the quality and maintainability of code. In this post, we'll explore why static typing is crucial for developers, accompanied by practical examples through markdown code snippets.
## Improved Code Quality and Safety
One of the most compelling reasons to use static typing is the improvement it brings to code quality and safety. By enforcing type checks at compile time, static typing catches errors early in the development process, reducing the chances of runtime errors.
```ts
function greet(name: string): string {
return `Hello, ${name}!`
}
// This will throw an error at compile time, preventing potential runtime issues.
let message: string = greet(123)
```
## Enhanced Readability and Maintainability
Static typing makes code more readable and maintainable. By explicitly declaring types, developers provide a clear contract of what the code does, making it easier for others (or themselves in the future) to understand and modify the codebase.
## Facilitates Tooling and Refactoring
Modern IDEs leverage static typing to offer advanced features like code completion, refactoring, and static analysis. These tools can automatically detect issues, suggest fixes, and safely refactor code, enhancing developer productivity and reducing the likelihood of introducing bugs during refactoring.
```csharp
// Refactoring example: Renaming a method in C#
public class Calculator {
public int Add(int a, int b) {
return a + b;
}
}
// After refactoring `Add` to `Sum`, all references are automatically updated.
public class Calculator {
public int Sum(int a, int b) {
return a + b;
}
}
```
## Performance Optimizations
Static typing can lead to better performance. Since types are known at compile time, compilers can optimize the generated code more effectively. This can result in faster execution times and lower resource consumption.
## Conclusion
Static typing offers numerous benefits that contribute to the development of robust, efficient, and maintainable software. By catching errors early, enhancing readability, facilitating tooling, and enabling optimizations, static typing is an invaluable asset for developers. As the software industry continues to mature, the importance of static typing in ensuring code quality and performance cannot be overstated. Whether you're working on a large-scale enterprise application or a small project, embracing static typing can lead to better software development outcomes.

39
app/blog/posts/vim.mdx Normal file
View File

@ -0,0 +1,39 @@
---
title: 'Embracing Vim: The Unsung Hero of Code Editors'
publishedAt: '2024-04-09'
summary: 'Discover why Vim, with its steep learning curve, remains a beloved tool among developers for editing code efficiently and effectively.'
---
In the world of software development, where the latest and greatest tools frequently capture the spotlight, Vim stands out as a timeless classic. Despite its age and initial complexity, Vim has managed to retain a devoted following of developers who swear by its efficiency, versatility, and power.
This article delves into the reasons behind Vim's enduring appeal and why it continues to be a great tool for coding in the modern era.
## Efficiency and Speed
At the heart of Vim's philosophy is the idea of minimizing keystrokes to achieve maximum efficiency.
Unlike other text editors where the mouse is often relied upon for navigation and text manipulation, Vim's keyboard-centric design allows developers to perform virtually all coding tasks without leaving the home row. This not only speeds up coding but also reduces the risk of repetitive strain injuries.
## Highly Customizable
Vim can be extensively customized to suit any developer's preferences and workflow. With a vibrant ecosystem of plugins and a robust scripting language, users can tailor the editor to their specific needs, whether it's programming in Python, writing in Markdown, or managing projects.
This level of customization ensures that Vim remains relevant and highly functional for a wide range of programming tasks and languages.
## Ubiquity and Portability
Vim is virtually everywhere. It's available on all major platforms, and because it's lightweight and terminal-based, it can be used on remote servers through SSH, making it an indispensable tool for sysadmins and developers working in a cloud-based environment.
The ability to use the same editor across different systems without a graphical interface is a significant advantage for those who need to maintain a consistent workflow across multiple environments.
## Vibrant Community
Despite—or perhaps because of—its learning curve, Vim has cultivated a passionate and active community. Online forums, dedicated websites, and plugins abound, offering support, advice, and improvements.
This community not only helps newcomers climb the steep learning curve but also continually contributes to Vim's evolution, ensuring it remains adaptable and up-to-date with the latest programming trends and technologies.
## Conclusion
Vim is not just a text editor; it's a way of approaching coding with efficiency and thoughtfulness. Its steep learning curve is a small price to pay for the speed, flexibility, and control it offers.
For those willing to invest the time to master its commands, Vim proves to be an invaluable tool that enhances productivity and enjoyment in coding. In an age of ever-changing development tools, the continued popularity of Vim is a testament to its enduring value and utility.

90
app/blog/utils.ts Normal file
View File

@ -0,0 +1,90 @@
import fs from 'fs'
import path from 'path'
type Metadata = {
title: string
publishedAt: string
summary: string
image?: string
}
function parseFrontmatter(fileContent: string) {
let frontmatterRegex = /---\s*([\s\S]*?)\s*---/
let match = frontmatterRegex.exec(fileContent)
let frontMatterBlock = match![1]
let content = fileContent.replace(frontmatterRegex, '').trim()
let frontMatterLines = frontMatterBlock.trim().split('\n')
let metadata: Partial<Metadata> = {}
frontMatterLines.forEach((line) => {
let [key, ...valueArr] = line.split(': ')
let value = valueArr.join(': ').trim()
value = value.replace(/^['"](.*)['"]$/, '$1') // Remove quotes
metadata[key.trim() as keyof Metadata] = value
})
return { metadata: metadata as Metadata, content }
}
function getMDXFiles(dir) {
return fs.readdirSync(dir).filter((file) => path.extname(file) === '.mdx')
}
function readMDXFile(filePath) {
let rawContent = fs.readFileSync(filePath, 'utf-8')
return parseFrontmatter(rawContent)
}
function getMDXData(dir) {
let mdxFiles = getMDXFiles(dir)
return mdxFiles.map((file) => {
let { metadata, content } = readMDXFile(path.join(dir, file))
let slug = path.basename(file, path.extname(file))
return {
metadata,
slug,
content,
}
})
}
export function getBlogPosts() {
return getMDXData(path.join(process.cwd(), 'app', 'blog', 'posts'))
}
export function formatDate(date: string, includeRelative = false) {
let currentDate = new Date()
if (!date.includes('T')) {
date = `${date}T00:00:00`
}
let targetDate = new Date(date)
let yearsAgo = currentDate.getFullYear() - targetDate.getFullYear()
let monthsAgo = currentDate.getMonth() - targetDate.getMonth()
let daysAgo = currentDate.getDate() - targetDate.getDate()
let formattedDate = ''
if (yearsAgo > 0) {
formattedDate = `${yearsAgo}y ago`
} else if (monthsAgo > 0) {
formattedDate = `${monthsAgo}mo ago`
} else if (daysAgo > 0) {
formattedDate = `${daysAgo}d ago`
} else {
formattedDate = 'Today'
}
let fullDate = targetDate.toLocaleString('en-us', {
month: 'long',
day: 'numeric',
year: 'numeric',
})
if (!includeRelative) {
return fullDate
}
return `${fullDate} (${formattedDate})`
}

View File

@ -0,0 +1,174 @@
"use client";
import { useState } from "react";
import { motion, AnimatePresence } from "motion/react";
import { cn } from "@/lib/utils";
export function ContactModal() {
const [isOpen, setIsOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitStatus, setSubmitStatus] = useState<"idle" | "success" | "error">("idle");
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setIsSubmitting(true);
const formData = new FormData(event.currentTarget);
formData.append("access_key", "861ad586-6ce2-4a29-a967-a64fed1a431f");
const object = Object.fromEntries(formData);
const json = JSON.stringify(object);
try {
const response = await fetch("https://api.web3forms.com/submit", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json"
},
body: json
});
const result = await response.json();
if (result.success) {
setSubmitStatus("success");
setTimeout(() => {
setIsOpen(false);
setSubmitStatus("idle");
}, 2000);
} else {
setSubmitStatus("error");
}
} catch (error) {
setSubmitStatus("error");
} finally {
setIsSubmitting(false);
}
}
return (
<>
<button
onClick={() => setIsOpen(true)}
className="text-xs text-[color:var(--accent)] hover:underline transition-colors duration-200"
>
Email me
</button>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
onClick={() => setIsOpen(false)}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
transition={{ type: "spring", stiffness: 300, damping: 30 }}
className="relative w-full max-w-md rounded-lg shadow-2xl glass-strong glass-refract"
onClick={(e) => e.stopPropagation()}
>
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-neutral-100">Contact Me</h2>
<button
onClick={() => setIsOpen(false)}
className="p-1 rounded-full hover:bg-white/5 transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium mb-1 text-neutral-300">
Name
</label>
<input
type="text"
id="name"
name="name"
required
className="w-full px-3 py-2 rounded-md border border-white/10 bg-black/20 text-neutral-200 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-[color:var(--accent)]"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium mb-1 text-neutral-300">
Email
</label>
<input
type="email"
id="email"
name="email"
required
className="w-full px-3 py-2 rounded-md border border-white/10 bg-black/20 text-neutral-200 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-[color:var(--accent)]"
/>
</div>
<div>
<label htmlFor="message" className="block text-sm font-medium mb-1 text-neutral-300">
Message
</label>
<textarea
id="message"
name="message"
required
rows={4}
className="w-full px-3 py-2 rounded-md border border-white/10 bg-black/20 text-neutral-200 placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-[color:var(--accent)] resize-none"
/>
</div>
<div className="flex gap-3 pt-2">
<button
type="submit"
disabled={isSubmitting}
className={cn(
"flex-1 px-4 py-2 rounded-md glass text-neutral-200 font-medium transition-colors hover:opacity-95 focus:outline-none focus:ring-2 focus:ring-[color:var(--accent)]",
"disabled:opacity-50 disabled:cursor-not-allowed"
)}
>
{isSubmitting ? "Sending..." : "Send Message"}
</button>
<button
type="button"
onClick={() => setIsOpen(false)}
className="px-4 py-2 rounded-md border border-white/10 text-neutral-300 hover:bg-white/5 transition-colors"
>
Cancel
</button>
</div>
</form>
{submitStatus === "success" && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="mt-4 p-3 glass text-neutral-200 rounded-md text-sm"
>
Message sent successfully! I'll get back to you soon.
</motion.div>
)}
{submitStatus === "error" && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="mt-4 p-3 glass text-neutral-200 rounded-md text-sm"
>
Something went wrong. Please try again or email me directly.
</motion.div>
)}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</>
);
}

View File

@ -0,0 +1,113 @@
"use client";
import React, { Component, ErrorInfo, ReactNode } from "react";
import { motion } from "motion/react";
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
export class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
};
public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("Uncaught error:", error, errorInfo);
}
private handleReset = () => {
this.setState({ hasError: false, error: undefined });
};
public render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<motion.div
className="flex min-h-screen items-center justify-center p-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.5 }}
>
<div className="text-center space-y-4 max-w-md">
<motion.div
initial={{ scale: 0.8 }}
animate={{ scale: 1 }}
transition={{ delay: 0.2, type: "spring", stiffness: 200 }}
>
<svg
className="w-16 h-16 mx-auto text-red-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</motion.div>
<motion.h2
className="text-xl font-semibold text-neutral-900 dark:text-neutral-100"
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.3 }}
>
Something went wrong
</motion.h2>
<motion.p
className="text-neutral-600 dark:text-neutral-400"
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.4 }}
>
{this.state.error?.message || "An unexpected error occurred"}
</motion.p>
<motion.div
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ delay: 0.5 }}
className="space-y-2"
>
<button
onClick={this.handleReset}
className="px-4 py-2 bg-neutral-900 dark:bg-neutral-100 text-neutral-100 dark:text-neutral-900 rounded-md hover:bg-neutral-800 dark:hover:bg-neutral-200 transition-colors"
>
Try again
</button>
<button
onClick={() => window.location.reload()}
className="px-4 py-2 text-neutral-600 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 transition-colors"
>
Reload page
</button>
</motion.div>
</div>
</motion.div>
);
}
return this.props.children;
}
}

50
app/components/footer.tsx Normal file
View File

@ -0,0 +1,50 @@
function ArrowIcon() {
return (
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.07102 11.3494L0.963068 10.2415L9.2017 1.98864H2.83807L2.85227 0.454545H11.8438V9.46023H10.2955L10.3097 3.09659L2.07102 11.3494Z"
fill="currentColor"
/>
</svg>
)
}
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">
<li>
<a
className="flex items-center transition-all hover:text-neutral-800 dark:hover:text-neutral-100"
rel="noopener noreferrer"
target="_blank"
href="/rss"
>
<ArrowIcon />
<p className="ml-2 h-7">rss</p>
</a>
</li>
<li>
<a
className="flex items-center transition-all hover:text-neutral-800 dark:hover:text-neutral-100"
rel="noopener noreferrer"
target="_blank"
href="https://github.com/vercel/next.js"
>
<ArrowIcon />
<p className="ml-2 h-7">github</p>
</a>
</li>
</ul>
<p className="mt-8 text-neutral-600 dark:text-neutral-300">
© {new Date().getFullYear()} MIT Licensed
</p>
</footer>
)
}

View File

@ -0,0 +1,46 @@
"use client";
import { motion } from "motion/react";
interface LoadingSpinnerProps {
size?: "sm" | "md" | "lg";
className?: string;
}
export function LoadingSpinner({ size = "md", className = "" }: LoadingSpinnerProps) {
const sizeClasses = {
sm: "w-4 h-4",
md: "w-8 h-8",
lg: "w-12 h-12",
};
return (
<motion.div
className={`inline-block ${sizeClasses[size]} ${className}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<svg
className="animate-spin text-neutral-900 dark:text-neutral-100"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</motion.div>
);
}

109
app/components/mdx.tsx Normal file
View File

@ -0,0 +1,109 @@
import Link from 'next/link'
import Image from 'next/image'
import { MDXRemote } from 'next-mdx-remote/rsc'
import { highlight } from 'sugar-high'
import React from 'react'
function Table({ data }) {
let headers = data.headers.map((header, index) => (
<th key={index}>{header}</th>
))
let rows = data.rows.map((row, index) => (
<tr key={index}>
{row.map((cell, cellIndex) => (
<td key={cellIndex}>{cell}</td>
))}
</tr>
))
return (
<table>
<thead>
<tr>{headers}</tr>
</thead>
<tbody>{rows}</tbody>
</table>
)
}
function CustomLink(props) {
let href = props.href
if (href.startsWith('/')) {
return (
<Link href={href} {...props}>
{props.children}
</Link>
)
}
if (href.startsWith('#')) {
return <a {...props} />
}
return <a target="_blank" rel="noopener noreferrer" {...props} />
}
function RoundedImage(props) {
return <Image alt={props.alt} className="rounded-lg" {...props} />
}
function Code({ children, ...props }) {
let codeHTML = highlight(children)
return <code dangerouslySetInnerHTML={{ __html: codeHTML }} {...props} />
}
function slugify(str) {
return str
.toString()
.toLowerCase()
.trim() // Remove whitespace from both ends of a string
.replace(/\s+/g, '-') // Replace spaces with -
.replace(/&/g, '-and-') // Replace & with 'and'
.replace(/[^\w\-]+/g, '') // Remove all non-word characters except for -
.replace(/\-\-+/g, '-') // Replace multiple - with single -
}
function createHeading(level) {
const Heading = ({ children }) => {
let slug = slugify(children)
return React.createElement(
`h${level}`,
{ id: slug },
[
React.createElement('a', {
href: `#${slug}`,
key: `link-${slug}`,
className: 'anchor',
}),
],
children
)
}
Heading.displayName = `Heading${level}`
return Heading
}
let components = {
h1: createHeading(1),
h2: createHeading(2),
h3: createHeading(3),
h4: createHeading(4),
h5: createHeading(5),
h6: createHeading(6),
Image: RoundedImage,
a: CustomLink,
code: Code,
Table,
}
export function CustomMDX(props) {
return (
<MDXRemote
{...props}
components={{ ...components, ...(props.components || {}) }}
/>
)
}

40
app/components/nav.tsx Normal file
View File

@ -0,0 +1,40 @@
import Link from 'next/link'
const navItems = {
'/': {
name: 'home',
},
'/blog': {
name: 'blog',
},
'https://vercel.com/templates/next.js/portfolio-starter-kit': {
name: 'deploy',
},
}
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>
</nav>
</div>
</aside>
)
}

36
app/components/posts.tsx Normal file
View File

@ -0,0 +1,36 @@
import Link from 'next/link'
import { formatDate, getBlogPosts } from 'app/blog/utils'
export function BlogPosts() {
let allBlogs = getBlogPosts()
return (
<div>
{allBlogs
.sort((a, b) => {
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">
{formatDate(post.metadata.publishedAt, false)}
</p>
<p className="text-neutral-900 dark:text-neutral-100 tracking-tight">
{post.metadata.title}
</p>
</div>
</Link>
))}
</div>
)
}

View File

@ -2,6 +2,9 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { DotBackground } from "@/app/components/dotbackground";
import { Navbar } from './components/nav'
import Footer from './components/footer'
import { baseUrl } from './sitemap';
const geistSans = Geist({
variable: "--font-geist-sans",
@ -15,6 +18,7 @@ const geistMono = Geist_Mono({
export const metadata: Metadata = {
metadataBase: new URL(baseUrl),
title: {
default: "Nicholai",
template: "%s · Nicholai",
@ -23,14 +27,14 @@ export const metadata: Metadata = {
openGraph: {
title: "Nicholai",
description: "Professional portfolio of Nicholai — VFX Supervisor & Developer",
url: "https://nicholai.work",
url: baseUrl,
siteName: "Nicholai",
images: ["/images/profile.jpg"],
locale: "en_US",
type: "website",
},
alternates: {
canonical: "https://nicholai.work",
canonical: baseUrl,
},
icons: {
icon: "/favicon.ico",
@ -55,8 +59,12 @@ export default function RootLayout({
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<Navbar />
<DotBackground />
{children}
<main className="flex-auto min-w-0 mt-6 flex flex-col px-2 md:px-0">
{children}
<Footer />
</main>
</body>
</html>
);

10
app/not-found.tsx Normal file
View File

@ -0,0 +1,10 @@
export default function NotFound() {
return (
<section>
<h1 className="mb-8 text-2xl font-semibold tracking-tighter">
404 - Page Not Found
</h1>
<p className="mb-4">The page you are looking for does not exist.</p>
</section>
)
}

22
app/og/route.tsx Normal file
View File

@ -0,0 +1,22 @@
import { ImageResponse } from 'next/og'
export function GET(request: Request) {
let url = new URL(request.url)
let title = url.searchParams.get('title') || 'Next.js Portfolio Starter'
return new ImageResponse(
(
<div tw="flex flex-col w-full h-full items-center justify-center bg-white">
<div tw="flex flex-col md:flex-row w-full py-12 px-4 md:items-center justify-between p-8">
<h2 tw="flex flex-col text-4xl font-bold tracking-tight text-left">
{title}
</h2>
</div>
</div>
),
{
width: 1200,
height: 630,
}
)
}

View File

@ -1,6 +1,7 @@
import React from "react"
import { FlipWords } from "@/components/ui/flip-words"
import { AvatarMotion } from "@/app/components/avatar-motion"
import { BlogPosts } from "@/components/posts"
export const metadata = {
title: "Nicholai",
@ -90,6 +91,9 @@ export default function Home() {
</li>
</ul>
</nav>
<div className="my-8">
<BlogPosts />
</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">

View File

@ -0,0 +1,106 @@
"use client";
import React, { createContext, useContext, useEffect, useMemo, useRef } from "react";
import Lenis from "lenis";
import { useMotionValue, type MotionValue } from "motion/react";
type ScrollContextValue = {
lenis: Lenis | null;
scrollY: MotionValue<number>; // in px
progress: MotionValue<number>; // 0..1 for whole page
};
const ScrollContext = createContext<ScrollContextValue | null>(null);
type LenisScrollEvent = {
scroll: number;
limit: number;
progress?: number;
};
export function useScrollContext() {
const ctx = useContext(ScrollContext);
if (!ctx) {
throw new Error("useScrollContext must be used within <LenisProvider>");
}
return ctx;
}
export function LenisProvider({ children }: { children: React.ReactNode }) {
const lenisRef = useRef<Lenis | null>(null);
const scrollY = useMotionValue(0);
const progress = useMotionValue(0);
useEffect(() => {
// Respect user preferences
const prefersReducedMotion =
typeof window !== "undefined" &&
window.matchMedia &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const lenis = new Lenis({
// Good defaults for premium-feel scroll
smoothWheel: !prefersReducedMotion,
syncTouch: true,
duration: 1.2, // seconds to ease to target position
easing: (t: number) => 1 - Math.pow(1 - t, 3), // easeOutCubic
// Use native on reduced motion
wrapper: undefined,
content: undefined,
// If reduced motion, disable smoothing entirely
lerp: prefersReducedMotion ? 1 : 0.1,
});
lenisRef.current = lenis;
const onScroll = (e: unknown) => {
const { scroll, limit, progress: p } = (e as LenisScrollEvent);
scrollY.set(scroll);
// Some versions of Lenis may not send progress, compute fallback if needed
if (typeof p === "number" && Number.isFinite(p)) {
progress.set(p);
} else {
const fallback =
limit > 0
? Math.min(1, Math.max(0, scroll / limit))
: 0;
progress.set(fallback);
}
};
lenis.on("scroll", onScroll);
let rafId = 0;
const raf = (time: number) => {
lenis.raf(time);
rafId = requestAnimationFrame(raf);
};
rafId = requestAnimationFrame(raf);
// Initialize values
onScroll({
scroll: window.scrollY,
limit: Math.max(0, document.documentElement.scrollHeight - window.innerHeight),
progress: (document.documentElement.scrollHeight - window.innerHeight) > 0
? window.scrollY / (document.documentElement.scrollHeight - window.innerHeight)
: 0,
});
return () => {
cancelAnimationFrame(rafId);
lenis.off("scroll", onScroll);
lenis.destroy();
lenisRef.current = null;
};
}, [progress, scrollY]);
const value = useMemo<ScrollContextValue>(() => {
return {
lenis: lenisRef.current,
scrollY,
progress,
};
}, [scrollY, progress]);
return <ScrollContext.Provider value={value}>{children}</ScrollContext.Provider>;
}

View File

@ -0,0 +1,35 @@
"use client";
import React from "react";
import { MotionConfig } from "motion/react";
/**
* Centralize motion defaults: durations, easings, reduced-motion handling.
* Wrap the app in this provider (see app/layout.tsx).
*/
export function MotionConfigProvider({ children }: { children: React.ReactNode }) {
// Respect prefers-reduced-motion by simplifying animations
const prefersReducedMotion =
typeof window !== "undefined" &&
window.matchMedia &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Shared transition tokens
const duration = prefersReducedMotion ? 0 : 0.6;
const delay = prefersReducedMotion ? 0 : 0.0;
// A premium-feel cubic-bezier; slightly snappier in the end
const ease: [number, number, number, number] = [0.2, 0.8, 0.2, 1];
return (
<MotionConfig
reducedMotion={prefersReducedMotion ? "always" : "never"}
transition={{
duration,
ease,
delay,
}}
>
{children}
</MotionConfig>
);
}

12
app/robots.ts Normal file
View File

@ -0,0 +1,12 @@
import { baseUrl } from './sitemap'
export default function robots() {
return {
rules: [
{
userAgent: '*',
},
],
sitemap: `${baseUrl}/sitemap.xml`,
}
}

42
app/rss/route.ts Normal file
View File

@ -0,0 +1,42 @@
import { baseUrl } from '../sitemap'
import { getBlogPosts } from '../blog/utils'
export async function GET() {
let allBlogs = await getBlogPosts()
const itemsXml = allBlogs
.sort((a, b) => {
if (new Date(a.metadata.publishedAt) > new Date(b.metadata.publishedAt)) {
return -1
}
return 1
})
.map(
(post) =>
`<item>
<title>${post.metadata.title}</title>
<link>${baseUrl}/blog/${post.slug}</link>
<description>${post.metadata.summary || ''}</description>
<pubDate>${new Date(
post.metadata.publishedAt
).toUTCString()}</pubDate>
</item>`
)
.join('\n')
const rssFeed = `<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0">
<channel>
<title>My Portfolio</title>
<link>${baseUrl}</link>
<description>This is my portfolio RSS feed</description>
${itemsXml}
</channel>
</rss>`
return new Response(rssFeed, {
headers: {
'Content-Type': 'text/xml',
},
})
}

View File

@ -0,0 +1,103 @@
"use client";
import React, { useRef } from "react";
import { motion, useTransform } from "motion/react";
import { Parallax } from "@/components/parallax/Parallax";
import { Reveal } from "@/components/motion/Reveal";
import { Stagger } from "@/components/motion/Stagger";
import { TRANSITIONS } from "@/lib/animation";
export function AboutSection() {
const imageRef = useRef<HTMLDivElement>(null);
return (
<section
id="about"
aria-label="About"
className="relative w-full overflow-clip py-24 md:py-36"
>
{/* Ambient vignette */}
<Parallax speed={0.06} className="pointer-events-none absolute inset-0 -z-10">
<div className="absolute inset-0 bg-[radial-gradient(800px_400px_at_80%_10%,rgba(255,255,255,0.08),transparent_70%)]" />
</Parallax>
<div className="mx-auto grid w-full max-w-6xl grid-cols-1 items-center gap-10 px-6 md:grid-cols-12 md:gap-12">
{/* Copy */}
<div className="md:col-span-6 lg:col-span-7">
<Stagger delayChildren={0.05}>
<Reveal>
<h2 className="text-pretty text-3xl font-bold tracking-tight sm:text-4xl md:text-5xl bg-clip-text text-transparent bg-gradient-to-b from-neutral-100 to-neutral-300">
Craft meets code.
</h2>
</Reveal>
<Reveal delay={0.05}>
<p className="mt-4 text-balance text-base leading-relaxed text-neutral-300 md:text-lg">
I shape cinematic experiences for the webwhere motion guides, parallax breathes, and details
obsess. From VFX supervision to fullstack development, I build interfaces that feel alive while
staying accessible and fast.
</p>
</Reveal>
<Reveal delay={0.1}>
<ul className="mt-6 grid grid-cols-1 gap-3 text-sm text-neutral-200 sm:grid-cols-2">
<li className="rounded-lg glass p-3">
Smooth-scrolling narratives with Lenis
</li>
<li className="rounded-lg glass p-3">
Scroll-linked motion via Framer Motion
</li>
<li className="rounded-lg glass p-3">
Performance-first, a11y conscious
</li>
<li className="rounded-lg glass p-3">
Filmic depth, considered typography
</li>
</ul>
</Reveal>
</Stagger>
</div>
{/* Visual */}
<div className="md:col-span-6 lg:col-span-5">
<motion.div
ref={imageRef}
className="relative aspect-[4/5] w-full overflow-hidden rounded-2xl glass-strong"
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "0px 0px -10% 0px" }}
transition={TRANSITIONS.base}
>
{/* Parallax layers */}
<Parallax speed={0.12} className="absolute -inset-8">
<div className="h-full w-full bg-[radial-gradient(600px_300px_at_30%_10%,rgba(255,255,255,0.10),transparent_70%)]" />
</Parallax>
<Parallax speed={-0.08} className="absolute inset-0">
<div className="h-full w-full bg-[linear-gradient(180deg,transparent,rgba(255,255,255,0.06)_40%,transparent_80%)]" />
</Parallax>
{/* Grid accent */}
<motion.div
className="absolute inset-0 opacity-[0.08]"
style={{
backgroundImage:
"linear-gradient(to right, currentColor 1px, transparent 1px), linear-gradient(to bottom, currentColor 1px, transparent 1px)",
backgroundSize: "24px 24px",
color: "rgb(255 255 255)",
}}
/>
{/* Caption */}
<motion.div
className="absolute bottom-0 left-0 right-0 p-4 text-xs text-neutral-300/80 backdrop-blur-sm"
initial={{ opacity: 0, y: 6 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ ...TRANSITIONS.base, delay: 0.1 }}
>
Parallax composition with layered gradients and light bloom.
</motion.div>
</motion.div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,82 @@
"use client";
import React from "react";
import { motion } from "motion/react";
import { Parallax } from "@/components/parallax/Parallax";
import { ContactModal } from "@/app/components/contact-modal";
import { TRANSITIONS } from "@/lib/animation";
/**
* Sticky CTA band that invites contact and opens the ContactModal.
*/
export function ContactSection() {
return (
<section id="contact" aria-label="Contact" className="relative w-full">
{/* Ambient depth gradients */}
<Parallax speed={0.04} className="pointer-events-none absolute inset-0 -z-10">
<div className="absolute inset-0 bg-[radial-gradient(1200px_520px_at_50%_0%,rgba(255,255,255,0.08),transparent_70%)]" />
</Parallax>
<div className="sticky top-0 z-10 w-full">
<div className="mx-auto w-full max-w-5xl px-6 py-16">
<motion.div
className="relative overflow-hidden rounded-3xl glass-strong glass-refract p-8 text-center"
initial={{ opacity: 0, y: 16 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.4 }}
transition={TRANSITIONS.base}
>
{/* Light sheen */}
<motion.h2
className="text-2xl font-bold tracking-tight text-neutral-100 sm:text-3xl"
initial={{ opacity: 0, y: 8 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={TRANSITIONS.base}
>
Lets build something cinematic.
</motion.h2>
<motion.p
className="mx-auto mt-2 max-w-2xl text-sm text-neutral-300 sm:text-base"
initial={{ opacity: 0, y: 6 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ ...TRANSITIONS.base, delay: 0.05 }}
>
I combine VFX sensibilities with engineering rigor to craft polished, highperformance experiences.
</motion.p>
<motion.div
className="mt-6 flex items-center justify-center gap-3"
initial={{ opacity: 0, y: 6 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ ...TRANSITIONS.base, delay: 0.1 }}
>
{/* Reuse the existing modal trigger */}
<ContactModal />
<a
href="mailto:hello@nicholai.work"
className="rounded-full glass px-4 py-2 text-sm font-medium text-neutral-200 transition-colors hover:opacity-95"
>
Email link
</a>
</motion.div>
</motion.div>
<motion.footer
className="mt-8 text-center text-xs text-neutral-400"
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={TRANSITIONS.base}
>
© {new Date().getFullYear()} Nicholai Designed with motion and care.
</motion.footer>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,94 @@
"use client";
import React from "react";
import { motion } from "motion/react";
import { Parallax } from "@/components/parallax/Parallax";
import { FlipWords } from "@/components/ui/flip-words";
import { AvatarMotion } from "@/app/components/avatar-motion";
import { TRANSITIONS } from "@/lib/animation";
export function HeroSection() {
return (
<section
id="hero"
aria-label="Hero"
className="relative min-h-[130vh] w-full overflow-clip flex items-center"
>
{/* Background depth layers */}
<Parallax speed={0.05} className="pointer-events-none absolute inset-0 -z-20">
<div className="absolute inset-0 bg-[radial-gradient(1200px_600px_at_50%_-20%,rgba(255,255,255,0.10),transparent_70%)]" />
</Parallax>
<Parallax speed={0.08} className="pointer-events-none absolute inset-0 -z-10">
<div className="absolute -inset-x-10 -inset-y-20 bg-gradient-to-b from-transparent via-white/5 to-transparent blur-2xl" />
</Parallax>
{/* Content */}
<div className="relative z-10 mx-auto flex w-full max-w-5xl flex-col items-center gap-8 px-6">
<Parallax speed={-0.12} className="mt-24">
<AvatarMotion
src="/images/profile.jpg"
srcSet={{
avif: {
'120': '/images/profile-120.avif',
'160': '/images/profile-160.avif',
'original': '/images/profile.avif'
},
fallback: '/images/profile.jpg'
}}
alt="Hand drawn portrait of Nicholai"
size={200}
className="ring-1 ring-white/10"
/>
</Parallax>
<div className="text-center">
<motion.h1
className="mx-auto max-w-3xl bg-clip-text text-5xl font-extrabold tracking-tight text-transparent sm:text-6xl md:text-7xl
bg-gradient-to-b from-neutral-100 to-neutral-300"
initial={{ opacity: 0, y: 24, filter: "blur(8px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
transition={TRANSITIONS.base}
>
Nicholai
</motion.h1>
<motion.p
className="mx-auto mt-3 max-w-xl text-balance text-sm text-neutral-300 sm:text-base"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ ...TRANSITIONS.base, delay: 0.1 }}
>
Building cinematic web moments with code and craft.
</motion.p>
<motion.div
className="mx-auto mt-4 text-base sm:text-lg text-neutral-200"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ ...TRANSITIONS.base, delay: 0.2 }}
>
<FlipWords
words={["VFX Artist", "Developer", "Experience Designer"]}
className="font-medium"
/>
</motion.div>
</div>
{/* Scroll cue */}
<motion.div
aria-hidden
className="absolute bottom-8 left-1/2 -translate-x-1/2 text-neutral-400"
initial={{ opacity: 0, y: 0 }}
animate={{ opacity: 1, y: [0, 6, 0] }}
transition={{ duration: 1.8, repeat: Infinity, ease: [0.2, 0.8, 0.2, 1] }}
>
<svg width="20" height="28" viewBox="0 0 20 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1.5" y="1.5" width="17" height="25" rx="8.5" stroke="currentColor" opacity="0.45"/>
<circle cx="10" cy="7" r="2" fill="currentColor"/>
</svg>
</motion.div>
</div>
</section>
);
}

View File

@ -0,0 +1,115 @@
"use client";
import React, { useRef } from "react";
import { motion, useTransform } from "motion/react";
import { useSectionProgress } from "@/lib/scroll";
import { Parallax } from "@/components/parallax/Parallax";
import { Reveal } from "@/components/motion/Reveal";
import { Stagger } from "@/components/motion/Stagger";
import { TRANSITIONS } from "@/lib/animation";
export function ProcessSection() {
const sectionRef = useRef<HTMLElement>(null!);
const progress = useSectionProgress(sectionRef);
const pathLength = useTransform(progress, [0, 1], [0, 1]);
const steps = [
{
title: "Discover",
desc:
"Align on goals, audience, and tone. Define constraints and success metrics.",
},
{
title: "Design",
desc:
"Establish visual language, motion rhythm, and section-level compositions.",
},
{
title: "Build",
desc:
"Implement Lenis scroll orchestration and Framer Motion systems with a11y in mind.",
},
{
title: "Polish",
desc:
"Optimize performance, refine microinteractions, and tune parallax depth.",
},
];
return (
<section
id="process"
ref={sectionRef}
aria-label="Process"
className="relative w-full overflow-clip py-28 md:py-36"
>
{/* Ambient vignette */}
<Parallax speed={0.04} className="pointer-events-none absolute inset-0 -z-10">
<div className="absolute inset-0 bg-[radial-gradient(900px_480px_at_10%_20%,rgba(255,255,255,0.08),transparent_70%)]" />
</Parallax>
<div className="mx-auto w-full max-w-6xl px-6">
<div className="text-center mb-12">
<motion.h2
className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight text-neutral-100"
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={TRANSITIONS.base}
>
Process
</motion.h2>
<motion.p
className="mt-2 text-sm text-neutral-400"
initial={{ opacity: 0, y: 6 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ ...TRANSITIONS.base, delay: 0.05 }}
>
A simple path from idea to cinematic, performant delivery.
</motion.p>
</div>
{/* Timeline drawing */}
<div className="relative">
<motion.svg
width="100%"
height="220"
viewBox="0 0 1200 220"
className="hidden md:block"
>
<motion.path
d="M 40 180 C 320 60, 880 300, 1160 80"
fill="none"
stroke="currentColor"
className="text-neutral-700"
strokeWidth="2"
strokeLinecap="round"
style={{ pathLength }}
/>
</motion.svg>
<div className="mt-0 grid grid-cols-1 gap-6 md:-mt-16 md:grid-cols-4">
<Stagger delayChildren={0.05}>
{steps.map((s, i) => (
<Reveal key={s.title} delay={i * 0.05} distance={16}>
<article className="relative rounded-2xl glass p-5">
<div className="mb-2 text-xs font-semibold uppercase tracking-wider text-neutral-400">
{String(i + 1).padStart(2, "0")}
</div>
<h3 className="text-lg font-semibold text-neutral-100">
{s.title}
</h3>
<p className="mt-2 text-sm text-neutral-300">
{s.desc}
</p>
</article>
</Reveal>
))}
</Stagger>
</div>
</div>
</div>
</section>
);
}

View File

@ -0,0 +1,103 @@
"use client";
import React from "react";
import { motion } from "motion/react";
import { Parallax } from "@/components/parallax/Parallax";
import { TRANSITIONS } from "@/lib/animation";
/**
* Lightweight logo/text marquee with reveal-in animations.
* Replace placeholder items with real logos as needed.
*/
export function TestimonialsSection() {
const items = [
"Biohazard VFX",
"Fortura Data",
"Cinematic Labs",
"Nebula Studio",
"Pixel Foundry",
"Prisma Motion",
];
return (
<section
id="testimonials"
aria-label="Testimonials"
className="relative w-full overflow-clip py-24 md:py-32"
>
<Parallax speed={0.05} className="pointer-events-none absolute inset-0 -z-10">
<div className="absolute inset-0 bg-[radial-gradient(1100px_520px_at_50%_0%,rgba(255,255,255,0.08),transparent_70%)]" />
</Parallax>
<div className="mx-auto w-full max-w-6xl px-6">
<div className="mb-10 text-center">
<motion.h2
className="text-2xl sm:text-3xl md:text-4xl font-bold tracking-tight text-neutral-100"
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={TRANSITIONS.base}
>
Trusted by teams who care about craft
</motion.h2>
<motion.p
className="mt-2 text-sm text-neutral-400"
initial={{ opacity: 0, y: 6 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ ...TRANSITIONS.base, delay: 0.05 }}
>
Subtle marquee with tasteful depth and reveals.
</motion.p>
</div>
<div className="relative overflow-hidden rounded-2xl glass md:rounded-3xl">
{/* Edge gradient masks */}
<div className="pointer-events-none absolute inset-y-0 left-0 w-24 bg-gradient-to-r from-black to-transparent" />
<div className="pointer-events-none absolute inset-y-0 right-0 w-24 bg-gradient-to-l from-black to-transparent" />
{/* Marquee row 1 */}
<motion.div
className="flex w-[200%] gap-8 py-6 will-change-transform"
initial={{ x: 0 }}
whileInView={{ x: ["0%", "-50%"] }}
viewport={{ once: false, amount: 0.3 }}
transition={{ duration: 20, ease: "linear", repeat: Infinity }}
>
{[...items, ...items].map((it, i) => (
<LogoPill key={`a-${i}`} text={it} />
))}
</motion.div>
{/* Marquee row 2 (reverse) */}
<motion.div
className="flex w-[200%] gap-8 py-6 will-change-transform"
initial={{ x: "-50%" }}
whileInView={{ x: ["-50%", "0%"] }}
viewport={{ once: false, amount: 0.3 }}
transition={{ duration: 22, ease: "linear", repeat: Infinity }}
>
{[...items, ...items].map((it, i) => (
<LogoPill key={`b-${i}`} text={it} />
))}
</motion.div>
</div>
</div>
</section>
);
}
function LogoPill({ text }: { text: string }) {
return (
<motion.div
className="inline-flex min-w-40 items-center justify-center rounded-full glass px-4 py-2 text-sm font-medium text-neutral-300"
initial={{ opacity: 0, y: 6 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: false, amount: 0.6 }}
whileHover={{ scale: 1.03 }}
transition={TRANSITIONS.base}
>
{text}
</motion.div>
);
}

View File

@ -0,0 +1,167 @@
"use client";
import React from "react";
import { motion, useTransform, type MotionValue } from "motion/react";
import { Pin } from "@/components/parallax/Pin";
import { Parallax } from "@/components/parallax/Parallax";
import { TRANSITIONS } from "@/lib/animation";
export function WorkSection() {
return (
<section id="work" aria-label="Selected Work" className="relative w-full">
{/* Subtle ambient background */}
<Parallax speed={0.04} className="pointer-events-none absolute inset-0 -z-10">
<div className="absolute inset-0 bg-[radial-gradient(1200px_600px_at_10%_20%,rgba(255,255,255,0.06),transparent_70%)]" />
</Parallax>
<Pin heightVH={400} className="w-full">
{(progress) => <WorkPinnedContent progress={progress} />}
</Pin>
</section>
);
}
function WorkPinnedContent({ progress }: { progress: MotionValue<number> }) {
// Safe to use React hooks here (top-level of a component)
const x = useTransform(progress, [0, 1], ["0%", "-400%"]);
return (
<div className="relative h-full w-full overflow-hidden">
{/* Section header overlays the sticky area */}
<div className="pointer-events-none absolute left-1/2 top-10 z-20 -translate-x-1/2 text-center">
<motion.h2
className="text-balance text-2xl font-semibold tracking-tight text-neutral-100 sm:text-3xl md:text-4xl"
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={TRANSITIONS.base}
>
Highlights
</motion.h2>
<motion.p
className="mt-2 text-sm text-neutral-400"
initial={{ opacity: 0, y: 6 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ ...TRANSITIONS.base, delay: 0.05 }}
>
A scroll-pinned horizontal showcase powered by Lenis + Framer Motion.
</motion.p>
</div>
{/* Horizontal track */}
<motion.div
style={{ x }}
className="absolute inset-0 flex h-full w-[500vw] items-center gap-[5vw] px-[10vw]"
>
<WorkCard
title="Cinematic Portfolio"
subtitle="Parallax-first hero and narrative scroll"
/>
<WorkCard
title="Interactive Showcase"
subtitle="Pinned scenes and composited layers"
/>
<WorkCard
title="Motion System"
subtitle="Variants, micro-interactions, and rhythm"
/>
<WorkCard
title="A11y + Performance"
subtitle="Prefers-reduced-motion, optimized images"
/>
<WorkCard
title="Design Polish"
subtitle="Grain, gradients, light bloom and depth"
/>
</motion.div>
{/* Edge gradient masks for an infinite feel */}
<div className="pointer-events-none absolute inset-y-0 left-0 w-24 bg-gradient-to-r from-black to-transparent" />
<div className="pointer-events-none absolute inset-y-0 right-0 w-24 bg-gradient-to-l from-black to-transparent" />
</div>
);
}
function WorkCard({
title,
subtitle,
}: {
title: string;
subtitle: string;
}) {
return (
<motion.article
className="group relative h-[66vh] w-[80vw] max-w-[720px] overflow-hidden rounded-3xl glass-strong p-6"
initial={{ opacity: 0, y: 24, scale: 0.98 }}
whileInView={{ opacity: 1, y: 0, scale: 1 }}
viewport={{ once: true, margin: "0px 0px -10% 0px" }}
transition={TRANSITIONS.base}
whileHover={{ scale: 1.02 }}
>
{/* Accent background layers with parallax depth */}
<Parallax speed={0.08} className="pointer-events-none absolute -inset-20 -z-10">
<div className="absolute inset-0 bg-[radial-gradient(600px_300px_at_70%_30%,rgba(255,255,255,0.08),transparent_70%)] blur-2xl" />
</Parallax>
<Parallax speed={-0.06} className="pointer-events-none absolute inset-0 -z-10">
<div className="absolute inset-0 bg-[radial-gradient(600px_300px_at_70%_30%,rgba(255,255,255,0.08),transparent_70%)]" />
</Parallax>
{/* Content */}
<div className="flex h-full flex-col justify-between">
<div>
<motion.h3
className="text-xl font-semibold tracking-tight text-neutral-100"
initial={{ opacity: 0, y: 8 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={TRANSITIONS.base}
>
{title}
</motion.h3>
<motion.p
className="mt-2 max-w-[48ch] text-sm text-neutral-300"
initial={{ opacity: 0, y: 6 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ ...TRANSITIONS.base, delay: 0.05 }}
>
{subtitle}
</motion.p>
</div>
{/* Placeholder visual block; replace with Next/Image for real work later */}
<motion.div
className="relative mt-6 flex flex-1 items-center justify-center overflow-hidden rounded-2xl glass-strong"
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ ...TRANSITIONS.base, delay: 0.08 }}
>
<div className="absolute inset-0 bg-[linear-gradient(180deg,transparent,rgba(255,255,255,0.06)_40%,transparent_80%)]" />
<div className="text-xs text-neutral-400">Project visual placeholder</div>
</motion.div>
{/* Footer */}
<div className="mt-4 flex items-center justify-between">
<motion.span
className="text-xs text-neutral-400"
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
viewport={{ once: true }}
transition={TRANSITIONS.base}
>
Scroll to explore
</motion.span>
<motion.button
className="rounded-full glass px-3 py-1 text-xs font-medium transition-colors hover:opacity-95"
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.97 }}
>
View case
</motion.button>
</div>
</div>
</motion.article>
);
}

17
app/sitemap.ts Normal file
View File

@ -0,0 +1,17 @@
import { getBlogPosts } from './blog/utils'
export const baseUrl = 'http://localhost:3001'
export default async function sitemap() {
let blogs = getBlogPosts().map((post) => ({
url: `${baseUrl}/blog/${post.slug}`,
lastModified: post.metadata.publishedAt,
}))
let routes = ['', '/blog'].map((route) => ({
url: `${baseUrl}${route}`,
lastModified: new Date().toISOString().split('T')[0],
}))
return [...routes, ...blogs]
}

61
components/footer.tsx Normal file
View File

@ -0,0 +1,61 @@
function ArrowIcon() {
return (
<svg
width="12"
height="12"
viewBox="0 0 12 12"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.07102 11.3494L0.963068 10.2415L9.2017 1.98864H2.83807L2.85227 0.454545H11.8438V9.46023H10.2955L10.3097 3.09659L2.07102 11.3494Z"
fill="currentColor"
/>
</svg>
)
}
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">
<li>
<a
className="flex items-center transition-all hover:text-neutral-800 dark:hover:text-neutral-100"
rel="noopener noreferrer"
target="_blank"
href="/rss"
>
<ArrowIcon />
<p className="ml-2 h-7">rss</p>
</a>
</li>
<li>
<a
className="flex items-center transition-all hover:text-neutral-800 dark:hover:text-neutral-100"
rel="noopener noreferrer"
target="_blank"
href="https://github.com/vercel/next.js"
>
<ArrowIcon />
<p className="ml-2 h-7">github</p>
</a>
</li>
<li>
<a
className="flex items-center transition-all hover:text-neutral-800 dark:hover:text-neutral-100"
rel="noopener noreferrer"
target="_blank"
href="https://vercel.com/templates/next.js/portfolio-starter-kit"
>
<ArrowIcon />
<p className="ml-2 h-7">view source</p>
</a>
</li>
</ul>
<p className="mt-8 text-neutral-600 dark:text-neutral-300">
© {new Date().getFullYear()} MIT Licensed
</p>
</footer>
)
}

109
components/mdx.tsx Normal file
View File

@ -0,0 +1,109 @@
import Link from 'next/link'
import Image from 'next/image'
import { MDXRemote } from 'next-mdx-remote/rsc'
import { highlight } from 'sugar-high'
import React from 'react'
function Table({ data }) {
let headers = data.headers.map((header, index) => (
<th key={index}>{header}</th>
))
let rows = data.rows.map((row, index) => (
<tr key={index}>
{row.map((cell, cellIndex) => (
<td key={cellIndex}>{cell}</td>
))}
</tr>
))
return (
<table>
<thead>
<tr>{headers}</tr>
</thead>
<tbody>{rows}</tbody>
</table>
)
}
function CustomLink(props) {
let href = props.href
if (href.startsWith('/')) {
return (
<Link href={href} {...props}>
{props.children}
</Link>
)
}
if (href.startsWith('#')) {
return <a {...props} />
}
return <a target="_blank" rel="noopener noreferrer" {...props} />
}
function RoundedImage(props) {
return <Image alt={props.alt} className="rounded-lg" {...props} />
}
function Code({ children, ...props }) {
let codeHTML = highlight(children)
return <code dangerouslySetInnerHTML={{ __html: codeHTML }} {...props} />
}
function slugify(str) {
return str
.toString()
.toLowerCase()
.trim() // Remove whitespace from both ends of a string
.replace(/\s+/g, '-') // Replace spaces with -
.replace(/&/g, '-and-') // Replace & with 'and'
.replace(/[^\w\-]+/g, '') // Remove all non-word characters except for -
.replace(/\-\-+/g, '-') // Replace multiple - with single -
}
function createHeading(level) {
const Heading = ({ children }) => {
let slug = slugify(children)
return React.createElement(
`h${level}`,
{ id: slug },
[
React.createElement('a', {
href: `#${slug}`,
key: `link-${slug}`,
className: 'anchor',
}),
],
children
)
}
Heading.displayName = `Heading${level}`
return Heading
}
let components = {
h1: createHeading(1),
h2: createHeading(2),
h3: createHeading(3),
h4: createHeading(4),
h5: createHeading(5),
h6: createHeading(6),
Image: RoundedImage,
a: CustomLink,
code: Code,
Table,
}
export function CustomMDX(props) {
return (
<MDXRemote
{...props}
components={{ ...components, ...(props.components || {}) }}
/>
)
}

View File

@ -0,0 +1,36 @@
"use client";
import React from "react";
import { motion } from "motion/react";
import type { Variants } from "motion/react";
import { fadeInUp } from "@/lib/animation";
type RevealProps = {
delay?: number;
distance?: number;
className?: string;
children: React.ReactNode;
once?: boolean;
};
export function Reveal({
delay = 0,
distance = 20,
className,
children,
once = true,
}: RevealProps) {
const variants: Variants = fadeInUp(delay, distance);
return (
<motion.div
className={className}
initial="initial"
whileInView="animate"
viewport={{ once, margin: "0px 0px -10% 0px" }}
variants={variants}
>
{children}
</motion.div>
);
}

View File

@ -0,0 +1,66 @@
"use client";
import React from "react";
import { motion } from "motion/react";
import type { Variants } from "motion/react";
import { TRANSITIONS } from "@/lib/animation";
type SplitTextRevealProps = {
text: string;
className?: string;
once?: boolean;
wordClassName?: string;
stagger?: number;
};
export function SplitTextReveal({
text,
className,
once = true,
wordClassName,
stagger = 0.06,
}: SplitTextRevealProps) {
const words = text.trim().split(/\s+/);
const container: Variants = {
initial: {},
animate: {
transition: {
staggerChildren: stagger,
},
},
};
const child: Variants = {
initial: { opacity: 0, y: 8, filter: "blur(6px)" },
animate: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: TRANSITIONS.base,
},
};
return (
<motion.span
className={className}
style={{ display: "inline-block" }}
variants={container}
initial="initial"
whileInView="animate"
viewport={{ once, margin: "0px 0px -10% 0px" }}
>
{words.map((w, i) => (
<motion.span
key={`${w}-${i}`}
className={wordClassName}
style={{ display: "inline-block", willChange: "transform" }}
variants={child}
>
{w}
{i < words.length - 1 ? " " : ""}
</motion.span>
))}
</motion.span>
);
}

View File

@ -0,0 +1,35 @@
"use client";
import React from "react";
import { motion } from "motion/react";
import type { Variants } from "motion/react";
import { staggerContainer } from "@/lib/animation";
type StaggerProps = {
className?: string;
children: React.ReactNode;
stagger?: number;
delayChildren?: number;
once?: boolean;
};
export function Stagger({
className,
children,
stagger = 0.08,
delayChildren = 0,
once = true,
}: StaggerProps) {
const variants: Variants = staggerContainer(stagger, delayChildren);
return (
<motion.div
className={className}
initial="initial"
whileInView="animate"
viewport={{ once, margin: "0px 0px -10% 0px" }}
variants={variants}
>
{children}
</motion.div>
);
}

40
components/nav.tsx Normal file
View File

@ -0,0 +1,40 @@
import Link from 'next/link'
const navItems = {
'/': {
name: 'home',
},
'/blog': {
name: 'blog',
},
'https://vercel.com/templates/next.js/portfolio-starter-kit': {
name: 'deploy',
},
}
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>
</nav>
</div>
</aside>
)
}

View File

@ -0,0 +1,23 @@
"use client";
import React, { useRef } from "react";
import { motion } from "motion/react";
import { useParallax } from "@/lib/scroll";
type ParallaxProps = {
speed?: number; // positive moves with scroll, negative opposite
axis?: "y" | "x";
className?: string;
children?: React.ReactNode;
} & React.ComponentProps<typeof motion.div>;
export function Parallax({ speed = 0.2, axis = "y", className, children, ...rest }: ParallaxProps) {
const localRef = useRef<HTMLDivElement>(null!);
const style = useParallax(localRef, speed, axis);
return (
<motion.div ref={localRef} style={style} className={className} {...rest}>
{children}
</motion.div>
);
}

View File

@ -0,0 +1,47 @@
"use client";
import React, { useRef } from "react";
import { type MotionValue } from "motion/react";
import { useSectionProgress } from "@/lib/scroll";
type PinProps = {
/**
* Height of the pin section in viewport heights.
* 300 means the section is 300vh tall, so the sticky area lasts for 3 screens.
*/
heightVH?: number;
className?: string;
/**
* Render prop that receives a MotionValue<number> progress [0..1]
* representing how far through the pin section the user has scrolled.
*/
children: (progress: MotionValue<number>) => React.ReactNode;
};
/**
* Pin creates a tall section with an inner sticky container.
* It computes a normalized progress [0..1] across the entire section using Lenis-driven scroll updates.
*/
export function Pin({ heightVH = 300, className, children }: PinProps) {
const sectionRef = useRef<HTMLElement>(null!);
const progress = useSectionProgress(sectionRef);
return (
<section
ref={sectionRef}
className={className}
style={{
height: `${heightVH}vh`,
position: "relative",
}}
aria-hidden={false}
>
<div
className="sticky top-0 h-screen w-full"
style={{ willChange: "transform" }}
>
{children(progress)}
</div>
</section>
);
}

36
components/posts.tsx Normal file
View File

@ -0,0 +1,36 @@
import Link from 'next/link'
import { formatDate, getBlogPosts } from '@/app/blog/utils'
export function BlogPosts() {
const allBlogs = getBlogPosts()
return (
<div>
{allBlogs
.sort((a, b) => {
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">
{formatDate(post.metadata.publishedAt, false)}
</p>
<p className="text-neutral-900 dark:text-neutral-100 tracking-tight">
{post.metadata.title}
</p>
</div>
</Link>
))}
</div>
)
}

43
implementation_plan.md Normal file
View File

@ -0,0 +1,43 @@
# Implementation Plan
## Overview
Integrate the navigation and blog functionality from the EXAMPLE/blog into the main site, ensuring all dependencies are properly installed and configured.
## Types
No new type definitions are required for this implementation.
## Files
- Create app/robots.ts for SEO optimization
- Update app/layout.tsx to include Footer component
- Update app/components/footer.tsx to remove Vercel references
## Functions
No new functions are required, but existing functions need to be properly connected.
## Classes
No new classes are required for this implementation.
## Dependencies
Install missing dependencies:
- next-mdx-remote
- sugar-high
Remove unnecessary dependencies:
- @vercel/analytics
- @vercel/speed-insights
## Testing
Verify that:
- Blog posts are properly displayed
- Navigation works correctly
- All dependencies are properly installed
- RSS feed is working
- OG images are generated correctly
## Implementation Order
1. Install missing dependencies
2. Create robots.ts file
3. Update layout to include Footer
4. Update footer to remove Vercel references
5. Test blog functionality
6. Verify all links and navigation work correctly

52
lib/animation.ts Normal file
View File

@ -0,0 +1,52 @@
import type { Variants, Transition } from "motion/react";
export const EASE: [number, number, number, number] = [0.2, 0.8, 0.2, 1];
export const TRANSITIONS = {
fast: { duration: 0.3, ease: EASE } as Transition,
base: { duration: 0.6, ease: EASE } as Transition,
slow: { duration: 0.9, ease: EASE } as Transition,
springSoft: { type: "spring", stiffness: 120, damping: 18 } as Transition,
springFirm: { type: "spring", stiffness: 200, damping: 22 } as Transition,
};
export function fadeInUp(delay = 0, distance = 20): Variants {
return {
initial: { opacity: 0, y: distance, filter: "blur(4px)" },
animate: {
opacity: 1,
y: 0,
filter: "blur(0px)",
transition: { ...TRANSITIONS.base, delay },
},
exit: { opacity: 0, y: -distance * 0.5, transition: TRANSITIONS.fast },
};
}
export function scalePop(delay = 0): Variants {
return {
initial: { opacity: 0, scale: 0.9 },
animate: { opacity: 1, scale: 1, transition: { ...TRANSITIONS.springSoft, delay } },
exit: { opacity: 0, scale: 0.96, transition: TRANSITIONS.fast },
};
}
export function staggerContainer(stagger = 0.08, delayChildren = 0): Variants {
return {
initial: {},
animate: {
transition: {
staggerChildren: stagger,
delayChildren,
},
},
};
}
export function blurReveal(delay = 0): Variants {
return {
initial: { opacity: 0, y: 8, filter: "blur(6px)" },
animate: { opacity: 1, y: 0, filter: "blur(0px)", transition: { ...TRANSITIONS.base, delay } },
exit: { opacity: 0, y: 8, filter: "blur(6px)", transition: TRANSITIONS.fast },
};
}

137
lib/scroll.ts Normal file
View File

@ -0,0 +1,137 @@
"use client";
import { useEffect, useMemo, useRef } from "react";
import { useMotionValue, type MotionValue } from "motion/react";
import { useScrollContext } from "@/app/providers/LenisProvider";
/**
* Math utilities
*/
export function clamp(n: number, min = 0, max = 1) {
return Math.min(max, Math.max(min, n));
}
export function lerp(a: number, b: number, t: number) {
return a + (b - a) * t;
}
export function mapRange(
inMin: number,
inMax: number,
outMin: number,
outMax: number,
v: number,
clampOutput = true,
) {
const t = (v - inMin) / (inMax - inMin || 1);
const m = outMin + (outMax - outMin) * t;
return clampOutput ? clamp(m, Math.min(outMin, outMax), Math.max(outMin, outMax)) : m;
}
/**
* Returns a MotionValue<number> that represents normalized progress [0..1]
* for the given section element as it scrolls through the viewport.
*
* Progress is 0 when the section just touches the bottom of viewport and
* 1 when it has completely exited at the top. The range used is
* (section height + viewport height) to distribute progress smoothly.
*/
export function useSectionProgress<T extends HTMLElement>(
ref: React.RefObject<T>,
): MotionValue<number> {
const { scrollY } = useScrollContext();
const progress = useMotionValue(0);
const viewportHRef = useRef<number>(typeof window !== "undefined" ? window.innerHeight : 0);
useEffect(() => {
const onResize = () => {
viewportHRef.current = window.innerHeight;
// force update after resize
update();
};
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const update = () => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
const viewport = viewportHRef.current || window.innerHeight || 0;
const range = rect.height + viewport;
// 0 when bottom of viewport touches top of element (rect.top === viewport)
// 1 when element's bottom crosses top of viewport (rect.bottom <= 0)
const value = clamp((viewport - rect.top) / (range || 1), 0, 1);
progress.set(value);
};
useEffect(() => {
// Subscribe to Lenis-driven scroll updates
const unsub = (scrollY as MotionValue<number>).on("change", update);
// Initialize
update();
return () => {
unsub();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ref, scrollY]);
return progress;
}
/**
* Hook to compute parallax offset (in px) tied to page scroll for a given element.
* speed: positive moves with scroll (slower if between 0..1), negative moves opposite.
* axis: "y" or "x"
*/
export function useParallax<T extends HTMLElement>(ref: React.RefObject<T>, speed = 0.2, axis: "y" | "x" = "y") {
const { scrollY } = useScrollContext();
const offset = useMotionValue(0);
const baseRef = useRef<number | null>(null);
const measureBase = () => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
// document scroll position
const docScroll = typeof window !== "undefined" ? window.scrollY : 0;
baseRef.current = rect.top + docScroll;
};
const update = (sy: number) => {
if (baseRef.current === null) measureBase();
if (baseRef.current === null) return;
const d = sy - baseRef.current;
offset.set(d * speed);
};
useEffect(() => {
// Measure on mount and on resize to handle layout shifts
measureBase();
const onResize = () => {
baseRef.current = null;
measureBase();
update((scrollY as MotionValue<number>).get());
};
window.addEventListener("resize", onResize);
const unsub = (scrollY as MotionValue<number>).on("change", update);
// Initialize
update((scrollY as MotionValue<number>).get());
return () => {
window.removeEventListener("resize", onResize);
unsub();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ref, speed, axis, scrollY]);
return useMemo(
() => ({
[axis]: offset,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[axis, offset],
) as { x?: MotionValue<number>; y?: MotionValue<number> };
}

2261
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,10 +10,13 @@
},
"dependencies": {
"clsx": "^2.1.1",
"mdx": "^0.3.1",
"motion": "^12.23.12",
"next": "15.5.2",
"next-mdx-remote": "^5.0.0",
"react": "19.1.0",
"react-dom": "19.1.0",
"sugar-high": "^0.9.3",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

BIN
public/images/profile.avif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 961 KiB