245 lines
10 KiB
TypeScript
245 lines
10 KiB
TypeScript
"use client";
|
|
|
|
import { useState } from "react";
|
|
import { m, useReducedMotion } from "motion/react";
|
|
|
|
import { dossiers, type Accent, type Dossier } from "@/lib/portfolio";
|
|
|
|
const accentText: Record<Accent, string> = {
|
|
cyan: "text-signal-cyan",
|
|
orange: "text-signal-orange",
|
|
green: "text-signal-green",
|
|
violet: "text-signal-violet",
|
|
};
|
|
|
|
const accentBorder: Record<Accent, string> = {
|
|
cyan: "border-l-signal-cyan",
|
|
orange: "border-l-signal-orange",
|
|
green: "border-l-signal-green",
|
|
violet: "border-l-signal-violet",
|
|
};
|
|
|
|
const accentGlow: Record<Accent, string> = {
|
|
cyan: "shadow-[0_0_32px_rgba(0,240,255,0.12)]",
|
|
orange: "shadow-[0_0_32px_rgba(255,90,31,0.12)]",
|
|
green: "shadow-[0_0_32px_rgba(182,255,0,0.1)]",
|
|
violet: "shadow-[0_0_32px_rgba(128,93,255,0.12)]",
|
|
};
|
|
|
|
function isLiveStatus(status: string) {
|
|
return /\b(live|deployed)\b/i.test(status);
|
|
}
|
|
|
|
function headlineProof(dossier: Dossier) {
|
|
return dossier.proof.find((proof) => proof.metric) ?? dossier.proof[0];
|
|
}
|
|
|
|
export function WorkIndex() {
|
|
const shouldReduceMotion = useReducedMotion();
|
|
const [activeId, setActiveId] = useState<string | null>(null);
|
|
|
|
return (
|
|
<section
|
|
id="work"
|
|
aria-labelledby="work-title"
|
|
className="mx-auto max-w-7xl px-4 py-24 text-blacksite-text sm:px-6 lg:px-8"
|
|
>
|
|
<div className="mb-10 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
|
<div>
|
|
<p className="section-label text-signal-cyan">WORK INDEX /.04</p>
|
|
<h2
|
|
id="work-title"
|
|
className="mt-3 font-display text-4xl leading-none tracking-[-0.04em] text-blacksite-text sm:text-6xl"
|
|
>
|
|
Eight field dossiers. No thumbnails.
|
|
</h2>
|
|
</div>
|
|
<p className="max-w-md text-sm leading-6 text-blacksite-muted">
|
|
Proof-first project records: mission, system constraints, and measurable
|
|
outcomes are exposed on hover, keyboard focus, touch, and reduced motion.
|
|
</p>
|
|
</div>
|
|
|
|
<m.div
|
|
initial={shouldReduceMotion ? false : { opacity: 0, y: 18 }}
|
|
whileInView={shouldReduceMotion ? undefined : { opacity: 1, y: 0 }}
|
|
viewport={{ once: true, margin: "-80px" }}
|
|
transition={{ duration: 0.5, ease: "easeOut" }}
|
|
className="overflow-hidden border-y border-blacksite-line bg-blacksite-bg2/70"
|
|
>
|
|
<div className="section-label grid grid-cols-[minmax(0,1.35fr)_minmax(0,1fr)_minmax(0,0.8fr)_5rem] gap-4 border-b border-blacksite-line px-4 py-3 text-blacksite-faint max-md:hidden">
|
|
<span>PROJECT</span>
|
|
<span>TYPE</span>
|
|
<span>STATUS</span>
|
|
<span className="text-right">YEAR</span>
|
|
</div>
|
|
|
|
<div className="divide-y divide-blacksite-line">
|
|
{dossiers.map((dossier, index) => {
|
|
const isActive = activeId === dossier.id;
|
|
const shouldDim = activeId !== null && !isActive;
|
|
const proof = headlineProof(dossier);
|
|
const detailsExpanded = shouldReduceMotion || isActive;
|
|
const liveStatus = isLiveStatus(dossier.status);
|
|
|
|
return (
|
|
<article
|
|
key={dossier.id}
|
|
tabIndex={0}
|
|
onMouseEnter={() => setActiveId(dossier.id)}
|
|
onMouseLeave={() => setActiveId(null)}
|
|
onFocus={() => setActiveId(dossier.id)}
|
|
onBlur={(event) => {
|
|
if (
|
|
!event.currentTarget.contains(
|
|
event.relatedTarget as Node | null,
|
|
)
|
|
) {
|
|
setActiveId(null);
|
|
}
|
|
}}
|
|
className={`group border-l-2 border-l-transparent bg-blacksite-bg2/20 px-4 py-5 outline-none transition duration-200 focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-signal-cyan ${
|
|
isActive
|
|
? `${accentBorder[dossier.accent]} bg-blacksite-surface/70 ${accentGlow[dossier.accent]}`
|
|
: ""
|
|
} ${shouldDim ? "opacity-45" : "opacity-100"}`}
|
|
>
|
|
<div className="grid gap-3 md:grid-cols-[minmax(0,1.35fr)_minmax(0,1fr)_minmax(0,0.8fr)_5rem] md:items-start md:gap-4">
|
|
<div className="min-w-0">
|
|
<p className="section-label mb-1 text-blacksite-faint md:hidden">
|
|
PROJECT
|
|
</p>
|
|
<div className="flex items-baseline gap-3">
|
|
<span className="font-mono text-xs text-blacksite-faint">
|
|
{String(index + 1).padStart(2, "0")}
|
|
</span>
|
|
<h3
|
|
className={`font-display text-2xl leading-none tracking-[-0.03em] transition-colors duration-200 ${
|
|
isActive
|
|
? accentText[dossier.accent]
|
|
: "text-blacksite-text"
|
|
}`}
|
|
>
|
|
{dossier.title}
|
|
</h3>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="section-label mb-1 text-blacksite-faint md:hidden">
|
|
TYPE
|
|
</p>
|
|
<p className="text-sm leading-5 text-blacksite-muted">
|
|
{dossier.type}
|
|
</p>
|
|
</div>
|
|
|
|
<div>
|
|
<p className="section-label mb-1 text-blacksite-faint md:hidden">
|
|
STATUS
|
|
</p>
|
|
<p
|
|
className={`font-mono text-xs uppercase tracking-[0.16em] ${
|
|
liveStatus ? "text-signal-green" : "text-blacksite-muted"
|
|
}`}
|
|
>
|
|
{dossier.status}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="md:text-right">
|
|
<p className="section-label mb-1 text-blacksite-faint md:hidden">
|
|
YEAR
|
|
</p>
|
|
<p className="font-mono text-xs text-blacksite-muted">
|
|
{dossier.year}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<m.div
|
|
initial={false}
|
|
animate={
|
|
detailsExpanded
|
|
? { opacity: 1, height: "auto" }
|
|
: { opacity: 0, height: 0 }
|
|
}
|
|
transition={
|
|
shouldReduceMotion
|
|
? { duration: 0 }
|
|
: { duration: 0.22, ease: "easeOut" }
|
|
}
|
|
className="overflow-hidden [@media_(hover:none)]:!h-auto [@media_(hover:none)]:!opacity-100"
|
|
>
|
|
<div className="grid gap-5 pt-5 md:grid-cols-[minmax(0,1.35fr)_minmax(0,1fr)_minmax(0,0.8fr)_5rem] md:gap-4">
|
|
<div className="space-y-2 md:col-span-2">
|
|
<p className="section-label">MISSION</p>
|
|
<p className="text-sm leading-6 text-blacksite-text">
|
|
{dossier.mission}
|
|
</p>
|
|
{dossier.constraint ? (
|
|
<p className="text-xs leading-5 text-blacksite-muted">
|
|
Constraint: {dossier.constraint}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<p className="section-label">STACK</p>
|
|
<div className="flex flex-wrap gap-2">
|
|
{dossier.stack.map((item) => (
|
|
<span
|
|
key={item}
|
|
className="border border-blacksite-line bg-blacksite-surface px-2 py-1 font-mono text-[11px] uppercase tracking-[0.14em] text-blacksite-muted"
|
|
>
|
|
{item}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2 md:text-right">
|
|
<p className="section-label">PROOF</p>
|
|
<p
|
|
className={`font-display text-xl leading-none ${accentText[dossier.accent]}`}
|
|
>
|
|
{proof?.metric ?? proof?.label ?? dossier.result}
|
|
</p>
|
|
<p className="text-xs leading-5 text-blacksite-muted">
|
|
{proof?.metric ? proof.label : dossier.result}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2 md:col-span-4">
|
|
<p className="section-label">LINKS</p>
|
|
{dossier.links.length > 0 ? (
|
|
<div className="flex flex-wrap gap-2">
|
|
{dossier.links.map((link) => (
|
|
<a
|
|
key={`${dossier.id}-${link.href}-${link.label}`}
|
|
href={link.href}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="border border-blacksite-line bg-blacksite-surface2 px-3 py-2 font-mono text-xs uppercase tracking-[0.14em] text-blacksite-text transition-colors hover:border-signal-cyan hover:text-signal-cyan focus-visible:outline-signal-cyan"
|
|
>
|
|
{link.label} ↗
|
|
</a>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="font-mono text-xs uppercase tracking-[0.14em] text-blacksite-faint">
|
|
Closed / internal artifact
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</m.div>
|
|
</article>
|
|
);
|
|
})}
|
|
</div>
|
|
</m.div>
|
|
</section>
|
|
);
|
|
}
|