initial commit, setup

This commit is contained in:
Hunter W.
2026-03-26 23:50:36 -04:00
parent 1ec3a56705
commit 960f74a754
21 changed files with 865 additions and 120 deletions

View File

@@ -2,8 +2,6 @@ This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-
## Getting Started ## Getting Started
First, run the development server:
```bash ```bash
npm run dev npm run dev
# or # or
@@ -14,23 +12,4 @@ pnpm dev
bun dev bun dev
``` ```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -1,26 +1,107 @@
@import "tailwindcss";
:root { :root {
--background: #ffffff;
--foreground: #171717; /* Background */
--bg: #101114;
--bg-raised: #141518;
--surface: #1a1b1f;
--surface-hover: #212229;
--surface-active: #282a33;
/* Border */
--border: #2c2d34;
--border-strong: #3d3f48;
--border-subtle: #232429;
/* Foreground / text */
--fg: #e4e5e9;
--fg-secondary: #a0a3ad;
--muted: #5f6270;
--faint: #43454f;
/* Accent */
--accent: #8a9dff;
--accent-hover: #9dacff;
--accent-strong: #7088ff;
--accent-bg: rgba(138, 157, 255, 0.08);
--accent-bg-hover: rgba(138, 157, 255, 0.14);
/* Semantic */
--success: #5ec98f;
--success-bg: rgba(94, 201, 143, 0.08);
--warning: #e0a855;
--warning-bg: rgba(224, 168, 85, 0.08);
--error: #e06565;
--error-bg: rgba(224, 101, 101, 0.08);
--info: #60a5d6;
--info-bg: rgba(96, 165, 214, 0.08);
/* Overlay / depth */
--overlay: rgba(0, 0, 0, 0.55);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.25);
--shadow-md: 0 4px 16px rgba(0, 0, 0, 0.30);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.35);
/* Radii */
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 10px;
--radius-xl: 16px;
/* Spacing */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
--space-8: 32px;
--space-10: 40px;
--space-12: 48px;
--space-16: 64px;
/* Typography */
--text-xs: 11px;
--text-sm: 13px;
--text-base: 15px;
--text-lg: 18px;
--text-xl: 22px;
--text-2xl: 28px;
--text-3xl: 36px;
--text-4xl: 42px;
--leading-tight: 1.2;
--leading-normal: 1.5;
--leading-relaxed: 1.7;
/* Transitions */
--ease-out: cubic-bezier(0.16, 1, 0.3, 1);
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
--duration-fast: 120ms;
--duration-normal: 200ms;
--duration-slow: 350ms;
} }
@theme inline { *,
--color-background: var(--background); *::before,
--color-foreground: var(--foreground); *::after {
--font-sans: var(--font-geist-sans); box-sizing: border-box;
--font-mono: var(--font-geist-mono); margin: 0;
} padding: 0;
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
} }
body { body {
background: var(--background); background: var(--bg);
color: var(--foreground); color: var(--fg);
font-family: Arial, Helvetica, sans-serif; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
::selection {
background: rgba(110, 231, 183, 0.25);
color: var(--fg);
}
@media (max-width: 640px) {
h1 {
font-size: 32px !important;
}
} }

View File

@@ -1,33 +1,33 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { JetBrains_Mono, DM_Sans } from "next/font/google";
import { SITE } from "@/data/content";
import "./globals.css"; import "./globals.css";
const geistSans = Geist({ const mono = JetBrains_Mono({
variable: "--font-geist-sans",
subsets: ["latin"], subsets: ["latin"],
variable: "--mono",
display: "swap",
}); });
const geistMono = Geist_Mono({ const sans = DM_Sans({
variable: "--font-geist-mono",
subsets: ["latin"], subsets: ["latin"],
variable: "--sans",
display: "swap",
}); });
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Create Next App", title: SITE.title,
description: "Generated by create next app", description: SITE.description,
}; };
export default function RootLayout({ export default function RootLayout({
children, children,
}: Readonly<{ }: {
children: React.ReactNode; children: React.ReactNode;
}>) { }) {
return ( return (
<html <html lang="en" className={`${mono.variable} ${sans.variable}`}>
lang="en" <body>{children}</body>
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
</html> </html>
); );
} }

