From b12f3c0d9bdd058423a34c3eb8612bba70e8d83f Mon Sep 17 00:00:00 2001
From: Saahith <22772542+saahithjanapati@users.noreply.github.com>
Date: Sun, 12 Oct 2025 08:17:31 -0400
Subject: [PATCH] Add auto-follow camera setting with UI toggle
---
src/App.js | 42 +++++++++++-------------
src/CatalogPage.js | 36 ++++++++++++++++++---
src/PageTemplate.js | 71 +++++++++++++++++++++++++++++++++++++----
src/SettingsContext.js | 72 ++++++++++++++++++++++++++++++++++++++++++
src/modalStyles.css | 56 ++++++++++++++++++++++++++++++++
5 files changed, 243 insertions(+), 34 deletions(-)
create mode 100644 src/SettingsContext.js
diff --git a/src/App.js b/src/App.js
index f0d9b57..58a51bf 100644
--- a/src/App.js
+++ b/src/App.js
@@ -1,34 +1,30 @@
import React from "react";
import { BrowserRouter as Router, Route, Routes, Navigate} from 'react-router-dom';
import PageTemplate from './PageTemplate';
-import './modalStyles.css'
+import './modalStyles.css';
import './App.css';
import { latestSemester } from './LatestSemester';
+import { SettingsProvider } from './SettingsContext';
function App() {
-
-
-// loop over the data and create a list of links
-return (
-
-
-
-
- } />
- } />
- } />
- }/>
-
- } />
-
- }/>
- }/>
- } />
-
-
-)
-
+ // loop over the data and create a list of links
+ return (
+
+
+
+ } />
+ } />
+ } />
+ }/>
+ } />
+ }/>
+ }/>
+ } />
+
+
+
+ );
}
export default App;
diff --git a/src/CatalogPage.js b/src/CatalogPage.js
index bb875ac..710c7f4 100644
--- a/src/CatalogPage.js
+++ b/src/CatalogPage.js
@@ -3,6 +3,7 @@ import { useParams, useNavigate} from 'react-router-dom';
import './Catalog.css'
import './Catalog.css'
import { requirementMapping } from './RequirementMap';
+import { useSettings } from './SettingsContext';
function CatalogPage() {
const { semester, department, org, number} = useParams();
@@ -12,6 +13,7 @@ function CatalogPage() {
const [tableExpansions, setTableExpansions] = useState({});
const [allTablesExpanded, setAllTablesExpanded] = useState(false); // state for overall table expansion
+ const [activeCourseKey, setActiveCourseKey] = useState(null);
const [allSemesters, setAllSemesters] = useState([]);
const [selectedSemester, setSelectedSemester] = useState(semester);
@@ -19,6 +21,8 @@ function CatalogPage() {
const [noDataFound, setNoDataFound] = useState(false);
const [showBackToTop, setShowBackToTop] = useState(false);
+ const { settings } = useSettings();
+ const autoFollowCamera = settings.autoFollowCamera;
const scrollToTop = () => {
@@ -69,6 +73,9 @@ function CatalogPage() {
}
if (org && number) {
newTableExpansions[`${org}${number}`] = true;
+ setActiveCourseKey(`${org}${number}`);
+ } else {
+ setActiveCourseKey(null);
}
setTableExpansions(newTableExpansions);
setAllTablesExpanded(false);
@@ -114,21 +121,28 @@ function CatalogPage() {
if(org && number){
scrollKey = org + number;
}
- if (scrollKey) {
+ if (scrollKey && autoFollowCamera) {
setTimeout(() => {
// Use JavaScript to scroll to the specified table
const tableElement = document.getElementById(scrollKey);
if (tableElement) {
- tableElement.scrollIntoView({ behavior: 'smooth' });
+ tableElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 750);
}
- }, [number, org]);
+ }, [number, org, autoFollowCamera]);
const toggleTableExpansion = (tableKey) => {
setTableExpansions((prevTableExpansions) => {
- return { ...prevTableExpansions, [tableKey]: !prevTableExpansions[tableKey] };
+ const shouldExpand = !prevTableExpansions[tableKey];
+ setActiveCourseKey((previousActive) => {
+ if (shouldExpand) {
+ return tableKey;
+ }
+ return previousActive === tableKey ? null : previousActive;
+ });
+ return { ...prevTableExpansions, [tableKey]: shouldExpand };
});
};
@@ -141,10 +155,24 @@ function CatalogPage() {
return state;
}, {});
setTableExpansions(newExpansionState);
+ setActiveCourseKey(null);
return newState;
});
};
+ useEffect(() => {
+ if (!autoFollowCamera || !activeCourseKey) {
+ return;
+ }
+ if (!tableExpansions[activeCourseKey]) {
+ return;
+ }
+ const element = document.getElementById(activeCourseKey);
+ if (element) {
+ element.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ }
+ }, [autoFollowCamera, activeCourseKey, tableExpansions]);
+
const generateMeetingTable = (meetings) => {
if (!meetings || meetings.length === 0) {
diff --git a/src/PageTemplate.js b/src/PageTemplate.js
index d1bbbe8..6e09c92 100644
--- a/src/PageTemplate.js
+++ b/src/PageTemplate.js
@@ -1,19 +1,30 @@
import Modal from 'react-modal';
-import React, { useState} from "react";
+import React, { useEffect, useState } from "react";
import Catalog from './Catalog';
import SearchComponent from './SearchComponent';
import CatalogPage from './CatalogPage';
import './modalStyles.css'
import {latestSemester} from './LatestSemester';
import AllSemesters from './AllSemesters';
+import { useSettings } from './SettingsContext';
function PageTemplate(props){
-
- const [isModalOpen, setIsModalOpen] = useState(false);
- Modal.setAppElement('#root');
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [isSettingsOpen, setIsSettingsOpen] = useState(false);
+ const { settings, updateSetting } = useSettings();
+
+ useEffect(() => {
+ if (typeof document === 'undefined') {
+ return;
+ }
+ const appRoot = document.getElementById('root');
+ if (appRoot) {
+ Modal.setAppElement(appRoot);
+ }
+ }, []);
const openModal = () => {
setIsModalOpen(true);
@@ -23,6 +34,14 @@ function PageTemplate(props){
setIsModalOpen(false);
};
+ const openSettingsModal = () => {
+ setIsSettingsOpen(true);
+ };
+
+ const closeSettingsModal = () => {
+ setIsSettingsOpen(false);
+ };
+
const renderTarget = props.target;
@@ -49,8 +68,46 @@ function PageTemplate(props){
- { renderTarget !== "catalog-page" && }
-
+
+ { renderTarget !== "catalog-page" && }
+
+
+
+
Settings
+
+
+
Auto-follow camera
+
+ Automatically centers the visualization on the active layer so you can follow the main action without manual panning.
+
+
+
+
+
+
+
+
+
+
diff --git a/src/SettingsContext.js b/src/SettingsContext.js
new file mode 100644
index 0000000..4cf784a
--- /dev/null
+++ b/src/SettingsContext.js
@@ -0,0 +1,72 @@
+import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
+
+const defaultSettings = {
+ autoFollowCamera: true,
+};
+
+const SettingsContext = createContext();
+
+const STORAGE_KEY = 'course-explorer-settings';
+
+function readStoredSettings() {
+ if (typeof window === 'undefined') {
+ return null;
+ }
+ try {
+ const raw = window.localStorage.getItem(STORAGE_KEY);
+ if (!raw) {
+ return null;
+ }
+ const parsed = JSON.parse(raw);
+ if (typeof parsed !== 'object' || parsed === null) {
+ return null;
+ }
+ return parsed;
+ } catch (error) {
+ console.error('Failed to read stored settings', error);
+ return null;
+ }
+}
+
+export function SettingsProvider({ children }) {
+ const [settings, setSettings] = useState(() => {
+ const stored = readStoredSettings();
+ if (stored) {
+ return { ...defaultSettings, ...stored };
+ }
+ return defaultSettings;
+ });
+
+ useEffect(() => {
+ if (typeof window === 'undefined') {
+ return;
+ }
+ try {
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
+ } catch (error) {
+ console.error('Failed to persist settings', error);
+ }
+ }, [settings]);
+
+ const updateSetting = useCallback((key, value) => {
+ setSettings((prevSettings) => ({ ...prevSettings, [key]: value }));
+ }, []);
+
+ const value = useMemo(
+ () => ({
+ settings,
+ updateSetting,
+ }),
+ [settings, updateSetting],
+ );
+
+ return {children};
+}
+
+export function useSettings() {
+ const context = useContext(SettingsContext);
+ if (!context) {
+ throw new Error('useSettings must be used within a SettingsProvider');
+ }
+ return context;
+}
diff --git a/src/modalStyles.css b/src/modalStyles.css
index 4040868..f9e4b9e 100644
--- a/src/modalStyles.css
+++ b/src/modalStyles.css
@@ -54,6 +54,14 @@
margin: "0 auto";
}
+.settings-button {
+ right: 4.5em;
+}
+
+.info-button {
+ right: 1em;
+}
+
.fixed-button:hover {
background: #c9732c;
}
@@ -85,6 +93,54 @@
padding-right: 10%;
}
+.settings-modal {
+ max-width: 40rem;
+ width: 90%;
+ height: auto;
+ max-height: 80vh;
+}
+
+.settings-content {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5em;
+}
+
+.settings-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 1.5em;
+}
+
+.settings-copy {
+ flex: 1;
+}
+
+.settings-title {
+ font-size: 1.1rem;
+ font-weight: 600;
+ margin-bottom: 0.35em;
+}
+
+.settings-description {
+ font-size: 0.95rem;
+ line-height: 1.4;
+ opacity: 0.85;
+}
+
+.settings-toggle {
+ display: flex;
+ align-items: center;
+ gap: 0.5em;
+ font-size: 1rem;
+}
+
+.settings-toggle input[type="checkbox"] {
+ width: 1.2em;
+ height: 1.2em;
+}
+
/* modalStyles.css */
.custom-modal-content {
width: 60%; /* Set the width of the modal content */