208 lines
7.0 KiB
TypeScript
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>
|
|
);
|
|
}
|