View File

@@ -1,65 +1,59 @@
import Image from "next/image"; "use client";
import { useState } from "react";
import { PROFILE } from "@/data/content";
import { useMountTransition } from "@/hooks/useAnimations";
import Nav from "@/components/Nav";
import Profile from "@/components/Profile";
import Projects from "@/components/Projects";
import Footer from "@/components/Footer";
export default function Home() { export default function Home() {
const [section, setSection] = useState("profile");
const mounted = useMountTransition();
return ( return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black"> <div style={{ minHeight: "100vh", paddingBottom: 36 }}>
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start"> <div
<Image style={{
className="dark:invert" maxWidth: 880,
src="/next.svg" margin: "0 auto",
alt="Next.js logo" padding: "0 24px",
width={100} opacity: mounted ? 1 : 0,
height={20} transform: mounted ? "none" : "translateY(8px)",
priority transition: "all 0.5s cubic-bezier(0.16, 1, 0.3, 1)",
/> }}
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left"> >
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50"> <header
To get started, edit the page.tsx file. style={{
</h1> padding: "24px 0 0",
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400"> display: "flex",
Looking for a starting point or more instructions? Head over to{" "} justifyContent: "space-between",
<a alignItems: "center",
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" }}
className="font-medium text-zinc-950 dark:text-zinc-50" >
> <div
Templates style={{
</a>{" "} fontFamily: "var(--mono)",
or the{" "} fontSize: 14,
<a fontWeight: 700,
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" color: "var(--accent)",
className="font-medium text-zinc-950 dark:text-zinc-50" letterSpacing: "-0.02em",
> }}
Learning >
</a>{" "} </div>
center.
</p> </header>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row"> <Nav section={section} setSection={setSection} />
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]" <main>
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app" {section === "profile" && <Profile />}
target="_blank" {section === "projects" && <Projects />}
rel="noopener noreferrer" </main>
> </div>
<Image
className="dark:invert" <Footer />
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div> </div>
); );
} }

View File

@@ -0,0 +1,153 @@
"use client";
import { useState } from "react";
import type { ContactMethod } from "@/data/content";
import { useStaggerReveal } from "@/hooks/useAnimations";
function ContactIcon({ type }: { type: string }) {
const s = {
width: 16,
height: 16,
strokeWidth: 1.5,
stroke: "currentColor",
fill: "none" as const,
};
if (type === "tel")
return (
<svg {...s} viewBox="0 0 24 24">
<path d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6 19.79 19.79 0 01-3.07-8.67A2 2 0 014.11 2h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 16.92z" />
</svg>
);
if (type === "email")
return (
<svg {...s} viewBox="0 0 24 24">
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="M22 4L12 13 2 4" />
</svg>
);
return (
<svg {...s} viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" />
<path d="M12 16v-4M12 8h.01" />
</svg>
);
}
function maskValue(label: string, icon: string): string {
if (icon === "tel") {
const digits = label.replace(/\D/g, "");
return `•••-•••-${digits.slice(-4)}`;
}
return "•".repeat(Math.min(label.length, 12));
}
function getHref(label: string, icon: string): string {
if (icon === "tel") return `tel:${label.replace(/\D/g, "")}`;
if (icon === "email") return `mailto:${label}`;
return "#";
}
type Props = {
methods: ContactMethod[];
visibleCount: number;
visibleComponent: Set<Number>;
isVisible: boolean;
};
export default function ContactMethods({ methods, visibleComponent, visibleCount, isVisible }: Props) {
const [revealed, setRevealed] = useState<Set<number>>(new Set());
const [hovered, setHovered] = useState<number | null>(null);
return (
<div style={{ marginTop: 32 }}>
<div
style={{
marginBottom: 12,
fontFamily: "var(--mono)",
fontSize: "var(--text-xs)",
color: "var(--muted)",
letterSpacing: "0.08em",
textTransform: "uppercase",
opacity: isVisible ? 1.0 : 0.0
}}
>
{"contact"}
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{methods.map((method, i) => {
const isRevealed = !method.masked || revealed.has(i);
const isHovered = hovered === i;
return (
<div
key={`${method.icon}-${i}`}
onMouseEnter={() => {
setHovered(i);
if (method.masked) {
setRevealed((prev) => new Set(prev).add(i));
}
}}
onMouseLeave={() => setHovered(null)}
style={{
display: "inline-flex",
alignItems: "center",
gap: 10,
padding: "8px 14px",
fontFamily: "var(--mono)",
fontSize: "var(--text-sm)",
color: isHovered ? "var(--fg)" : "var(--fg-secondary)",
background: isHovered ? "var(--surface-hover)" : "var(--surface)",
border: `1px solid ${isHovered ? "var(--border-strong)" : "var(--border)"}`,
borderRadius: "var(--radius-md)",
transition: `all var(--duration-normal) var(--ease-in-out)`,
cursor: isRevealed ? "pointer" : "default",
alignSelf: "flex-start",
opacity: visibleComponent.has(i + visibleCount) ? 1.0 : 0.0
}}
onClick={() => {
if (isRevealed) {
window.location.href = getHref(method.label, method.icon);
}
}}
>
<span
style={{
color: isHovered ? "var(--accent)" : "var(--muted)",
transition: `color var(--duration-normal) var(--ease-in-out)`,
display: "flex",
flexShrink: 0,
}}
>
<ContactIcon type={method.icon} />
</span>
<span
style={{
transition: `opacity var(--duration-normal) var(--ease-in-out)`,
}}
>
{isRevealed ? method.label : maskValue(method.label, method.icon)}
</span>
{method.masked && !revealed.has(i) && (
<span
style={{
fontSize: "var(--text-xs)",
color: "var(--faint)",
fontStyle: "italic",
marginLeft: 4,
}}
>
hover to reveal
</span>
)}
</div>
);
})}
</div>
</div>
);
}

