Skip to content

Commit 2b291f1

Browse files
feat: add search result highlighting and recent searches (#28) (#73)
1 parent 3883a6a commit 2b291f1

File tree

1 file changed

+168
-10
lines changed

1 file changed

+168
-10
lines changed

frontend/src/components/SearchDialog.tsx

Lines changed: 168 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,70 @@
22

33
import { useState, useEffect, useRef, useCallback } from "react";
44
import { useRouter } from "next/navigation";
5-
import { Search, X, Loader2 } from "lucide-react";
5+
import { Search, SearchX, X, Loader2, Clock, Trash2 } from "lucide-react";
66
import { getPosts } from "@/lib/api";
77
import type { Post } from "@/lib/types";
88

9+
const RECENT_SEARCHES_KEY = "tbc-recent-searches";
10+
const MAX_RECENT_SEARCHES = 5;
11+
12+
function getRecentSearches(): string[] {
13+
if (typeof window === "undefined") return [];
14+
try {
15+
const raw = localStorage.getItem(RECENT_SEARCHES_KEY);
16+
if (!raw) return [];
17+
const parsed = JSON.parse(raw);
18+
return Array.isArray(parsed) ? parsed.slice(0, MAX_RECENT_SEARCHES) : [];
19+
} catch {
20+
return [];
21+
}
22+
}
23+
24+
function saveRecentSearch(query: string): void {
25+
const trimmed = query.trim();
26+
if (!trimmed) return;
27+
const existing = getRecentSearches();
28+
const filtered = existing.filter((s) => s.toLowerCase() !== trimmed.toLowerCase());
29+
const updated = [trimmed, ...filtered].slice(0, MAX_RECENT_SEARCHES);
30+
localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated));
31+
}
32+
33+
function removeRecentSearch(query: string): string[] {
34+
const existing = getRecentSearches();
35+
const updated = existing.filter((s) => s !== query);
36+
localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated));
37+
return updated;
38+
}
39+
40+
function clearAllRecentSearches(): void {
41+
localStorage.removeItem(RECENT_SEARCHES_KEY);
42+
}
43+
44+
function HighlightedText({ text, query }: { text: string; query: string }) {
45+
if (!query.trim()) return <>{text}</>;
46+
47+
const escapedQuery = query.trim().replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
48+
const regex = new RegExp(`(${escapedQuery})`, "gi");
49+
const parts = text.split(regex);
50+
51+
return (
52+
<>
53+
{parts.map((part, i) =>
54+
regex.test(part) ? (
55+
<mark
56+
key={i}
57+
className="bg-[var(--color-accent)]/25 text-inherit rounded-sm px-0.5"
58+
>
59+
{part}
60+
</mark>
61+
) : (
62+
<span key={i}>{part}</span>
63+
),
64+
)}
65+
</>
66+
);
67+
}
68+
969
interface SearchDialogProps {
1070
open: boolean;
1171
onClose: () => void;
@@ -18,12 +78,16 @@ export default function SearchDialog({ open, onClose }: SearchDialogProps) {
1878
const [results, setResults] = useState<Post[]>([]);
1979
const [loading, setLoading] = useState(false);
2080
const [selectedIndex, setSelectedIndex] = useState(0);
81+
const [hasSearched, setHasSearched] = useState(false);
82+
const [recentSearches, setRecentSearches] = useState<string[]>([]);
2183

2284
useEffect(() => {
2385
if (open) {
2486
setQuery("");
2587
setResults([]);
2688
setSelectedIndex(0);
89+
setHasSearched(false);
90+
setRecentSearches(getRecentSearches());
2791
requestAnimationFrame(() => inputRef.current?.focus());
2892
}
2993
}, [open]);
@@ -32,6 +96,7 @@ export default function SearchDialog({ open, onClose }: SearchDialogProps) {
3296
if (!query.trim()) {
3397
setResults([]);
3498
setLoading(false);
99+
setHasSearched(false);
35100
return;
36101
}
37102

@@ -41,8 +106,10 @@ export default function SearchDialog({ open, onClose }: SearchDialogProps) {
41106
const data = await getPosts({ search: query.trim(), limit: 8 });
42107
setResults(data.posts);
43108
setSelectedIndex(0);
109+
setHasSearched(true);
44110
} catch {
45111
setResults([]);
112+
setHasSearched(true);
46113
} finally {
47114
setLoading(false);
48115
}
@@ -53,30 +120,58 @@ export default function SearchDialog({ open, onClose }: SearchDialogProps) {
53120

54121
const navigateToResult = useCallback(
55122
(post: Post) => {
123+
saveRecentSearch(query);
56124
onClose();
57125
router.push(`/post/${post.id}`);
58126
},
59-
[onClose, router],
127+
[onClose, router, query],
60128
);
61129

62130
const handleKeyDown = useCallback(
63131
(e: React.KeyboardEvent) => {
132+
const showingRecent = !query.trim() && recentSearches.length > 0;
133+
64134
if (e.key === "ArrowDown") {
65135
e.preventDefault();
66-
setSelectedIndex((i) => Math.min(i + 1, results.length - 1));
136+
const maxIndex = showingRecent ? recentSearches.length - 1 : results.length - 1;
137+
setSelectedIndex((i) => Math.min(i + 1, maxIndex));
67138
} else if (e.key === "ArrowUp") {
68139
e.preventDefault();
69140
setSelectedIndex((i) => Math.max(i - 1, 0));
70-
} else if (e.key === "Enter" && results[selectedIndex]) {
141+
} else if (e.key === "Enter") {
71142
e.preventDefault();
72-
navigateToResult(results[selectedIndex]);
143+
if (showingRecent && recentSearches[selectedIndex]) {
144+
setQuery(recentSearches[selectedIndex]);
145+
} else if (results[selectedIndex]) {
146+
navigateToResult(results[selectedIndex]);
147+
}
73148
}
74149
},
75-
[results, selectedIndex, navigateToResult],
150+
[results, selectedIndex, navigateToResult, query, recentSearches],
76151
);
77152

153+
const handleRemoveRecent = useCallback((searchTerm: string, e: React.MouseEvent) => {
154+
e.stopPropagation();
155+
const updated = removeRecentSearch(searchTerm);
156+
setRecentSearches(updated);
157+
setSelectedIndex(0);
158+
}, []);
159+
160+
const handleClearAll = useCallback(() => {
161+
clearAllRecentSearches();
162+
setRecentSearches([]);
163+
setSelectedIndex(0);
164+
}, []);
165+
166+
const handleRecentClick = useCallback((searchTerm: string) => {
167+
setQuery(searchTerm);
168+
}, []);
169+
78170
if (!open) return null;
79171

172+
const showRecentSearches = !query.trim() && recentSearches.length > 0;
173+
const showNoResults = query.trim() && hasSearched && !loading && results.length === 0;
174+
80175
return (
81176
<div
82177
className="fixed inset-0 z-[100] flex items-start justify-center pt-[15vh]"
@@ -108,6 +203,59 @@ export default function SearchDialog({ open, onClose }: SearchDialogProps) {
108203
<X size={16} />
109204
</button>
110205
</div>
206+
207+
{showRecentSearches && (
208+
<div className="py-2">
209+
<div className="flex items-center justify-between px-4 py-1.5">
210+
<span className="text-xs font-medium text-[var(--color-text-muted)] uppercase tracking-wider">
211+
Recent Searches
212+
</span>
213+
<button
214+
onClick={handleClearAll}
215+
className="text-xs text-[var(--color-text-muted)] hover:text-[var(--color-error)] transition-colors"
216+
>
217+
Clear all
218+
</button>
219+
</div>
220+
<ul>
221+
{recentSearches.map((term, i) => (
222+
<li key={term}>
223+
<button
224+
onClick={() => handleRecentClick(term)}
225+
onMouseEnter={() => setSelectedIndex(i)}
226+
className={`w-full text-left px-4 py-2 flex items-center gap-3 transition-colors ${
227+
i === selectedIndex
228+
? "bg-[var(--color-bg-hover)]"
229+
: "hover:bg-[var(--color-bg-hover)]"
230+
}`}
231+
>
232+
<Clock size={14} className="shrink-0 text-[var(--color-text-muted)]" />
233+
<span className="flex-1 text-sm text-[var(--color-text-primary)] truncate">
234+
{term}
235+
</span>
236+
<span
237+
role="button"
238+
tabIndex={0}
239+
onClick={(e) => handleRemoveRecent(term, e)}
240+
onKeyDown={(e) => {
241+
if (e.key === "Enter" || e.key === " ") {
242+
e.stopPropagation();
243+
const updated = removeRecentSearch(term);
244+
setRecentSearches(updated);
245+
setSelectedIndex(0);
246+
}
247+
}}
248+
className="shrink-0 text-[var(--color-text-muted)] hover:text-[var(--color-error)] transition-colors p-0.5 rounded"
249+
>
250+
<Trash2 size={12} />
251+
</span>
252+
</button>
253+
</li>
254+
))}
255+
</ul>
256+
</div>
257+
)}
258+
111259
{results.length > 0 && (
112260
<ul className="max-h-80 overflow-y-auto py-2">
113261
{results.map((post, i) => (
@@ -121,7 +269,9 @@ export default function SearchDialog({ open, onClose }: SearchDialogProps) {
121269
: "text-[var(--color-text-primary)] hover:bg-[var(--color-bg-hover)]"
122270
}`}
123271
>
124-
<span className="text-sm font-medium line-clamp-1">{post.title}</span>
272+
<span className="text-sm font-medium line-clamp-1">
273+
<HighlightedText text={post.title} query={query} />
274+
</span>
125275
<span
126276
className={`text-xs ${
127277
i === selectedIndex
@@ -136,11 +286,19 @@ export default function SearchDialog({ open, onClose }: SearchDialogProps) {
136286
))}
137287
</ul>
138288
)}
139-
{query.trim() && !loading && results.length === 0 && (
140-
<div className="px-4 py-8 text-center text-sm text-[var(--color-text-muted)]">
141-
No posts found for &ldquo;{query}&rdquo;
289+
290+
{showNoResults && (
291+
<div className="px-4 py-8 flex flex-col items-center gap-2 text-center">
292+
<SearchX size={32} className="text-[var(--color-text-muted)]" />
293+
<p className="text-sm font-medium text-[var(--color-text-primary)]">
294+
No results found for &ldquo;{query}&rdquo;
295+
</p>
296+
<p className="text-xs text-[var(--color-text-muted)]">
297+
Try different keywords or check spelling
298+
</p>
142299
</div>
143300
)}
301+
144302
<div className="px-4 py-2 border-t border-[var(--color-border)] flex items-center gap-4 text-xs text-[var(--color-text-muted)]">
145303
<span><kbd className="px-1.5 py-0.5 bg-[var(--color-bg-hover)] rounded text-[10px] font-mono">Esc</kbd> to close</span>
146304
<span><kbd className="px-1.5 py-0.5 bg-[var(--color-bg-hover)] rounded text-[10px] font-mono">&uarr;&darr;</kbd> to navigate</span>

0 commit comments

Comments
 (0)