feat(portfolio): add contact terminal and closing cta tiles
This commit is contained in:
90
components/portfolio/ClosingCta.tsx
Normal file
90
components/portfolio/ClosingCta.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { contact } from "@/lib/portfolio";
|
||||||
|
|
||||||
|
const sectionLinks = [
|
||||||
|
{ label: "Work", href: "#work" },
|
||||||
|
{ label: "Capabilities", href: "#capabilities" },
|
||||||
|
{ label: "Operator", href: "#operator" },
|
||||||
|
{ label: "Contact", href: "#contact" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const elsewhereLinks = [
|
||||||
|
{ label: "Git", href: contact.git },
|
||||||
|
{ label: "LinkedIn", href: contact.linkedIn },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ClosingCta() {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="border-t border-blacksite-line bg-blacksite-bg px-4 py-16 text-blacksite-text sm:px-6 lg:px-8">
|
||||||
|
<div className="mx-auto max-w-7xl">
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<a
|
||||||
|
href="#work"
|
||||||
|
className="group min-h-52 border border-blacksite-line bg-blacksite-surface p-6 transition-colors hover:border-signal-orange/70 focus-visible:outline-signal-orange sm:p-8"
|
||||||
|
>
|
||||||
|
<p className="section-label mb-12">CTA /.01</p>
|
||||||
|
<span className="font-display text-4xl font-semibold tracking-[-0.04em] text-blacksite-text transition-colors group-hover:text-signal-orange sm:text-6xl">
|
||||||
|
View Selected Work →
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={`mailto:${contact.email}`}
|
||||||
|
className="group min-h-52 border border-blacksite-line bg-blacksite-surface p-6 transition-colors hover:border-signal-orange/70 focus-visible:outline-signal-orange sm:p-8"
|
||||||
|
>
|
||||||
|
<p className="section-label mb-12">CTA /.02</p>
|
||||||
|
<span className="font-display text-4xl font-semibold tracking-[-0.04em] text-blacksite-text transition-colors group-hover:text-signal-orange sm:text-6xl">
|
||||||
|
Start a Conversation →
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 grid gap-8 border border-blacksite-line bg-blacksite-surface2/35 p-6 sm:grid-cols-2 lg:grid-cols-[1fr_1fr_1fr_1.2fr] lg:p-8">
|
||||||
|
<nav aria-label="Footer sections">
|
||||||
|
<p className="section-label mb-4">Sections</p>
|
||||||
|
<ul className="grid gap-3 font-mono text-xs uppercase tracking-[0.14em] text-blacksite-muted">
|
||||||
|
{sectionLinks.map((link) => (
|
||||||
|
<li key={link.href}>
|
||||||
|
<a href={link.href} className="transition-colors hover:text-signal-orange focus-visible:outline-signal-orange">
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<nav aria-label="Footer field notes">
|
||||||
|
<p className="section-label mb-4">Field Notes</p>
|
||||||
|
<Link href="/blog/" className="font-mono text-xs uppercase tracking-[0.14em] text-blacksite-muted transition-colors hover:text-signal-orange focus-visible:outline-signal-orange">
|
||||||
|
/blog/
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<nav aria-label="Footer elsewhere">
|
||||||
|
<p className="section-label mb-4">Elsewhere</p>
|
||||||
|
<ul className="grid gap-3 font-mono text-xs uppercase tracking-[0.14em] text-blacksite-muted">
|
||||||
|
{elsewhereLinks.map((link) => (
|
||||||
|
<li key={link.href}>
|
||||||
|
<a href={link.href} target="_blank" rel="noreferrer" className="transition-colors hover:text-signal-orange focus-visible:outline-signal-orange">
|
||||||
|
{link.label} ↗
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="section-label mb-4">Build</p>
|
||||||
|
<p className="font-mono text-xs uppercase leading-6 tracking-[0.14em] text-blacksite-muted">
|
||||||
|
Next.js · Tailwind · deployed from out/
|
||||||
|
</p>
|
||||||
|
<p className="mt-4 font-mono text-xs uppercase tracking-[0.14em] text-blacksite-faint">© {year}</p>
|
||||||
|
<p className="mt-6 font-mono text-xs uppercase tracking-[0.14em] text-blacksite-muted">⌘K opens command index</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
114
components/portfolio/ContactTerminal.tsx
Normal file
114
components/portfolio/ContactTerminal.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { contact } from "@/lib/portfolio";
|
||||||
|
|
||||||
|
type CopyTarget = "email" | "ssh";
|
||||||
|
type CopyStatus = "idle" | "copied" | "failed";
|
||||||
|
|
||||||
|
const rows = [
|
||||||
|
{ label: "EMAIL", value: contact.email, kind: "copy" as const, target: "email" as CopyTarget },
|
||||||
|
{ label: "GIT", value: contact.git, kind: "link" as const },
|
||||||
|
{ label: "LINKEDIN", value: contact.linkedIn, kind: "link" as const },
|
||||||
|
{ label: "SSH", value: contact.ssh, kind: "copy" as const, target: "ssh" as CopyTarget },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ContactTerminal() {
|
||||||
|
const [copyState, setCopyState] = useState<Record<CopyTarget, CopyStatus>>({
|
||||||
|
email: "idle",
|
||||||
|
ssh: "idle",
|
||||||
|
});
|
||||||
|
const resetTimers = useRef<Partial<Record<CopyTarget, ReturnType<typeof setTimeout>>>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timers = resetTimers.current;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
Object.values(timers).forEach((timer) => {
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function copyValue(target: CopyTarget, value: string) {
|
||||||
|
if (resetTimers.current[target]) {
|
||||||
|
clearTimeout(resetTimers.current[target]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(value);
|
||||||
|
setCopyState((current) => ({ ...current, [target]: "copied" }));
|
||||||
|
} catch {
|
||||||
|
setCopyState((current) => ({ ...current, [target]: "failed" }));
|
||||||
|
}
|
||||||
|
|
||||||
|
resetTimers.current[target] = setTimeout(() => {
|
||||||
|
setCopyState((current) => ({ ...current, [target]: "idle" }));
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="contact" aria-labelledby="contact-title" className="mx-auto max-w-7xl px-4 py-20 text-blacksite-text sm:px-6 lg:px-8">
|
||||||
|
<div className="border border-blacksite-line bg-blacksite-surface shadow-[0_32px_120px_rgba(0,0,0,0.42)]">
|
||||||
|
<div className="flex items-center justify-between gap-4 border-b border-blacksite-line px-5 py-4 sm:px-7">
|
||||||
|
<p className="font-mono text-xs text-blacksite-muted">> open contact.channel</p>
|
||||||
|
<span className="section-label">SECURE HANDOFF</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-0 lg:grid-cols-[0.8fr_1.2fr]">
|
||||||
|
<div className="border-b border-blacksite-line px-5 py-8 sm:px-7 lg:border-b-0 lg:border-r lg:px-10">
|
||||||
|
<p className="section-label mb-4">CONTACT TERMINAL</p>
|
||||||
|
<h2 id="contact-title" className="font-mono text-3xl font-semibold uppercase tracking-[-0.04em] text-blacksite-text sm:text-5xl">
|
||||||
|
Send signal.
|
||||||
|
</h2>
|
||||||
|
<p className="mt-5 max-w-md text-sm leading-7 text-blacksite-muted sm:text-base">
|
||||||
|
Reach the operator directly for systems work, hardened infrastructure, or unusually interesting problems.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-blacksite-line">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<div key={row.label} className="grid gap-3 px-5 py-5 sm:px-7 md:grid-cols-[7rem_minmax(0,1fr)] lg:px-8">
|
||||||
|
<p className="section-label pt-1">{row.label}</p>
|
||||||
|
{row.kind === "copy" ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => copyValue(row.target, row.value)}
|
||||||
|
className="group flex min-w-0 items-center justify-between gap-4 text-left font-mono text-sm text-blacksite-text transition-colors hover:underline focus-visible:outline-blacksite-text"
|
||||||
|
>
|
||||||
|
<span className="break-all">{row.value}</span>
|
||||||
|
<span className="shrink-0 text-[11px] uppercase tracking-[0.16em] text-blacksite-muted group-hover:text-blacksite-text">
|
||||||
|
{copyState[row.target] === "copied" ? "copied" : copyState[row.target] === "failed" ? "copy failed" : "copy"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
href={row.value}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="group flex min-w-0 items-center justify-between gap-4 font-mono text-sm text-blacksite-text transition-colors hover:underline focus-visible:outline-blacksite-text"
|
||||||
|
>
|
||||||
|
<span className="break-all">{row.value}</span>
|
||||||
|
<span aria-hidden="true" className="shrink-0 text-blacksite-muted transition-transform group-hover:translate-x-0.5 group-hover:text-blacksite-text">
|
||||||
|
↗
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<div className="grid gap-3 bg-blacksite-surface2/45 px-5 py-5 sm:px-7 md:grid-cols-[7rem_minmax(0,1fr)] lg:px-8">
|
||||||
|
<p className="section-label">AVAILABILITY</p>
|
||||||
|
<p className="flex items-center gap-3 font-mono text-sm uppercase tracking-[0.14em] text-blacksite-text">
|
||||||
|
<span aria-hidden="true" className="size-2 rounded-full bg-signal-green shadow-[0_0_18px_rgba(182,255,0,0.5)]" />
|
||||||
|
Open to interesting problems
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user