feat(portfolio): add operator profile with banner, publication, and honors
This commit is contained in:
84
components/portfolio/FieldNotes.tsx
Normal file
84
components/portfolio/FieldNotes.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import Link from "next/link";
|
||||
import type { PostMeta } from "@/lib/posts";
|
||||
|
||||
type FieldNotesProps = {
|
||||
posts: PostMeta[];
|
||||
};
|
||||
|
||||
function formatPostDate(date: string) {
|
||||
const parsed = new Date(date);
|
||||
|
||||
if (Number.isNaN(parsed.getTime())) return date;
|
||||
|
||||
return parsed.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function FieldNotes({ posts }: FieldNotesProps) {
|
||||
const recentPosts = posts.slice(0, 4);
|
||||
|
||||
return (
|
||||
<section
|
||||
id="notes"
|
||||
className="border-y border-blacksite-line bg-blacksite-bg2 px-4 py-24 text-blacksite-text sm:px-6 lg:px-8"
|
||||
>
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div className="mb-10 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="section-label mb-3">FIELD NOTES /.04</p>
|
||||
<h2 className="font-display text-4xl font-semibold tracking-[-0.04em] text-blacksite-text sm:text-6xl">
|
||||
Blog telemetry from the archive.
|
||||
</h2>
|
||||
</div>
|
||||
<Link
|
||||
href="/blog/"
|
||||
className="section-label w-fit rounded-md border border-blacksite-line px-4 py-3 !text-blacksite-text transition-colors hover:border-signal-cyan/70 hover:!text-signal-cyan focus-visible:outline-signal-cyan"
|
||||
>
|
||||
All field notes →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{recentPosts.length > 0 ? (
|
||||
<div className="divide-y divide-blacksite-line border-y border-blacksite-line">
|
||||
{recentPosts.map((post) => (
|
||||
<Link
|
||||
key={post.slug}
|
||||
href={`/blog/${post.slug}/`}
|
||||
className="group grid gap-3 px-0 py-6 transition-colors hover:bg-blacksite-surface/55 focus-visible:outline-signal-cyan sm:grid-cols-[12rem_minmax(0,1fr)] sm:px-4"
|
||||
>
|
||||
<time className="section-label pt-1" dateTime={post.date}>
|
||||
{formatPostDate(post.date)}
|
||||
</time>
|
||||
<span className="min-w-0">
|
||||
<span className="block font-display text-2xl font-medium tracking-[-0.03em] text-blacksite-text transition-colors group-hover:text-signal-cyan group-focus-visible:text-signal-cyan">
|
||||
{post.title}
|
||||
</span>
|
||||
{post.excerpt && (
|
||||
<span className="mt-2 block max-w-3xl text-sm leading-6 text-blacksite-muted">
|
||||
{post.excerpt}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="border border-dashed border-blacksite-line bg-blacksite-surface/45 p-8 sm:p-10">
|
||||
<p className="section-label mb-5 !text-blacksite-text">
|
||||
NO FIELD NOTES ON RECORD — ARCHIVE OPENS SOON
|
||||
</p>
|
||||
<Link
|
||||
href="/blog/"
|
||||
className="section-label inline-flex rounded-md border border-blacksite-line px-4 py-3 !text-blacksite-text transition-colors hover:border-signal-cyan/70 hover:!text-signal-cyan focus-visible:outline-signal-cyan"
|
||||
>
|
||||
Visit archive →
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
68
components/portfolio/Hero.tsx
Normal file
68
components/portfolio/Hero.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { m, useReducedMotion } from "motion/react";
|
||||
import { hero } from "@/lib/portfolio";
|
||||
import { HeroCanvas } from "./HeroCanvas";
|
||||
|
||||
const paletteEventName = "blacksite:palette";
|
||||
|
||||
export function Hero() {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
const openCommandPalette = () => {
|
||||
window.dispatchEvent(new CustomEvent(paletteEventName));
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="hero-heading"
|
||||
className="relative isolate flex min-h-screen overflow-hidden bg-blacksite-bg px-5 py-28 text-blacksite-text sm:px-8 lg:px-12"
|
||||
>
|
||||
<HeroCanvas />
|
||||
<div className="absolute inset-0 z-0 bg-[radial-gradient(circle_at_20%_20%,rgba(255,90,31,0.12),transparent_30%),linear-gradient(90deg,rgba(3,5,6,0.98),rgba(3,5,6,0.78)_52%,rgba(3,5,6,0.96))]" />
|
||||
|
||||
<m.div
|
||||
initial={shouldReduceMotion ? false : { opacity: 0, y: 18 }}
|
||||
animate={shouldReduceMotion ? undefined : { opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="relative z-10 mx-auto flex w-full max-w-7xl flex-col justify-center"
|
||||
>
|
||||
<div className="mb-8 inline-flex w-fit items-center gap-3 border border-blacksite-line bg-blacksite-surface/80 px-4 py-2 font-mono text-[11px] uppercase tracking-[0.16em] text-blacksite-muted shadow-[0_0_40px_rgba(0,0,0,0.35)] backdrop-blur-sm">
|
||||
<span className="h-2 w-2 rounded-full bg-signal-green shadow-[0_0_14px_rgba(182,255,0,0.75)]" />
|
||||
[STATUS: ONLINE]
|
||||
</div>
|
||||
|
||||
<h1
|
||||
id="hero-heading"
|
||||
className="max-w-6xl font-display text-[clamp(64px,10vw,140px)] leading-[0.92] tracking-[-0.06em] text-balance text-blacksite-text"
|
||||
>
|
||||
{hero.heading}
|
||||
</h1>
|
||||
|
||||
<p className="mt-8 max-w-2xl text-lg leading-8 text-blacksite-muted sm:text-xl">
|
||||
{hero.subline}
|
||||
</p>
|
||||
|
||||
<div className="mt-10 flex flex-col gap-3 sm:flex-row">
|
||||
<a
|
||||
href="#work"
|
||||
className="group inline-flex items-center justify-center border border-blacksite-line border-l-signal-orange bg-blacksite-surface px-6 py-3 font-mono text-xs uppercase tracking-[0.16em] text-blacksite-text transition-colors hover:border-signal-orange hover:bg-blacksite-surface2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-signal-orange"
|
||||
>
|
||||
View Work
|
||||
<span className="ml-3 text-signal-orange" aria-hidden="true">
|
||||
/.
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCommandPalette}
|
||||
className="inline-flex items-center justify-center border border-blacksite-line bg-blacksite-bg2/85 px-6 py-3 font-mono text-xs uppercase tracking-[0.16em] text-blacksite-muted transition-colors hover:border-signal-orange hover:text-blacksite-text focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-signal-orange"
|
||||
>
|
||||
Open Command Palette
|
||||
</button>
|
||||
</div>
|
||||
</m.div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
200
components/portfolio/HeroCanvas.tsx
Normal file
200
components/portfolio/HeroCanvas.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useReducedMotion } from "motion/react";
|
||||
|
||||
const COLORS = {
|
||||
bg: "#030506",
|
||||
line: "rgba(44, 53, 58, 0.46)",
|
||||
dot: "rgba(244, 247, 248, 0.38)",
|
||||
cyan: "rgba(0, 240, 255, 0.82)",
|
||||
};
|
||||
|
||||
const GRID_COLUMNS = 14;
|
||||
const GRID_ROWS = 13;
|
||||
const PULSE_PERIOD_MS = 5200;
|
||||
|
||||
function projectX(width: number, xIndex: number, yIndex: number, time: number) {
|
||||
const normalizedX = (xIndex / (GRID_COLUMNS - 1) - 0.5) * 2;
|
||||
const normalizedY = yIndex / (GRID_ROWS - 1);
|
||||
const depth = normalizedY * normalizedY;
|
||||
|
||||
return (
|
||||
width * 0.5 +
|
||||
normalizedX * width * (0.13 + depth * 0.5) +
|
||||
Math.sin(time * 0.00008 + yIndex * 0.45) * 7
|
||||
);
|
||||
}
|
||||
|
||||
function projectY(height: number, yIndex: number, time: number) {
|
||||
const normalizedY = yIndex / (GRID_ROWS - 1);
|
||||
const drift = Math.sin(time * 0.00012 + yIndex * 0.7) * 5;
|
||||
|
||||
return height * 0.24 + height * 0.82 * normalizedY * normalizedY + drift;
|
||||
}
|
||||
|
||||
export function HeroCanvas() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
const context = canvas.getContext("2d", { alpha: true });
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let dpr = 1;
|
||||
let frameId = 0;
|
||||
let isMounted = true;
|
||||
|
||||
const resize = () => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
width = Math.max(1, rect.width);
|
||||
height = Math.max(1, rect.height);
|
||||
canvas.width = Math.floor(width * dpr);
|
||||
canvas.height = Math.floor(height * dpr);
|
||||
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
|
||||
if (shouldReduceMotion) {
|
||||
draw(0);
|
||||
}
|
||||
};
|
||||
|
||||
const drawGridLines = (time: number) => {
|
||||
context.lineWidth = 1;
|
||||
context.strokeStyle = COLORS.line;
|
||||
|
||||
for (let column = 0; column < GRID_COLUMNS; column += 1) {
|
||||
context.beginPath();
|
||||
for (let row = 0; row < GRID_ROWS; row += 1) {
|
||||
const x = projectX(width, column, row, time);
|
||||
const y = projectY(height, row, time);
|
||||
if (row === 0) {
|
||||
context.moveTo(x, y);
|
||||
} else {
|
||||
context.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
for (let row = 0; row < GRID_ROWS; row += 1) {
|
||||
context.beginPath();
|
||||
for (let column = 0; column < GRID_COLUMNS; column += 1) {
|
||||
const x = projectX(width, column, row, time);
|
||||
const y = projectY(height, row, time);
|
||||
if (column === 0) {
|
||||
context.moveTo(x, y);
|
||||
} else {
|
||||
context.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
context.stroke();
|
||||
}
|
||||
};
|
||||
|
||||
const drawDots = (time: number) => {
|
||||
context.fillStyle = COLORS.dot;
|
||||
|
||||
for (let row = 0; row < GRID_ROWS; row += 1) {
|
||||
const normalizedY = row / (GRID_ROWS - 1);
|
||||
const radius = 0.9 + normalizedY * 1.15;
|
||||
|
||||
for (let column = 0; column < GRID_COLUMNS; column += 1) {
|
||||
context.beginPath();
|
||||
context.arc(
|
||||
projectX(width, column, row, time),
|
||||
projectY(height, row, time),
|
||||
radius,
|
||||
0,
|
||||
Math.PI * 2,
|
||||
);
|
||||
context.fill();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const drawPulse = (time: number) => {
|
||||
if (shouldReduceMotion) {
|
||||
return;
|
||||
}
|
||||
|
||||
const progress = (time % PULSE_PERIOD_MS) / PULSE_PERIOD_MS;
|
||||
const row = 8;
|
||||
const maxColumn = Math.min(
|
||||
GRID_COLUMNS - 1,
|
||||
Math.max(1, Math.floor(progress * (GRID_COLUMNS + 2))),
|
||||
);
|
||||
|
||||
context.save();
|
||||
context.globalAlpha = progress > 0.84 ? (1 - progress) / 0.16 : 1;
|
||||
context.lineWidth = 2;
|
||||
context.strokeStyle = COLORS.cyan;
|
||||
context.shadowBlur = 14;
|
||||
context.shadowColor = COLORS.cyan;
|
||||
context.beginPath();
|
||||
|
||||
for (let column = 0; column <= maxColumn; column += 1) {
|
||||
const x = projectX(width, column, row, time);
|
||||
const y = projectY(height, row, time);
|
||||
if (column === 0) {
|
||||
context.moveTo(x, y);
|
||||
} else {
|
||||
context.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
context.stroke();
|
||||
context.restore();
|
||||
};
|
||||
|
||||
const draw = (time: number) => {
|
||||
context.clearRect(0, 0, width, height);
|
||||
context.fillStyle = COLORS.bg;
|
||||
context.fillRect(0, 0, width, height);
|
||||
drawGridLines(time);
|
||||
drawDots(time);
|
||||
drawPulse(time);
|
||||
};
|
||||
|
||||
const tick = (time: number) => {
|
||||
if (!isMounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
draw(time);
|
||||
frameId = window.requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
resize();
|
||||
window.addEventListener("resize", resize, { passive: true });
|
||||
|
||||
if (!shouldReduceMotion) {
|
||||
frameId = window.requestAnimationFrame(tick);
|
||||
}
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
window.removeEventListener("resize", resize);
|
||||
if (frameId) {
|
||||
window.cancelAnimationFrame(frameId);
|
||||
}
|
||||
};
|
||||
}, [shouldReduceMotion]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 h-full w-full opacity-[0.32]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
143
components/portfolio/OperatorProfile.tsx
Normal file
143
components/portfolio/OperatorProfile.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { honors, identity, publication, skillsMatrix } from "@/lib/portfolio";
|
||||
|
||||
export function OperatorProfile() {
|
||||
return (
|
||||
<section
|
||||
id="operator"
|
||||
className="bg-blacksite-bg px-4 py-20 text-blacksite-text sm:px-6 lg:px-8"
|
||||
>
|
||||
<div className="mx-auto max-w-7xl">
|
||||
<div className="section-label mb-4">OPERATOR PROFILE /.06</div>
|
||||
|
||||
<div className="overflow-hidden rounded-2xl border border-blacksite-line bg-blacksite-surface shadow-[0_24px_90px_rgba(0,0,0,0.35)]">
|
||||
<div className="relative h-48 overflow-hidden border-b border-blacksite-line bg-blacksite-bg2 sm:h-56 lg:h-64">
|
||||
<img
|
||||
src="/banner.jpg"
|
||||
alt=""
|
||||
className="h-full w-full object-cover opacity-45 saturate-50"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/85" aria-hidden="true" />
|
||||
<div
|
||||
className="absolute inset-0 bg-[linear-gradient(90deg,rgba(0,240,255,0.12),transparent_34%,rgba(255,90,31,0.08))]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-px bg-blacksite-line lg:grid-cols-[1fr_1.15fr]">
|
||||
<div className="bg-blacksite-surface p-6 sm:p-8 lg:p-10">
|
||||
<p className="section-label mb-4">IDENTITY</p>
|
||||
<h2 className="font-display text-4xl leading-tight tracking-tight text-blacksite-text sm:text-5xl">
|
||||
{identity.name}
|
||||
</h2>
|
||||
<dl className="mt-8 grid gap-5 text-sm text-blacksite-muted sm:text-base">
|
||||
<div>
|
||||
<dt className="section-label mb-2">ROLE</dt>
|
||||
<dd className="text-blacksite-text">{identity.roleLine}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="section-label mb-2">EDUCATION</dt>
|
||||
<dd>{identity.education}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="section-label mb-2">SITE</dt>
|
||||
<dd>{identity.site}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-px bg-blacksite-line">
|
||||
<article className="bg-blacksite-surface2 p-6 sm:p-8 lg:p-10">
|
||||
<p className="section-label mb-4">PUBLICATION</p>
|
||||
<h3 className="font-display text-2xl leading-tight text-blacksite-text">
|
||||
{publication.title}
|
||||
</h3>
|
||||
<p className="mt-4 text-sm text-blacksite-muted sm:text-base">
|
||||
{publication.venue} · {publication.year} · {publication.authorship}
|
||||
</p>
|
||||
<a
|
||||
href={publication.href}
|
||||
className="section-label mt-6 inline-flex text-signal-cyan underline decoration-signal-cyan/35 underline-offset-4 transition-colors hover:text-blacksite-text focus-visible:outline-signal-cyan"
|
||||
>
|
||||
DOI: {publication.doi}
|
||||
</a>
|
||||
</article>
|
||||
|
||||
<div className="bg-blacksite-surface p-6 sm:p-8 lg:p-10">
|
||||
<p className="section-label mb-5">HONORS</p>
|
||||
<ul className="grid gap-4">
|
||||
{honors.map((honor) => (
|
||||
<li
|
||||
key={`${honor.title}-${honor.year}`}
|
||||
className="grid gap-2 border-l border-signal-orange/50 pl-4 sm:grid-cols-[auto_1fr] sm:items-baseline sm:gap-4"
|
||||
>
|
||||
<span className="section-label text-signal-orange">
|
||||
{honor.year || "RECOGNITION"}
|
||||
</span>
|
||||
<span>
|
||||
<span className="text-blacksite-text">{honor.title}</span>
|
||||
{honor.detail ? (
|
||||
<span className="text-blacksite-muted"> — {honor.detail}</span>
|
||||
) : null}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-blacksite-line bg-blacksite-bg2 p-6 sm:p-8 lg:p-10">
|
||||
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<p className="section-label mb-3">SKILLS MATRIX</p>
|
||||
<h3 className="font-display text-2xl text-blacksite-text">
|
||||
Domain → Capabilities
|
||||
</h3>
|
||||
</div>
|
||||
<p className="section-label text-blacksite-faint">OPERATING SURFACE</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-blacksite-line">
|
||||
<table className="w-full border-collapse text-left text-sm sm:text-base">
|
||||
<thead className="bg-blacksite-surface2">
|
||||
<tr>
|
||||
<th scope="col" className="section-label px-4 py-3 sm:px-5">
|
||||
Domain
|
||||
</th>
|
||||
<th scope="col" className="section-label px-4 py-3 sm:px-5">
|
||||
Capabilities
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-blacksite-line bg-blacksite-surface">
|
||||
{skillsMatrix.map((row) => (
|
||||
<tr key={row.category}>
|
||||
<th
|
||||
scope="row"
|
||||
className="w-56 px-4 py-4 align-top font-mono text-xs uppercase tracking-[0.16em] text-blacksite-text sm:px-5"
|
||||
>
|
||||
{row.category}
|
||||
</th>
|
||||
<td className="px-4 py-4 text-blacksite-muted sm:px-5">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{row.skills.map((skill) => (
|
||||
<span
|
||||
key={skill}
|
||||
className="rounded-full border border-blacksite-line bg-blacksite-surface2 px-3 py-1 text-xs text-blacksite-text sm:text-sm"
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user