SPB Git

Repository Public MIT

command-palette.tsx 7.2 KB 175 lines tsx

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
Files main
Menu

Theme