1111// ============================================================
1212// Detect which page we are on
1313// ============================================================
14- var isIndexPage = ! ! document . getElementById ( "recommend-form" ) ;
14+ // !! trick turns the DOM result into a simple true/false
15+ var isIndexPage = ! ! document . getElementById ( "recommend-form" ) ;
16+ // PROJECT_ID is set by the server only on detail pages, so if it's missing we're elsewhere
1517var isDetailPage = typeof PROJECT_ID !== "undefined" ;
1618
1719
@@ -22,11 +24,14 @@ var isDetailPage = typeof PROJECT_ID !== "undefined";
2224 var toggle = document . getElementById ( "nav-mobile-toggle" ) ;
2325 var menu = document . getElementById ( "nav-mobile-menu" ) ;
2426
27+ // Nothing to do if the nav isn't on this page, just bail out
2528 if ( ! toggle || ! menu ) return ;
2629
2730 toggle . addEventListener ( "click" , function ( ) {
31+ // classList.toggle returns true if class was added, false if removed
2832 var isOpen = menu . classList . toggle ( "open" ) ;
2933 toggle . classList . toggle ( "open" , isOpen ) ;
34+ // Keep aria-expanded in sync so screen readers know if menu is open or closed
3035 toggle . setAttribute ( "aria-expanded" , isOpen ) ;
3136 } ) ;
3237
@@ -116,13 +121,11 @@ if (isIndexPage) {
116121 var matchedSkill = availableSkills . find ( function ( skill ) {
117122 return normalizeSkill ( skill ) === normalizedSkill ;
118123 } ) ;
119-
120124 return matchedSkill || rawSkill . trim ( ) ;
121125 }
122126
123127 function getFilteredSkills ( query ) {
124128 var normalizedQuery = normalizeSkill ( query ) ;
125-
126129 return availableSkills . filter ( function ( skill ) {
127130 return normalizeSkill ( skill ) . includes ( normalizedQuery ) && ! isSkillSelected ( skill ) ;
128131 } ) . slice ( 0 , 8 ) ;
@@ -134,7 +137,6 @@ if (isIndexPage) {
134137
135138 function renderActiveSuggestion ( ) {
136139 if ( ! suggestionsDiv ) return ;
137-
138140 suggestionsDiv . querySelectorAll ( ".suggestion-item" ) . forEach ( function ( item , index ) {
139141 var isActive = index === activeSuggestionIndex ;
140142 item . classList . toggle ( "suggestion-item--active" , isActive ) ;
@@ -145,12 +147,10 @@ if (isIndexPage) {
145147 function hideSuggestions ( ) {
146148 visibleSuggestions = [ ] ;
147149 activeSuggestionIndex = - 1 ;
148-
149150 if ( suggestionsDiv ) {
150151 suggestionsDiv . style . display = "none" ;
151152 suggestionsDiv . innerHTML = "" ;
152153 }
153-
154154 syncSuggestionsA11yState ( ) ;
155155 }
156156
@@ -163,15 +163,12 @@ if (isIndexPage) {
163163
164164 function displaySuggestions ( items ) {
165165 if ( ! suggestionsDiv ) return ;
166-
167166 visibleSuggestions = items ;
168167 activeSuggestionIndex = - 1 ;
169-
170168 if ( items . length === 0 ) {
171169 hideSuggestions ( ) ;
172170 return ;
173171 }
174-
175172 suggestionsDiv . innerHTML = "" ;
176173 items . forEach ( function ( skill , index ) {
177174 var item = document . createElement ( "div" ) ;
@@ -197,7 +194,6 @@ if (isIndexPage) {
197194
198195 suggestionsDiv . appendChild ( item ) ;
199196 } ) ;
200-
201197 suggestionsDiv . style . display = "block" ;
202198 syncSuggestionsA11yState ( ) ;
203199 }
@@ -216,9 +212,7 @@ if (isIndexPage) {
216212 if ( visibleSuggestions . length === 0 ) {
217213 displaySuggestions ( getFilteredSkills ( skillsTextInput . value ) ) ;
218214 }
219-
220215 if ( visibleSuggestions . length === 0 ) return ;
221-
222216 evt . preventDefault ( ) ;
223217 if ( evt . key === "ArrowDown" ) {
224218 activeSuggestionIndex = ( activeSuggestionIndex + 1 ) % visibleSuggestions . length ;
@@ -227,7 +221,6 @@ if (isIndexPage) {
227221 ? visibleSuggestions . length - 1
228222 : activeSuggestionIndex - 1 ;
229223 }
230-
231224 renderActiveSuggestion ( ) ;
232225 return ;
233226 }
@@ -239,30 +232,25 @@ if (isIndexPage) {
239232
240233 if ( evt . key === "Enter" ) {
241234 evt . preventDefault ( ) ;
242-
243235 if ( activeSuggestionIndex >= 0 && visibleSuggestions [ activeSuggestionIndex ] ) {
244236 selectSuggestion ( visibleSuggestions [ activeSuggestionIndex ] ) ;
245237 return ;
246238 }
247-
248239 if ( skillsTextInput . value . trim ( ) ) {
249240 addSkill ( skillsTextInput . value ) ;
250241 skillsTextInput . value = "" ;
251242 }
252-
253243 hideSuggestions ( ) ;
254244 }
255245 } ) ;
256246
257247 // Show suggestions on input
258248 skillsTextInput . addEventListener ( "input" , function ( evt ) {
259249 var typedValue = evt . target . value . trim ( ) ;
260-
261250 if ( typedValue . length === 0 ) {
262251 hideSuggestions ( ) ;
263252 return ;
264253 }
265-
266254 displaySuggestions ( getFilteredSkills ( typedValue ) ) ;
267255 } ) ;
268256
@@ -299,7 +287,9 @@ if (isIndexPage) {
299287 } ) ;
300288
301289 function addSkill ( rawSkill ) {
290+ // Clean up any extra spaces and match to canonical skill name
302291 var skill = getCanonicalSkill ( rawSkill ) ;
292+ // Nothing to add if string is empty after trimming
303293 if ( ! skill ) return ;
304294
305295 // Block duplicate entries (case-insensitive)
@@ -309,20 +299,22 @@ if (isIndexPage) {
309299 renderSelectedChips ( ) ;
310300 syncSkillsHiddenInput ( ) ;
311301 updateQuickPickState ( ) ;
302+ // Once a skill is added, remove the "please add a skill" error if it was showing
312303 clearFieldError ( "skills-error" ) ;
313304 }
314305
315306 function removeSkill ( skill ) {
307+ // Rebuild the array without the skill that was just removed
316308 selectedSkills = selectedSkills . filter ( function ( selectedSkill ) {
317309 return normalizeSkill ( selectedSkill ) !== normalizeSkill ( skill ) ;
318310 } ) ;
319-
320311 renderSelectedChips ( ) ;
321312 syncSkillsHiddenInput ( ) ;
322313 updateQuickPickState ( ) ;
323314 }
324315
325316 function renderSelectedChips ( ) {
317+ // Wipe out old chips first so we don't end up with duplicates in the UI
326318 chipsSelectedEl . innerHTML = "" ;
327319 selectedSkills . forEach ( function ( skill ) {
328320 var chipEl = document . createElement ( "span" ) ;
@@ -336,6 +328,7 @@ if (isIndexPage) {
336328 removeBtn . innerHTML = "×" ;
337329 removeBtn . setAttribute ( "aria-label" , "Remove " + skill ) ;
338330 removeBtn . addEventListener ( "click" , function ( e ) {
331+ // Stop click from bubbling up to the chip wrap's click listener
339332 e . stopPropagation ( ) ;
340333 removeSkill ( skill ) ;
341334 } ) ;
@@ -347,6 +340,7 @@ if (isIndexPage) {
347340
348341 function syncSkillsHiddenInput ( ) {
349342 // Keep the hidden <input> in sync for form serialisation
343+ // The API expects a comma-separated string, so join the array that way
350344 skillsHidden . value = selectedSkills . join ( ", " ) ;
351345 }
352346
@@ -376,6 +370,7 @@ if (isIndexPage) {
376370 function validateForm ( ) {
377371 var valid = true ;
378372
373+ // Check both the array and the hidden input since skills can come from either source
379374 if ( selectedSkills . length === 0 && ! skillsHidden . value . trim ( ) ) {
380375 showFieldError ( "skills-error" , "Please add at least one skill." ) ;
381376 valid = false ;
@@ -416,10 +411,11 @@ if (isIndexPage) {
416411 setLoadingState ( true ) ;
417412
418413 var payload = {
419- skills : skillsHidden . value . trim ( ) || skillsTextInput . value . trim ( ) ,
420- level : document . getElementById ( "level" ) . value ,
414+ // Prefer the hidden input value; fall back to raw text box if hidden input is empty
415+ skills : skillsHidden . value . trim ( ) || skillsTextInput . value . trim ( ) ,
416+ level : document . getElementById ( "level" ) . value ,
421417 interest : document . getElementById ( "interest" ) . value ,
422- time : document . getElementById ( "time" ) . value
418+ time : document . getElementById ( "time" ) . value
423419 } ;
424420
425421 fetch ( "/api/recommend" , {
@@ -430,13 +426,11 @@ if (isIndexPage) {
430426 . then ( function ( res ) { return res . json ( ) ; } )
431427 . then ( function ( data ) {
432428 setLoadingState ( false ) ;
433-
434429 if ( data . error ) {
435430 var generalErr = document . getElementById ( "form-error-general" ) ;
436431 if ( generalErr ) generalErr . textContent = data . error ;
437432 return ;
438433 }
439-
440434 renderResults ( data . projects || [ ] , data . message ) ;
441435 } )
442436 . catch ( function ( err ) {
@@ -448,6 +442,7 @@ if (isIndexPage) {
448442 } ) ;
449443
450444 function setLoadingState ( isLoading ) {
445+ // Disable the button so the user can't accidentally submit twice
451446 submitBtn . disabled = isLoading ;
452447 btnLabel . style . display = isLoading ? "none" : "inline" ;
453448 btnLoading . style . display = isLoading ? "inline" : "none" ;
@@ -458,6 +453,7 @@ if (isIndexPage) {
458453 resultsLoadingEl . style . display = "block" ;
459454 resultsGrid . style . display = "none" ;
460455 resultsEmptyEl . style . display = "none" ;
456+ // Scroll down so the user can see the spinner without manually scrolling
461457 resultsSection . scrollIntoView ( { behavior : "smooth" } ) ;
462458 } else {
463459 resultsLoadingEl . style . display = "none" ;
@@ -473,6 +469,7 @@ if (isIndexPage) {
473469 function renderResults ( projects , message ) {
474470 resultsSection . style . display = "block" ;
475471 resultsLoadingEl . style . display = "none" ;
472+ // Clear out any cards from a previous search before showing new ones
476473 resultsGrid . innerHTML = "" ;
477474
478475 if ( ! projects || projects . length === 0 ) {
@@ -505,6 +502,7 @@ if (isIndexPage) {
505502 // Description (truncated for visual consistency)
506503 var desc = document . createElement ( "p" ) ;
507504 desc . className = "project-card-desc" ;
505+ // Cut description to 120 chars so all cards stay the same height
508506 desc . textContent = truncate ( project . description , 120 ) ;
509507
510508 // Tags row
@@ -517,6 +515,7 @@ if (isIndexPage) {
517515 } ) ;
518516
519517 // Level tag (colour-coded via CSS class)
518+ // Lowercase so it matches the CSS class names like "level beginner", "level advanced"
520519 var levelClass = "level " + ( project . level || "" ) . toLowerCase ( ) ;
521520 tagsRow . appendChild ( createTag ( project . level , levelClass ) ) ;
522521
@@ -544,13 +543,16 @@ if (isIndexPage) {
544543
545544 function createTag ( text , type ) {
546545 var span = document . createElement ( "span" ) ;
546+ // The type becomes a BEM modifier so CSS can style each tag differently
547547 span . className = "project-tag project-tag--" + type ;
548548 span . textContent = text ;
549549 return span ;
550550 }
551551
552552 function truncate ( text , maxLength ) {
553+ // Safety check — just return empty string if text is missing
553554 if ( ! text ) return "" ;
555+ // Only add "..." if the text is actually longer than the limit
554556 return text . length > maxLength ? text . slice ( 0 , maxLength ) + "..." : text ;
555557 }
556558
@@ -574,22 +576,27 @@ if (isDetailPage) {
574576 var codeFetched = false ;
575577
576578 function openCodePanel ( ) {
579+ // Panel element might not exist on every detail page, so check first
577580 if ( ! codePanel ) return ;
578581 codePanel . classList . add ( "active" ) ;
579582 if ( codePanelOverlay ) codePanelOverlay . classList . add ( "active" ) ;
583+ // Lock background scroll so the page doesn't scroll behind the panel
580584 document . body . style . overflow = "hidden" ;
581585
586+ // Only fetch the code on the first open, no need to re-fetch every time
582587 if ( ! codeFetched ) fetchStarterCode ( ) ;
583588 }
584589
585590 function closeCodePanel ( ) {
586591 if ( ! codePanel ) return ;
587592 codePanel . classList . remove ( "active" ) ;
588593 if ( codePanelOverlay ) codePanelOverlay . classList . remove ( "active" ) ;
594+ // Restore normal scrolling once the panel is closed
589595 document . body . style . overflow = "" ;
590596 }
591597
592598 function fetchStarterCode ( ) {
599+ // Show a loading message while we wait for the API response
593600 if ( codeContentEl ) codeContentEl . textContent = "Loading starter code..." ;
594601
595602 fetch ( "/project/" + PROJECT_ID + "/code" )
@@ -601,6 +608,7 @@ if (isDetailPage) {
601608 }
602609 if ( codePanelFilename ) codePanelFilename . textContent = data . filename ;
603610 if ( codeContentEl ) codeContentEl . textContent = data . code ;
611+ // Mark as fetched so we don't hit the API again on the next open
604612 codeFetched = true ;
605613 } )
606614 . catch ( function ( ) {
@@ -619,6 +627,7 @@ if (isDetailPage) {
619627 codePanelOverlay . addEventListener ( "click" , closeCodePanel ) ;
620628 }
621629
630+ // Let keyboard users close the panel with Escape — important for accessibility
622631 document . addEventListener ( "keydown" , function ( evt ) {
623632 if ( evt . key === "Escape" ) closeCodePanel ( ) ;
624633 } ) ;
@@ -642,6 +651,7 @@ if (isDetailPage) {
642651 if ( checkIcon ) checkIcon . style . display = "inline" ;
643652 if ( btnLabel ) btnLabel . textContent = "Copied!" ;
644653 btnCopyCode . classList . add ( "copied" ) ;
654+ // Disable button so user can't spam click it while toast is showing
645655 btnCopyCode . disabled = true ;
646656
647657 // Show toast
@@ -650,6 +660,7 @@ if (isDetailPage) {
650660 }
651661
652662 // Auto-reset after 2.5 s
663+ // Clear any previous timeout first so timers don't stack up
653664 clearTimeout ( toastTimeout ) ;
654665 toastTimeout = setTimeout ( function ( ) {
655666 if ( copyIcon ) copyIcon . style . display = "inline" ;
@@ -664,6 +675,7 @@ if (isDetailPage) {
664675 if ( btnCopyCode ) {
665676 btnCopyCode . addEventListener ( "click" , function ( ) {
666677 var code = codeContentEl ? codeContentEl . textContent : "" ;
678+ // Don't copy if the code hasn't loaded yet — just ignore the click
667679 if ( ! code || code === "Loading..." || code === "Loading starter code..." ) return ;
668680
669681 // Use Clipboard API with textarea fallback
@@ -678,14 +690,17 @@ if (isDetailPage) {
678690 }
679691
680692 function fallbackCopy ( text ) {
693+ // Some older browsers don't support navigator.clipboard, so we use a hidden textarea instead
681694 var ta = document . createElement ( "textarea" ) ;
682695 ta . value = text ;
696+ // Push it off-screen so it's not visible but can still be selected
683697 ta . style . cssText = "position:fixed;top:-9999px;left:-9999px;opacity:0" ;
684698 document . body . appendChild ( ta ) ;
685699 ta . focus ( ) ;
686700 ta . select ( ) ;
701+ // execCommand is old and deprecated but works as a last resort — fail silently if it doesn't
687702 try { document . execCommand ( "copy" ) ; showCopySuccess ( ) ; } catch ( e ) { /* silent fail */ }
688703 document . body . removeChild ( ta ) ;
689704 }
690705
691- } // end isDetailPage
706+ } // end isDetailPage
0 commit comments