diff --git a/package.json b/package.json index c3684f33f0..73357a5757 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "classnames": "^2.3.2", "conductor": "https://github.com/source-academy/conductor.git#0.2.1", "dayjs": "^1.11.13", + "disable-devtool": "^0.3.8", "dompurify": "^3.2.4", "flexboxgrid": "^6.3.1", "flexboxgrid-helpers": "^1.1.3", diff --git a/src/commons/application/Application.tsx b/src/commons/application/Application.tsx index 31f2b666de..5291334f84 100644 --- a/src/commons/application/Application.tsx +++ b/src/commons/application/Application.tsx @@ -1,4 +1,5 @@ -import React from 'react'; +import disableDevtool from 'disable-devtool'; +import React, { useState } from 'react'; import { useDispatch } from 'react-redux'; import { Outlet } from 'react-router-dom'; import Messages, { @@ -8,16 +9,19 @@ import Messages, { } from 'src/features/vscode/messages'; import NavigationBar from '../navigationBar/NavigationBar'; +import { PauseAcademyOverlay } from '../pauseAcademyOverlay/PauseAcademyOverlay'; import Constants from '../utils/Constants'; import { useLocalStorageState, useSession } from '../utils/Hooks'; +import { showDangerMessage, showWarningMessage } from '../utils/notifications/NotificationsHelper'; import WorkspaceActions from '../workspace/WorkspaceActions'; import { defaultWorkspaceSettings, WorkspaceSettingsContext } from '../WorkspaceSettingsContext'; import SessionActions from './actions/SessionActions'; import VscodeActions from './actions/VscodeActions'; +import { Role } from './ApplicationTypes'; const Application: React.FC = () => { const dispatch = useDispatch(); - const { isLoggedIn } = useSession(); + const { isLoggedIn, isPaused, enableExamMode } = useSession(); // Used in the mobile/PWA experience (e.g. separate handling of orientation changes on Andriod & iOS due to unique browser behaviours) const isMobile = /iPhone|iPad|Android/.test(navigator.userAgent); @@ -29,6 +33,16 @@ const Application: React.FC = () => { defaultWorkspaceSettings ); + // Used for dev tools detection + const [isPreviewExamMode, _] = useLocalStorageState( + Constants.isPreviewExamModeLocalStorageKey, + false + ); + const [pauseAcademy, setPauseAcademy] = useState(false); + const [pauseAcademyReason, setPauseAcademyReason] = useState(''); + const hasSentPauseUserRequest = React.useRef(false); + const { role } = useSession(); + // Effect to fetch the latest user info and course configurations from the backend on refresh, // if the user was previously logged in React.useEffect(() => { @@ -133,14 +147,90 @@ const Application: React.FC = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Effect for dev tools blocking/detection when exam mode enabled + React.useEffect(() => { + if (role !== Role.Student && !isPreviewExamMode) { + return; + } + + const showPauseAcademyOverlay = (reason: string) => { + setPauseAcademy(true); + setPauseAcademyReason(reason); + if (hasSentPauseUserRequest.current === false) { + dispatch(SessionActions.pauseUser()); + hasSentPauseUserRequest.current = true; + } + }; + + if (enableExamMode || isPreviewExamMode) { + dispatch(SessionActions.reportFocusRegain()); + + if (isPaused !== undefined && isPaused) { + showPauseAcademyOverlay('Browser was refreshed when Source Academy was paused'); + } else { + hasSentPauseUserRequest.current = false; + } + + // Disable/Detect dev tools + disableDevtool({ + ondevtoolopen: () => { + showPauseAcademyOverlay('Developer tools detected'); + } + }); + + document.addEventListener('contextmenu', event => event.preventDefault()); + document.addEventListener('keydown', event => { + if ( + event.key == 'F12' || + ((event.key == 'I' || event.key == 'J' || event.key == 'C') && + event.ctrlKey && + event.shiftKey) + ) { + event.preventDefault(); + } + }); + + // Detect when Source Academy tab's content are hidden (e.g., user changes tab while Source Academy is active) + document.addEventListener('visibilitychange', _ => { + if (document.visibilityState === 'hidden') { + dispatch(SessionActions.reportFocusLost()); + } else { + showDangerMessage('Source Academy was out of focus.', 5000); + dispatch(SessionActions.reportFocusRegain()); + } + }); + } + }, [dispatch, enableExamMode, isPaused, hasSentPauseUserRequest, role, isPreviewExamMode]); + + const resumeCodeSubmitHandler = (resumeCode: string) => { + if (!resumeCode || resumeCode.length === 0) { + showWarningMessage('Resume code cannot be empty.'); + } else { + dispatch( + SessionActions.validateResumeCode(resumeCode, (isResumeCodeValid: boolean) => { + if (isResumeCodeValid) { + setPauseAcademy(false); + hasSentPauseUserRequest.current = false; + } else { + showWarningMessage('Resume code is invalid.'); + } + }) + ); + } + }; + return ( -
- -
- + {pauseAcademy ? ( + + ) : ( +
+ +
+ +
-
+ )} ); }; diff --git a/src/commons/application/ApplicationTypes.ts b/src/commons/application/ApplicationTypes.ts index fbc2728d20..6fe847e638 100644 --- a/src/commons/application/ApplicationTypes.ts +++ b/src/commons/application/ApplicationTypes.ts @@ -21,7 +21,7 @@ import { import { RouterState } from './types/CommonsTypes'; import { ExternalLibraryName } from './types/ExternalTypes'; import { SessionState } from './types/SessionTypes'; -import { VscodeState as VscodeState } from './types/VscodeTypes'; +import { VscodeState } from './types/VscodeTypes'; export type OverallState = { readonly router: RouterState; @@ -554,7 +554,8 @@ export const defaultSession: SessionState = { students: undefined, teamFormationOverviews: undefined, gradings: {}, - notifications: [] + notifications: [], + isPaused: undefined }; export const defaultStories: StoriesState = { diff --git a/src/commons/application/actions/SessionActions.ts b/src/commons/application/actions/SessionActions.ts index 1fc5590ce5..951e475c60 100644 --- a/src/commons/application/actions/SessionActions.ts +++ b/src/commons/application/actions/SessionActions.ts @@ -149,7 +149,14 @@ const SessionActions = createActions('session', { deleteUserCourseRegistration: (courseRegId: number) => ({ courseRegId }), updateCourseResearchAgreement: (agreedToResearch: boolean) => ({ agreedToResearch }), updateStoriesUserRole: (userId: number, role: StoriesRole) => ({ userId, role }), - deleteStoriesUserUserGroups: (userId: number) => ({ userId }) + deleteStoriesUserUserGroups: (userId: number) => ({ userId }), + validateResumeCode: (resumeCode: string, callback: (isResumeCodeValid: boolean) => any) => ({ + resumeCode, + callback + }), + pauseUser: () => {}, + reportFocusLost: () => {}, + reportFocusRegain: () => {} }); // For compatibility with existing code (actions helper) diff --git a/src/commons/application/actions/__tests__/SessionActions.ts b/src/commons/application/actions/__tests__/SessionActions.ts index cf65144525..cccbd16e95 100644 --- a/src/commons/application/actions/__tests__/SessionActions.ts +++ b/src/commons/application/actions/__tests__/SessionActions.ts @@ -200,7 +200,8 @@ test('setUser generates correct action object', () => { role: Role.Staff, viewable: true } - ] + ], + isPaused: false }; const action = SessionActions.setUser(user); expect(action).toEqual({ diff --git a/src/commons/application/reducers/__tests__/SessionReducer.ts b/src/commons/application/reducers/__tests__/SessionReducer.ts index 8a589c4aad..28005dba47 100644 --- a/src/commons/application/reducers/__tests__/SessionReducer.ts +++ b/src/commons/application/reducers/__tests__/SessionReducer.ts @@ -63,7 +63,8 @@ test('SET_USER works correctly', () => { viewable: true, role: Role.Staff } - ] + ], + isPaused: false }; const action = { diff --git a/src/commons/application/types/SessionTypes.ts b/src/commons/application/types/SessionTypes.ts index 20d4bb4402..4abcd221a0 100644 --- a/src/commons/application/types/SessionTypes.ts +++ b/src/commons/application/types/SessionTypes.ts @@ -21,6 +21,7 @@ export type SessionState = { readonly userId?: number; readonly name?: string; readonly courses: UserCourse[]; + readonly isPaused?: boolean; // Course Registration readonly courseRegId?: number; @@ -39,10 +40,13 @@ export type SessionState = { readonly enableAchievements?: boolean; readonly enableSourcecast?: boolean; readonly enableStories?: boolean; + readonly enableExamMode?: boolean; + readonly resumeCode?: string; readonly sourceChapter?: Chapter; readonly sourceVariant?: Variant; readonly moduleHelpText?: string; readonly assetsPrefix?: string; + readonly isOfficialCourse?: boolean; readonly assessmentConfigurations?: AssessmentConfiguration[]; readonly userCourseRegistrations?: AdminPanelCourseRegistration[]; @@ -84,6 +88,7 @@ export type User = { name: string; username: string; courses: UserCourse[]; + isPaused: boolean; }; export type CourseRegistration = { @@ -105,10 +110,13 @@ export type CourseConfiguration = { enableAchievements: boolean; enableSourcecast: boolean; enableStories: boolean; + enableExamMode: boolean; + resumeCode: string; sourceChapter: Chapter; sourceVariant: Variant; moduleHelpText: string; assetsPrefix: string; + isOfficialCourse: boolean; }; export type AdminPanelCourseRegistration = { diff --git a/src/commons/assessmentWorkspace/AssessmentWorkspace.tsx b/src/commons/assessmentWorkspace/AssessmentWorkspace.tsx index cc3723a158..b4d8074a40 100644 --- a/src/commons/assessmentWorkspace/AssessmentWorkspace.tsx +++ b/src/commons/assessmentWorkspace/AssessmentWorkspace.tsx @@ -64,6 +64,7 @@ import MobileWorkspace, { MobileWorkspaceProps } from '../mobileWorkspace/Mobile import SideContentAutograder from '../sideContent/content/SideContentAutograder'; import SideContentContestLeaderboard from '../sideContent/content/SideContentContestLeaderboard'; import SideContentContestVotingContainer from '../sideContent/content/SideContentContestVotingContainer'; +import SideContentDocumentation from '../sideContent/content/SideContentDocumentation'; import SideContentToneMatrix from '../sideContent/content/SideContentToneMatrix'; import { SideContentProps } from '../sideContent/SideContent'; import { changeSideContentHeight } from '../sideContent/SideContentActions'; @@ -412,6 +413,7 @@ const AssessmentWorkspace: React.FC = props => { const isTeamAssessment = assessmentOverview !== undefined ? assessmentOverview.maxTeamSize > 1 : false; const isContestVoting = question?.type === QuestionTypes.voting; + const isPrivate = assessmentOverview?.private ?? false; const handleContestEntryClick = (_submissionId: number, answer: string) => { // TODO: Hardcoded to make use of the first editor tab. Refactoring is needed for this workspace to enable Folder mode. handleEditorValueChange(0, answer); @@ -557,6 +559,15 @@ const AssessmentWorkspace: React.FC = props => { }); } + if (isPrivate) { + tabs.push({ + label: `Documentation`, + iconName: IconNames.BOOK, + body: , + id: SideContentType.documentation + }); + } + const onChangeTabs = ( newTabId: SideContentType, prevTabId: SideContentType, diff --git a/src/commons/documentation/Sicp.tsx b/src/commons/documentation/Sicp.tsx new file mode 100644 index 0000000000..22ffd38138 --- /dev/null +++ b/src/commons/documentation/Sicp.tsx @@ -0,0 +1,213 @@ +import 'katex/dist/katex.min.css'; + +import { Button, Classes, NonIdealState, Spinner, TreeNodeInfo } from '@blueprintjs/core'; +import classNames from 'classnames'; +import React, { useRef, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { useNavigate } from 'react-router'; +import { Link } from 'react-router-dom'; +import Constants from 'src/commons/utils/Constants'; +import { setLocalStorage } from 'src/commons/utils/LocalStorageHelper'; +import WorkspaceActions from 'src/commons/workspace/WorkspaceActions'; +import { parseArr, ParseJsonError } from 'src/features/sicp/parser/ParseJson'; +import { getNext, getPrev } from 'src/features/sicp/TableOfContentsHelper'; +import { + setSicpSectionLocalStorage, + SICP_CACHE_KEY, + SICP_INDEX +} from 'src/features/sicp/utils/SicpUtils'; + +import SicpErrorBoundary from '../../features/sicp/errors/SicpErrorBoundary'; +import getSicpError, { SicpErrorType } from '../../features/sicp/errors/SicpErrors'; +import SicpIndexPage from './SicpIndexPage'; +import SicpNavigationBar from './SicpNavigationBar'; + +const baseUrl = Constants.sicpBackendUrl + 'json/'; +const extension = '.json'; + +// Context to determine which code snippet is active +export const CodeSnippetContext = React.createContext({ + active: '0', + setActive: (x: string) => {} +}); + +const loadingComponent = } />; + +type SicpProps = { + setSicpHomeCallBackFn: (fn: () => void) => void; +}; + +const Sicp: React.FC = props => { + const [data, setData] = useState(<>); + const [loading, setLoading] = useState(false); + const [active, setActive] = useState('0'); + const [section, setSection] = useState('index'); + const [hash, setHash] = useState('#begin'); + const refs = useRef>({}); + const navigate = useNavigate(); + + const scrollRefIntoView = (ref: HTMLElement | null) => { + // console.log(`Scrolling ${ref} into view...`); + if (!ref) { + return; + } + + ref.scrollIntoView({ + behavior: 'smooth' + }); + }; + + // Handle loading of latest viewed section and fetch json data + React.useEffect(() => { + props.setSicpHomeCallBackFn(() => { + setSection('index'); + }); + if (!section) { + /** + * Handles rerouting to the latest viewed section when clicking from + * the main application navbar. Navigate replace logic is used to allow the + * user to still use the browser back button to navigate the app. + */ + // navigate(`/sicpjs/${readSicpSectionLocalStorage()}`, { replace: true }); + return; + } + + if (section === SICP_INDEX) { + setSicpSectionLocalStorage(SICP_INDEX); + return; + } + + setLoading(true); + + fetch(baseUrl + section + extension) + .then(response => { + if (!response.ok) { + throw Error(response.statusText); + } + return response.json(); + }) + .then(myJson => { + try { + const newData = parseArr(myJson, refs); // Might throw error + setData(newData); + setSicpSectionLocalStorage(section); // Sets local storage if valid page + } catch (error) { + throw new ParseJsonError(error.message); + } + }) + .catch(error => { + console.error(error); + + if (error.message === 'Not Found') { + // page not found + setData(getSicpError(SicpErrorType.PAGE_NOT_FOUND_ERROR)); + } else if (error instanceof ParseJsonError) { + // error occurred while parsing JSON + setData(getSicpError(SicpErrorType.PARSING_ERROR)); + } else { + setData(getSicpError(SicpErrorType.UNEXPECTED_ERROR)); + } + setLocalStorage(SICP_CACHE_KEY, SICP_INDEX); // Prevents caching invalid page + }) + .finally(() => { + setLoading(false); + }); + }, [section, navigate, props]); + + // Scroll to correct position + React.useEffect(() => { + if (loading) { + return; + } + + const ref = refs.current[`#${hash}`]; + scrollRefIntoView(ref); + }, [loading, hash]); + + // Close all active code snippet when new page is loaded + React.useEffect(() => { + setActive('0'); + }, [data]); + + const dispatch = useDispatch(); + const handleSnippetEditorOpen = (s: string) => { + setActive(s); + dispatch(WorkspaceActions.resetWorkspace('sicp')); + dispatch(WorkspaceActions.toggleUsingSubst(false, 'sicp')); + }; + const handleNavigation = (sect: string) => { + // console.log(`Navigating to section: ${sect}`); + setSection(sect); + }; + + // `section` is defined due to the navigate logic in the useEffect above + const navigationButtons = ( +
+ {getPrev(section!) && ( + + )} + {getNext(section!) && ( + + )} +
+ ); + + const handleNodeClicked = React.useCallback( + (node: TreeNodeInfo) => { + // console.log(`Navigating to section: ${String(node.nodeData)}`); + setSection(String(node.nodeData)); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [section] + ); + + const handleNodeClickedString = React.useCallback( + (sect: string) => { + const urlPart = sect.split('#'); + if (urlPart.length > 1) { + console.log(`URL Part: ${urlPart[1]}`); + setHash(urlPart[1]); + } + // console.log(`Navigating to section: ${urlPart[0]}`); + setSection(urlPart[0]); + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [section] + ); + + return ( +
+ + + + {loading ? ( +
{loadingComponent}
+ ) : section === 'index' ? ( + + ) : ( +
+ (refs.current['#begin'] = ref)} /> + {data} + {navigationButtons} + (refs.current['#end'] = ref)} /> +
+ )} +
+
+
+ ); +}; + +// react-router lazy loading +// https://reactrouter.com/en/main/route/lazy +export const Component = Sicp; +Component.displayName = 'Sicp'; + +export default Sicp; diff --git a/src/commons/documentation/SicpIndexPage.tsx b/src/commons/documentation/SicpIndexPage.tsx new file mode 100644 index 0000000000..303738207b --- /dev/null +++ b/src/commons/documentation/SicpIndexPage.tsx @@ -0,0 +1,122 @@ +import { H1, H2, H4, TreeNodeInfo } from '@blueprintjs/core'; +import React from 'react'; + +import SicpToc from './SicpToc'; + +const originalAuthors = 'Harold Abelson and Gerald Jay Sussman'; +const originalWithAuthors = 'with Julie Sussman'; +const adaptedAuthors = 'Martin Henz and Tobias Wrigstad'; +const adaptedWithAuthors = 'with Julie Sussman'; +const developers = 'Samuel Fang'; + +const authors = ( +
+