26
components/Cursor.tsx Normal file
View File

@@ -0,0 +1,26 @@
"use client";
import { useState, useEffect } from "react";
export default function Cursor() {
const [on, setOn] = useState(true);
useEffect(() => {
const i = setInterval(() => setOn((v) => !v), 530);
return () => clearInterval(i);
}, []);
return (
<span
style={{
display: "inline-block",
width: 10,
height: "1.1em",
background: on ? "var(--accent)" : "transparent",
marginLeft: 2,
verticalAlign: "text-bottom",
transition: "background 0.05s",
}}
/>
);
}

50
components/Footer.tsx Normal file
View File

@@ -0,0 +1,50 @@
import { SITE } from "@/data/content";
export default function Footer() {
return (
<div
style={{
position: "fixed",
bottom: 0,
left: 0,
right: 0,
height: 28,
background: "var(--surface)",
borderTop: "1px solid var(--border)",
display: "flex",
alignItems: "center",
justifyContent: "space-evenly",
padding: "0 16px",
fontFamily: "var(--mono)",
fontSize: 11,
color: "var(--muted)",
zIndex: 100,
}}
>
<div style={{ display: "flex", gap: 16 }}>
<span>
(2026) - Hunter W.
</span>
</div>
<div style={{ display: "flex", gap: 16 }}>
<span>Use this design:
<a
href={SITE.source.link}
style={{
alignItems: "center",
gap: 8,
padding: "10px 12px",
fontFamily: "var(--mono)",
fontSize: 11,
fontWeight: 500,
color: "var(--fg-secondary)",
}}
>
{SITE.source.label}
</a>
</span>
</div>
</div>
);
}

63
components/LinkIcon.tsx Normal file
View File

