feat(portfolio): add cmdk command palette
This commit is contained in:
207
components/portfolio/CommandPalette.tsx
Normal file
207
components/portfolio/CommandPalette.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
"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", "ssh", "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="Type a command or search the blacksite..."
|
||||
className="mt-3 w-full bg-transparent font-mono text-base text-blacksite-text outline-none placeholder:text-blacksite-faint 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-faint sm:px-5">
|
||||
<span>↑↓ select</span>
|
||||
<span>Enter run · Esc close</span>
|
||||
</div>
|
||||
</Command.Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user