Implement Global Search Command Palette
swadhinbiswas committed 10 months ago
17ec4f8
1 parent d9b7b9f
commit 17ec4f8
6 files changed
+387 -15
Showing 6 changed files+387-15
▼package-lock.json
+18-0
| @@ -48,6 +48,7 @@ | ||
48 | 48 | "bullmq": "^5.65.1", |
49 | 49 | "class-variance-authority": "^0.7.0", |
50 | 50 | "clsx": "^2.1.1", |
51 | + "cmdk": "^1.1.1", | |
51 | 52 | "dockerode": "^4.0.2", |
52 | 53 | "drizzle-orm": "^0.45.1", |
53 | 54 | "framer-motion": "^11.2.12", |
| @@ -9049,6 +9050,22 @@ | ||
9049 | 9050 | "node": ">=0.10.0" |
9050 | 9051 | } |
9051 | 9052 | }, |
9053 | + "node_modules/cmdk": { | |
9054 | + "version": "1.1.1", | |
9055 | + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", | |
9056 | + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", | |
9057 | + "license": "MIT", | |
9058 | + "dependencies": { | |
9059 | + "@radix-ui/react-compose-refs": "^1.1.1", | |
9060 | + "@radix-ui/react-dialog": "^1.1.6", | |
9061 | + "@radix-ui/react-id": "^1.1.0", | |
9062 | + "@radix-ui/react-primitive": "^2.0.2" | |
9063 | + }, | |
9064 | + "peerDependencies": { | |
9065 | + "react": "^18 || ^19 || ^19.0.0-rc", | |
9066 | + "react-dom": "^18 || ^19 || ^19.0.0-rc" | |
9067 | + } | |
9068 | + }, | |
9052 | 9069 | "node_modules/color": { |
9053 | 9070 | "version": "4.2.3", |
9054 | 9071 | "license": "MIT", |
| @@ -12810,6 +12827,7 @@ | ||
12810 | 12827 | "node_modules/picomatch": { |
12811 | 12828 | "version": "4.0.3", |
12812 | 12829 | "license": "MIT", |
12830 | + "peer": true, | |
12813 | 12831 | "engines": { |
12814 | 12832 | "node": ">=12" |
12815 | 12833 | }, |
12816 | 12834 | |
▼package.json
+1-0
| @@ -57,6 +57,7 @@ | ||
57 | 57 | "bullmq": "^5.65.1", |
58 | 58 | "class-variance-authority": "^0.7.0", |
59 | 59 | "clsx": "^2.1.1", |
60 | + "cmdk": "^1.1.1", | |
60 | 61 | "dockerode": "^4.0.2", |
61 | 62 | "drizzle-orm": "^0.45.1", |
62 | 63 | "framer-motion": "^11.2.12", |
63 | 64 | |
▼src/components/layout/Header.astro
+28-15
| @@ -168,14 +168,17 @@ const user = Astro.locals.user; | ||
168 | 168 | <a href="/pulls" class="block px-2 py-1.5 text-sm hover:bg-accent rounded-md">Pull Requests</a> |
169 | 169 | <a href="/actions" class="block px-2 py-1.5 text-sm hover:bg-accent rounded-md">Actions</a> |
170 | 170 | <div class="pt-2"> |
171 | - <input | |
172 | - type="search" | |
173 | - placeholder="Search..." | |
174 | - class="w-full h-9 rounded-md border border-input bg-background px-3 text-sm placeholder:text-muted-foreground" | |
175 | - /> | |
171 | + <button | |
172 | + class="w-full flex items-center h-9 rounded-md border border-input bg-background px-3 text-sm text-muted-foreground text-left" | |
173 | + id="mobile-search-trigger" | |
174 | + > | |
175 | + Search... | |
176 | + </button> | |
176 | 177 | </div> |
177 | 178 | </nav> |
178 | 179 | </div> |
180 | + | |
181 | + <GlobalSearch client:idle /> | |
179 | 182 | </header> |
180 | 183 | |
181 | 184 | <script> |
| @@ -212,14 +215,24 @@ const user = Astro.locals.user; | ||
212 | 215 | }) |
213 | 216 | }) |
214 | 217 | |
215 | - // Keyboard shortcut for search (Cmd/Ctrl + K) | |
216 | - document.addEventListener('keydown', (e) => { | |
217 | - if ((e.metaKey || e.ctrlKey) && e.key === 'k') { | |
218 | - e.preventDefault() | |
219 | - const searchInput = document.querySelector('input[type="search"]') as HTMLInputElement | |
220 | - if (searchInput) { | |
221 | - searchInput.focus() | |
222 | - } | |
223 | - } | |
224 | - }) | |
218 | + // Search Triggers | |
219 | + const searchInput = document.querySelector('input[type="search"]') as HTMLInputElement | |
220 | + const mobileSearchTrigger = document.getElementById('mobile-search-trigger') | |
221 | + | |
222 | + function openSearch() { | |
223 | + window.dispatchEvent(new CustomEvent('open-global-search')); | |
224 | + } | |
225 | + | |
226 | + if (searchInput) { | |
227 | + searchInput.addEventListener('click', (e) => { | |
228 | + e.preventDefault(); | |
229 | + searchInput.blur(); // Don't actually focus | |
230 | + openSearch(); | |
231 | + }); | |
232 | + // Also handle keydown k if focused (though usually we intercept global k) | |
233 | + } | |
234 | + | |
235 | + if (mobileSearchTrigger) { | |
236 | + mobileSearchTrigger.addEventListener('click', openSearch); | |
237 | + } | |
225 | 238 | </script> |
226 | 239 | |
▼src/components/search/GlobalSearch.tsx
+122-0
| @@ -0,0 +1,122 @@ | ||
1 | + | |
2 | +import * as React from "react"; | |
3 | +import { | |
4 | + CommandDialog, | |
5 | + CommandEmpty, | |
6 | + CommandGroup, | |
7 | + CommandInput, | |
8 | + CommandItem, | |
9 | + CommandList, | |
10 | +} from "@/components/ui/command"; | |
11 | +import { Search, FileText, User, GitBranch } from "lucide-react"; | |
12 | + | |
13 | +export function GlobalSearch() { | |
14 | + const [open, setOpen] = React.useState(false); | |
15 | + const [query, setQuery] = React.useState(""); | |
16 | + const [results, setResults] = React.useState<any[]>([]); | |
17 | + const [loading, setLoading] = React.useState(false); | |
18 | + | |
19 | + React.useEffect(() => { | |
20 | + const down = (e: KeyboardEvent) => { | |
21 | + if ((e.key === "k" && (e.metaKey || e.ctrlKey)) || e.key === "/") { | |
22 | + if ( | |
23 | + (e.target as HTMLElement).tagName === "INPUT" || | |
24 | + (e.target as HTMLElement).tagName === "TEXTAREA" | |
25 | + ) { | |
26 | + return; | |
27 | + } | |
28 | + e.preventDefault(); | |
29 | + setOpen((open) => !open); | |
30 | + } | |
31 | + }; | |
32 | + | |
33 | + document.addEventListener("keydown", down); | |
34 | + return () => document.removeEventListener("keydown", down); | |
35 | + }, []); | |
36 | + | |
37 | + // Listen for custom open event | |
38 | + React.useEffect(() => { | |
39 | + const handleOpen = () => setOpen(true); | |
40 | + window.addEventListener("open-global-search", handleOpen); | |
41 | + return () => window.removeEventListener("open-global-search", handleOpen); | |
42 | + }, []); | |
43 | + | |
44 | + React.useEffect(() => { | |
45 | + if (query.length < 2) { | |
46 | + setResults([]); | |
47 | + return; | |
48 | + } | |
49 | + | |
50 | + const timer = setTimeout(async () => { | |
51 | + setLoading(true); | |
52 | + try { | |
53 | + const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`); | |
54 | + const data = await res.json(); | |
55 | + if (data.results) { | |
56 | + setResults(data.results); | |
57 | + } | |
58 | + } catch (e) { | |
59 | + console.error(e); | |
60 | + } finally { | |
61 | + setLoading(false); | |
62 | + } | |
63 | + }, 300); | |
64 | + | |
65 | + return () => clearTimeout(timer); | |
66 | + }, [query]); | |
67 | + | |
68 | + const handleSelect = (url: string) => { | |
69 | + setOpen(false); | |
70 | + window.location.href = url; | |
71 | + }; | |
72 | + | |
73 | + return ( | |
74 | + <CommandDialog open={open} onOpenChange={setOpen}> | |
75 | + <CommandInput | |
76 | + placeholder="Type a command or search..." | |
77 | + value={query} | |
78 | + onValueChange={setQuery} | |
79 | + /> | |
80 | + <CommandList> | |
81 | + <CommandEmpty>No results found.</CommandEmpty> | |
82 | + | |
83 | + {results.length > 0 && ( | |
84 | + <CommandGroup heading="Results"> | |
85 | + {results.map((result, i) => ( | |
86 | + <CommandItem | |
87 | + key={i} | |
88 | + onSelect={() => handleSelect(result.url)} | |
89 | + value={result.title + result.subtitle} // for internal filtering | |
90 | + > | |
91 | + {result.type === 'repository' && <GitBranch className="mr-2 h-4 w-4" />} | |
92 | + {result.type === 'user' && <User className="mr-2 h-4 w-4" />} | |
93 | + <div className="flex flex-col"> | |
94 | + <span>{result.title}</span> | |
95 | + <span className="text-xs text-muted-foreground">{result.subtitle}</span> | |
96 | + </div> | |
97 | + </CommandItem> | |
98 | + ))} | |
99 | + </CommandGroup> | |
100 | + )} | |
101 | + | |
102 | + {results.length === 0 && query.length === 0 && ( | |
103 | + <CommandGroup heading="Suggestions"> | |
104 | + <CommandItem | |
105 | + onSelect={() => handleSelect("/repositories")} | |
106 | + > | |
107 | + <GitBranch className="mr-2 h-4 w-4" /> | |
108 | + <span>Your Repositories</span> | |
109 | + </CommandItem> | |
110 | + <CommandItem | |
111 | + onSelect={() => handleSelect("/explore")} | |
112 | + > | |
113 | + <Search className="mr-2 h-4 w-4" /> | |
114 | + <span>Explore</span> | |
115 | + </CommandItem> | |
116 | + </CommandGroup> | |
117 | + )} | |
118 | + | |
119 | + </CommandList> | |
120 | + </CommandDialog> | |
121 | + ); | |
122 | +} | |
0 | 123 | |
▼src/components/ui/command.tsx
+154-0
| @@ -0,0 +1,154 @@ | ||
1 | + | |
2 | +import * as React from "react" | |
3 | +import { Command as CommandPrimitive } from "cmdk" | |
4 | +import { Search } from "lucide-react" | |
5 | + | |
6 | +import { cn } from "@/lib/utils" | |
7 | +import { Dialog, DialogContent } from "@/components/ui/dialog" | |
8 | + | |
9 | +const Command = React.forwardRef< | |
10 | + React.ElementRef<typeof CommandPrimitive>, | |
11 | + React.ComponentPropsWithoutRef<typeof CommandPrimitive> | |
12 | +>(({ className, ...props }, ref) => ( | |
13 | + <CommandPrimitive | |
14 | + ref={ref} | |
15 | + className={cn( | |
16 | + "flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground", | |
17 | + className | |
18 | + )} | |
19 | + {...props} | |
20 | + /> | |
21 | +)) | |
22 | +Command.displayName = CommandPrimitive.displayName | |
23 | + | |
24 | +const CommandDialog = ({ | |
25 | + children, | |
26 | + ...props | |
27 | +}: React.ComponentProps<typeof Dialog>) => { | |
28 | + return ( | |
29 | + <Dialog {...props}> | |
30 | + <DialogContent className="overflow-hidden p-0 shadow-lg"> | |
31 | + <Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5"> | |
32 | + {children} | |
33 | + </Command> | |
34 | + </DialogContent> | |
35 | + </Dialog> | |
36 | + ) | |
37 | +} | |
38 | + | |
39 | +const CommandInput = React.forwardRef< | |
40 | + React.ElementRef<typeof CommandPrimitive.Input>, | |
41 | + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> | |
42 | +>(({ className, ...props }, ref) => ( | |
43 | + <div className="flex items-center border-b px-3" cmdk-input-wrapper=""> | |
44 | + <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" /> | |
45 | + <CommandPrimitive.Input | |
46 | + ref={ref} | |
47 | + className={cn( | |
48 | + "flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50", | |
49 | + className | |
50 | + )} | |
51 | + {...props} | |
52 | + /> | |
53 | + </div> | |
54 | +)) | |
55 | + | |
56 | +CommandInput.displayName = CommandPrimitive.Input.displayName | |
57 | + | |
58 | +const CommandList = React.forwardRef< | |
59 | + React.ElementRef<typeof CommandPrimitive.List>, | |
60 | + React.ComponentPropsWithoutRef<typeof CommandPrimitive.List> | |
61 | +>(({ className, ...props }, ref) => ( | |
62 | + <CommandPrimitive.List | |
63 | + ref={ref} | |
64 | + className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)} | |
65 | + {...props} | |
66 | + /> | |
67 | +)) | |
68 | + | |
69 | +CommandList.displayName = CommandPrimitive.List.displayName | |
70 | + | |
71 | +const CommandEmpty = React.forwardRef< | |
72 | + React.ElementRef<typeof CommandPrimitive.Empty>, | |
73 | + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty> | |
74 | +>((props, ref) => ( | |
75 | + <CommandPrimitive.Empty | |
76 | + ref={ref} | |
77 | + className="py-6 text-center text-sm" | |
78 | + {...props} | |
79 | + /> | |
80 | +)) | |
81 | + | |
82 | +CommandEmpty.displayName = CommandPrimitive.Empty.displayName | |
83 | + | |
84 | +const CommandGroup = React.forwardRef< | |
85 | + React.ElementRef<typeof CommandPrimitive.Group>, | |
86 | + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group> | |
87 | +>(({ className, ...props }, ref) => ( | |
88 | + <CommandPrimitive.Group | |
89 | + ref={ref} | |
90 | + className={cn( | |
91 | + "overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground", | |
92 | + className | |
93 | + )} | |
94 | + {...props} | |
95 | + /> | |
96 | +)) | |
97 | + | |
98 | +CommandGroup.displayName = CommandPrimitive.Group.displayName | |
99 | + | |
100 | +const CommandSeparator = React.forwardRef< | |
101 | + React.ElementRef<typeof CommandPrimitive.Separator>, | |
102 | + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator> | |
103 | +>(({ className, ...props }, ref) => ( | |
104 | + <CommandPrimitive.Separator | |
105 | + ref={ref} | |
106 | + className={cn("-mx-1 h-px bg-border", className)} | |
107 | + {...props} | |
108 | + /> | |
109 | +)) | |
110 | +CommandSeparator.displayName = CommandPrimitive.Separator.displayName | |
111 | + | |
112 | +const CommandItem = React.forwardRef< | |
113 | + React.ElementRef<typeof CommandPrimitive.Item>, | |
114 | + React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item> | |
115 | +>(({ className, ...props }, ref) => ( | |
116 | + <CommandPrimitive.Item | |
117 | + ref={ref} | |
118 | + className={cn( | |
119 | + "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", | |
120 | + className | |
121 | + )} | |
122 | + {...props} | |
123 | + /> | |
124 | +)) | |
125 | + | |
126 | +CommandItem.displayName = CommandPrimitive.Item.displayName | |
127 | + | |
128 | +const CommandShortcut = ({ | |
129 | + className, | |
130 | + ...props | |
131 | +}: React.HTMLAttributes<HTMLSpanElement>) => { | |
132 | + return ( | |
133 | + <span | |
134 | + className={cn( | |
135 | + "ml-auto text-xs tracking-widest text-muted-foreground", | |
136 | + className | |
137 | + )} | |
138 | + {...props} | |
139 | + /> | |
140 | + ) | |
141 | +} | |
142 | +CommandShortcut.displayName = "CommandShortcut" | |
143 | + | |
144 | +export { | |
145 | + Command, | |
146 | + CommandDialog, | |
147 | + CommandInput, | |
148 | + CommandList, | |
149 | + CommandEmpty, | |
150 | + CommandGroup, | |
151 | + CommandItem, | |
152 | + CommandShortcut, | |
153 | + CommandSeparator, | |
154 | +} | |
0 | 155 | |
▼src/pages/api/search.ts
+64-0
| @@ -0,0 +1,64 @@ | ||
1 | + | |
2 | +import type { APIRoute } from 'astro'; | |
3 | +import { getDatabase, schema } from '@/db'; | |
4 | +import { ilike, or, and, eq, desc } from 'drizzle-orm'; | |
5 | +import { success, serverError } from '@/lib/api'; | |
6 | + | |
7 | +export const GET: APIRoute = async ({ request }) => { | |
8 | + try { | |
9 | + const url = new URL(request.url); | |
10 | + const query = url.searchParams.get('q'); | |
11 | + | |
12 | + if (!query || query.length < 2) { | |
13 | + return success({ results: [] }); | |
14 | + } | |
15 | + | |
16 | + const db = getDatabase(); | |
17 | + | |
18 | + // Search Repositories | |
19 | + const repos = await db.query.repositories.findMany({ | |
20 | + where: or( | |
21 | + ilike(schema.repositories.name, `%${query}%`), | |
22 | + ilike(schema.repositories.description, `%${query}%`) | |
23 | + ), | |
24 | + with: { | |
25 | + owner: true | |
26 | + }, | |
27 | + limit: 5, | |
28 | + orderBy: [desc(schema.repositories.starCount)] | |
29 | + }); | |
30 | + | |
31 | + // Search Users | |
32 | + const users = await db.query.users.findMany({ | |
33 | + where: or( | |
34 | + ilike(schema.users.username, `%${query}%`), | |
35 | + ilike(schema.users.displayName, `%${query}%`) | |
36 | + ), | |
37 | + limit: 3 | |
38 | + }); | |
39 | + | |
40 | + // Format Results | |
41 | + const results = [ | |
42 | + ...repos.map(repo => ({ | |
43 | + type: 'repository', | |
44 | + title: `${repo.owner.username}/${repo.name}`, // "owner/repo" | |
45 | + subtitle: repo.description, | |
46 | + url: `/${repo.owner.username}/${repo.name}`, | |
47 | + icon: 'repo' | |
48 | + })), | |
49 | + ...users.map(user => ({ | |
50 | + type: 'user', | |
51 | + title: user.displayName || user.username, | |
52 | + subtitle: `@${user.username}`, | |
53 | + url: `/${user.username}`, | |
54 | + icon: 'user' | |
55 | + })) | |
56 | + ]; | |
57 | + | |
58 | + return success({ results }); | |
59 | + | |
60 | + } catch (error) { | |
61 | + console.error('Search error:', error); | |
62 | + return serverError('Search failed'); | |
63 | + } | |
64 | +}; | |
0 | 65 | |