22
33import { useState , useEffect , useRef , useCallback } from "react" ;
44import { useRouter } from "next/navigation" ;
5- import { Search , X , Loader2 } from "lucide-react" ;
5+ import { Search , SearchX , X , Loader2 , Clock , Trash2 } from "lucide-react" ;
66import { getPosts } from "@/lib/api" ;
77import 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+
969interface 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 “{ query } ”
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 “{ query } ”
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" > ↑↓</ kbd > to navigate</ span >
0 commit comments