@@ -0,0 +1,63 @@
import type { SocialLink } from "@/data/content";
type Props = {
type: SocialLink["icon"];
};
export default function LinkIcon({ type }: Props) {
const s = {
width: 18,
height: 18,
strokeWidth: 1.5,
stroke: "currentColor",
fill: "none" as const,
};
if (type === "gh")
return (
<svg {...s} viewBox="0 0 24 24">
<path
d="M12 2C6.477 2 2 6.477 2 12c0 4.42 2.865 8.166 6.839 9.489.5.092.682-.217.682-.482 0-.237-.009-.866-.013-1.7-2.782.604-3.369-1.34-3.369-1.34-.454-1.156-1.11-1.463-1.11-1.463-.908-.62.069-.608.069-.608 1.003.07 1.531 1.03 1.531 1.03.892 1.529 2.341 1.087 2.91.831.092-.646.35-1.086.636-1.336-2.22-.253-4.555-1.11-4.555-4.943 0-1.091.39-1.984 1.029-2.683-.103-.253-.446-1.27.098-2.647 0 0 .84-.269 2.75 1.025A9.578 9.578 0 0112 6.836a9.59 9.59 0 012.504.337c1.909-1.294 2.747-1.025 2.747-1.025.546 1.377.203 2.394.1 2.647.64.699 1.028 1.592 1.028 2.683 0 3.842-2.339 4.687-4.566 4.935.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.163 22 16.418 22 12c0-5.523-4.477-10-10-10z"
fill="currentColor"
stroke="none"
/>
</svg>
);
if (type === "li")
return (
<svg {...s} viewBox="0 0 24 24">
<path d="M16 8a6 6 0 016 6v7h-4v-7a2 2 0 00-4 0v7h-4v-7a6 6 0 016-6z" />
<rect x="2" y="9" width="4" height="12" />
<circle cx="4" cy="4" r="2" />
</svg>
);
if (type === "x")
return (
<svg {...s} viewBox="0 0 24 24">
<path d="M4 4l6.5 8L4 20h2l5.5-6.8L16 20h4l-6.8-8.5L19.5 4h-2l-5 6.2L8 4H4z" />
</svg>
);
if (type === "dev")
return (
<svg {...s} viewBox="0 0 24 24">
<rect x="2" y="4" width="20" height="16" rx="2" />
<text
x="12"
y="15"
textAnchor="middle"
fontSize="7"
fontWeight="bold"
fill="currentColor"
stroke="none"
fontFamily="monospace"
>
DEV
</text>
</svg>
);
return null;
}

58
components/Nav.tsx Normal file
View File

@@ -0,0 +1,58 @@
"use client";
type Props = {
section: string;
setSection: (s: string) => void;
};
const items = ["profile", "projects"];
export default function Nav({ section, setSection }: Props) {
return (
<nav
style={{
display: "flex",
gap: 0,
borderBottom: "1px solid var(--border)",
fontFamily: "var(--mono)",
fontSize: 13,
userSelect: "none",
}}>
{items.map((item) => (
<button
key={item}
onClick={() => setSection(item)}
style={{
background: section === item ? "var(--surface)" : "transparent",
color: section === item ? "var(--accent)" : "var(--muted)",
border: "none",
borderBottom: section === item ? "2px solid var(--accent)" : "2px solid transparent",
padding: "12px 24px",
cursor: "pointer",
fontFamily: "inherit",
fontSize: "inherit",
fontWeight: 500,
letterSpacing: "0.03em",
textTransform: "lowercase",
transition: "all 0.2s",
}}
onMouseEnter={(e) => {
if (section !== item)
(e.target as HTMLElement).style.color = "var(--fg)";
}}
onMouseLeave={(e) => {
if (section !== item)
(e.target as HTMLElement).style.color = "var(--muted)";
}}>
<span style={{ opacity: 0.4, marginRight: 6 }}></span>
{item}
</button>
))}
</nav>
);
}

96
components/Profile.tsx Normal file
View File

