Repository Public MIT
1"use client";2import { Search } from "lucide-react";3import { useRouter } from "next/navigation";4import { useCallback, useEffect, useRef, useState } from "react";5import { t } from "@/lib/i18n";67interface Hit {8 name: string;9 type: string;10 summary: string;11 href: string;12}13const PAGES: Hit[] = [14 { name: "Explore the registry", type: "page", summary: "Browse and filter every public artifact", href: "/explore" },15 { name: "Agent Skills", type: "page", summary: "Skills with a standards-compatible SKILL.md", href: "/skills" },16 { name: "MCP Servers", type: "page", summary: "Model Context Protocol server listings", href: "/mcp-servers" },17 { name: "Collections", type: "page", summary: "Curated research toolkits", href: "/collections" },18 { name: "Documentation", type: "page", summary: "Install, publish, CLI, API and MCP reference", href: "/docs" },19 { name: "Publish", type: "page", summary: "How to publish your own artifacts", href: "/publish" },20];2122/** Global command palette: "/" or ⌘K / Ctrl+K. Arrow keys move, Enter opens, Escape closes. */23export function CommandPalette() {24 const router = useRouter();25 const dialog = useRef<HTMLDialogElement>(null);26 const input = useRef<HTMLInputElement>(null);27 const [q, setQ] = useState("");28 const [hits, setHits] = useState<Hit[]>(PAGES);29 const [active, setActive] = useState(0);30 const [status, setStatus] = useState<"idle" | "loading" | "error">("idle");3132 const open = useCallback(() => {33 if (!dialog.current?.open) dialog.current?.showModal();34 setTimeout(() => input.current?.focus(), 0);35 }, []);3637 useEffect(() => {38 const onKey = (e: KeyboardEvent) => {39 const el = e.target as HTMLElement | null;40 const typing = !!el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.tagName === "SELECT" || el.isContentEditable);41 if ((e.key === "k" && (e.metaKey || e.ctrlKey)) || (e.key === "/" && !typing && !e.metaKey && !e.ctrlKey && !e.altKey)) {42 e.preventDefault();43 open();44 }45 };46 const onOpen = () => open();47 window.addEventListener("keydown", onKey);48 window.addEventListener("skills:palette", onOpen);49 return () => {50 window.removeEventListener("keydown", onKey);51 window.removeEventListener("skills:palette", onOpen);52 };53 }, [open]);5455 useEffect(() => {56 const term = q.trim();57 if (!term) {58 setHits(PAGES);59 setStatus("idle");60 setActive(0);61 return;62 }63 const ctl = new AbortController();64 setStatus("loading");65 const id = setTimeout(async () => {66 try {67 const res = await fetch(`/api/v1/artifacts?per_page=8&q=${encodeURIComponent(term)}`, { signal: ctl.signal });68 if (!res.ok) throw new Error(String(res.status));69 const data = (await res.json()) as { items: { name: string; type: string; summary: string; namespace: string; slug: string }[] };70 setHits([71 ...data.items.map((i) => ({ name: i.name, type: i.type, summary: i.summary, href: `/@${i.namespace}/${i.slug}` })),72 { name: `Search for “${term}”`, type: "search", summary: "Open the full results with filters", href: `/explore?q=${encodeURIComponent(term)}` },73 ]);74 setActive(0);75 setStatus("idle");76 } catch (e) {77 if ((e as Error).name !== "AbortError") setStatus("error");78 }79 }, 160);80 return () => {81 ctl.abort();82 clearTimeout(id);83 };84 }, [q]);8586 const go = (h: Hit | undefined) => {87 if (!h) return;88 dialog.current?.close();89 setQ("");90 router.push(h.href);91 };9293 return (94 <dialog95 ref={dialog}96 aria-label="Search the registry"97 className="m-auto mt-[12vh] w-[min(40rem,calc(100vw-2rem))] rounded-xl border border-line-strong bg-graphite p-0 text-text [box-shadow:var(--shadow-pop)] backdrop:bg-black/40 backdrop:backdrop-blur-[2px]"98 onClick={(e) => {99 if (e.target === dialog.current) dialog.current?.close();100 }}101 >102 <div className="flex items-center gap-3 border-b border-line px-4">103 <Search size={16} aria-hidden className="text-faint" />104 <input105 ref={input}106 value={q}107 onChange={(e) => setQ(e.target.value)}108 onKeyDown={(e) => {109 if (e.key === "ArrowDown") {110 e.preventDefault();111 setActive((a) => Math.min(a + 1, hits.length - 1));112 } else if (e.key === "ArrowUp") {113 e.preventDefault();114 setActive((a) => Math.max(a - 1, 0));115 } else if (e.key === "Enter") {116 e.preventDefault();117 go(hits[active]);118 }119 }}120 role="combobox"121 aria-expanded="true"122 aria-controls="palette-results"123 aria-activedescendant={hits[active] ? `palette-${active}` : undefined}124 aria-autocomplete="list"125 placeholder={t.search.placeholder}126 className="h-12 w-full bg-transparent text-[15px] outline-none placeholder:text-faint"127 maxLength={200}128 />129 <kbd className="hidden rounded border border-line-strong px-1.5 py-0.5 font-mono text-[10px] text-faint sm:block">esc</kbd>130 </div>131 <ul id="palette-results" role="listbox" aria-label="Results" className="max-h-[50vh] overflow-y-auto p-2">132 {hits.map((h, i) => (133 <li key={h.href} id={`palette-${i}`} role="option" aria-selected={i === active}>134 <button135 type="button"136 onMouseEnter={() => setActive(i)}137 onClick={() => go(h)}138 className={`flex w-full items-start gap-3 rounded-lg px-3 py-2.5 text-left ${i === active ? "bg-raised" : ""}`}139 tabIndex={-1}140 >141 <span className="mt-0.5 w-20 shrink-0 font-mono text-[10px] uppercase tracking-wider text-faint">{h.type}</span>142 <span className="min-w-0">143 <span className="block truncate font-mono text-sm text-text">{h.name}</span>144 <span className="block truncate text-xs text-muted">{h.summary}</span>145 </span>146 </button>147 </li>148 ))}149 </ul>150 <p className="border-t border-line px-4 py-2 font-mono text-[11px] text-faint" aria-live="polite">151 {status === "loading" ? "Searching…" : status === "error" ? "Search is unavailable right now." : "↑↓ to navigate · enter to open · esc to close"}152 </p>153 </dialog>154 );155}156157export function SearchTrigger({ large = false }: { large?: boolean }) {158 return (159 <button160 type="button"161 onClick={() => window.dispatchEvent(new Event("skills:palette"))}162 className={163 large164 ? "flex h-13 w-full items-center gap-3 rounded-xl border border-line-strong bg-graphite px-4 py-3.5 text-left text-[15px] text-faint transition-colors hover:border-accent/60"165 : "flex h-9 items-center gap-2 rounded-lg border border-line px-3 text-sm text-faint transition-colors hover:border-line-strong hover:text-muted"166 }167 title="Search the registry (shortcut: /)"168 >169 <Search size={large ? 17 : 14} aria-hidden />170 <span className={large ? "flex-1" : "sr-only flex-1 lg:not-sr-only"}>{large ? t.search.placeholder : "Search"}</span>171 <kbd className="rounded border border-line-strong px-1.5 py-0.5 font-mono text-[10px]">/</kbd>172 </button>173 );174}175