Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
239 changes: 218 additions & 21 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,192 @@
import './App.css';
import {
ClearFilter,
ContactForm,
Course,
FloatingActionable,
Loader,
MobileNavigation,
Navigation,
TagBar,
TopBar,
} from './components';
// import { ParsePDF } from './components/ParsePDF';
import { auth, db, provider } from './config';
import { TAGS } from './data';
import localCourseData from './data/coursedata.json';
import { CourseDataType, CourseType } from './types';
import { getCookieValue, getWidth } from './utils';
import firebase from 'firebase/compat/app';
import { FC, useCallback, useEffect } from 'react';
import { FC, useCallback, useEffect, useMemo, useState } from 'react';

interface AppProps {
user: firebase.User | null;
classItems: JSX.Element;
authLevel: number;
}
const COURSE_ID_REGEX = /[A-Z]{3}[0-9]{3}/g;
const COURSE_ID_REGEX_SINGLE = /[A-Z]{3}[0-9]{3}/;

const App: FC<AppProps> = ({ user, classItems, authLevel }): JSX.Element => {
// Hacky workaround because something with React probably interferes with the default browser behavior
// Also, reactivates the highlight on the course element
const getNumColumns = (width: number): number => {
if (width > 1400) return 4;
if (width > 1100) return 3;
if (width > 800) return 2;
return 1;
};

const App: FC = (): JSX.Element => {
const [courseData, setCourseData] = useState<CourseDataType | null>(null);
const [user, setUser] = useState<firebase.User | null>(() => {
const cookie = getCookieValue('user');
if (!cookie) return null;
try {
return JSON.parse(cookie);
} catch {
return null;
}
});
Comment on lines +33 to +41
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The user state initialization from cookies lacks validation. If the cookie contains a malformed or malicious JSON payload that successfully parses but doesn't match the expected Firebase User structure, it could cause runtime errors when the app tries to access user properties like user.email or user.photoURL. Consider adding type validation or using a schema validator to ensure the parsed object conforms to the expected Firebase User interface.

Copilot uses AI. Check for mistakes.
const [authLevel, setAuthLevel] = useState(0);
const [searchQuery, setSearchQuery] = useState('');
const [activeTags, setActiveTags] = useState<string[]>([]);
const [windowWidth, setWindowWidth] = useState(getWidth());

// Fetch course data from Firebase, fall back to local data
useEffect(() => {
db.ref()
.get()
.then((snapshot) => {
setCourseData(
snapshot.exists() ? snapshot.val().courses : localCourseData
);
})
.catch(() => setCourseData(localCourseData));
}, []);

// Fetch auth level for the signed-in user
useEffect(() => {
if (!user) return;
db.ref()
.get()
.then((snapshot) => {
if (!snapshot.exists()) return;
const users: { [key: string]: { email: string; level: number } } =
snapshot.val().users;
Object.values(users).forEach(({ email, level }) => {
if (user.email === email) setAuthLevel(level);
});
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The auth level fetch effect lacks error handling. If the database read fails, the error will be unhandled. Add a .catch() handler to the promise chain to handle potential database errors gracefully.

Suggested change
});
});
})
.catch((error) => {
console.error('Failed to fetch auth level:', error);

Copilot uses AI. Check for mistakes.
});
}, [user]);

