@@ -34,6 +34,33 @@ const Portfolio = () => {
3434 const collaborativeProjects = useMemo ( ( ) => getCollaborativeProjects ( ) , [ ] ) ;
3535 const otherProjects = useMemo ( ( ) => getOtherProjects ( ) , [ ] ) ;
3636
37+ // Counts per filter — drives both the badge text and the "hide empty" rule.
38+ const counts = useMemo < Record < string , number > > ( ( ) => {
39+ const f = featuredProjects . length ;
40+ const c = communityProjects . length ;
41+ const x = collaborativeProjects . length ;
42+ const o = otherProjects . length ;
43+ return {
44+ Featured : f ,
45+ Community : c ,
46+ Collab : x ,
47+ Others : o ,
48+ All : f + c + x + o ,
49+ } ;
50+ } , [
51+ featuredProjects ,
52+ communityProjects ,
53+ collaborativeProjects ,
54+ otherProjects ,
55+ ] ) ;
56+
57+ // Skip categories with zero items so the filter bar never shows dead options.
58+ // "All" is kept even at zero so the bar still renders with a fallback.
59+ const visibleFilters = useMemo (
60+ ( ) => FILTERS . filter ( ( f ) => f === "All" || ( counts [ f ] ?? 0 ) > 0 ) ,
61+ [ counts ] ,
62+ ) ;
63+
3764 const filteredProjects = useMemo ( ( ) => {
3865 const featured = featuredProjects . map ( ( p ) => ( {
3966 ...p ,
@@ -84,40 +111,68 @@ const Portfolio = () => {
84111 } }
85112 variants = { fadeInUp }
86113 >
87- { FILTERS . map ( ( filter , idx ) => (
88- < motion . button
89- key = { filter }
90- onClick = { ( ) => handleFilterChange ( filter ) }
91- className = { activeFilter === filter ? "btn-primary" : "" }
92- style = {
93- activeFilter === filter
94- ? { }
95- : {
96- padding : "8px 20px" ,
97- borderRadius : 12 ,
98- fontSize : 14 ,
99- fontFamily : MONO_FONT ,
100- fontWeight : 500 ,
101- cursor : "pointer" ,
102- border : "1px solid rgba(255, 255, 255, 0.06)" ,
103- color : "#a5a5c0" ,
104- background : "rgba(255, 255, 255, 0.03)" ,
105- backdropFilter : "blur(8px)" ,
106- }
107- }
108- initial = { { opacity : 0 , y : 15 } }
109- animate = { { opacity : 1 , y : 0 } }
110- transition = { {
111- duration : 0.7 ,
112- ease : [ 0.4 , 0 , 0.2 , 1 ] ,
113- delay : 0.1 + idx * 0.08 ,
114- } }
115- whileHover = { { scale : 1.05 } }
116- whileTap = { { scale : 0.97 } }
117- >
118- { filter }
119- </ motion . button >
120- ) ) }
114+ { visibleFilters . map ( ( filter , idx ) => {
115+ const isActive = activeFilter === filter ;
116+ const count = counts [ filter ] ?? 0 ;
117+ return (
118+ < motion . button
119+ key = { filter }
120+ onClick = { ( ) => handleFilterChange ( filter ) }
121+ className = { isActive ? "btn-primary" : "" }
122+ style = {
123+ isActive
124+ ? { }
125+ : {
126+ padding : "8px 20px" ,
127+ borderRadius : 12 ,
128+ fontSize : 14 ,
129+ fontFamily : MONO_FONT ,
130+ fontWeight : 500 ,
131+ cursor : "pointer" ,
132+ border :
133+ "1px solid rgba(255, 255, 255, 0.06)" ,
134+ color : "#a5a5c0" ,
135+ background : "rgba(255, 255, 255, 0.03)" ,
136+ backdropFilter : "blur(8px)" ,
137+ display : "inline-flex" ,
138+ alignItems : "center" ,
139+ gap : 8 ,
140+ }
141+ }
142+ initial = { { opacity : 0 , y : 15 } }
143+ animate = { { opacity : 1 , y : 0 } }
144+ transition = { {
145+ duration : 0.7 ,
146+ ease : [ 0.4 , 0 , 0.2 , 1 ] ,
147+ delay : 0.1 + idx * 0.08 ,
148+ } }
149+ whileHover = { { scale : 1.05 } }
150+ whileTap = { { scale : 0.97 } }
151+ aria-label = { `${ filter } (${ count } project${ count === 1 ? "" : "s" } )` }
152+ >
153+ < span > { filter } </ span >
154+ < span
155+ aria-hidden = "true"
156+ style = { {
157+ fontFamily : MONO_FONT ,
158+ fontSize : 11 ,
159+ fontWeight : 600 ,
160+ opacity : 0.65 ,
161+ padding : "1px 6px" ,
162+ borderRadius : 6 ,
163+ background : isActive
164+ ? "rgba(0, 0, 0, 0.18)"
165+ : "rgba(255, 255, 255, 0.06)" ,
166+ // Use tabular digits so 1-digit and 2-digit counts
167+ // don't shift the button width during filter swaps.
168+ fontVariantNumeric : "tabular-nums" ,
169+ } }
170+ >
171+ { count }
172+ </ span >
173+ </ motion . button >
174+ ) ;
175+ } ) }
121176 </ motion . div >
122177
123178 { /* Vertical timeline */ }
0 commit comments