@@ -0,0 +1,96 @@
"use client";
import { useState } from "react";
import { PROFILE } from "@/data/content";
import LinkIcon from "./LinkIcon";
import ContactMethods from "./ContactMethods";
import { useStaggerReveal } from "@/hooks/useAnimations";
export default function Profile() {
const [hovered, setHovered] = useState<number | null>(null);
const visible = useStaggerReveal(PROFILE.links.length + PROFILE.contactMethods.length, 100);
return (
<div style={{ padding: "48px 0", maxWidth: 640 }}>
<div
style={{
marginBottom: 8,
fontFamily: "var(--mono)",
fontSize: 11,
color: "var(--muted)",
letterSpacing: "0.08em",
textTransform: "uppercase",
}}
>
{"# about"}
</div>
<h1
style={{
fontFamily: "var(--sans)",
fontSize: 42,
fontWeight: 700,
color: "var(--fg)",
margin: "0 0 4px 0",
lineHeight: 1.1,
letterSpacing: "-0.02em",
}}
>
{PROFILE.name}
</h1>
<div
style={{
fontFamily: "var(--mono)",
fontSize: 15,
color: "var(--accent)",
marginBottom: 32,
fontWeight: 500,
}}
>
{PROFILE.title}
</div>
<p
style={{
fontFamily: "var(--sans)",
fontSize: 16,
lineHeight: 1.7,
color: "var(--fg-secondary)",
margin: "0 0 40px 0",
maxWidth: 560,
}}
>
{PROFILE.bio}
</p>
<div style={{ display: "flex", gap: 12, flexWrap: "wrap" }}>
{PROFILE.links.map((link, i) => (
<a key={link.label}
href={link.url}
target="_blank"
rel="noopener noreferrer"
onMouseEnter={() => setHovered(i)}
onMouseLeave={() => setHovered(null)}
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
padding: "10px 18px",
fontFamily: "var(--mono)",
fontSize: 13,
fontWeight: 500,
color: hovered === i ? "var(--accent)" : "var(--fg-secondary)",
background: hovered === i ? "var(--accent-bg)" : "var(--surface)",
border: `1px solid ${hovered === i ? "var(--accent)" : "var(--border)"}`,
borderRadius: 6,
textDecoration: "none",
transition: "all var(--duration-slow)",
transform: hovered === i ? "translateY(-2px)" : "none",
opacity: visible.has(i) ? 1.0 : 0
}}
>
<LinkIcon type={link.icon} />
{link.label}
</a>
))}
</div>
<ContactMethods methods={PROFILE.contactMethods} visibleCount={PROFILE.links.length} visibleComponent={visible} isVisible={visible.has(PROFILE.links.length-1)} />
</div>
);
}

103
components/ProjectCard.tsx Normal file
View File

@@ -0,0 +1,103 @@
"use client";
import { useState } from "react";
import type { Project } from "@/data/content";
type Props = {
project: Project;
visible: boolean;
};
export default function ProjectCard({ project, visible }: Props) {
const [hovered, setHovered] = useState(false);
return (
<a
href={project.link}
target="_blank"
rel="noopener noreferrer"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
style={{
display: "block",
textDecoration: "none",
padding: 28,
background: hovered ? "var(--surface-hover)" : "var(--surface)",
border: `1px solid ${hovered ? "var(--accent)" : "var(--border)"}`,
borderRadius: 8,
transition: "all 0.35s cubic-bezier(0.16, 1, 0.3, 1)",
transform: visible
? hovered
? "translateY(-3px)"
: "translateY(0)"
: "translateY(12px)",
opacity: visible ? 1 : 0,
boxShadow: hovered ? "0 8px 32px rgba(0,0,0,0.12)" : "none",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: 12,
}}
>
<div
style={{
fontFamily: "var(--mono)",
fontSize: 18,
fontWeight: 700,
color: hovered ? "var(--accent)" : "var(--fg)",
transition: "color 0.2s",
}}
>
{project.title}
</div>
<div
style={{
fontFamily: "var(--mono)",
fontSize: 11,
color: "var(--muted)",
padding: "2px 8px",
background: "var(--bg)",
borderRadius: 4,
border: "1px solid var(--border)",
}}
>
{project.year}
</div>
</div>
<p
style={{
fontFamily: "var(--sans)",
fontSize: 14,
lineHeight: 1.6,
color: "var(--fg-secondary)",
margin: "0 0 16px 0",
}}
>
{project.description}
</p>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{project.tags.map((tag) => (
<span
key={tag}
style={{
fontFamily: "var(--mono)",
fontSize: 11,
fontWeight: 500,
color: "var(--accent)",
padding: "3px 10px",
background: "var(--accent-bg)",
borderRadius: 4,
letterSpacing: "0.02em",
}}
>
{tag}
</span>
))}
</div>
</a>
);
}