// Window resize listener for responsive column layout
useEffect(() => {
const handleResize = () => setWindowWidth(getWidth());
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);

// Scroll listener: show/hide desktop back-to-top button and mobile nav
useEffect(() => {
let prevScrollPos = window.scrollY;
const handleScroll = () => {
const topButton = document.getElementById('to-top');
if (topButton) {
const visible = window.scrollY >= 80 && windowWidth >= 525;
topButton.style.visibility = visible ? 'visible' : 'hidden';
topButton.style.opacity = visible ? '1' : '0';
}
if (windowWidth < 525) {
const nav = document.getElementById('mobile-nav');
if (nav) {
nav.style.bottom =
window.scrollY < prevScrollPos ? '0' : `-${nav.offsetHeight}px`;
}
}
prevScrollPos = window.scrollY;
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, [windowWidth]);
Comment on lines +82 to +102
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The scroll handler in App.tsx continues to use imperative DOM manipulation to control the visibility of the back-to-top button and mobile navigation. This is inconsistent with the PR's stated goal of using React best practices instead of imperative DOM manipulation. Consider refactoring to use React state to track scroll position and control visibility through conditional rendering or className props.

Copilot uses AI. Check for mistakes.

// Re-trigger hash jump after initial course data loads
// (React rendering can interfere with native hash navigation)
useEffect(() => {
const jumpId = window.location.hash;
window.location.hash = '';
window.location.hash = jumpId;
}, []);

const courseIDtoNameMap = useMemo(() => {
const map = new Map<string, string>();
for (const courseName in courseData) {
courseData![courseName].courseid.match(COURSE_ID_REGEX)?.forEach((id) => {
map.set(id, courseName);
});
}
return map;
}, [courseData]);
Comment on lines +112 to +120
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The courseIDtoNameMap computation will crash when courseData is null. The useMemo checks if courseData exists in the loop condition but uses the non-null assertion operator, which can cause a runtime error if courseData is null during the initial render before the useEffect sets it.

Add a guard check at the beginning of the useMemo: if (!courseData) return new Map();

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Ascent817 is coursedata gonna ever be null, don't we load in the pre-downloaded coursedata json regardless

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, we should always have local to fall back on. We should make sure to update the local version when we update the db.


const courseIDtoCourse = useCallback(
(courseID: string): CourseType => {
const id = courseID.match(COURSE_ID_REGEX_SINGLE)?.[0] ?? '';
return (
courseData?.[courseIDtoNameMap.get(id) ?? ''] ??
('' as unknown as CourseType)
);
},
[courseData, courseIDtoNameMap]
);

const filteredCourseItems = useMemo(() => {
if (!courseData) return [];
const key = encodeURIComponent(
searchQuery.toLowerCase().replaceAll(' ', '-')
).replace(/\./g, '%2E');
return Object.keys(courseData)
.filter((name) => {
const matchesSearch = name.search(key) !== -1;
const matchesTags =
activeTags.length === 0 ||
activeTags.some((tag) =>
courseData[name].tags?.includes(
TAGS[TAGS.findIndex((t) => t.id === tag)].label
)
);
return matchesSearch && matchesTags;
})
.map((name) => (
<Course
key={name}
authLevel={authLevel}
course={courseData[name]}
jumpId={courseData[name].courseid}
courseIDtoCourse={courseIDtoCourse}
/>
));
}, [courseData, searchQuery, activeTags, authLevel, courseIDtoCourse]);

const handleTagToggle = useCallback((id: string) => {
setActiveTags((prev) =>
prev.includes(id) ? prev.filter((t) => t !== id) : [...prev, id]
);
}, []);

const handleClearFilters = useCallback(() => {
setSearchQuery('');
setActiveTags([]);
}, []);

const handleSignIn = useCallback(() => {
if (user) {
auth.signOut().then(() => {
setUser(null);
setAuthLevel(0);
document.cookie =
'user=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
});
} else {
auth.signInWithPopup(provider).then((result) => {
const signedInUser = result.user;
if (!signedInUser) return;
setUser(signedInUser);
document.cookie = `user=${JSON.stringify(signedInUser)}`;
});
}
}, [user]);

const handleContactModalOpen = useCallback(() => {
const contactModal = document.getElementById(
'contact-modal'
Expand All @@ -34,26 +195,62 @@ const App: FC<AppProps> = ({ user, classItems, authLevel }): JSX.Element => {
contactModal?.showModal();
}, []);
Comment on lines 190 to 196
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The handleContactModalOpen function uses imperative DOM manipulation to open the contact modal. This is inconsistent with the PR's goal of eliminating imperative DOM manipulation. Consider managing modal state through React state and passing it as a prop to the ContactForm component.

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dang we still have a lot of technical debt...


const userElement: JSX.Element =
user && user.photoURL ? (
<div className="user">
<img id="user-img" src={user.photoURL} alt="User profile" />
const numColumns = getNumColumns(windowWidth);

const classItems = useMemo(() => {
const items =
filteredCourseItems.length > 0
? filteredCourseItems
: [<ClearFilter key="clear" onClearFilters={handleClearFilters} />];
const flexParents = Array.from({ length: numColumns }, (_, i) => (
<div key={i} className="flex-parent">
{items.filter((_, j) => j % numColumns === i)}
</div>
) : (
<div className="hide"></div>
);
));
return <div className="parent">{flexParents}</div>;
}, [filteredCourseItems, numColumns, handleClearFilters]);

const userElement: JSX.Element = user?.photoURL ? (
<div className="user">
<img id="user-img" src={user.photoURL} alt="User profile" />
</div>
) : (
<div className="hide" />
);

if (!courseData) {
return <Loader />;
}

return (
<div className="App">
<ContactForm />
{/* <ParsePDF /> */}
<div className="container">
<Navigation authLevel={authLevel} />
</div>
<div className="main">
<TopBar user={user} userElement={userElement} />
<TagBar />
<div id="course-container">{classItems}</div>
<TopBar
user={user}
userElement={userElement}
searchQuery={searchQuery}
onSearch={setSearchQuery}
onSignIn={handleSignIn}
/>
<TagBar
activeTags={activeTags}
onTagToggle={handleTagToggle}
onClearTags={handleClearFilters}
/>
<div
id="course-container"
style={
filteredCourseItems.length === 0
? { display: 'flex', justifyContent: 'center' }
: undefined
}
>
{classItems}
</div>
</div>
<FloatingActionable handleContactModalOpen={handleContactModalOpen} />
<MobileNavigation handleContactModalOpen={handleContactModalOpen} />
Expand Down
57 changes: 25 additions & 32 deletions src/components/ClearFilter.tsx
Original file line number Diff line number Diff line change
@@ -1,45 +1,38 @@
import '../App.css';
import { filterCourses } from '../index';
import { getWidth } from '../utils';
import { FC } from 'react';
import { FC, useEffect } from 'react';

export const ClearFilter: FC = (): JSX.Element => {
const courseContainer = document.getElementById(
'course-container'
) as HTMLDivElement;
courseContainer.style.display = 'flex';
courseContainer.style.justifyContent = 'center';
interface ClearFilterProps {
onClearFilters: () => void;
}

const adjustCourseContainerHeight = () => {
if (getWidth() >= 525) {
courseContainer.style.height = 'calc(100vh - 180px)';
} else {
courseContainer.style.height =
'calc(100vh - (45.5px + 65.5px + (clamp(15px, 5vw, 20px) * 2)))';
}
};

adjustCourseContainerHeight();
window.addEventListener('resize', adjustCourseContainerHeight);
export const ClearFilter: FC<ClearFilterProps> = ({
onClearFilters,
}): JSX.Element => {
useEffect(() => {
const courseContainer = document.getElementById(
'course-container'
) as HTMLDivElement;

const clearResults = () => {
const search = document.getElementById('searchbar') as HTMLInputElement;
search.value = '';

const tags = document.getElementsByClassName('tag');
for (let i = 0; i < tags.length; i++) {
if (tags[i].classList.contains('tag-true')) {
tags[i].classList.remove('tag-true');
}
}
const adjustHeight = () => {
courseContainer.style.height =
getWidth() >= 525
? 'calc(100vh - 180px)'
: 'calc(100vh - (45.5px + 65.5px + (clamp(15px, 5vw, 20px) * 2)))';
};

filterCourses();
};
adjustHeight();
window.addEventListener('resize', adjustHeight);
return () => {
window.removeEventListener('resize', adjustHeight);
courseContainer.style.height = '';
};
Comment on lines +12 to +29
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ClearFilter component directly manipulates the DOM to set the height of the course-container element. This goes against the PR's stated goal of eliminating imperative DOM manipulation in favor of React best practices. Consider managing the container height through React state and inline styles on the container div in App.tsx, similar to how display and justifyContent are already handled on lines 262-266.

Copilot uses AI. Check for mistakes.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Ascent817 well well well

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

well this could be fixed if we used CSS subgrid

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but thats a LOT of work

}, []);

return (
<div className="ClearFilter" id="clear-filter-class">
<h1>No Results</h1>
<button className="clear-results" onClick={clearResults}>
<button className="clear-results" onClick={onClearFilters}>
Clear Filters
</button>
</div>
Expand Down
16 changes: 4 additions & 12 deletions src/components/Course.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,8 @@
import '../App.css';
import { firebaseConfig } from '../config';
import { db } from '../config';
import { CourseType } from '../types';
import firebase from 'firebase/compat/app';
import 'firebase/compat/database';
import { FC, useCallback, useRef, useState, useMemo } from 'react';

firebase.initializeApp(firebaseConfig);
firebase.database().ref();

interface CourseProps {
course: CourseType;
authLevel: number;
Expand Down Expand Up @@ -114,12 +109,9 @@ export const Course: FC<CourseProps> = ({
})
);

firebase
.database()
.ref('courses')
.update({
[loopName]: { ...course, ...overwriteCourse },
});
db.ref('courses').update({
[loopName]: { ...course, ...overwriteCourse },
});

// Exit the edit menu
setIsEditing(false);
Expand Down
Loading
Loading