Skip to content

Commit c1d07b6

Browse files
committed
feat(search): implement search functionality with debounced input and results display
1 parent 5967e85 commit c1d07b6

File tree

6 files changed

+178
-27
lines changed

6 files changed

+178
-27
lines changed

src/actions/resources.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,31 @@ export const getResourcesByCategory = async (
4848
return data;
4949
};
5050

51+
export const searchResources = async (
52+
query: string,
53+
): Promise<ResourceList[]> => {
54+
const supabase = await createClient();
55+
const { data, error } = await supabase
56+
.from("resources")
57+
.select(
58+
`
59+
id,
60+
name,
61+
slug,
62+
image,
63+
tags,
64+
category:categories (id, name, slug, created_at)
65+
`,
66+
)
67+
.or(`name.ilike.%${query}%,slug.ilike.%${query}%`)
68+
.order("name", { ascending: true })
69+
.limit(20);
70+
71+
if (error) throw new Error(error.message);
72+
// @ts-ignore - supabase returns an array, but we want to return a single object
73+
return data;
74+
};
75+
5176
export const getResourceBySlug = async (
5277
slug: string,
5378
): Promise<Resource | null> => {

src/app/(public)/search/page.tsx

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { redirect } from "next/navigation";
2+
import { searchResources } from "@/actions/resources";
3+
import { ResourceList } from "@/components/resources";
4+
import { Heading } from "@/components/ui";
5+
import { PROJECT_NAME, HOME_PATH } from "@/config/constants";
6+
7+
type Props = {
8+
searchParams: Promise<{ query?: string }>;
9+
};
10+
11+
export async function generateMetadata({ searchParams }: Props) {
12+
const resolvedSearchParams = await searchParams;
13+
const query = resolvedSearchParams?.query ?? "";
14+
15+
return {
16+
title: `${query ? `Results for "${query}"` : "Explore"} - ${PROJECT_NAME}`,
17+
};
18+
}
19+
20+
export default async function SearchPage({ searchParams }: Props) {
21+
const resolvedSearchParams = await searchParams;
22+
const query = resolvedSearchParams?.query?.trim();
23+
if (!query) redirect(HOME_PATH);
24+
const results = await searchResources(query);
25+
26+
const renderResources = () => {
27+
if (results.length === 0) {
28+
return <p className="text-gray-500">No results found</p>;
29+
}
30+
return <ResourceList resources={results} />;
31+
};
32+
33+
return (
34+
<section className="max-w-screen-2xl mx-auto text-center py-6 px-6">
35+
<Heading title={`Results for "${query}"`} />
36+
{renderResources()}
37+
</section>
38+
);
39+
}

src/components/layout/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export { default as EmptyList } from "./empty-list";
22
export { default as Footer } from "./footer";
33
export { default as Navbar } from "./navbar";
4+
export { default as SearchBar } from "./search-bar";
45
export { default as Sidebar } from "./sidebar";

src/components/layout/search-bar.tsx

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
"use client";
2+
3+
import { useState, useEffect, useRef, useCallback } from "react";
4+
import { useRouter, useSearchParams } from "next/navigation";
5+
import { Input, Kbd } from "@heroui/react";
6+
import { Search } from "lucide-react";
7+
import { HOME_PATH } from "@/config/constants";
8+
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
9+
10+
export default function SearchBar() {
11+
const router = useRouter();
12+
const searchParams = useSearchParams();
13+
const [query, setQuery] = useState(searchParams.get("query") || "");
14+
const inputRef = useRef<HTMLInputElement>(null);
15+
16+
useEffect(() => {
17+
const handleKeyDown = (e: KeyboardEvent) => {
18+
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
19+
e.preventDefault();
20+
inputRef.current?.focus();
21+
}
22+
};
23+
24+
window.addEventListener("keydown", handleKeyDown);
25+
return () => window.removeEventListener("keydown", handleKeyDown);
26+
}, []);
27+
28+
const debouncedPush = useDebouncedCallback((value: string) => {
29+
if (value.trim()) {
30+
router.push(`/search?query=${value}`);
31+
} else {
32+
router.push(HOME_PATH);
33+
}
34+
}, 400);
35+
36+
const handleSearchChange = useCallback(
37+
(e: React.ChangeEvent<HTMLInputElement>) => {
38+
const searchValue = e.target.value;
39+
setQuery(searchValue);
40+
debouncedPush(searchValue);
41+
},
42+
[debouncedPush],
43+
);
44+
45+
return (
46+
<form onSubmit={(e) => e.preventDefault()}>
47+
<Input
48+
ref={inputRef}
49+
size="sm"
50+
radius="sm"
51+
variant="bordered"
52+
placeholder="Search resources..."
53+
value={query}
54+
onChange={handleSearchChange}
55+
aria-label="Search"
56+
className="w-full"
57+
classNames={{
58+
inputWrapper:
59+
"border-2 border-neutral-200 dark:border-neutral-800 text-neutral-950 dark:text-white shadow-none",
60+
input:
61+
"placeholder:text-neutral-400 dark:placeholder:text-neutral-600",
62+
}}
63+
startContent={<Search size={20} />}
64+
endContent={
65+
<Kbd
66+
keys={["command"]}
67+
classNames={{
68+
base: "bg-neutral-200 dark:bg-neutral-800 px-1 py-0 rounded-sm text-xs",
69+
}}
70+
>
71+
K
72+
</Kbd>
73+
}
74+
/>
75+
</form>
76+
);
77+
}

