1"use client";
2
3import { useEffect, useMemo, useRef, useState } from "react";
4import { useParams, useRouter } from "next/navigation";
5import {
6 Bell,
7 FilePlus2,
8 LayoutGrid,
9 LogOut,
10 Search,
11 Settings,
12 Sparkles,
13 Table2,
14 UserPlus
15} from "lucide-react";
16import { searchApi } from "@/lib/api";
17import { paletteSearchResult } from "@/lib/search";
18import { useAuth } from "@/hooks/use-auth";
19import { useCreatePage } from "@/hooks/use-pages";
20import { useWorkspace } from "@/hooks/use-workspace";
21import { useUiStore, toast } from "@/stores/ui-store";
22import { cn, fuzzyMatch } from "@/lib/utils";
23import type { SearchResult } from "@/lib/types";
24
25interface PaletteItem {
26 id: string;
27 label: string;
28 hint?: string;
29 icon: React.ComponentType<{ className?: string }>;
30 perform: () => void | Promise<void>;
31 keywords: string;
32}
33
34
39export function CommandPalette() {
40 const open = useUiStore((s) => s.commandPaletteOpen);
41 const setOpen = useUiStore((s) => s.setCommandPalette);
42 const router = useRouter();
43 const params = useParams<{ workspace: string }>();
44 const workspaceSlug = params?.workspace ?? "default";
45 const { data: workspace } = useWorkspace(workspaceSlug);
46 const createPage = useCreatePage(workspace?.id ?? "");
47
48
49 const canGenerate = (workspace?.role ?? "").toLowerCase() !== "guest";
50 const { user } = useAuth();
51 const setAiPanel = useUiStore((s) => s.setAiPanel);
52 const setGenerate = useUiStore((s) => s.setGenerate);
53 const setAssistant = useUiStore((s) => s.setAssistant);
54
55 const [query, setQuery] = useState("");
56 const [results, setResults] = useState<SearchResult[]>([]);
57 const [active, setActive] = useState(0);
58 const inputRef = useRef<HTMLInputElement>(null);
59 const listRef = useRef<HTMLUListElement>(null);
60
61 useEffect(() => {
62 if (open) {
63 setQuery("");
64 setResults([]);
65 setActive(0);
66
67 requestAnimationFrame(() => inputRef.current?.focus());
68 }
69 }, [open]);
70
71
72
73
74 useEffect(() => {
75 if (!open || query.trim().length < 2) {
76 setResults([]);
77 return;
78 }
79 const t = setTimeout(() => {
80 searchApi
81 .query(query.trim(), { workspaceId: workspace?.id, limit: 8 })
82 .then((items) => setResults(items.map(paletteSearchResult)))
83 .catch(() => setResults([]));
84 }, 200);
85 return () => clearTimeout(t);
86 }, [query, open, workspace?.id]);
87
88 const close = () => setOpen(false);
89
90 const actions = useMemo<PaletteItem[]>(() => {
91 const go = (href: string) => () => {
92 router.push(href);
93 close();
94 };
95 const items: PaletteItem[] = [
96 {
97 id: "new-page",
98 label: "New page",
99 hint: "Create an empty page here",
100 icon: FilePlus2,
101 keywords: "create add page blank",
102 perform: () =>
103 createPage.mutateAsync({ parentId: null, title: "" }).then((p) => {
104 router.push(`/w/${workspaceSlug}/page/${p.id}`);
105 })
106 },
107 {
108 id: "new-database",
109 label: "New database",
110 hint: "Create a database page",
111 icon: Table2,
112 keywords: "create table database",
113 perform: () =>
114 createPage.mutateAsync({ parentId: null, type: "database", title: "" }).then((p) => {
115 router.push(`/w/${workspaceSlug}/page/${p.id}`);
116 })
117 },
118 {
119 id: "ask-ai",
120 label: "Ask AI",
121 hint: "Open the AI assistant",
122 icon: Sparkles,
123 keywords: "ai ask assistant chat sparkle",
124 perform: () => {
125 setAiPanel(true, null);
126 close();
127 }
128 },
129 {
130 id: "generate-ai",
131 label: "Generate page with AI",
132 hint: "Describe a page and let AI build it",
133 icon: Sparkles,
134 keywords: "ai generate page create build sparkle",
135 perform: () => {
136 setGenerate(true, null);
137 close();
138 }
139 },
140 {
141 id: "assistant-extract",
142 label: "Extract action items (AI)",
143 hint: "Turn notes into tasks",
144 icon: Sparkles,
145 keywords: "ai assistant extract tasks actions todo sparkle",
146 perform: () => {
147 setAssistant(true, { mode: "extract-actions" });
148 close();
149 }
150 },
151 {
152 id: "assistant-duplicates",
153 label: "Find duplicate pages (AI)",
154 hint: "Likely-duplicate pages in this workspace",
155 icon: Sparkles,
156 keywords: "ai assistant duplicates find similar sparkle",
157 perform: () => {
158 setAssistant(true, { mode: "find-duplicates" });
159 close();
160 }
161 },
162 {
163 id: "assistant-stale",
164 label: "Find stale pages (AI)",
165 hint: "Pages not updated recently",
166 icon: Sparkles,
167 keywords: "ai assistant stale old outdated cleanup sparkle",
168 perform: () => {
169 setAssistant(true, { mode: "find-stale" });
170 close();
171 }
172 },
173 {
174 id: "ask-ai-workspace",
175 label: "Ask AI about this workspace",
176 hint: "Search this workspace with AI (RAG)",
177 icon: Sparkles,
178 keywords: "ai ask workspace search rag retrieve",
179 perform: go(`/w/${workspaceSlug}/ask?open=1`)
180 },
181 {
182 id: "go-home",
183 label: "Go to Home",
184 icon: LayoutGrid,
185 keywords: "home start",
186 perform: go(`/w/${workspaceSlug}`)
187 },
188 {
189 id: "invite",
190 label: "Invite people",
191 icon: UserPlus,
192 keywords: "invite member team people",
193 perform: go(`/w/${workspaceSlug}/settings/members`)
194 },
195 {
196 id: "notifications",
197 label: "Notifications",
198 icon: Bell,
199 keywords: "inbox bell alerts",
200 perform: go(`/w/${workspaceSlug}/notifications`)
201 },
202 {
203 id: "settings",
204 label: "Workspace settings",
205 icon: Settings,
206 keywords: "preferences configure",
207 perform: go(`/w/${workspaceSlug}/settings`)
208 },
209 {
210 id: "logout",
211 label: "Log out",
212 hint: user?.email,
213 icon: LogOut,
214 keywords: "sign out exit",
215 perform: async () => {
216 const { authApi } = await import("@/lib/api");
217 const { authStore } = await import("@/lib/auth-store");
218 try {
219 await authApi.logout();
220 } catch {
221
222 }
223 authStore.clear();
224 router.replace("/login");
225 }
226 }
227 ];
228
229
230
231 return canGenerate
232 ? items
233 : items.filter((a) => a.id !== "generate-ai" && a.id !== "assistant-extract");
234
235 }, [workspaceSlug, createPage, router, user?.email, setAiPanel, setGenerate, setAssistant, canGenerate]);
236
237 const items = useMemo<PaletteItem[]>(() => {
238 const q = query.trim().toLowerCase();
239 const searchItems: PaletteItem[] = results.map((r) => ({
240 id: `result-${r.id}`,
241 label: r.title || "Untitled",
242 hint: r.path?.join(" / ") || r.type,
243 icon: r.type === "database" ? Table2 : FilePlus2,
244 keywords: `${r.title} ${r.snippet ?? ""}`,
245 perform: () => {
246
247
248
249
250 router.push(
251 r.type === "database" || r.type === "record"
252 ? `/w/${workspaceSlug}/databases/${r.id}`
253 : `/w/${workspaceSlug}/page/${r.id}`
254 );
255 close();
256 }
257 }));
258 const filteredActions = actions.filter((a) => fuzzyMatch(q, a.label + " " + (a.keywords ?? "")));
259
260 return q ? [...filteredActions, ...searchItems] : actions;
261 }, [actions, query, results, router, workspaceSlug]);
262
263 useEffect(() => {
264 if (active >= items.length) setActive(Math.max(0, items.length - 1));
265 }, [items.length, active]);
266
267
268 const onKeyDown = (e: React.KeyboardEvent) => {
269 if (e.key === "ArrowDown") {
270 e.preventDefault();
271 setActive((a) => Math.min(a + 1, items.length - 1));
272 } else if (e.key === "ArrowUp") {
273 e.preventDefault();
274 setActive((a) => Math.max(a - 1, 0));
275 } else if (e.key === "Enter") {
276 e.preventDefault();
277 const item = items[active];
278
279
280
281 if (item) void Promise.resolve(item.perform()).catch((err: unknown) => {
282 toast("error", "Action failed", err instanceof Error ? err.message : undefined);
283 });
284 } else if (e.key === "Escape") {
285 close();
286 }
287 };
288
289
290 useEffect(() => {
291 const el = listRef.current?.querySelector<HTMLElement>(`[data-index="${active}"]`);
292 el?.scrollIntoView({ block: "nearest" });
293 }, [active]);
294
295 if (!open) return null;
296
297 return (
298 <div className="fixed inset-0 z-[80] flex items-start justify-center p-4 pt-[12vh]">
299 <div className="absolute inset-0 bg-black/40" onClick={close} aria-hidden />
300 <div
301 role="dialog"
302 aria-modal="true"
303 aria-label="Command palette"
304 className="relative w-full max-w-lg overflow-hidden rounded-lg border border-border bg-popover shadow-2xl"
305 >
306 <div className="flex items-center gap-2 border-b border-border px-3">
307 <Search className="size-4 shrink-0 text-muted-foreground" aria-hidden />
308 <input
309 ref={inputRef}
310 value={query}
311 onChange={(e) => {
312 setQuery(e.target.value);
313 setActive(0);
314 }}
315 onKeyDown={onKeyDown}
316 placeholder="Search pages or type a command…"
317 className="h-11 w-full bg-transparent text-sm outline-none placeholder:text-muted-foreground"
318 role="combobox"
319 aria-expanded="true"
320 aria-controls="command-palette-list"
321 />
322 <kbd className="shrink-0 rounded border border-border bg-background px-1.5 py-0.5 text-[10px] text-muted-foreground">
323 esc
324 </kbd>
325 </div>
326 <ul id="command-palette-list" ref={listRef} className="max-h-80 overflow-y-auto p-1" role="listbox">
327 {items.length === 0 ? (
328 <li className="px-3 py-6 text-center text-sm text-muted-foreground">
329 No results — try a different search.
330 </li>
331 ) : (
332 items.map((item, i) => (
333 <li key={item.id} role="option" aria-selected={i === active} data-index={i}>
334 <button
335 type="button"
336 onMouseEnter={() => setActive(i)}
337 onClick={() =>
338
339 void Promise.resolve(item.perform()).catch((err: unknown) => {
340 toast("error", "Action failed", err instanceof Error ? err.message : undefined);
341 })
342 }
343 className={cn(
344 "flex w-full items-center gap-3 rounded-md px-3 py-2 text-left text-sm",
345 i === active ? "bg-accent text-foreground" : "text-foreground/90"
346 )}
347 >
348 <item.icon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
349 <span className="min-w-0 flex-1 truncate">{item.label}</span>
350 {item.hint ? (
351 <span className="shrink-0 truncate text-xs text-muted-foreground">{item.hint}</span>
352 ) : null}
353 </button>
354 </li>
355 ))
356 )}
357 </ul>
358 </div>
359 </div>
360 );
361}
362