1- import React , { useState } from 'react' ;
1+ import React , { useState , useEffect , useMemo , useCallback } from 'react' ;
22import { Search , Filter , Grid , List } from 'lucide-react' ;
33import { ChallengeCard , Challenge } from './ChallengeCard' ;
44
@@ -10,69 +10,75 @@ const mockChallenges: Challenge[] = [
1010 {
1111 id : '1' ,
1212 title : 'Design a Mobile App Onboarding Flow' ,
13- description : 'Create a 3-screen onboarding experience for a fitness tracking app. Focus on user engagement and clear value proposition.' ,
13+ description :
14+ 'Create a 3-screen onboarding experience for a fitness tracking app. Focus on user engagement and clear value proposition.' ,
1415 domain : 'Design' ,
1516 difficulty : 'Medium' ,
1617 estimatedTime : '2-4 hours' ,
1718 participants : 234 ,
1819 rating : 4.7 ,
19- tags : [ 'UI/UX' , 'Mobile' , 'Figma' , 'Onboarding' ]
20+ tags : [ 'UI/UX' , 'Mobile' , 'Figma' , 'Onboarding' ] ,
2021 } ,
2122 {
2223 id : '2' ,
2324 title : 'Build a Real-Time Chat Application' ,
24- description : 'Develop a chat app using WebSockets. Include user authentication, message history, and typing indicators.' ,
25+ description :
26+ 'Develop a chat app using WebSockets. Include user authentication, message history, and typing indicators.' ,
2527 domain : 'Development' ,
2628 difficulty : 'Hard' ,
2729 estimatedTime : '6-8 hours' ,
2830 participants : 156 ,
2931 rating : 4.8 ,
30- tags : [ 'React' , 'Node.js' , 'WebSocket' , 'Real-time' ]
32+ tags : [ 'React' , 'Node.js' , 'WebSocket' , 'Real-time' ] ,
3133 } ,
3234 {
3335 id : '3' ,
3436 title : 'Write a Technical Blog Post' ,
35- description : 'Explain a complex programming concept to beginners. Make it engaging with examples and practical applications.' ,
37+ description :
38+ 'Explain a complex programming concept to beginners. Make it engaging with examples and practical applications.' ,
3639 domain : 'Writing' ,
3740 difficulty : 'Easy' ,
3841 estimatedTime : '1-2 hours' ,
3942 participants : 89 ,
4043 rating : 4.5 ,
41- tags : [ 'Technical Writing' , 'Blog' , 'Tutorial' ]
44+ tags : [ 'Technical Writing' , 'Blog' , 'Tutorial' ] ,
4245 } ,
4346 {
4447 id : '4' ,
4548 title : 'Analyze E-commerce Sales Data' ,
46- description : 'Use Python to analyze a real e-commerce dataset. Create visualizations and provide actionable insights.' ,
49+ description :
50+ 'Use Python to analyze a real e-commerce dataset. Create visualizations and provide actionable insights.' ,
4751 domain : 'Data' ,
4852 difficulty : 'Medium' ,
4953 estimatedTime : '3-5 hours' ,
5054 participants : 67 ,
5155 rating : 4.6 ,
52- tags : [ 'Python' , 'Pandas' , 'Visualization' , 'Analysis' ]
56+ tags : [ 'Python' , 'Pandas' , 'Visualization' , 'Analysis' ] ,
5357 } ,
5458 {
5559 id : '5' ,
5660 title : 'Create a Brand Identity System' ,
57- description : 'Design a complete brand identity for a sustainable fashion startup, including logo, colors, and guidelines.' ,
61+ description :
62+ 'Design a complete brand identity for a sustainable fashion startup, including logo, colors, and guidelines.' ,
5863 domain : 'Design' ,
5964 difficulty : 'Hard' ,
6065 estimatedTime : '8-12 hours' ,
6166 participants : 123 ,
6267 rating : 4.9 ,
63- tags : [ 'Branding' , 'Logo Design' , 'Brand Guidelines' ]
68+ tags : [ 'Branding' , 'Logo Design' , 'Brand Guidelines' ] ,
6469 } ,
6570 {
6671 id : '6' ,
6772 title : 'Build a REST API with Authentication' ,
68- description : 'Create a secure REST API for a task management system. Include JWT authentication and proper error handling.' ,
73+ description :
74+ 'Create a secure REST API for a task management system. Include JWT authentication and proper error handling.' ,
6975 domain : 'Development' ,
7076 difficulty : 'Medium' ,
7177 estimatedTime : '4-6 hours' ,
7278 participants : 198 ,
7379 rating : 4.4 ,
74- tags : [ 'API' , 'Node.js' , 'JWT' , 'Authentication' ]
75- }
80+ tags : [ 'API' , 'Node.js' , 'JWT' , 'Authentication' ] ,
81+ } ,
7682] ;
7783
7884export const ChallengeBoard : React . FC < ChallengeBoardProps > = ( { onChallengeSelect } ) => {
@@ -82,101 +88,141 @@ export const ChallengeBoard: React.FC<ChallengeBoardProps> = ({ onChallengeSelec
8288 const [ viewMode , setViewMode ] = useState < 'grid' | 'list' > ( 'grid' ) ;
8389 const [ bookmarkedChallenges , setBookmarkedChallenges ] = useState < Set < string > > ( new Set ( ) ) ;
8490
91+ // Load bookmarks safely
92+ useEffect ( ( ) => {
93+ try {
94+ const saved = localStorage . getItem ( 'bookmarkedChallenges' ) ;
95+ if ( saved ) {
96+ const parsed : unknown = JSON . parse ( saved ) ;
97+ if ( Array . isArray ( parsed ) ) {
98+ setBookmarkedChallenges ( new Set ( parsed as string [ ] ) ) ;
99+ }
100+ }
101+ } catch ( err ) {
102+ // corrupted localStorage; ignore and reset
103+ console . warn ( 'Failed to parse bookmarkedChallenges from localStorage:' , err ) ;
104+ localStorage . removeItem ( 'bookmarkedChallenges' ) ;
105+ setBookmarkedChallenges ( new Set ( ) ) ;
106+ }
107+ } , [ ] ) ;
108+
109+ // Persist bookmarks
110+ useEffect ( ( ) => {
111+ try {
112+ localStorage . setItem ( 'bookmarkedChallenges' , JSON . stringify ( Array . from ( bookmarkedChallenges ) ) ) ;
113+ } catch ( err ) {
114+ console . warn ( 'Failed to save bookmarkedChallenges to localStorage:' , err ) ;
115+ }
116+ } , [ bookmarkedChallenges ] ) ;
117+
85118 const domains = [ 'All' , 'Design' , 'Development' , 'Writing' , 'Data' , 'Creative' ] ;
86119 const difficulties = [ 'All' , 'Easy' , 'Medium' , 'Hard' ] ;
87120
88- const filteredChallenges = mockChallenges . filter ( challenge => {
89- const matchesSearch = challenge . title . toLowerCase ( ) . includes ( searchTerm . toLowerCase ( ) ) ||
90- challenge . description . toLowerCase ( ) . includes ( searchTerm . toLowerCase ( ) ) ||
91- challenge . tags . some ( tag => tag . toLowerCase ( ) . includes ( searchTerm . toLowerCase ( ) ) ) ;
92-
93- const matchesDomain = selectedDomain === 'All' || challenge . domain === selectedDomain ;
94- const matchesDifficulty = selectedDifficulty === 'All' || challenge . difficulty === selectedDifficulty ;
95-
96- return matchesSearch && matchesDomain && matchesDifficulty ;
97- } ) ;
98-
99- const handleBookmark = ( challengeId : string ) => {
100- const newBookmarked = new Set ( bookmarkedChallenges ) ;
101- if ( newBookmarked . has ( challengeId ) ) {
102- newBookmarked . delete ( challengeId ) ;
103- } else {
104- newBookmarked . add ( challengeId ) ;
105- }
106- setBookmarkedChallenges ( newBookmarked ) ;
107- } ;
121+ // Filter (memoized)
122+ const filteredChallenges = useMemo ( ( ) => {
123+ const q = searchTerm . trim ( ) . toLowerCase ( ) ;
124+ return mockChallenges . filter ( ( challenge ) => {
125+ const matchesSearch =
126+ ! q ||
127+ challenge . title . toLowerCase ( ) . includes ( q ) ||
128+ challenge . description . toLowerCase ( ) . includes ( q ) ||
129+ challenge . tags . some ( ( tag ) => tag . toLowerCase ( ) . includes ( q ) ) ;
108130
109- const challengesWithBookmarks = filteredChallenges . map ( challenge => ( {
110- ...challenge ,
111- isBookmarked : bookmarkedChallenges . has ( challenge . id )
112- } ) ) ;
131+ const matchesDomain = selectedDomain === 'All' || challenge . domain === selectedDomain ;
132+ const matchesDifficulty = selectedDifficulty === 'All' || challenge . difficulty === selectedDifficulty ;
133+
134+ return matchesSearch && matchesDomain && matchesDifficulty ;
135+ } ) ;
136+ } , [ searchTerm , selectedDomain , selectedDifficulty ] ) ;
137+
138+ // Bookmark handler (stable identity)
139+ const handleBookmark = useCallback ( ( challengeId : string ) => {
140+ setBookmarkedChallenges ( ( prev ) => {
141+ const next = new Set ( prev ) ;
142+ if ( next . has ( challengeId ) ) next . delete ( challengeId ) ;
143+ else next . add ( challengeId ) ;
144+ return next ;
145+ } ) ;
146+ } , [ ] ) ;
147+
148+ const challengesWithBookmarks = useMemo (
149+ ( ) =>
150+ filteredChallenges . map ( ( challenge ) => ( {
151+ ...challenge ,
152+ isBookmarked : bookmarkedChallenges . has ( challenge . id ) ,
153+ } ) ) ,
154+ [ filteredChallenges , bookmarkedChallenges ]
155+ ) ;
113156
114157 return (
115- < div className = "min-h-screen bg-gray-50 dark:bg-gray-900 pt -8 transition-colors duration-300" >
158+ < div className = "min-h-screen bg-gray-50 dark:bg-gray-900 py -8 transition-colors duration-300" >
116159 < div className = "max-w-7xl mx-auto px-4 sm:px-6 lg:px-8" >
117160 { /* Header */ }
118161 < div className = "mb-8" >
119- < h1 className = "text-3xl font-bold text-gray-900 dark:text-white mb-2" > Challenge Board</ h1 >
120- < p className = "text-gray-600 dark:text-gray-400" > Discover real-world challenges to level up your skills</ p >
162+ < h1 className = "text-4xl font-extrabold text-gray-900 dark:text-white mb-2" > Challenge Board</ h1 >
163+ < p className = "text-gray-600 dark:text-gray-400 text-lg" > Explore real-world challenges and level up your skills</ p >
121164 </ div >
122165
123166 { /* Filters */ }
124- < div className = "bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6 mb-8 transition-colors duration-300" >
167+ < div className = "bg-white dark:bg-gray-800 rounded-xl shadow-lg border border-gray-200 dark:border-gray-700 p-6 mb-8 transition-colors duration-300" >
125168 < div className = "flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4" >
126169 { /* Search */ }
127170 < div className = "relative flex-1 max-w-md" >
128- < Search className = "absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400 dark:text-gray-500" />
171+ < Search className = "absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400 dark:text-gray-500" />
129172 < input
130173 type = "text"
174+ aria-label = "Search challenges"
131175 placeholder = "Search challenges..."
132176 value = { searchTerm }
133177 onChange = { ( e ) => setSearchTerm ( e . target . value ) }
134- className = "w-full pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
178+ className = "w-full pl-10 pr-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white transition-shadow duration-200 "
135179 />
136180 </ div >
137181
138182 { /* Filters */ }
139183 < div className = "flex items-center space-x-4" >
140184 < select
185+ aria-label = "Filter by domain"
141186 value = { selectedDomain }
142187 onChange = { ( e ) => setSelectedDomain ( e . target . value ) }
143- className = "px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
188+ className = "px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white transition-colors duration-200 "
144189 >
145- { domains . map ( domain => (
146- < option key = { domain } value = { domain } > { domain } Domain</ option >
190+ { domains . map ( ( domain ) => (
191+ < option key = { domain } value = { domain } >
192+ { domain } Domain
193+ </ option >
147194 ) ) }
148195 </ select >
149196
150197 < select
198+ aria-label = "Filter by difficulty"
151199 value = { selectedDifficulty }
152200 onChange = { ( e ) => setSelectedDifficulty ( e . target . value ) }
153- className = "px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
201+ className = "px-3 py-2 rounded-lg border border-gray-300 dark:border-gray-600 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent bg-white dark:bg-gray-700 text-gray-900 dark:text-white transition-colors duration-200 "
154202 >
155- { difficulties . map ( difficulty => (
203+ { difficulties . map ( ( difficulty ) => (
156204 < option key = { difficulty } value = { difficulty } >
157205 { difficulty === 'All' ? 'All Levels' : difficulty }
158206 </ option >
159207 ) ) }
160208 </ select >
161209
162- < div className = "flex border border-gray-300 dark:border-gray-600 rounded-lg" >
210+ < div className = "flex border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden" role = "tablist" aria-label = "View mode ">
163211 < button
212+ type = "button"
213+ aria-pressed = { viewMode === 'grid' }
164214 onClick = { ( ) => setViewMode ( 'grid' ) }
165- className = { `p-2 transition-colors duration-200 ${
166- viewMode === 'grid'
167- ? 'bg-purple-100 text-purple-600 dark:bg-purple-900 dark:text-purple-400'
168- : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
169- } `}
215+ className = { `p-2 transition-colors duration-200 ${ viewMode === 'grid' ? 'bg-purple-100 text-purple-600 dark:bg-purple-900 dark:text-purple-400' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700' } ` }
216+ title = "Grid view"
170217 >
171218 < Grid className = "h-4 w-4" />
172219 </ button >
173220 < button
221+ type = "button"
222+ aria-pressed = { viewMode === 'list' }
174223 onClick = { ( ) => setViewMode ( 'list' ) }
175- className = { `p-2 transition-colors duration-200 ${
176- viewMode === 'list'
177- ? 'bg-purple-100 text-purple-600 dark:bg-purple-900 dark:text-purple-400'
178- : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
179- } `}
224+ className = { `p-2 transition-colors duration-200 ${ viewMode === 'list' ? 'bg-purple-100 text-purple-600 dark:bg-purple-900 dark:text-purple-400' : 'text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700' } ` }
225+ title = "List view"
180226 >
181227 < List className = "h-4 w-4" />
182228 </ button >
@@ -193,22 +239,13 @@ export const ChallengeBoard: React.FC<ChallengeBoardProps> = ({ onChallengeSelec
193239 </ div >
194240
195241 { /* Challenge Grid */ }
196- < div className = { `grid gap-6 ${
197- viewMode === 'grid'
198- ? 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3'
199- : 'grid-cols-1'
200- } `} >
242+ < div className = { `grid gap-6 transition-all duration-300 ${ viewMode === 'grid' ? 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3' : 'grid-cols-1' } ` } >
201243 { challengesWithBookmarks . length > 0 ? (
202244 challengesWithBookmarks . map ( ( challenge ) => (
203- < ChallengeCard
204- key = { challenge . id }
205- challenge = { challenge }
206- onSelect = { onChallengeSelect }
207- onBookmark = { handleBookmark }
208- />
245+ < ChallengeCard key = { challenge . id } challenge = { challenge } onSelect = { onChallengeSelect } onBookmark = { handleBookmark } />
209246 ) )
210247 ) : (
211- < div className = "text-center py-12 col-span-full" >
248+ < div className = "text-center py-16 col-span-full" >
212249 < Filter className = "h-12 w-12 text-gray-400 dark:text-gray-600 mx-auto mb-4" />
213250 < h3 className = "text-lg font-medium text-gray-900 dark:text-white mb-2" > No challenges found</ h3 >
214251 < p className = "text-gray-600 dark:text-gray-400" > Try adjusting your search or filters</ p >
0 commit comments