Skip to content

Commit 2d19cbd

Browse files
committed
add search via typesense
1 parent ac60623 commit 2d19cbd

File tree

9 files changed

+210
-166
lines changed

9 files changed

+210
-166
lines changed

app/page.tsx

+2-5
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import ProjectList from "@/components/projects/project-list";
2-
import SearchBar, { SearchBarPlaceholder } from "@/components/ui/search-box";
2+
import SearchBar from "@/components/ui/search-bar";
33
import { Twitter } from "@dub/ui";
4-
import { Suspense } from "react";
54

65
export default function Home() {
76
return (
@@ -35,9 +34,7 @@ export default function Home() {
3534
className="mx-auto mt-6 flex animate-fade-up items-center justify-center space-x-5 opacity-0"
3635
style={{ animationDelay: "0.3s", animationFillMode: "forwards" }}
3736
>
38-
<Suspense fallback={<SearchBarPlaceholder />}>
39-
<SearchBar />
40-
</Suspense>
37+
<SearchBar />
4138
</div>
4239
</div>
4340

app/projects/[slug]/layout.tsx

+17-10
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { getProject } from "@/lib/actions/get-project";
77
import { dub } from "@/lib/dub";
88
import { getRepo } from "@/lib/github";
99
import prisma from "@/lib/prisma";
10+
import typesense from "@/lib/typesense";
1011
import { constructMetadata } from "@/lib/utils";
1112
import { cn, nFormatter } from "@dub/utils";
1213
import { BadgeCheck, Globe, Star } from "lucide-react";
@@ -79,16 +80,22 @@ export default async function ProjectLayout({
7980
console.error(e);
8081
}
8182

82-
await prisma.project.update({
83-
where: {
84-
slug,
85-
},
86-
data: {
87-
...(stars !== project.stars && { stars }),
88-
...(totalClicks &&
89-
totalClicks !== project.clicks && { clicks: totalClicks }),
90-
},
91-
});
83+
await Promise.all([
84+
prisma.project.update({
85+
where: {
86+
slug,
87+
},
88+
data: {
89+
...(stars !== project.stars && { stars }),
90+
...(totalClicks &&
91+
totalClicks !== project.clicks && { clicks: totalClicks }),
92+
},
93+
}),
94+
typesense()
95+
.collections("projects")
96+
.documents(project.id)
97+
.update({ stars, clicks: totalClicks }),
98+
]);
9299

93100
return (
94101
<ProjectProvider props={project}>

components/ui/search-autocomplete.tsx

-92
This file was deleted.

components/ui/search-bar.tsx

+145
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
"use client";
2+
3+
import typesense, { ProjectHit } from "@/lib/typesense";
4+
import { BlurImage, LoadingSpinner } from "@dub/ui";
5+
import { cn } from "@dub/utils";
6+
import { Command } from "cmdk";
7+
import { Link2 } from "lucide-react";
8+
import { useRouter, useSearchParams } from "next/navigation";
9+
import { useEffect, useState } from "react";
10+
import Highlighter from "react-highlight-words";
11+
import { toast } from "sonner";
12+
import { useDebounce } from "use-debounce";
13+
14+
export default function SearchBar() {
15+
const [loading, setLoading] = useState(false);
16+
const [items, setItems] = useState<ProjectHit[]>([]);
17+
18+
const searchParams = useSearchParams();
19+
const q = searchParams?.get("q") || "";
20+
21+
function search(query: string) {
22+
const params = new URLSearchParams(searchParams.toString());
23+
if (query) {
24+
params.set("q", query);
25+
} else {
26+
params.delete("q");
27+
}
28+
window.history.replaceState(null, "", `?${params.toString()}`);
29+
}
30+
31+
const [debouncedQuery] = useDebounce(q, 150);
32+
33+
useEffect(() => {
34+
const abortController = new AbortController();
35+
36+
if (!debouncedQuery) return setItems([]);
37+
const fetchAutocomplete = async (q: string) => {
38+
try {
39+
setLoading(true);
40+
const results = await typesense({ client: true })
41+
.collections("projects")
42+
.documents()
43+
.search(
44+
{
45+
q,
46+
query_by: ["name", "description"],
47+
highlight_full_fields: ["name", "description"],
48+
sort_by: "stars:desc",
49+
num_typos: 1,
50+
limit: 8,
51+
exclude_fields: ["out_of", "search_time_ms"],
52+
infix: ["always", "off"],
53+
},
54+
{
55+
abortSignal: abortController.signal,
56+
},
57+
);
58+
setItems(results.hits as ProjectHit[]);
59+
} catch (error) {
60+
toast.error(error);
61+
}
62+
setLoading(false);
63+
};
64+
fetchAutocomplete(debouncedQuery);
65+
66+
return () => {
67+
abortController.abort();
68+
};
69+
}, [debouncedQuery]);
70+
71+
const router = useRouter();
72+
73+
return (
74+
<Command className="peer relative w-full max-w-md" loop>
75+
<div className="absolute inset-y-0 left-3 mt-3 text-gray-400">
76+
{loading ? (
77+
<LoadingSpinner className="h-4" />
78+
) : (
79+
<Link2 className="h-4" />
80+
)}
81+
</div>
82+
<Command.Input
83+
name="query"
84+
id="query"
85+
className="block w-full rounded-md border-gray-200 pl-10 text-sm text-gray-900 placeholder-gray-400 shadow-lg focus:border-gray-500 focus:outline-none focus:ring-gray-500"
86+
placeholder="Search for a project"
87+
value={q}
88+
onValueChange={search}
89+
/>
90+
<Command.List
91+
className={cn(
92+
"absolute z-10 mt-2 h-[calc(var(--cmdk-list-height)+17px)] max-h-[300px] w-full overflow-auto rounded-md border border-gray-200 bg-white p-2 shadow-md transition-all duration-75",
93+
{
94+
hidden: items.length === 0,
95+
},
96+
)}
97+
>
98+
{items.map((item) => {
99+
return (
100+
<Command.Item
101+
key={item.document.id}
102+
value={item.document.name}
103+
onSelect={() => {
104+
router.push(`/projects/${item.document.slug}`);
105+
}}
106+
className="group flex cursor-pointer items-center justify-between rounded-md px-4 py-2 text-sm text-gray-900 hover:bg-gray-100 hover:text-gray-900 active:bg-gray-200 aria-disabled:cursor-not-allowed aria-disabled:opacity-75 aria-disabled:hover:bg-white aria-selected:bg-gray-100 aria-selected:text-gray-900"
107+
>
108+
<div className="flex items-center space-x-2">
109+
<BlurImage
110+
src={item.document.logo}
111+
alt={item.document.name}
112+
className="h-6 w-6 rounded-full"
113+
width={16}
114+
height={16}
115+
/>
116+
<div className="flex flex-col space-y-0.5">
117+
<Highlighter
118+
highlightClassName="underline bg-transparent text-purple-500"
119+
searchWords={
120+
item.highlights.find((h) => h.field === "name")
121+
?.matched_tokens || []
122+
}
123+
autoEscape={true}
124+
textToHighlight={item.document.name}
125+
className="text-sm font-medium text-gray-600 group-aria-selected:text-purple-600 sm:group-hover:text-purple-600"
126+
/>
127+
<Highlighter
128+
highlightClassName="underline bg-transparent text-purple-500"
129+
searchWords={
130+
item.highlights.find((h) => h.field === "description")
131+
?.matched_tokens || []
132+
}
133+
autoEscape={true}
134+
textToHighlight={item.document.description}
135+
className="line-clamp-1 text-xs text-gray-400"
136+
/>
137+
</div>
138+
</div>
139+
</Command.Item>
140+
);
141+
})}
142+
</Command.List>
143+
</Command>
144+
);
145+
}

components/ui/search-box.tsx

-58
This file was deleted.

lib/typesense.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import Typesense from "typesense";
33
import { SearchResponseHit } from "typesense/lib/Typesense/Documents";
44

55
export type ProjectHit = SearchResponseHit<
6-
Pick<Project, "id" | "name" | "description" | "slug">
6+
Pick<Project, "id" | "name" | "description" | "slug" | "logo">
77
>;
88

99
const typesense = ({ client }: { client?: boolean } = {}) => {

package.json

+2
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,15 @@
2424
"@tremor/react": "^3.16.1",
2525
"@vercel/analytics": "^1.2.2",
2626
"class-variance-authority": "^0.7.0",
27+
"cmdk": "^1.0.0",
2728
"dub": "^0.24.4",
2829
"framer-motion": "^11.1.7",
2930
"lucide-react": "0.372.0",
3031
"next": "14.3.0-canary.11",
3132
"next-auth": "5.0.0-beta.16",
3233
"react": "18.2.0",
3334
"react-dom": "18.2.0",
35+
"react-highlight-words": "^0.20.0",
3436
"react-hook-form": "^7.51.3",
3537
"react-markdown": "^9.0.1",
3638
"react-textarea-autosize": "^8.5.3",

0 commit comments

Comments
 (0)