1 |
import useDebouncedState from "@/hooks/useDebouncedState"; |
2 |
import { Button, CircularProgress, TextField } from "@mui/material"; |
3 |
import { useEffect, useState } from "react"; |
4 |
import { MdClose } from "react-icons/md"; |
5 |
import SearchResult from "./SearchResult"; |
6 |
|
7 |
type SearchModalProps = { |
8 |
onClose: () => void; |
9 |
}; |
10 |
|
11 |
export type SearchResultItem = { |
12 |
title?: string; |
13 |
description?: string; |
14 |
data: string; |
15 |
match: "title" | "description" | "data"; |
16 |
url: string; |
17 |
}; |
18 |
|
19 |
export default function SearchModal({ onClose }: SearchModalProps) { |
20 |
const [query, isQueued, setQuery] = useDebouncedState<string | null>( |
21 |
null, |
22 |
500, |
23 |
); |
24 |
const [results, setResults] = useState<SearchResultItem[] | null>(null); |
25 |
const [isLoading, setIsLoading] = useState(false); |
26 |
const [isNotFound, setIsNotFound] = useState(false); |
27 |
|
28 |
useEffect(() => { |
29 |
if (!query?.trim()) { |
30 |
return; |
31 |
} |
32 |
|
33 |
const controller = new AbortController(); |
34 |
|
35 |
if (!isLoading) { |
36 |
setIsLoading(true); |
37 |
} |
38 |
|
39 |
fetch(`/search?q=${encodeURIComponent(query)}`, { |
40 |
signal: controller.signal, |
41 |
}) |
42 |
.then(response => response.json()) |
43 |
.then(data => { |
44 |
if (isNotFound) { |
45 |
setIsNotFound(false); |
46 |
} |
47 |
|
48 |
setIsLoading(false); |
49 |
setResults(data.results); |
50 |
|
51 |
if (data.results.length === 0) { |
52 |
setIsNotFound(true); |
53 |
} |
54 |
}) |
55 |
.catch(console.error); |
56 |
|
57 |
return () => controller.abort(); |
58 |
}, [query]); |
59 |
|
60 |
return ( |
61 |
<> |
62 |
<div |
63 |
className="h-[100vh] w-[100vw] fixed top-0 left-0 bg-[rgba(0,0,0,0.3)] z-[10001]" |
64 |
onClick={onClose} |
65 |
> |
66 |
<div |
67 |
onClick={event => event.stopPropagation()} |
68 |
className="max-h-[95vh] block z-[10002] shadow-[0_0_1px_1px_rgba(255,255,255,0.2)] fixed bottom-[10px] lg:top-[50vh] left-[50%] translate-x-[-50%] lg:translate-y-[-50%] bg-[#222] min-h-[50vh] overflow-y-scroll w-[calc(100%-20px)] lg:w-[auto] md:min-w-[50vw] rounded-md p-4" |
69 |
> |
70 |
<div className="text-xl lg:text-2xl text-center mb-5 grid grid-cols-[1fr_5fr_1fr]"> |
71 |
<span></span> |
72 |
<span>Search Docs</span> |
73 |
<div className="flex justify-end"> |
74 |
<Button |
75 |
style={{ minWidth: 0, color: "white" }} |
76 |
onClick={onClose} |
77 |
> |
78 |
<MdClose /> |
79 |
</Button> |
80 |
</div> |
81 |
</div> |
82 |
|
83 |
<TextField |
84 |
fullWidth |
85 |
autoFocus |
86 |
type="text" |
87 |
variant="outlined" |
88 |
placeholder="Type here to search" |
89 |
onChange={event => setQuery(event.target.value.trim())} |
90 |
onKeyUp={event => { |
91 |
if (!(event.target as HTMLInputElement).value) { |
92 |
setQuery(null); |
93 |
setResults(null); |
94 |
} |
95 |
|
96 |
if (isNotFound) { |
97 |
setIsNotFound(false); |
98 |
} |
99 |
}} |
100 |
/> |
101 |
<br /> |
102 |
<div className="mt-4"> |
103 |
{isLoading ? ( |
104 |
<div className="flex justify-center items-center"> |
105 |
<CircularProgress /> |
106 |
</div> |
107 |
) : results && results.length > 0 && !isNotFound ? ( |
108 |
<> |
109 |
{results?.length && ( |
110 |
<> |
111 |
<p className="text-[#aaa] text-sm"> |
112 |
Found {results.length} results. |
113 |
</p> |
114 |
<br /> |
115 |
</> |
116 |
)} |
117 |
|
118 |
{results?.map((result, index) => ( |
119 |
<SearchResult |
120 |
result={result} |
121 |
query={query ?? ""} |
122 |
key={index} |
123 |
onClick={onClose} |
124 |
/> |
125 |
))} |
126 |
</> |
127 |
) : isNotFound ? ( |
128 |
<h3 className="text-lg md:text-xl text-center"> |
129 |
No results found.{" "} |
130 |
<span className="text-[#999]"> |
131 |
Maybe search again with a different |
132 |
keyboard? |
133 |
</span> |
134 |
</h3> |
135 |
) : ( |
136 |
"" |
137 |
)} |
138 |
</div> |
139 |
</div> |
140 |
</div> |
141 |
</> |
142 |
); |
143 |
} |