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

@@ -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>
);
}