Files
public-blog/components/portfolio/CommandPalette.tsx

208 lines
7.0 KiB
TypeScript

"use client";
import { Command } from "cmdk";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { contact } from "@/lib/portfolio";
type PaletteAction =
| { kind: "scroll"; targetId: "work" | "capabilities" | "operator" | "contact" }
| { kind: "route"; href: "/blog/" }
| { kind: "copy-email" }
| { kind: "external"; href: string };
const commands: {
label: string;
hint: string;
keywords: string[];
action: PaletteAction;
}[] = [
{
label: "Go to Work",
hint: "#work",
keywords: ["projects", "dossiers", "selected work"],
action: { kind: "scroll", targetId: "work" },
},
{
label: "Go to Capabilities",
hint: "#capabilities",
keywords: ["skills", "systems", "kernel"],
action: { kind: "scroll", targetId: "capabilities" },
},
{
label: "Go to Operator",
hint: "#operator",
keywords: ["profile", "identity", "honors"],
action: { kind: "scroll", targetId: "operator" },
},
{
label: "Go to Contact",
hint: "#contact",
keywords: ["email", "signal"],
action: { kind: "scroll", targetId: "contact" },
},
{
label: "Open Field Notes",
hint: "/blog/",
keywords: ["blog", "notes", "archive"],
action: { kind: "route", href: "/blog/" },
},
{
label: "Copy Email",
hint: "copy",
keywords: [contact.email, "mail", "contact"],
action: { kind: "copy-email" },
},
{
label: "Open Git profile",
hint: "git",
keywords: ["source", "forge", "code"],
action: { kind: "external", href: contact.git },
},
{
label: "Open LinkedIn",
hint: "in",
keywords: ["profile", "social", "linkedin"],
action: { kind: "external", href: contact.linkedIn },
},
];
export function CommandPalette() {
const router = useRouter();
const [open, setOpen] = useState(false);
const [pendingAction, setPendingAction] = useState<PaletteAction | null>(null);
const lastFocusedElement = useRef<HTMLElement | null>(null);
const wasOpen = useRef(false);
useEffect(() => {
function rememberFocus() {
lastFocusedElement.current =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
}
function onKeyDown(event: KeyboardEvent) {
if (event.key.toLowerCase() !== "k" || (!event.metaKey && !event.ctrlKey)) {
return;
}
event.preventDefault();
rememberFocus();
setOpen((current) => !current);
}
function onPaletteEvent() {
rememberFocus();
setOpen(true);
}
window.addEventListener("keydown", onKeyDown);
window.addEventListener("blacksite:palette", onPaletteEvent);
return () => {
window.removeEventListener("keydown", onKeyDown);
window.removeEventListener("blacksite:palette", onPaletteEvent);
};
}, []);
useEffect(() => {
if (wasOpen.current && !open) {
lastFocusedElement.current?.focus();
}
wasOpen.current = open;
}, [open]);
useEffect(() => {
if (!pendingAction) {
return;
}
let cancelled = false;
async function runAction(action: PaletteAction) {
if (action.kind === "scroll") {
document.getElementById(action.targetId)?.scrollIntoView({ block: "start" });
}
if (action.kind === "route") {
router.push(action.href);
}
if (action.kind === "copy-email") {
try {
await navigator.clipboard.writeText(contact.email);
} catch {
// Clipboard failures should not strand the command dialog open.
}
}
if (action.kind === "external") {
window.open(action.href, "_blank", "noopener");
}
if (!cancelled) {
setOpen(false);
setPendingAction(null);
}
}
void runAction(pendingAction);
return () => {
cancelled = true;
};
}, [pendingAction, router]);
return (
<Command.Dialog
open={open}
onOpenChange={setOpen}
label="Signal Blacksite command palette"
loop
className="border border-blacksite-line bg-blacksite-surface text-blacksite-text shadow-[0_32px_120px_rgba(0,0,0,0.62)]"
overlayClassName="fixed inset-0 z-[90] bg-black/72 backdrop-blur-sm"
contentClassName="fixed left-1/2 top-[12vh] z-[100] w-[calc(100vw-2rem)] max-w-2xl -translate-x-1/2 overflow-hidden rounded-lg border border-blacksite-line bg-blacksite-surface shadow-[0_32px_120px_rgba(0,0,0,0.62)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-signal-cyan"
>
<div className="border-b border-blacksite-line px-4 py-3 sm:px-5">
<p className="section-label">COMMAND PALETTE</p>
<Command.Input
autoFocus
placeholder="Command…"
className="mt-3 w-full bg-transparent font-mono text-base text-blacksite-text outline-none placeholder:text-blacksite-muted focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-signal-cyan sm:text-lg"
/>
</div>
<Command.List className="max-h-[min(24rem,60vh)] overflow-y-auto p-2 [scroll-padding-block:0.5rem]">
<Command.Empty className="px-3 py-8 text-center font-mono text-xs uppercase tracking-[0.16em] text-blacksite-muted">
No matching command.
</Command.Empty>
<Command.Group
heading="Navigation"
className="[&_[cmdk-group-heading]]:px-3 [&_[cmdk-group-heading]]:pb-2 [&_[cmdk-group-heading]]:pt-3 [&_[cmdk-group-heading]]:font-mono [&_[cmdk-group-heading]]:text-[11px] [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-[0.16em] [&_[cmdk-group-heading]]:text-blacksite-muted"
>
{commands.map((command) => (
<Command.Item
key={command.label}
value={command.label}
keywords={command.keywords}
onSelect={() => setPendingAction(command.action)}
className="group flex cursor-pointer select-none items-center justify-between gap-4 rounded-md border border-transparent px-3 py-3 font-mono text-sm text-blacksite-text outline-none transition-colors data-[selected=true]:border-signal-cyan/60 data-[selected=true]:bg-signal-cyan/12 data-[selected=true]:text-signal-cyan data-[disabled=true]:cursor-not-allowed data-[disabled=true]:opacity-50"
>
<span>{command.label}</span>
<kbd className="shrink-0 rounded border border-blacksite-line bg-blacksite-bg2 px-2 py-1 text-[10px] uppercase tracking-[0.16em] text-blacksite-muted transition-colors group-data-[selected=true]:border-signal-cyan/50 group-data-[selected=true]:text-signal-cyan">
{command.hint}
</kbd>
</Command.Item>
))}
</Command.Group>
</Command.List>
<div className="flex items-center justify-between border-t border-blacksite-line px-4 py-3 font-mono text-[11px] uppercase tracking-[0.16em] text-blacksite-muted sm:px-5">
<span> select</span>
<span>Enter run · Esc close</span>
</div>
</Command.Dialog>
);
}