Implement Global Search Command Palette

swadhinbiswas committed 10 months ago
17ec4f8
1 parent d9b7b9f
commit 17ec4f8
6 files changed
+387 -15
▼package-lock.json
+18-0
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
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
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
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
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
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
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
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
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