src/components/layout/sidebar.tsx

Lines changed: 6 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@
22

33
import type { Category } from "@/types";
44

5+
import { Suspense } from "react";
56
import Link from "next/link";
67
import { usePathname } from "next/navigation";
7-
import { Accordion, AccordionItem, Input, Kbd } from "@heroui/react";
8+
import { Accordion, AccordionItem, Input, Kbd, Skeleton } from "@heroui/react";
89
import {
910
BadgeInfo,
1011
BookOpen,
@@ -13,9 +14,8 @@ import {
1314
Folder,
1415
HeartHandshake,
1516
Origami,
16-
Search,
1717
} from "lucide-react";
18-
import { Footer } from "@/components/layout";
18+
import { Footer, SearchBar } from "@/components/layout";
1919
import { HOME_PATH, PROJECT_NAME } from "@/config/constants";
2020

2121
type Props = {
@@ -39,30 +39,9 @@ export default function Sidebar({ categories }: Props) {
3939
</div>
4040
</Link>
4141
</header>
42-
<Input
43-
size="sm"
44-
radius="sm"
45-
variant="bordered"
46-
placeholder="Search resources..."
47-
// value={search}
48-
// onChange={(e) => setSearch(e.target.value)}
49-
className="w-full"
50-
classNames={{
51-
inputWrapper:
52-
"border-2 border-neutral-200 dark:border-neutral-800 text-neutral-950 dark:text-white shadow-none",
53-
}}
54-
startContent={<Search size={20} />}
55-
endContent={
56-
<Kbd
57-
keys={["command"]}
58-
classNames={{
59-
base: "bg-neutral-200 dark:bg-neutral-800 px-1 py-0 rounded-sm text-xs",
60-
}}
61-
>
62-
K
63-
</Kbd>
64-
}
65-
/>
42+
<Suspense fallback={<Skeleton className="h-10 w-full rounded-xl" />}>
43+
<SearchBar />
44+
</Suspense>
6645

6746
<nav className="overflow-hidden relative text-sm">
6847
<ul className="styled-scrollbar flex h-[calc(100vh-60px)] flex-col overflow-y-scroll dark:text-white">

src/hooks/use-debounced-callback.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { useRef, useEffect, useCallback } from "react";
2+
3+
export function useDebouncedCallback<T extends (...args: any[]) => void>(
4+
callback: T,
5+
delay: number,
6+
) {
7+
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
8+
9+
const debouncedFn = useCallback(
10+
(...args: Parameters<T>) => {
11+
if (timeoutRef.current) {
12+
clearTimeout(timeoutRef.current);
13+
}
14+
timeoutRef.current = setTimeout(() => {
15+
callback(...args);
16+
}, delay);
17+
},
18+
[callback, delay],
19+
);
20+
21+
useEffect(() => {
22+
return () => {
23+
if (timeoutRef.current) {
24+
clearTimeout(timeoutRef.current);
25+
}
26+
};
27+
}, []);
28+
29+
return debouncedFn;
30+
}

0 commit comments

Comments
 (0)