53
components/Projects.tsx Normal file
View File

@@ -0,0 +1,53 @@
"use client";
import { PROJECTS } from "@/data/content";
import { useStaggerReveal } from "@/hooks/useAnimations";
import ProjectCard from "./ProjectCard";
export default function Projects() {
const visible = useStaggerReveal(PROJECTS.length, 100);
return (
<div style={{ padding: "48px 0" }}>
<div
style={{
marginBottom: 8,
fontFamily: "var(--mono)",
fontSize: 11,
color: "var(--muted)",
letterSpacing: "0.08em",
textTransform: "uppercase",
}}
>
{"# projects"}
</div>
<h2
style={{
fontFamily: "var(--sans)",
fontSize: 28,
fontWeight: 700,
color: "var(--fg)",
margin: "0 0 32px 0",
letterSpacing: "-0.01em",
}}
>
{"Things I've built"}
</h2>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(320px, 1fr))",
gap: 16,
}}
>
{PROJECTS.map((project, i) => (
<ProjectCard
key={project.id}
project={project}
visible={visible.has(i)}
/>
))}
</div>
</div>
);
}

62
data/content.ts Normal file
View File

@@ -0,0 +1,62 @@
export type SocialLink = {
label: string;
url: string;
icon: "gh" | "li" | "x" | "dev";
};
export type ContactMethod = {
masked: boolean,
label: string,
icon: string
}
export type Project = {
id: number;
slug: string;
title: string;
description: string;
tags: string[];
link: string;
year: string;
};
export const PROFILE = {
name: "Hunter W",
title: "Software Engineer",
email: "contact@hwilliams.dev",
bio: "I build user experiences before I ever write code. Experience with full-stack development across a variety of frameworks and languages including JS [Vue, React, Next.JS, Nuxt, Node.JS, Express], Python [Flask, FastAPI, Gunicorn], and more.",
links: [
{ label: "GitHub", url: "https://github.com/FerrenF", icon: "gh" },
{ label: "LinkedIn", url: "https://www.linkedin.com/in/hwilliamsf/", icon: "li" }
] satisfies SocialLink[],
contactMethods: [{
masked: true,
label: "478-331-2258",
icon: "tel"
}, {
masked: false,
label: "contact@hwilliams.dev",
icon: "email"
}
] satisfies ContactMethod[]
};
export const SITE = {
title: "Hunter W. - Software Engineer",
description: "SWE Looking for work",
source: {
label: "Git",
link: ""
}
};
export const PROJECTS: Project[] = [{
id: 1,
slug: "clean-space",
title: "Clean Space",
description: "Clean Space is an architecture, proof of concept, and an in-development tool for Space Engineers and Space Engineers 2 server owners featuring client and server side .",
tags: ["C#", ".NET", "WPF", "Netcode", "Security"],
link: "https://github.com/FerrenF/CleanSpace",
year: "2025",
}];

33
hooks/useAnimations.ts Normal file
View File

@@ -0,0 +1,33 @@
"use client";
import { useState, useEffect } from "react";
export function useStaggerReveal(count: number, baseDelay = 80) {
const [visible, setVisible] = useState<Set<number>>(new Set());
useEffect(() => {
const timers: ReturnType<typeof setTimeout>[] = [];
for (let i = 0; i < count; i++) {
timers.push(
setTimeout(
() => setVisible((prev) => new Set(prev).add(i)),
baseDelay * (i + 1) + 200
)
);
}
return () => timers.forEach(clearTimeout);
}, [count, baseDelay]);
return visible;
}
export function useMountTransition(delay = 50) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
const t = setTimeout(() => setMounted(true), delay);
return () => clearTimeout(t);
}, [delay]);
return mounted;
}

View File

@@ -1,7 +1,6 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */
reactCompiler: true, reactCompiler: true,
}; };

View File

@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

View File

@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B