103 lines
4.2 KiB
TypeScript
103 lines
4.2 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { contact } from "@/lib/portfolio";
|
|
|
|
type CopyTarget = "email";
|
|
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 },
|
|
];
|
|
|
|
export function ContactTerminal() {
|
|
const [copyState, setCopyState] = useState<Record<CopyTarget, CopyStatus>>({
|
|
email: "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-6 py-28 text-blacksite-text sm:px-8 sm:py-36 lg:px-12">
|
|
<p className="section-label mb-12">CONTACT /.07</p>
|
|
<div className="border border-blacksite-line bg-blacksite-surface shadow-[0_32px_120px_rgba(0,0,0,0.42)]">
|
|
<div className="flex items-center gap-3 border-b border-blacksite-line px-5 py-4 sm:px-7">
|
|
<span aria-hidden="true" className="size-2 rounded-full bg-signal-green shadow-[0_0_18px_rgba(182,255,0,0.5)]" />
|
|
<p className="font-mono text-xs text-blacksite-muted">> contact.channel</p>
|
|
</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">
|
|
<h2 id="contact-title" className="font-display text-3xl font-semibold uppercase tracking-[-0.04em] text-blacksite-text sm:text-5xl">
|
|
Send signal.
|
|
</h2>
|
|
</div>
|
|
|
|
<div className="divide-y divide-blacksite-line">
|
|
{rows.map((row) => (
|
|
<div key={row.label} className="grid gap-3 px-5 py-6 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>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|