{originalAuthors}

+

+ {originalWithAuthors} + — original authors +

+

{adaptedAuthors}

+

+ {adaptedWithAuthors} + — adapters to JavaScript +

+

{developers}

+

+ — designer and developer of this Interactive SICP JS edition +

+
+); + +const bookTitle = ( +
+

Structure and Interpretation of Computer Programs

+

— JavaScript Edition

+
+); + +const licenses = ( +
+
+ + CC BY-SA 4.0 + +
+
+

+ This work is licensed under a{' '} + + Creative Commons Attribution-ShareAlike 4.0 International License + + . +

+
+
+
+ + GPL 3 + +
+
+

+ All JavaScript programs in this work are licensed under the{' '} + + GNU General Public License Version 3 + + . +

+
+
+
+ + CC BY-NC-SA 4.0 + +
+ +
+
+); + +type IndexProps = { + handleNodeClick: (node: TreeNodeInfo) => void; +}; + +const SicpIndexPage: React.FC = props => { + return ( +
+
+ SICP +
+ {bookTitle} + {authors} +
+
+
+

Content

+ +
+

Licenses

+ {licenses} +
+ ); +}; + +export default SicpIndexPage; diff --git a/src/commons/documentation/SicpNavigationBar.tsx b/src/commons/documentation/SicpNavigationBar.tsx new file mode 100644 index 0000000000..15258e3cd3 --- /dev/null +++ b/src/commons/documentation/SicpNavigationBar.tsx @@ -0,0 +1,458 @@ +import { + Alignment, + Button, + Classes, + Drawer, + Icon, + Menu, + MenuItem, + Navbar, + NavbarGroup, + Position, + Tag, + Text, + TreeNodeInfo +} from '@blueprintjs/core'; +import { IconNames } from '@blueprintjs/icons'; +import { Omnibar } from '@blueprintjs/select'; +import React from 'react'; +import Latex from 'react-latex-next'; +import { useParams } from 'react-router'; +import ControlButton from 'src/commons/ControlButton'; +import Constants from 'src/commons/utils/Constants'; +import { TableOfContentsButton } from 'src/features/sicp/TableOfContentsButton'; +import { getNext, getPrev } from 'src/features/sicp/TableOfContentsHelper'; + +import SicpToc from './SicpToc'; + +type IndexSearchResult = { text: string; order: string; id: string; hasSubindex: boolean }; + +type SicpNavigationBarProps = { + handleNodeClicked: (node: TreeNodeInfo) => void; + handleNodeClickedString: (sect: string) => void; +}; + +const SicpNavigationBar: React.FC = props => { + // this section responsible for the travel and table of content + const [isTocOpen, setIsTocOpen] = React.useState(false); + const { section } = useParams<{ section: string }>(); + const prev = getPrev(section!); + const next = getNext(section!); + const handleCloseToc = () => setIsTocOpen(false); + const handleOpenToc = () => setIsTocOpen(true); + const handleNavigation = props.handleNodeClickedString; + + // Button to open table of contents + const tocButton = ; + + // Previous button only displayed when next page is valid. + const prevButton = prev && ( +
+ handleNavigation(prev)} + /> +
+ ); + + // Next button only displayed when next page is valid. + const nextButton = next && ( +
+ handleNavigation(next)} + options={{ iconOnRight: true }} + /> +
+ ); + + const drawerProps = { + onClose: handleCloseToc, + autoFocus: true, + canEscapeKeyClose: true, + canOutsideClickClose: true, + enforceFocus: true, + hasBackdrop: true, + isOpen: isTocOpen, + position: Position.LEFT, + usePortal: false + }; + + // this section responsible for the search + type TrieNode = { + children: Record; + value: string[] & IndexSearchResult[]; + key: string; + }; + + type SearchData = { + indexTrie: TrieNode; + textTrie: TrieNode; + idToContentMap: Record; + }; + + const fetchSearchData = () => { + const xhr = new XMLHttpRequest(); + const url = Constants.sicpBackendUrl + 'json/rewritedSearchData.json'; + xhr.open('GET', url, false); //sync download + xhr.send(); + if (xhr.status !== 200) { + alert('Unable to get rewrited search data. Error code = ' + xhr.status + ' url is ' + url); + throw new Error('Unable to get search data. Error code = ' + xhr.status + ' url is ' + url); + } else { + const searchData: SearchData = JSON.parse(xhr.responseText); + return searchData; + } + }; + + function search(keyStr: string, trie: TrieNode) { + const keys = [...keyStr]; + let node = trie; + for (let i = 0; i < keys.length; i++) { + if (node === undefined || node.children === undefined) { + return []; + } + + if (!node.children[keys[i]]) { + return []; + } + node = node.children[keys[i]]; + } + return node.value; + } + + function sentenceSearch(keyStr: string) { + const words = keyStr.split(' '); + const longestWord = words.reduce((a, b) => (a.length > b.length ? a : b), ''); + const results = search(longestWord, rewritedSearchData.textTrie).filter(id => { + const text = rewritedSearchData.idToContentMap[id].toLowerCase().replaceAll('\n', ' '); + return text.includes(keyStr); + }); + return results; + } + + function autocomplete(incompleteKeys: string, trie: TrieNode, n: number = 25) { + let node = trie; + for (let i = 0; i < incompleteKeys.length; i++) { + if (!node.children[incompleteKeys[i]]) { + return []; + } + node = node.children[incompleteKeys[i]]; + } + const result = []; + const queue = [node]; + while (queue.length > 0 && result.length < n) { + const currNode = queue.shift(); + if (currNode && currNode.value.length > 0) { + result.push(currNode.key); + } + if (currNode && currNode.children) { + for (const child of Object.values(currNode.children)) { + queue.push(child); + } + } + } + return result; + } + function indexAutoComplete(incompleteKeys: string, n: number = 25) { + const firstIsLowerCase = incompleteKeys[0].toLowerCase() + incompleteKeys.slice(1); + const firstIsUpperCase = incompleteKeys[0].toUpperCase() + incompleteKeys.slice(1); + const result1 = autocomplete(firstIsLowerCase, rewritedSearchData.indexTrie, n); + const result2 = autocomplete(firstIsUpperCase, rewritedSearchData.indexTrie, n); + while (result1.length < n && result2.length > 0) { + const toPush = result2.shift(); + if (toPush === undefined) { + console.log('when searching, got undefined toPush'); + continue; + } + result1.push(toPush); + } + return result1; + } + + function sentenceAutoComplete(incompleteKeys: string, n: number = 25) { + const words = incompleteKeys.split(' '); + if (words.length === 0) { + return []; + } + if (words.length === 1) { + return autocomplete(words[0], rewritedSearchData.textTrie, n); + } + const pre = words.slice(0, -1).join(' '); + const results = sentenceSearch(pre).map(id => + rewritedSearchData.idToContentMap[id].toLowerCase() + ); + const answers: string[] = []; + while (answers.length < n && results.length > 0) { + let sentence = results.shift(); + if (sentence === undefined) { + continue; + } + sentence = sentence.replaceAll('\n', ' '); + const start = sentence.indexOf(incompleteKeys) + incompleteKeys.length; + if (start >= incompleteKeys.length) { + const rest = sentence.slice(start); + let end = rest.search(/[^a-zA-Z _]/); + if (end === -1) { + end = rest.length; + } + const toPush = incompleteKeys + rest.slice(0, end); + if (!answers.includes(toPush.trim())) { + answers.push(toPush.trim()); + } + } + } + return answers; + } + + // fetch search catalog only once + const rewritedSearchData: SearchData = React.useMemo(fetchSearchData, []); + + const focusResult = (result: string, query: string): React.ReactNode => { + result = result.replaceAll('\n', ' ').toLowerCase(); + const startIndex = result.indexOf(query); + let start = startIndex; + while (start > 0) { + if (result[start - 1].match(/[^a-zA-Z, _]/)) { + break; + } + start--; + } + const endIndex = startIndex + query.length; + let end = endIndex; + while (end < result.length) { + if (result[end].match(/[^a-zA-Z _,]/)) { + break; + } + end++; + } + let subStr = result.slice(start, end); + if (start > 0) { + subStr = '...' + subStr; + } + if (end < result.length) { + subStr = subStr + '...'; + } + subStr = subStr.trim(); + return ( + <> + {subStr.slice(0, subStr.indexOf(query))} + + + {subStr.slice(subStr.indexOf(query), subStr.indexOf(query) + query.length)} + + + {subStr.slice(subStr.indexOf(query) + query.length)} + + ); + }; + const getIndex = (id: string) => { + const index = id.indexOf('#'); + const numId = index === -1 ? id : id.slice(0, index); + return numId; + }; + + const makeTextSearchSubmenuItem = (result: string) => { + return ( + + Section {getIndex(result)} +
+ {focusResult(rewritedSearchData.idToContentMap[result], query)} + + } + onClick={() => { + handleNavigation(result); + setIsOmnibarOpen(false); + }} + /> + ); + }; + + const processIndexSearchResults = (searchResults: IndexSearchResult[]) => { + return searchResults + .filter(result => result.id) + .sort((a, b) => { + if (a.hasSubindex && !b.hasSubindex) { + return 1; + } + if (!a.hasSubindex && b.hasSubindex) { + return -1; + } + return a.order.localeCompare(b.order); + }); + }; + + const makeIndexSearchSubmenuItem = (result: IndexSearchResult) => { + return ( + {result.text.replaceAll('LATEX: ', '')}} + onClick={() => { + handleNavigation(result.id); + setIsOmnibarOpen(false); + }} + /> + ); + }; + + const [isOmnibarOpen, setIsOmnibarOpen] = React.useState(false); + const [omnibarMode, setOmnibarMode] = React.useState<'text' | 'index' | 'submenu'>('text'); + const [previousMode, setPreviousMode] = React.useState<'text' | 'index' | null>(null); + const [query, setQuery] = React.useState(''); + const [searchResults, setSearchResults] = React.useState([]); + + const initTextSearch = () => { + setOmnibarMode('text'); + setIsOmnibarOpen(true); + setQuery(''); + setSearchResults([]); + }; + + const initIndexSearch = () => { + setOmnibarMode('index'); + setIsOmnibarOpen(true); + setQuery(''); + setSearchResults([]); + }; + + const handleQueryChange = (query: string) => { + setQuery(query); + if (query.length === 0) { + setSearchResults([]); + return; + } + + switch (omnibarMode) { + case 'text': + setSearchResults(sentenceAutoComplete(query)); + break; + case 'index': + setSearchResults(indexAutoComplete(query)); + break; + } + }; + + const userSearch = ( + + ); + + const indexSearch = ( + + ); + + const searchWrapper = ( +
+ {userSearch} + {indexSearch} +
+ ); + + const handleResultClick = (result: string) => { + console.log(`Clicked on result ${result}`); + setQuery(result); + // Safe to typecast due to logic + setPreviousMode(omnibarMode as 'text' | 'index'); + setOmnibarMode('submenu'); + switch (omnibarMode) { + case 'text': + setSearchResults(sentenceSearch(result)); + break; + case 'index': + setSearchResults( + // Supposed to be IndexSearchResult[], but typing can be improved with further, future refactoring + processIndexSearchResults(search(result, rewritedSearchData.indexTrie)) as any[] + ); + break; + } + }; + + return ( + <> + setIsOmnibarOpen(false)} + items={searchResults} + // Handled by individual items + onItemSelect={() => {}} + query={query} + onQueryChange={handleQueryChange} + itemListRenderer={({ itemsParentRef, renderItem, items }) => { + return ( + + {omnibarMode === 'submenu' && ( + + Showing results for {query}…{' '} + + + )} + {items.map(renderItem)} + + ); + }} + itemRenderer={result => { + switch (omnibarMode) { + case 'text': + case 'index': + return ( + handleResultClick(result)} + labelElement={} + /> + ); + case 'submenu': + // Safe to assert non-null due to logic + switch (previousMode!) { + case 'text': + return makeTextSearchSubmenuItem(result); + case 'index': + return makeIndexSearchSubmenuItem(result as unknown as IndexSearchResult); + } + } + }} + /> + + {tocButton} + {[prevButton, nextButton]} + {searchWrapper} + + + + + + ); +}; + +export default SicpNavigationBar; diff --git a/src/commons/documentation/SicpToc.tsx b/src/commons/documentation/SicpToc.tsx new file mode 100644 index 0000000000..4ef099bfda --- /dev/null +++ b/src/commons/documentation/SicpToc.tsx @@ -0,0 +1,42 @@ +import { Tree, TreeNodeInfo } from '@blueprintjs/core'; +import { cloneDeep } from 'lodash'; +import React, { useState } from 'react'; +import toc from 'src/features/sicp/data/toc.json'; + +type Props = { + handleCloseToc?: () => void; + handleNodeClicked?: (node: TreeNodeInfo) => void; +}; + +/** + * Table of contents of SICP. + */ +const SicpToc: React.FC = props => { + const [sidebarContent, setSidebarContent] = useState(toc as TreeNodeInfo[]); + + const handleNodeExpand = (_node: TreeNodeInfo, path: integer[]) => { + const newState = cloneDeep(sidebarContent); + Tree.nodeFromPath(path, newState).isExpanded = true; + setSidebarContent(newState); + }; + + const handleNodeCollapse = (_node: TreeNodeInfo, path: integer[]) => { + const newState = cloneDeep(sidebarContent); + Tree.nodeFromPath(path, newState).isExpanded = false; + setSidebarContent(newState); + }; + + return ( +
+ +
+ ); +}; + +export default SicpToc; diff --git a/src/commons/dropdown/Dropdown.tsx b/src/commons/dropdown/Dropdown.tsx index 5d0f5a2f42..fd25cf25d3 100644 --- a/src/commons/dropdown/Dropdown.tsx +++ b/src/commons/dropdown/Dropdown.tsx @@ -5,9 +5,11 @@ import { useTranslation } from 'react-i18next'; import { useDispatch } from 'react-redux'; import { logOut } from '../application/actions/CommonsActions'; +import { Role } from '../application/ApplicationTypes'; import ControlButton from '../ControlButton'; import Profile from '../profile/Profile'; -import { useSession } from '../utils/Hooks'; +import Constants from '../utils/Constants'; +import { useLocalStorageState, useSession } from '../utils/Hooks'; import DropdownAbout from './DropdownAbout'; import DropdownCourses from './DropdownCourses'; import DropdownCreateCourse from './DropdownCreateCourse'; @@ -21,10 +23,14 @@ const Dropdown: React.FC = () => { const [isProfileOpen, setIsProfileOpen] = useState(false); const [isMyCoursesOpen, setIsMyCoursesOpen] = useState(false); const [isCreateCourseOpen, setIsCreateCourseOpen] = useState(false); + const [isPreviewExamMode, _] = useLocalStorageState( + Constants.isPreviewExamModeLocalStorageKey, + false + ); const { t } = useTranslation('commons', { keyPrefix: 'dropdown' }); - const { isLoggedIn, name, courses, courseId } = useSession(); + const { isLoggedIn, name, courses, courseId, enableExamMode, role } = useSession(); const dispatch = useDispatch(); const handleLogOut = () => dispatch(logOut()); @@ -49,9 +55,10 @@ const Dropdown: React.FC = () => { ) : null; - const createCourse = isLoggedIn ? ( - - ) : null; + const createCourse = + isLoggedIn && !isPreviewExamMode && (!enableExamMode || role !== Role.Student) ? ( + + ) : null; const logout = isLoggedIn ? ( diff --git a/src/commons/dropdown/DropdownCourses.tsx b/src/commons/dropdown/DropdownCourses.tsx index 6bc10e5f6c..d9c1f276f7 100644 --- a/src/commons/dropdown/DropdownCourses.tsx +++ b/src/commons/dropdown/DropdownCourses.tsx @@ -5,6 +5,8 @@ import { useNavigate } from 'react-router'; import { Role } from '../application/ApplicationTypes'; import { UserCourse } from '../application/types/SessionTypes'; +import Constants from '../utils/Constants'; +import { useLocalStorageState, useSession } from '../utils/Hooks'; type Props = { isOpen: boolean; @@ -15,6 +17,11 @@ type Props = { const DropdownCourses: React.FC = ({ isOpen, onClose, courses, courseId }) => { const navigate = useNavigate(); + const { enableExamMode, role } = useSession(); + const [isPreviewExamMode, _] = useLocalStorageState( + Constants.isPreviewExamModeLocalStorageKey, + false + ); const options = courses.map(course => ({ value: course.courseId, @@ -42,7 +49,9 @@ const DropdownCourses: React.FC = ({ isOpen, onClose, courses, courseId } options={options} fill onChange={onChangeHandler} - disabled={courses.length <= 1} + disabled={ + courses.length <= 1 || isPreviewExamMode || (enableExamMode && role == Role.Student) + } /> diff --git a/src/commons/dropdown/DropdownCreateCourse.tsx b/src/commons/dropdown/DropdownCreateCourse.tsx index 2d37ce7eb9..38835bb2bc 100644 --- a/src/commons/dropdown/DropdownCreateCourse.tsx +++ b/src/commons/dropdown/DropdownCreateCourse.tsx @@ -40,6 +40,7 @@ const DropdownCreateCourse: React.FC = props => { enableAchievements: true, enableSourcecast: true, enableStories: false, + enableExamMode: false, sourceChapter: Chapter.SOURCE_1, sourceVariant: Variant.DEFAULT, moduleHelpText: '' diff --git a/src/commons/mocks/UserMocks.ts b/src/commons/mocks/UserMocks.ts index 523d68fc05..9dddf1e49b 100644 --- a/src/commons/mocks/UserMocks.ts +++ b/src/commons/mocks/UserMocks.ts @@ -45,6 +45,7 @@ export const mockUser: User = { userId: 123, name: 'DevAdmin', username: 'DevAdmin', + isPaused: false, courses: [ { courseId: 1, @@ -75,6 +76,7 @@ export const mockStudents: User[] = [ userId: 101, name: 'Papito Sakolomoto', username: 'Papito Sakolomoto', + isPaused: false, courses: [ { courseId: 1, @@ -89,6 +91,7 @@ export const mockStudents: User[] = [ userId: 102, name: 'Carina Heng Xin Ting', username: 'Carina Heng Xin Ting', + isPaused: false, courses: [ { courseId: 2, @@ -103,6 +106,7 @@ export const mockStudents: User[] = [ userId: 103, name: 'Valentino Gusion', username: 'Valentino Gusion', + isPaused: false, courses: [ { courseId: 3, @@ -117,6 +121,7 @@ export const mockStudents: User[] = [ userId: 104, name: 'Ixia Arlot Rambutan', username: 'Ixia Arlot Rambutan', + isPaused: false, courses: [ { courseId: 4, @@ -131,6 +136,7 @@ export const mockStudents: User[] = [ userId: 105, name: 'Ariel Shockatia Ligament', username: 'Ariel Shockatia Ligament', + isPaused: false, courses: [ { courseId: 5, @@ -145,6 +151,7 @@ export const mockStudents: User[] = [ userId: 106, name: 'Lolita Sim', username: 'Lolita Sim', + isPaused: false, courses: [ { courseId: 5, @@ -159,6 +166,7 @@ export const mockStudents: User[] = [ userId: 107, name: 'Lim Jun Ming', username: 'Lim Jun Ming', + isPaused: false, courses: [ { courseId: 5, @@ -173,6 +181,7 @@ export const mockStudents: User[] = [ userId: 108, name: 'Tobias Gray', username: 'Tobias Gray', + isPaused: false, courses: [ { courseId: 5, @@ -187,6 +196,7 @@ export const mockStudents: User[] = [ userId: 109, name: 'Lenard Toh See Ming', username: 'Lenard Toh See Ming', + isPaused: false, courses: [ { courseId: 5, @@ -201,6 +211,7 @@ export const mockStudents: User[] = [ userId: 110, name: 'Richard Gray', username: 'Richard Gray', + isPaused: false, courses: [ { courseId: 5, @@ -215,6 +226,7 @@ export const mockStudents: User[] = [ userId: 111, name: 'Benedict Lim', username: 'Benedict Lim', + isPaused: false, courses: [ { courseId: 5, @@ -229,6 +241,7 @@ export const mockStudents: User[] = [ userId: 112, name: 'Harshvathini Tharman', username: 'Harshvathini Tharman', + isPaused: false, courses: [ { courseId: 5, @@ -243,6 +256,7 @@ export const mockStudents: User[] = [ userId: 113, name: 'James Cook', username: 'James Cook', + isPaused: false, courses: [ { courseId: 5, @@ -257,6 +271,7 @@ export const mockStudents: User[] = [ userId: 114, name: 'Mike Chang', username: 'Mike Chang', + isPaused: false, courses: [ { courseId: 5, @@ -271,6 +286,7 @@ export const mockStudents: User[] = [ userId: 115, name: 'Giyu Tomioka', username: 'Giyu Tomioka', + isPaused: false, courses: [ { courseId: 5, @@ -285,6 +301,7 @@ export const mockStudents: User[] = [ userId: 116, name: 'Oliver Sandy', username: 'Oliver Sandy', + isPaused: false, courses: [ { courseId: 5, @@ -299,6 +316,7 @@ export const mockStudents: User[] = [ userId: 117, name: 'Muthu Valakrishnan', username: 'Muthu Valakrishnan', + isPaused: false, courses: [ { courseId: 5, @@ -373,10 +391,13 @@ export const mockCourseConfigurations: CourseConfiguration[] = [ enableAchievements: true, enableSourcecast: true, enableStories: false, + enableExamMode: false, + resumeCode: '', sourceChapter: Chapter.SOURCE_1, sourceVariant: Variant.DEFAULT, moduleHelpText: '', - assetsPrefix: '' + assetsPrefix: '', + isOfficialCourse: false }, { courseName: `CS2040S Data Structures and Algorithms (AY20/21 Sem 2)`, @@ -386,10 +407,13 @@ export const mockCourseConfigurations: CourseConfiguration[] = [ enableAchievements: false, enableSourcecast: false, enableStories: false, + enableExamMode: false, + resumeCode: '', sourceChapter: Chapter.SOURCE_2, sourceVariant: Variant.DEFAULT, moduleHelpText: 'Help Text!', - assetsPrefix: '' + assetsPrefix: '', + isOfficialCourse: false } ]; diff --git a/src/commons/pauseAcademyOverlay/PauseAcademyOverlay.tsx b/src/commons/pauseAcademyOverlay/PauseAcademyOverlay.tsx new file mode 100644 index 0000000000..4809751e5c --- /dev/null +++ b/src/commons/pauseAcademyOverlay/PauseAcademyOverlay.tsx @@ -0,0 +1,34 @@ +import { Button, FormGroup, InputGroup } from '@blueprintjs/core'; +import { useState } from 'react'; + +import classes from '../../styles/PauseAcademyOverlay.module.scss'; + +type PauseAcademyOverlayProps = { + reason?: string; + onSubmit: (resumeCode: string) => void; +}; + +export const PauseAcademyOverlay: React.FC = props => { + const [resumeCode, setResumeCode] = useState(''); + + return ( +
+

Source Academy Paused

+ {props.reason &&

Reason: {props.reason}

} +

Please inform any of the invigilators

+ + setResumeCode((e.target as HTMLInputElement).value)} + /> + +
+ ); +}; diff --git a/src/commons/sagas/BackendSaga.ts b/src/commons/sagas/BackendSaga.ts index b9c57609b8..43e7e4668f 100644 --- a/src/commons/sagas/BackendSaga.ts +++ b/src/commons/sagas/BackendSaga.ts @@ -73,6 +73,7 @@ import { getUser, getUserCourseRegistrations, handleResponseError, + pauseUser, postAcknowledgeNotifications, postAnswer, postAssessment, @@ -96,10 +97,13 @@ import { putUserRole, removeAssessmentConfig, removeUserCourseRegistration, + reportFocusLost, + reportFocusRegain, unpublishGrading, unpublishGradingAll, updateAssessment, - uploadAssessment + uploadAssessment, + validateResumeCode } from './RequestsSaga'; import { safeTakeEvery as takeEvery } from './SafeEffects'; @@ -601,7 +605,26 @@ const newBackendSagaOne = combineSagaHandlers(sagaActions, { yield put(actions.updateGradingOverviews({ count: totalPossibleEntries, data: newOverviews })); }, submitGrading: sendGrade, - submitGradingAndContinue: sendGradeAndContinue + submitGradingAndContinue: sendGradeAndContinue, + validateResumeCode: function* (action) { + const tokens: Tokens = yield selectTokens(); + const { resumeCode, callback } = action.payload; + + const isResumeCodeValid = yield call(validateResumeCode, tokens, resumeCode); + callback(isResumeCodeValid); + }, + pauseUser: function* () { + const tokens: Tokens = yield selectTokens(); + yield call(pauseUser, tokens); + }, + reportFocusLost: function* () { + const tokens: Tokens = yield selectTokens(); + yield call(reportFocusLost, tokens); + }, + reportFocusRegain: function* () { + const tokens: Tokens = yield selectTokens(); + yield call(reportFocusRegain, tokens); + } }); function* sendGrade( @@ -822,6 +845,21 @@ const newBackendSagaTwo = combineSagaHandlers(sagaActions, { assessmentConfigurations: AssessmentConfiguration[] | null; } = yield call(getLatestCourseRegistrationAndConfiguration, tokens); + if (courseConfiguration?.enableExamMode) { + const { + user + }: { + user: User | null; + courseRegistration: CourseRegistration | null; + courseConfiguration: CourseConfiguration | null; + assessmentConfigurations: AssessmentConfiguration[] | null; + } = yield call(getUser, tokens); + + if (user) { + yield put(actions.setUser(user)); + } + } + if (!courseRegistration || !courseConfiguration || !assessmentConfigurations) { yield call(showWarningMessage, `Failed to load course!`); return yield routerNavigate('/welcome'); diff --git a/src/commons/sagas/RequestsSaga.ts b/src/commons/sagas/RequestsSaga.ts index 29f88df173..d24b690bc6 100644 --- a/src/commons/sagas/RequestsSaga.ts +++ b/src/commons/sagas/RequestsSaga.ts @@ -1396,6 +1396,44 @@ export const removeUserCourseRegistration = async ( return resp; }; +/** + * POST /courses/{course_Id}/resume_code + */ +export const validateResumeCode = async (tokens: Tokens, resumeCode: string): Promise => { + const resp = await request(`${courseId()}/resume_code`, 'POST', { + ...tokens, + body: { + resume_code: resumeCode + } + }); + + return resp != null && resp.ok; +}; + +export const pauseUser = async (tokens: Tokens): Promise => { + const resp = await request(`${courseId()}/user/pause`, 'PUT', { + ...tokens + }); + + return resp != null && resp.ok; +}; + +export const reportFocusLost = async (tokens: Tokens): Promise => { + const resp = await request(`${courseId()}/user/focus/0`, 'POST', { + ...tokens + }); + + return resp != null && resp.ok; +}; + +export const reportFocusRegain = async (tokens: Tokens): Promise => { + const resp = await request(`${courseId()}/user/focus/1`, 'POST', { + ...tokens + }); + + return resp != null && resp.ok; +}; + /** * GET /devices */ diff --git a/src/commons/sagas/__tests__/BackendSaga.ts b/src/commons/sagas/__tests__/BackendSaga.ts index 3111a21901..e4daf08fc8 100644 --- a/src/commons/sagas/__tests__/BackendSaga.ts +++ b/src/commons/sagas/__tests__/BackendSaga.ts @@ -105,7 +105,8 @@ const mockUser: User = { role: Role.Staff, viewable: true } - ] + ], + isPaused: false }; const mockCourseRegistration1: CourseRegistration = { @@ -133,10 +134,13 @@ const mockCourseConfiguration1: CourseConfiguration = { enableAchievements: true, enableSourcecast: true, enableStories: false, + enableExamMode: false, sourceChapter: Chapter.SOURCE_1, sourceVariant: Variant.DEFAULT, moduleHelpText: 'Help text', - assetsPrefix: '' + assetsPrefix: '', + isOfficialCourse: false, + resumeCode: '' }; const mockCourseRegistration2: CourseRegistration = { @@ -164,10 +168,13 @@ const mockCourseConfiguration2: CourseConfiguration = { enableAchievements: true, enableSourcecast: true, enableStories: false, + enableExamMode: false, sourceChapter: Chapter.SOURCE_4, sourceVariant: Variant.DEFAULT, moduleHelpText: 'Help text', - assetsPrefix: '' + assetsPrefix: '', + isOfficialCourse: false, + resumeCode: '' }; const mockAssessmentConfigurations: AssessmentConfiguration[] = [ @@ -930,10 +937,13 @@ describe('Test UPDATE_COURSE_CONFIG action', () => { enableAchievements: false, enableSourcecast: false, enableStories: false, + enableExamMode: false, sourceChapter: Chapter.SOURCE_4, sourceVariant: Variant.DEFAULT, moduleHelpText: 'Help', - assetsPrefix: '' + assetsPrefix: '', + isOfficialCourse: false, + resumeCode: '' }; test('when course config is changed', () => { @@ -1028,9 +1038,12 @@ describe('Test CREATE_COURSE action', () => { enableAchievements: true, enableSourcecast: true, enableStories: false, + enableExamMode: false, sourceChapter: Chapter.SOURCE_1, sourceVariant: Variant.DEFAULT, - moduleHelpText: 'Help Text' + moduleHelpText: 'Help Text', + isOfficialCourse: false, + resumeCode: '' }; const user = mockUser; const courseConfiguration = mockCourseConfiguration1; diff --git a/src/commons/sideContent/SideContentTypes.ts b/src/commons/sideContent/SideContentTypes.ts index f4598968ca..2ae22f08ec 100644 --- a/src/commons/sideContent/SideContentTypes.ts +++ b/src/commons/sideContent/SideContentTypes.ts @@ -8,6 +8,7 @@ export enum SideContentType { contestVoting = 'contest_voting', cseMachine = 'cse_machine', dataVisualizer = 'data_visualizer', + documentation = 'documentation', editorGrading = 'editor_grading', editorAutograder = 'editor_autograder', editorBriefing = 'editor_briefing', diff --git a/src/commons/sideContent/content/SideContentDocumentation.tsx b/src/commons/sideContent/content/SideContentDocumentation.tsx new file mode 100644 index 0000000000..a6f16050f0 --- /dev/null +++ b/src/commons/sideContent/content/SideContentDocumentation.tsx @@ -0,0 +1,158 @@ +import { Button } from '@blueprintjs/core'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import Sicp from 'src/commons/documentation/Sicp'; + +const SideContentDocumentation: React.FC = () => { + const pages: { + name: string; + src: string; + component: JSX.Element | null; + }[] = [ + { + name: 'Modules', + src: 'https://source-academy.github.io/modules/documentation/index.html', + component: null + }, + { + name: 'Docs', + src: 'https://docs.sourceacademy.org/', + component: null + } + ]; + + const [activePage, setActivePage] = useState(pages[0]); + const activeIframeRef = useRef(null); + const documentationDivRef = useRef(null); + + // Used to resize the docs tab to an initial height only once + useEffect(() => { + const ref = documentationDivRef.current as HTMLDivElement; + const visibilityCallback = ( + entries: IntersectionObserverEntry[], + observer: IntersectionObserver + ) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + const resizableSideContentElement = document + .getElementsByClassName('resize-side-content') + .item(0) as HTMLDivElement; + if (resizableSideContentElement !== null) { + resizableSideContentElement.style.height = '500px'; + } + + observer.unobserve(ref); + } + }); + }; + + const observer = new IntersectionObserver(visibilityCallback, { + root: null, + threshold: 0.1 + }); + + observer.observe(ref); + return () => { + observer.unobserve(ref); + }; + }, []); + + let sicpHomeCallbackFn: () => void = () => {}; + + const changeActivePage = (index: number) => { + setActivePage(pages[index]); + }; + + const handleDocsHome = useCallback(() => { + if (sicpHomeCallbackFn !== null && activePage.src == 'https://sicp.sourceacademy.org') { + sicpHomeCallbackFn(); + } + + if (activeIframeRef.current !== null) { + activeIframeRef.current.src = activePage.src; + } + }, [activePage.src]); + + const sicpHomeCallbackSetter = (fn: () => void) => { + sicpHomeCallbackFn = fn; + }; + + pages.push({ + name: 'SICP JS', + src: 'https://sicp.sourceacademy.org', + component: + }); + + return ( +
+
+
+
+ {pages.map(page => + page.component ? ( +
+ {page.component} +
+ ) : ( +