1- import { useEffect , useState } from "react" ;
1+ import {
2+ useEffect ,
3+ useState ,
4+ type ChangeEvent ,
5+ type KeyboardEvent ,
6+ } from "react" ;
27import ReactSelect from "./ReactSelect" ;
38import type { CollectionEntry } from "astro:content" ;
49import { formatDistance } from "date-fns" ;
10+ import { setSearchParams } from "~/util/url" ;
511
612type DocsData = keyof CollectionEntry < "docs" > [ "data" ] ;
713type VideosData = keyof CollectionEntry < "stream" > [ "data" ] ;
@@ -15,6 +21,7 @@ interface Props {
1521 columns : number ;
1622 showDescriptions : boolean ;
1723 showLastUpdated : boolean ;
24+ filterPlacement : string ;
1825}
1926
2027export default function ResourcesBySelector ( {
@@ -24,8 +31,16 @@ export default function ResourcesBySelector({
2431 columns,
2532 showDescriptions,
2633 showLastUpdated,
34+ filterPlacement,
2735} : Props ) {
2836 const [ selectedFilter , setSelectedFilter ] = useState < string | null > ( null ) ;
37+ const [ leftFilters , setLeftFilters ] = useState < {
38+ search : string ;
39+ selectedValues : Record < string , string [ ] > ;
40+ } > ( {
41+ search : "" ,
42+ selectedValues : { } ,
43+ } ) ;
2944
3045 const timeAgo = ( date ?: Date ) => {
3146 if ( ! date ) return undefined ;
@@ -44,36 +59,176 @@ export default function ResourcesBySelector({
4459 } ) ) ,
4560 } ) ) ;
4661
62+ // Keep facets organized by filterable field for left sidebar
63+
4764 const visibleResources = resources . filter ( ( resource ) => {
48- if ( ! selectedFilter || ! filters ) return true ;
49-
50- const filterableValues : string [ ] = [ ] ;
51- for ( const filter of filters ) {
52- const val = resource . data [ filter as keyof typeof resource . data ] ;
53- if ( val ) {
54- if ( Array . isArray ( val ) && val . every ( ( v ) => typeof v === "string" ) ) {
55- filterableValues . push ( ...val ) ;
56- } else if ( typeof val === "string" ) {
57- filterableValues . push ( val ) ;
65+ // Handle top filter (ReactSelect)
66+ if ( filterPlacement === "top" && selectedFilter && filters ) {
67+ const filterableValues : string [ ] = [ ] ;
68+ for ( const filter of filters ) {
69+ const val = resource . data [ filter as keyof typeof resource . data ] ;
70+ if ( val ) {
71+ if ( Array . isArray ( val ) && val . every ( ( v ) => typeof v === "string" ) ) {
72+ filterableValues . push ( ...val ) ;
73+ } else if ( typeof val === "string" ) {
74+ filterableValues . push ( val ) ;
75+ }
76+ }
77+ }
78+ if ( ! filterableValues . includes ( selectedFilter ) ) return false ;
79+ }
80+
81+ // Handle left sidebar filters
82+ if ( filterPlacement === "left" && filters ) {
83+ // Check each filterable field separately
84+ for ( const [ filterField , selectedValues ] of Object . entries ( leftFilters . selectedValues ) ) {
85+ if ( selectedValues . length > 0 ) {
86+ const resourceValues : string [ ] = [ ] ;
87+ const val = resource . data [ filterField as keyof typeof resource . data ] ;
88+ if ( val ) {
89+ if ( Array . isArray ( val ) && val . every ( ( v ) => typeof v === "string" ) ) {
90+ resourceValues . push ( ...val ) ;
91+ } else if ( typeof val === "string" ) {
92+ resourceValues . push ( val ) ;
93+ }
94+ }
95+ if ( ! resourceValues . some ( ( v ) => selectedValues . includes ( v ) ) ) {
96+ return false ;
97+ }
98+ }
99+ }
100+
101+ // Search filter
102+ if ( leftFilters . search ) {
103+ const searchTerm = leftFilters . search . toLowerCase ( ) ;
104+ const title = resource . data . title ?. toLowerCase ( ) || "" ;
105+ const description = resource . data . description ?. toLowerCase ( ) || "" ;
106+
107+ if ( ! title . includes ( searchTerm ) && ! description . includes ( searchTerm ) ) {
108+ return false ;
58109 }
59110 }
60111 }
61112
62- return filterableValues . includes ( selectedFilter ) ;
113+ return true ;
63114 } ) ;
64115
65116 useEffect ( ( ) => {
66117 const params = new URLSearchParams ( window . location . search ) ;
67- const value = params . get ( "filters" ) ;
68118
69- if ( value ) {
70- setSelectedFilter ( value ) ;
119+ if ( filterPlacement === "top" ) {
120+ const value = params . get ( "filters" ) ;
121+ if ( value ) {
122+ setSelectedFilter ( value ) ;
123+ }
124+ } else if ( filterPlacement === "left" ) {
125+ // Handle left sidebar URL params
126+ const searchTerm = params . get ( "search-term" ) ?? "" ;
127+ const selectedValues : Record < string , string [ ] > = { } ;
128+
129+ // Get values for each filterable field from URL params
130+ if ( filters ) {
131+ for ( const filter of filters ) {
132+ const values = params . getAll ( `filter-${ filter } ` ) ;
133+ if ( values . length > 0 ) {
134+ selectedValues [ filter ] = values ;
135+ }
136+ }
137+ }
138+
139+ if ( Object . keys ( selectedValues ) . length > 0 || searchTerm ) {
140+ setLeftFilters ( {
141+ search : searchTerm ,
142+ selectedValues : selectedValues ,
143+ } ) ;
144+ }
71145 }
72- } , [ ] ) ;
146+ } , [ filterPlacement ] ) ;
147+
148+ // Update URL params for left sidebar filters
149+ useEffect ( ( ) => {
150+ if ( filterPlacement === "left" ) {
151+ const params = new URLSearchParams ( ) ;
152+
153+ if ( leftFilters . search ) {
154+ params . set ( "search-term" , leftFilters . search ) ;
155+ }
156+
157+ // Add URL params for each filterable field
158+ for ( const [ filterField , selectedValues ] of Object . entries ( leftFilters . selectedValues ) ) {
159+ selectedValues . forEach ( ( value ) =>
160+ params . append ( `filter-${ filterField } ` , value ) ,
161+ ) ;
162+ }
163+
164+ setSearchParams ( params ) ;
165+ }
166+ } , [ leftFilters , filterPlacement ] ) ;
73167
74168 return (
75- < div >
76- { filters && (
169+ < div className = { filterPlacement === "left" ? "md:flex" : "" } >
170+ { filterPlacement === "left" && filters && (
171+ < div className = "mr-8 w-full md:w-1/4" >
172+ < input
173+ type = "text"
174+ className = "mb-8 w-full rounded-md border-2 border-gray-200 bg-white px-2 py-2 dark:border-gray-700 dark:bg-gray-800"
175+ placeholder = "Search resources"
176+ value = { leftFilters . search }
177+ onChange = { ( e ) =>
178+ setLeftFilters ( { ...leftFilters , search : e . target . value } )
179+ }
180+ onKeyDown = { ( e : KeyboardEvent < HTMLInputElement > ) => {
181+ if ( e . key === "Escape" ) {
182+ setLeftFilters ( { ...leftFilters , search : "" } ) ;
183+ }
184+ } }
185+ />
186+
187+ { Object . entries ( facets ) . map ( ( [ filterField , values ] ) => (
188+ < div key = { filterField } className = "mb-8! hidden md:block" >
189+ < span className = "text-sm font-bold text-gray-600 uppercase dark:text-gray-200" >
190+ { filterField . replace ( / _ / g, ' ' ) . replace ( / \b \w / g, l => l . toUpperCase ( ) ) }
191+ </ span >
192+
193+ { values . map ( ( value ) => (
194+ < label key = { `${ filterField } -${ value } ` } className = "my-2! block" >
195+ < input
196+ type = "checkbox"
197+ className = "mr-2"
198+ value = { value }
199+ checked = { leftFilters . selectedValues [ filterField ] ?. includes ( value ) || false }
200+ onChange = { ( e : ChangeEvent < HTMLInputElement > ) => {
201+ const currentValues = leftFilters . selectedValues [ filterField ] || [ ] ;
202+ if ( e . target . checked ) {
203+ setLeftFilters ( {
204+ ...leftFilters ,
205+ selectedValues : {
206+ ...leftFilters . selectedValues ,
207+ [ filterField ] : [ ...currentValues , e . target . value ] ,
208+ } ,
209+ } ) ;
210+ } else {
211+ setLeftFilters ( {
212+ ...leftFilters ,
213+ selectedValues : {
214+ ...leftFilters . selectedValues ,
215+ [ filterField ] : currentValues . filter (
216+ ( v ) => v !== e . target . value ,
217+ ) ,
218+ } ,
219+ } ) ;
220+ }
221+ } }
222+ /> { " " }
223+ { value }
224+ </ label >
225+ ) ) }
226+ </ div >
227+ ) ) }
228+ </ div >
229+ ) }
230+
231+ { filterPlacement === "top" && filters && (
77232 < div className = "not-content" >
78233 < ReactSelect
79234 className = "mt-2"
@@ -91,48 +246,62 @@ export default function ResourcesBySelector({
91246 ) }
92247
93248 < div
94- className = { `grid ${ columns === 2 ? "md:grid-cols-2" : " md:grid-cols-3" } grid-cols-1 gap-4` }
249+ className = { filterPlacement === "left" ? "mt-0! w-full md:w-3/4" : "" }
95250 >
96- { visibleResources . map ( ( page ) => {
97- const href =
98- page . collection === "stream"
99- ? `/videos/${ page . data . url } /`
100- : `/${ page . id } /` ;
101-
102- // title can either be set directly in title or added as a meta.title property when we want something different for sidebar and SEO titles
103- let title ;
104-
105- if ( page . collection === "docs" ) {
106- const titleItem = page . data . head . find (
107- ( item ) => item . tag === "title" ,
108- ) ;
109- title = titleItem ? titleItem . content : page . data . title ;
110- } else {
111- title = page . data . title ;
112- }
251+ { filterPlacement === "left" && visibleResources . length === 0 && (
252+ < div className = "flex w-full flex-col justify-center rounded-md border bg-gray-50 py-6 text-center align-middle dark:border-gray-500 dark:bg-gray-800" >
253+ < span className = "text-lg font-bold!" > No resources found</ span >
254+ < p >
255+ Try a different search term, or broaden your search by removing
256+ filters.
257+ </ p >
258+ </ div >
259+ ) }
260+
261+ < div
262+ className = { `grid ${ columns === 2 ? "md:grid-cols-2" : "md:grid-cols-3" } grid-cols-1 gap-4` }
263+ >
264+ { visibleResources . map ( ( page ) => {
265+ const href =
266+ page . collection === "stream"
267+ ? `/videos/${ page . data . url } /`
268+ : `/${ page . id } /` ;
113269
114- return (
115- < a
116- key = { page . id }
117- href = { href }
118- className = "flex flex-col gap-2 rounded-sm border border-solid border-gray-200 p-6 text-black no-underline hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
119- >
120- < p className = "decoration-accent underline decoration-2 underline-offset-4" >
121- { title }
122- </ p >
123- { showDescriptions && (
124- < span className = "line-clamp-3" title = { page . data . description } >
125- { page . data . description }
126- </ span >
127- ) }
128- { showLastUpdated && "reviewed" in page . data && (
129- < span className = "line-clamp-3" title = { page . data . description } >
130- Updated { timeAgo ( page . data . reviewed ) }
131- </ span >
132- ) }
133- </ a >
134- ) ;
135- } ) }
270+ // title can either be set directly in title or added as a meta.title property when we want something different for sidebar and SEO titles
271+ let title ;
272+
273+ if ( page . collection === "docs" ) {
274+ const titleItem = page . data . head . find (
275+ ( item ) => item . tag === "title" ,
276+ ) ;
277+ title = titleItem ? titleItem . content : page . data . title ;
278+ } else {
279+ title = page . data . title ;
280+ }
281+
282+ return (
283+ < a
284+ key = { page . id }
285+ href = { href }
286+ className = "flex flex-col gap-2 rounded-sm border border-solid border-gray-200 p-6 text-black no-underline hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800"
287+ >
288+ < p className = "decoration-accent underline decoration-2 underline-offset-4" >
289+ { title }
290+ </ p >
291+ { showDescriptions && (
292+ < span className = "line-clamp-3" title = { page . data . description } >
293+ { page . data . description }
294+ </ span >
295+ ) }
296+ { showLastUpdated && "reviewed" in page . data && (
297+ < span className = "line-clamp-3" title = { page . data . description } >
298+ Updated { timeAgo ( page . data . reviewed ) }
299+ </ span >
300+ ) }
301+ </ a >
302+ ) ;
303+ } ) }
304+ </ div >
136305 </ div >
137306 </ div >
138307 ) ;
0 commit comments