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 */