|
| 1 | +"use client"; |
| 2 | + |
| 3 | +import { useEffect, useId, useMemo, useState } from "react"; |
| 4 | +import { useRouter } from "next/navigation"; |
| 5 | +import { useTheme } from "next-themes"; |
| 6 | +import { Dialog } from "@base-ui/react/dialog"; |
| 7 | +import { Autocomplete } from "@base-ui/react/autocomplete"; |
| 8 | +import { ArrowLeftRight, CornerDownLeft, FileText, Moon, Search, Sun } from "lucide-react"; |
| 9 | +import TokenAvatar from "@/components/launchpad/TokenAvatar"; |
| 10 | +import { CHAIN_SHORT } from "@/lib/chainPublic"; |
| 11 | +import { marketChange, marketUsd } from "@/lib/launchpad/market-format"; |
| 12 | +import { setPendingToken } from "@/lib/launchpad/token-transition"; |
| 13 | +import { buildPaletteGroups, isSearchableQuery, paletteTokenFrom, type PaletteGroup, type PaletteItem, type PaletteToken } from "@/lib/command-palette"; |
| 14 | +import { useOpenBridge } from "./bridge/BridgeProvider"; |
| 15 | + |
| 16 | +type LaunchesResponse = { launches?: Parameters<typeof paletteTokenFrom>[0][] }; |
| 17 | + |
| 18 | +/** |
| 19 | + * ⌘K search: tokens (by name, symbol or address), pages and a few actions, from any screen. |
| 20 | + * Base UI's Dialog + inline Autocomplete own focus, keyboard navigation and the listbox semantics. |
| 21 | + */ |
| 22 | +export default function CommandPalette({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { |
| 23 | + const router = useRouter(); |
| 24 | + const { resolvedTheme, setTheme } = useTheme(); |
| 25 | + const openBridge = useOpenBridge(); |
| 26 | + const hintId = useId(); |
| 27 | + const [query, setQuery] = useState(""); |
| 28 | + const [trending, setTrending] = useState<PaletteToken[] | null>(null); |
| 29 | + const [results, setResults] = useState<{ q: string; tokens: PaletteToken[]; failed: boolean } | null>(null); |
| 30 | + |
| 31 | + // What is trending fills the list before anything is typed. Fetched once, on the first open. |
| 32 | + useEffect(() => { |
| 33 | + if (!open || trending !== null) return; |
| 34 | + const ac = new AbortController(); |
| 35 | + fetch("/api/launch/list?sort=trending&limit=6", { signal: ac.signal, cache: "no-store" }) |
| 36 | + .then((r) => (r.ok ? (r.json() as Promise<LaunchesResponse>) : null)) |
| 37 | + .then((j) => setTrending((j?.launches ?? []).map(paletteTokenFrom))) |
| 38 | + .catch(() => { |
| 39 | + if (!ac.signal.aborted) setTrending([]); |
| 40 | + }); |
| 41 | + return () => ac.abort(); |
| 42 | + }, [open, trending]); |
| 43 | + |
| 44 | + // Token search, debounced; a late answer for an older query is dropped. |
| 45 | + const q = query.trim(); |
| 46 | + useEffect(() => { |
| 47 | + if (!open || !isSearchableQuery(q)) return; |
| 48 | + const ac = new AbortController(); |
| 49 | + const timer = setTimeout(() => { |
| 50 | + // order=holders: among equally relevant names, the token people actually hold comes first |
| 51 | + fetch(`/api/launch/search?q=${encodeURIComponent(q)}&order=holders`, { signal: ac.signal, cache: "no-store" }) |
| 52 | + .then((r) => (r.ok ? (r.json() as Promise<LaunchesResponse>) : Promise.reject(new Error(String(r.status))))) |
| 53 | + .then((j) => setResults({ q, tokens: (j.launches ?? []).slice(0, 8).map(paletteTokenFrom), failed: false })) |
| 54 | + .catch(() => { |
| 55 | + if (!ac.signal.aborted) setResults({ q, tokens: [], failed: true }); |
| 56 | + }); |
| 57 | + }, 160); |
| 58 | + return () => { |
| 59 | + clearTimeout(timer); |
| 60 | + ac.abort(); |
| 61 | + }; |
| 62 | + }, [open, q]); |
| 63 | + |
| 64 | + const current = results && results.q === q ? results : null; |
| 65 | + const searching = isSearchableQuery(q) && !current; |
| 66 | + const theme = resolvedTheme === "dark" ? "dark" : "light"; |
| 67 | + const groups = useMemo(() => buildPaletteGroups({ query, tokens: current?.tokens ?? [], trending: trending ?? [], theme }), [query, current, trending, theme]); |
| 68 | + const status = searching ? "Searching tokens…" : current?.failed ? "Token search is unavailable right now. Pages and actions still work." : ""; |
| 69 | + |
| 70 | + const close = () => { |
| 71 | + onOpenChange(false); |
| 72 | + setQuery(""); |
| 73 | + }; |
| 74 | + |
| 75 | + const run = (item: PaletteItem) => { |
| 76 | + close(); |
| 77 | + if (item.kind === "token") { |
| 78 | + const t = item.token; |
| 79 | + setPendingToken({ chain: t.chain, token: t.token, name: t.name, symbol: t.symbol, image: t.image }); |
| 80 | + router.push(`/t/${t.chain}/${t.token}`); |
| 81 | + } else if (item.kind === "page") { |
| 82 | + router.push(item.href); |
| 83 | + } else if (item.action === "bridge") { |
| 84 | + // let this dialog finish closing first, so focus lands in the bridge panel |
| 85 | + requestAnimationFrame(() => openBridge?.()); |
| 86 | + } else { |
| 87 | + setTheme(theme === "dark" ? "light" : "dark"); |
| 88 | + } |
| 89 | + }; |
| 90 | + |
| 91 | + return ( |
| 92 | + <Dialog.Root open={open} onOpenChange={(next) => (next ? onOpenChange(true) : close())}> |
| 93 | + <Dialog.Portal> |
| 94 | + <Dialog.Backdrop className="fixed inset-0 z-[90] bg-scrim/50 transition-opacity duration-150 data-[ending-style]:opacity-0 data-[starting-style]:opacity-0 motion-reduce:transition-none" /> |
| 95 | + <Dialog.Viewport className="fixed inset-0 z-[91] flex items-start justify-center overflow-hidden px-3 pt-[min(14vh,7rem)] pb-3"> |
| 96 | + <Dialog.Popup |
| 97 | + aria-label="Search openlaunch" |
| 98 | + className="relative flex max-h-[min(36rem,calc(100dvh-6rem))] w-full max-w-xl flex-col overflow-hidden rounded-2xl border border-line-strong bg-raised text-ink shadow-dialog transition-[opacity,translate,scale] duration-150 ease-out data-[ending-style]:-translate-y-2 data-[ending-style]:scale-[0.98] data-[ending-style]:opacity-0 data-[starting-style]:-translate-y-2 data-[starting-style]:scale-[0.98] data-[starting-style]:opacity-0 motion-reduce:transition-none" |
| 99 | + > |
| 100 | + <Autocomplete.Root |
| 101 | + open |
| 102 | + inline |
| 103 | + items={groups} |
| 104 | + filter={null} |
| 105 | + value={query} |
| 106 | + onValueChange={(next, details) => { |
| 107 | + // choosing an item writes its label into the field; the dialog is closing, so keep the query as typed |
| 108 | + if (details.reason !== "item-press") setQuery(next); |
| 109 | + }} |
| 110 | + itemToStringValue={(item: PaletteItem) => item.label} |
| 111 | + autoHighlight="always" |
| 112 | + keepHighlight |
| 113 | + > |
| 114 | + {/* focus shows as the field's underline turning blue (the global ring would box the whole header row) */} |
| 115 | + <Autocomplete.InputGroup className="flex items-center gap-3 border-b border-line px-4 transition-colors focus-within:border-brand"> |
| 116 | + <Search size={18} className="shrink-0 text-muted" aria-hidden /> |
| 117 | + <Autocomplete.Input |
| 118 | + aria-label="Search tokens, pages and actions" |
| 119 | + aria-describedby={hintId} |
| 120 | + placeholder="Search tokens by name, symbol or address" |
| 121 | + className="h-14 w-full border-0 bg-transparent text-base text-ink outline-none! placeholder:text-muted" |
| 122 | + /> |
| 123 | + <Dialog.Close className="shrink-0 rounded-md border border-line-strong px-1.5 py-0.5 font-mono text-[11px] text-muted hover:text-ink">Esc</Dialog.Close> |
| 124 | + </Autocomplete.InputGroup> |
| 125 | + |
| 126 | + <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain py-2 [scroll-padding-block:0.5rem]"> |
| 127 | + <Autocomplete.Status className="px-4 pt-1 pb-2 text-xs text-muted empty:hidden">{status}</Autocomplete.Status> |
| 128 | + <Autocomplete.Empty> |
| 129 | + <div className="px-4 py-8 text-center text-sm text-muted"> |
| 130 | + {searching ? "Searching tokens…" : <>Nothing matches “{q}”. Try a token symbol or paste its contract address.</>} |
| 131 | + </div> |
| 132 | + </Autocomplete.Empty> |
| 133 | + <Autocomplete.List> |
| 134 | + {(group: PaletteGroup) => ( |
| 135 | + <Autocomplete.Group key={group.value} items={group.items} className="px-2 pb-1"> |
| 136 | + <Autocomplete.GroupLabel className="px-2 pt-2 pb-1.5 text-xs font-medium text-muted select-none">{group.value}</Autocomplete.GroupLabel> |
| 137 | + <Autocomplete.Collection> |
| 138 | + {(item: PaletteItem) => ( |
| 139 | + <Autocomplete.Item |
| 140 | + key={item.id} |
| 141 | + value={item} |
| 142 | + onClick={() => run(item)} |
| 143 | + className="group flex min-h-11 cursor-pointer items-center gap-3 rounded-xl px-2 text-sm text-ink outline-none select-none data-[highlighted]:bg-line" |
| 144 | + > |
| 145 | + <PaletteRow item={item} /> |
| 146 | + </Autocomplete.Item> |
| 147 | + )} |
| 148 | + </Autocomplete.Collection> |
| 149 | + </Autocomplete.Group> |
| 150 | + )} |
| 151 | + </Autocomplete.List> |
| 152 | + </div> |
| 153 | + |
| 154 | + <div className="flex items-center gap-4 border-t border-line bg-card px-4 py-2.5 text-[11px] text-muted"> |
| 155 | + <span id={hintId}>Use the arrow keys to move and Enter to open.</span> |
| 156 | + <span className="ml-auto inline-flex items-center gap-1"><CornerDownLeft size={12} aria-hidden /> Open</span> |
| 157 | + </div> |
| 158 | + </Autocomplete.Root> |
| 159 | + </Dialog.Popup> |
| 160 | + </Dialog.Viewport> |
| 161 | + </Dialog.Portal> |
| 162 | + </Dialog.Root> |
| 163 | + ); |
| 164 | +} |
| 165 | + |
| 166 | +function PaletteRow({ item }: { item: PaletteItem }) { |
| 167 | + if (item.kind === "token") { |
| 168 | + const t = item.token; |
| 169 | + const change = marketChange(t.change); |
| 170 | + return ( |
| 171 | + <> |
| 172 | + <TokenAvatar chain={t.chain} token={t.token} symbol={t.symbol} image={t.image} size={28} className="shrink-0 rounded-lg" /> |
| 173 | + <span className="min-w-0 flex-1"> |
| 174 | + <span className="block truncate font-medium">{t.name}</span> |
| 175 | + <span className="mt-0.5 flex items-center gap-1.5 text-xs text-muted"> |
| 176 | + <span className="font-mono text-body">{t.symbol}</span> |
| 177 | + <span className="rounded-md border border-line px-1.5 text-[10px] leading-4">{CHAIN_SHORT[t.chain as keyof typeof CHAIN_SHORT] ?? t.chain}</span> |
| 178 | + </span> |
| 179 | + </span> |
| 180 | + <span className="shrink-0 text-right font-mono text-xs tnum"> |
| 181 | + <span className="block text-ink">{t.capUsd !== null ? marketUsd(t.capUsd) : "—"}</span> |
| 182 | + <span className={change.direction === "up" ? "text-up" : change.direction === "down" ? "text-down-ink" : "text-muted"}>{change.label}</span> |
| 183 | + </span> |
| 184 | + </> |
| 185 | + ); |
| 186 | + } |
| 187 | + const Icon = item.kind === "page" ? FileText : item.action === "bridge" ? ArrowLeftRight : item.label.includes("light") ? Sun : Moon; |
| 188 | + return ( |
| 189 | + <> |
| 190 | + <span className="grid size-7 shrink-0 place-items-center rounded-lg bg-card text-body group-data-[highlighted]:text-ink"><Icon size={15} aria-hidden /></span> |
| 191 | + <span className="min-w-0 flex-1 truncate">{item.label}</span> |
| 192 | + <span className="shrink-0 text-xs text-muted">{item.kind === "page" ? "Page" : "Action"}</span> |
| 193 | + </> |
| 194 | + ); |
| 195 | +} |
0 commit comments