11import './App.css' ;
22import {
3+ ClearFilter ,
34 ContactForm ,
5+ Course ,
46 FloatingActionable ,
7+ Loader ,
58 MobileNavigation ,
69 Navigation ,
710 TagBar ,
811 TopBar ,
912} from './components' ;
10- // import { ParsePDF } from './components/ParsePDF';
13+ import { auth , db , provider } from './config' ;
14+ import { TAGS } from './data' ;
15+ import localCourseData from './data/coursedata.json' ;
16+ import { CourseDataType , CourseType } from './types' ;
17+ import { getCookieValue , getWidth } from './utils' ;
1118import firebase from 'firebase/compat/app' ;
12- import { FC , useCallback , useEffect } from 'react' ;
19+ import { FC , useCallback , useEffect , useMemo , useState } from 'react' ;
1320
14- interface AppProps {
15- user : firebase . User | null ;
16- classItems : JSX . Element ;
17- authLevel : number ;
18- }
21+ const COURSE_ID_REGEX = / [ A - Z ] { 3 } [ 0 - 9 ] { 3 } / g;
22+ const COURSE_ID_REGEX_SINGLE = / [ A - Z ] { 3 } [ 0 - 9 ] { 3 } / ;
1923
20- const App : FC < AppProps > = ( { user, classItems, authLevel } ) : JSX . Element => {
21- // Hacky workaround because something with React probably interferes with the default browser behavior
22- // Also, reactivates the highlight on the course element
24+ const getNumColumns = ( width : number ) : number => {
25+ if ( width > 1400 ) return 4 ;
26+ if ( width > 1100 ) return 3 ;
27+ if ( width > 800 ) return 2 ;
28+ return 1 ;
29+ } ;
30+
31+ const App : FC = ( ) : JSX . Element => {
32+ const [ courseData , setCourseData ] = useState < CourseDataType | null > ( null ) ;
33+ const [ user , setUser ] = useState < firebase . User | null > ( ( ) => {
34+ const cookie = getCookieValue ( 'user' ) ;
35+ if ( ! cookie ) return null ;
36+ try {
37+ return JSON . parse ( cookie ) ;
38+ } catch {
39+ return null ;
40+ }
41+ } ) ;
42+ const [ authLevel , setAuthLevel ] = useState ( 0 ) ;
43+ const [ searchQuery , setSearchQuery ] = useState ( '' ) ;
44+ const [ activeTags , setActiveTags ] = useState < string [ ] > ( [ ] ) ;
45+ const [ windowWidth , setWindowWidth ] = useState ( getWidth ( ) ) ;
46+
47+ // Fetch course data from Firebase, fall back to local data
48+ useEffect ( ( ) => {
49+ db . ref ( )
50+ . get ( )
51+ . then ( ( snapshot ) => {
52+ setCourseData (
53+ snapshot . exists ( ) ? snapshot . val ( ) . courses : localCourseData
54+ ) ;
55+ } )
56+ . catch ( ( ) => setCourseData ( localCourseData ) ) ;
57+ } , [ ] ) ;
58+
59+ // Fetch auth level for the signed-in user
60+ useEffect ( ( ) => {
61+ if ( ! user ) return ;
62+ db . ref ( )
63+ . get ( )
64+ . then ( ( snapshot ) => {
65+ if ( ! snapshot . exists ( ) ) return ;
66+ const users : { [ key : string ] : { email : string ; level : number } } =
67+ snapshot . val ( ) . users ;
68+ Object . values ( users ) . forEach ( ( { email, level } ) => {
69+ if ( user . email === email ) setAuthLevel ( level ) ;
70+ } ) ;
71+ } ) ;
72+ } , [ user ] ) ;
73+
74+ // Window resize listener for responsive column layout
75+ useEffect ( ( ) => {
76+ const handleResize = ( ) => setWindowWidth ( getWidth ( ) ) ;
77+ window . addEventListener ( 'resize' , handleResize ) ;
78+ return ( ) => window . removeEventListener ( 'resize' , handleResize ) ;
79+ } , [ ] ) ;
80+
81+ // Scroll listener: show/hide desktop back-to-top button and mobile nav
82+ useEffect ( ( ) => {
83+ let prevScrollPos = window . scrollY ;
84+ const handleScroll = ( ) => {
85+ const topButton = document . getElementById ( 'to-top' ) ;
86+ if ( topButton ) {
87+ const visible = window . scrollY >= 80 && windowWidth >= 525 ;
88+ topButton . style . visibility = visible ? 'visible' : 'hidden' ;
89+ topButton . style . opacity = visible ? '1' : '0' ;
90+ }
91+ if ( windowWidth < 525 ) {
92+ const nav = document . getElementById ( 'mobile-nav' ) ;
93+ if ( nav ) {
94+ nav . style . bottom =
95+ window . scrollY < prevScrollPos ? '0' : `-${ nav . offsetHeight } px` ;
96+ }
97+ }
98+ prevScrollPos = window . scrollY ;
99+ } ;
100+ window . addEventListener ( 'scroll' , handleScroll ) ;
101+ return ( ) => window . removeEventListener ( 'scroll' , handleScroll ) ;
102+ } , [ windowWidth ] ) ;
103+
104+ // Re-trigger hash jump after initial course data loads
105+ // (React rendering can interfere with native hash navigation)
23106 useEffect ( ( ) => {
24107 const jumpId = window . location . hash ;
25108 window . location . hash = '' ;
26109 window . location . hash = jumpId ;
27110 } , [ ] ) ;
28111
112+ const courseIDtoNameMap = useMemo ( ( ) => {
113+ const map = new Map < string , string > ( ) ;
114+ for ( const courseName in courseData ) {
115+ courseData ! [ courseName ] . courseid . match ( COURSE_ID_REGEX ) ?. forEach ( ( id ) => {
116+ map . set ( id , courseName ) ;
117+ } ) ;
118+ }
119+ return map ;
120+ } , [ courseData ] ) ;
121+
122+ const courseIDtoCourse = useCallback (
123+ ( courseID : string ) : CourseType => {
124+ const id = courseID . match ( COURSE_ID_REGEX_SINGLE ) ?. [ 0 ] ?? '' ;
125+ return (
126+ courseData ?. [ courseIDtoNameMap . get ( id ) ?? '' ] ??
127+ ( '' as unknown as CourseType )
128+ ) ;
129+ } ,
130+ [ courseData , courseIDtoNameMap ]
131+ ) ;
132+
133+ const filteredCourseItems = useMemo ( ( ) => {
134+ if ( ! courseData ) return [ ] ;
135+ const key = encodeURIComponent (
136+ searchQuery . toLowerCase ( ) . replaceAll ( ' ' , '-' )
137+ ) . replace ( / \. / g, '%2E' ) ;
138+ return Object . keys ( courseData )
139+ . filter ( ( name ) => {
140+ const matchesSearch = name . search ( key ) !== - 1 ;
141+ const matchesTags =
142+ activeTags . length === 0 ||
143+ activeTags . some ( ( tag ) =>
144+ courseData [ name ] . tags ?. includes (
145+ TAGS [ TAGS . findIndex ( ( t ) => t . id === tag ) ] . label
146+ )
147+ ) ;
148+ return matchesSearch && matchesTags ;
149+ } )
150+ . map ( ( name ) => (
151+ < Course
152+ key = { name }
153+ authLevel = { authLevel }
154+ course = { courseData [ name ] }
155+ jumpId = { courseData [ name ] . courseid }
156+ courseIDtoCourse = { courseIDtoCourse }
157+ />
158+ ) ) ;
159+ } , [ courseData , searchQuery , activeTags , authLevel , courseIDtoCourse ] ) ;
160+
161+ const handleTagToggle = useCallback ( ( id : string ) => {
162+ setActiveTags ( ( prev ) =>
163+ prev . includes ( id ) ? prev . filter ( ( t ) => t !== id ) : [ ...prev , id ]
164+ ) ;
165+ } , [ ] ) ;
166+
167+ const handleClearFilters = useCallback ( ( ) => {
168+ setSearchQuery ( '' ) ;
169+ setActiveTags ( [ ] ) ;
170+ } , [ ] ) ;
171+
172+ const handleSignIn = useCallback ( ( ) => {
173+ if ( user ) {
174+ auth . signOut ( ) . then ( ( ) => {
175+ setUser ( null ) ;
176+ setAuthLevel ( 0 ) ;
177+ document . cookie =
178+ 'user=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;' ;
179+ } ) ;
180+ } else {
181+ auth . signInWithPopup ( provider ) . then ( ( result ) => {
182+ const signedInUser = result . user ;
183+ if ( ! signedInUser ) return ;
184+ setUser ( signedInUser ) ;
185+ document . cookie = `user=${ JSON . stringify ( signedInUser ) } ` ;
186+ } ) ;
187+ }
188+ } , [ user ] ) ;
189+
29190 const handleContactModalOpen = useCallback ( ( ) => {
30191 const contactModal = document . getElementById (
31192 'contact-modal'
@@ -34,26 +195,62 @@ const App: FC<AppProps> = ({ user, classItems, authLevel }): JSX.Element => {
34195 contactModal ?. showModal ( ) ;
35196 } , [ ] ) ;
36197
37- const userElement : JSX . Element =
38- user && user . photoURL ? (
39- < div className = "user" >
40- < img id = "user-img" src = { user . photoURL } alt = "User profile" />
198+ const numColumns = getNumColumns ( windowWidth ) ;
199+
200+ const classItems = useMemo ( ( ) => {
201+ const items =
202+ filteredCourseItems . length > 0
203+ ? filteredCourseItems
204+ : [ < ClearFilter key = "clear" onClearFilters = { handleClearFilters } /> ] ;
205+ const flexParents = Array . from ( { length : numColumns } , ( _ , i ) => (
206+ < div key = { i } className = "flex-parent" >
207+ { items . filter ( ( _ , j ) => j % numColumns === i ) }
41208 </ div >
42- ) : (
43- < div className = "hide" > </ div >
44- ) ;
209+ ) ) ;
210+ return < div className = "parent" > { flexParents } </ div > ;
211+ } , [ filteredCourseItems , numColumns , handleClearFilters ] ) ;
212+
213+ const userElement : JSX . Element = user ?. photoURL ? (
214+ < div className = "user" >
215+ < img id = "user-img" src = { user . photoURL } alt = "User profile" />
216+ </ div >
217+ ) : (
218+ < div className = "hide" />
219+ ) ;
220+
221+ if ( ! courseData ) {
222+ return < Loader /> ;
223+ }
45224
46225 return (
47226 < div className = "App" >
48227 < ContactForm />
49- { /* <ParsePDF /> */ }
50228 < div className = "container" >
51229 < Navigation authLevel = { authLevel } />
52230 </ div >
53231 < div className = "main" >
54- < TopBar user = { user } userElement = { userElement } />
55- < TagBar />
56- < div id = "course-container" > { classItems } </ div >
232+ < TopBar
233+ user = { user }
234+ userElement = { userElement }
235+ searchQuery = { searchQuery }
236+ onSearch = { setSearchQuery }
237+ onSignIn = { handleSignIn }
238+ />
239+ < TagBar
240+ activeTags = { activeTags }
241+ onTagToggle = { handleTagToggle }
242+ onClearTags = { handleClearFilters }
243+ />
244+ < div
245+ id = "course-container"
246+ style = {
247+ filteredCourseItems . length === 0
248+ ? { display : 'flex' , justifyContent : 'center' }
249+ : undefined
250+ }
251+ >
252+ { classItems }
253+ </ div >
57254 </ div >
58255 < FloatingActionable handleContactModalOpen = { handleContactModalOpen } />
59256 < MobileNavigation handleContactModalOpen = { handleContactModalOpen } />
0 commit comments