diff --git a/components/Header.tsx b/components/Header.tsx index 0be0f78..5ab2dab 100644 --- a/components/Header.tsx +++ b/components/Header.tsx @@ -17,7 +17,6 @@ import { Badge, useToast, } from "@chakra-ui/react" -import Head from "next/head" import { EditIcon, Icon, InfoIcon, SettingsIcon } from "@chakra-ui/icons" import { FaShareSquare } from "react-icons/fa" import { useAppContext } from "../src/SqrlContext" @@ -29,6 +28,8 @@ import useSections from "../src/useSections" import { DUPLICATE_TIMETABLE } from "../operations/mutations/duplicateTimetable" import { useMutation } from "@apollo/client" import { useTranslation } from "next-i18next" +import TitleMeta from "./TitleMeta" +import useSharePrefix from "../src/useSharePrefix" const HeaderComponent = styled(chakra.header)` /* display: grid; */ @@ -78,7 +79,8 @@ const Header = ({ setSidebarOpen }: { setSidebarOpen: any }) => { const [osModifier, setOsModifier] = useState("") useEffect(() => { - if (navigator.userAgent.indexOf("Mac OS X") !== -1) return setOsModifier("⌘") + if (navigator.userAgent.indexOf("Mac OS X") !== -1) + return setOsModifier("⌘") setOsModifier("Ctrl + ") }, []) @@ -90,6 +92,17 @@ const Header = ({ setSidebarOpen }: { setSidebarOpen: any }) => { const { name, updateName } = useSections() + // Handles overriding the server-side title. When wget or discord embed searches for the title, + // it will find the one in [id].tsx, and never gives the client a chance to load. When the client loads + // in a browser, this overrides that one, making it react to name changes appropriately. + const [showReactiveTitle, setShowReactiveTitle] = useState(false) + + // The first time updateName is called, override the server-shown + // title with the client-side context-driven one. + useEffect(() => { + setShowReactiveTitle(true) + }, [setShowReactiveTitle]) + const keydownListener = useCallback( (e: KeyboardEvent) => { if (!allowedToEdit) return @@ -149,13 +162,7 @@ const Header = ({ setSidebarOpen }: { setSidebarOpen: any }) => { const [duplicateTimetable] = useMutation(DUPLICATE_TIMETABLE) const [loading, setLoading] = useState(false) - const [sharePrefix, setSharePrefix] = useState("") - - useEffect(() => { - setSharePrefix( - `${window.location.protocol}//${window.location.host}/timetable/` - ) - }, []) + const [sharePrefix] = useSharePrefix() const id = router.query.id @@ -163,12 +170,9 @@ const Header = ({ setSidebarOpen }: { setSidebarOpen: any }) => { return ( - - Sqrl Planner | {name} - - - - + {showReactiveTitle && ( + + )} diff --git a/components/ShareModal.tsx b/components/ShareModal.tsx index f9528d9..efe5770 100644 --- a/components/ShareModal.tsx +++ b/components/ShareModal.tsx @@ -28,6 +28,7 @@ import { useMutation } from "@apollo/client" import { DUPLICATE_TIMETABLE } from "../operations/mutations/duplicateTimetable" import { CopyIcon, ExternalLinkIcon } from "@chakra-ui/icons" import { useTranslation } from "next-i18next" +import useSharePrefix from "../src/useSharePrefix" type props = { isOpen: boolean @@ -39,14 +40,7 @@ const ShareModal = ({ isOpen, onClose }: props) => { const id = router.query.id - const [sharePrefix, setSharePrefix] = useState("") - - useEffect(() => { - setSharePrefix( - `${window.location.protocol}//${window.location.host}/timetable/` - ) - }, []) - + const [sharePrefix] = useSharePrefix() const shareUrl = `${sharePrefix}${id}` const { onCopy, hasCopied } = useClipboard(shareUrl) @@ -61,7 +55,7 @@ const ShareModal = ({ isOpen, onClose }: props) => { duration: 9000, isClosable: true, }) - }, [hasCopied]) + }, [toast, hasCopied]) const [duplicateTimetable] = useMutation(DUPLICATE_TIMETABLE) diff --git a/components/TitleMeta.tsx b/components/TitleMeta.tsx new file mode 100644 index 0000000..144623c --- /dev/null +++ b/components/TitleMeta.tsx @@ -0,0 +1,23 @@ +import React from "react" +import Head from "next/head" + +type Props = { + name: string + sharePrefix: string + id: string | string[] | undefined +} + +function TitleMeta(props: Props) { + const { name, sharePrefix, id } = props + + return ( + + Sqrl Planner | {name} + + + + + ) +} + +export default TitleMeta diff --git a/components/preferences/PreferencesMeeting.tsx b/components/preferences/PreferencesMeeting.tsx index 9f19472..0e5bbfb 100644 --- a/components/preferences/PreferencesMeeting.tsx +++ b/components/preferences/PreferencesMeeting.tsx @@ -210,7 +210,7 @@ const PreferencesMeeting = () => { fontSize="sm" fontWeight={500} > - {t("cosmic ")} + {t("cosmic")} diff --git a/components/sidebar/CourseView.tsx b/components/sidebar/CourseView.tsx index 4d472ff..c10cf80 100644 --- a/components/sidebar/CourseView.tsx +++ b/components/sidebar/CourseView.tsx @@ -23,13 +23,9 @@ import { Tooltip, useClipboard, useToast, + VStack, } from "@chakra-ui/react" -import React, { - Fragment, - useEffect, - useRef, - useState, -} from "react" +import React, { Dispatch, Fragment, useEffect, useRef, useState } from "react" import reactStringReplace from "react-string-replace" import { useAppContext } from "../../src/SqrlContext" import { MeetingCategoryType } from "../timetable/Meeting" @@ -44,8 +40,18 @@ import useSections from "../../src/useSections" import { motion } from "framer-motion" import useTimetable from "../../src/useTimetable" import { SearchIcon } from "@chakra-ui/icons" +import { + getCourseLetterFromTerm, + computeSiblingCourseId, +} from "../../src/utils/course" +import { useLazyQuery } from "@apollo/client" +import { CHECK_COURSE_EXISTS } from "../../operations/queries/checkCourseExists" + +type Props = { + setSearchQuery: Dispatch> +} -const CourseView = ({ setSearchQuery }: { setSearchQuery: Function }) => { +const CourseView = ({ setSearchQuery }: Props) => { const { state: { // courses, @@ -61,9 +67,42 @@ const CourseView = ({ setSearchQuery }: { setSearchQuery: Function }) => { sections, }) const { allowedToEdit } = useTimetable({ id: router.query.id as string }) + const [siblingCourseId, setSiblingCourseId] = useState(null) const course = courses[identifier] + const [checkCourseExists] = useLazyQuery(CHECK_COURSE_EXISTS, { + errorPolicy: "all", + }) + + useEffect(() => { + // Cannot put async callback to useEffect, so wrap + // Check for the sibling course if it exists, and if it does, + // add the ID of that course to the state. If not, it's null + + // Run the query + ;(async () => { + if (!course) return + + const siblingCourseId = computeSiblingCourseId(course) + if (siblingCourseId === null) return + + const result = await checkCourseExists({ + variables: { + id: siblingCourseId, + }, + }) + + // I don't know how to show errors in sqrl :/ so return for now + if (result.error) return + + if (result.data.courseById) { + // courseById is null when there is course with matching id + setSiblingCourseId(result.data.courseById.id) + } + })() + }, [course, checkCourseExists]) + // TODO: meetings out of the timetable's display bounds are hidden without warning. Warn them. const boxRef = useRef(null) @@ -97,7 +136,7 @@ const CourseView = ({ setSearchQuery }: { setSearchQuery: Function }) => { if (missing.length !== 0) return toast.close("warn-missing-section") - }, [course, userMeetings, identifier]) + }, [toast, course, userMeetings, identifier]) useEffect(() => { if (!hasCopied) return @@ -108,7 +147,7 @@ const CourseView = ({ setSearchQuery }: { setSearchQuery: Function }) => { duration: 9000, isClosable: true, }) - }, [hasCopied]) + }, [toast, hasCopied]) if (!identifier) { return ( @@ -225,11 +264,7 @@ const CourseView = ({ setSearchQuery }: { setSearchQuery: Function }) => { {suffix} - {(() => { - if (course.term === "FIRST_SEMESTER") return "F" - if (course.term === "SECOND_SEMESTER") return "S" - return "Y" - })()} + {getCourseLetterFromTerm(course)} @@ -316,46 +351,76 @@ const CourseView = ({ setSearchQuery }: { setSearchQuery: Function }) => { )} - - + + )} + - - {t("sidebar:remove")} - + - - + + + {Object.values(MeetingCategoryType).map((method) => ( - setChosenCourse: Dispatch> - chosenCourse: string } const MotionFlex = motion(Flex) -const SearchResults = ({ courses, setChosenCourse, chosenCourse }: Props) => { +const SearchResults = ({ courses }: Props) => { const { dispatch } = useAppContext() const hoverBackground = useColorModeValue("gray.100", "gray.600") @@ -65,7 +62,6 @@ const SearchResults = ({ courses, setChosenCourse, chosenCourse }: Props) => { }} tabIndex={0} onClick={() => { - setChosenCourse(course.id) dispatch({ type: "SET_SIDEBAR", payload: 1, diff --git a/components/sidebar/SearchView/SearchView.test.tsx b/components/sidebar/SearchView/SearchView.test.tsx index 7924a2e..c3b548e 100644 --- a/components/sidebar/SearchView/SearchView.test.tsx +++ b/components/sidebar/SearchView/SearchView.test.tsx @@ -12,7 +12,12 @@ describe("Search view", () => { - {}} /> + {}} + searchOffset={0} + setSearchOffset={() => {}} + /> @@ -29,6 +34,8 @@ describe("Search view", () => { {}} + searchOffset={0} + setSearchOffset={() => {}} /> diff --git a/components/sidebar/SearchView/SearchView.tsx b/components/sidebar/SearchView/SearchView.tsx index f4c2c1f..a7766f5 100644 --- a/components/sidebar/SearchView/SearchView.tsx +++ b/components/sidebar/SearchView/SearchView.tsx @@ -34,20 +34,21 @@ import SearchViewHints from "./SearchViewHints" const MotionFlex = motion(Flex) const MotionButton = motion(Button) +type Props = { + searchQuery: string + setSearchQuery: Dispatch> + searchOffset: number + setSearchOffset: Dispatch> +} + const SearchView = ({ searchQuery, setSearchQuery, searchOffset, setSearchOffset, -}: { - searchQuery: string - setSearchQuery: Dispatch> - searchOffset: number - setSearchOffset: Dispatch> -}) => { +}: Props) => { const searchRef = useRef() as MutableRefObject const [searchLimit, setSearchLimit] = useState(7) - const [chosenCourse, setChosenCourse] = useState("") const [search, { loading, data, error, fetchMore }] = useLazyQuery(SEARCH_COURSES) @@ -69,8 +70,15 @@ const SearchView = ({ }) useEffect(() => { + if (!searchQuery) return + + debouncedZero(searchQuery) + }, []) + + useEffect(() => { + setSearchOffset(0) debounced(searchQuery) - }, [debounced, searchQuery]) + }, [debounced, setSearchOffset, searchQuery]) useEffect(() => { if (searchOffset === 0) return @@ -157,11 +165,7 @@ const SearchView = ({ {!error && !!data && searchQuery && ( {!!data.searchCourses.length && ( - + )} {!loading && data.searchCourses.length === 0 && ( { + const [sharePrefix] = useSharePrefix() + return ( + diff --git a/public/locales/de/sidebar.json b/public/locales/de/sidebar.json index 06222c6..d02ba1c 100644 --- a/public/locales/de/sidebar.json +++ b/public/locales/de/sidebar.json @@ -16,6 +16,8 @@ "second": "Zweite(n)", "year": "Jahr", "help": "Hilfe", + "see-in-second-semester": "Siehe Kurs in den zweiten Semester", + "see-in-first-semester": "Siehe Kurs in den ersten Semester", "remove": "Löschen", "missing-a": "Es fehlt einen", "section": "Sektion", diff --git a/public/locales/en/sidebar.json b/public/locales/en/sidebar.json index 9b2b950..37bfba6 100644 --- a/public/locales/en/sidebar.json +++ b/public/locales/en/sidebar.json @@ -16,6 +16,8 @@ "second": "Second", "year": "Year", "help": "Help", + "see-in-second-semester": "See course in second semester", + "see-in-first-semester": "See course in first semester", "remove": "Remove", "missing-a": "Missing a", "section": "Section", diff --git a/public/locales/fr/sidebar.json b/public/locales/fr/sidebar.json index 1e94a8f..ddd25d6 100644 --- a/public/locales/fr/sidebar.json +++ b/public/locales/fr/sidebar.json @@ -15,6 +15,8 @@ "second": "Second", "year": "Année", "help": "Help", + "see-in-second-semester": "See course in second semester", + "see-in-first-semester": "See course in first semester", "remove": "Remove", "missing-a": "Missing a", "section": "Section", diff --git a/public/locales/zh/sidebar.json b/public/locales/zh/sidebar.json index d862dc0..673fd4a 100644 --- a/public/locales/zh/sidebar.json +++ b/public/locales/zh/sidebar.json @@ -14,6 +14,8 @@ "second": "下学期", "year": "全年", "help": "Help", + "see-in-second-semester": "See course in second semester", + "see-in-first-semester": "See course in first semester", "remove": "Remove", "missing-a": "Missing a", "section": "Section", diff --git a/src/Course.ts b/src/Course.ts index 4113671..bed8855 100644 --- a/src/Course.ts +++ b/src/Course.ts @@ -111,8 +111,8 @@ export interface Course { prerequisites: string recommendedPreparation: string sections: Array
- term: string - sessionCode: string + term: string // Why is this not a FIRST_SEMESTER, SECOND_SEMESTER, YEAR enum? + sessionCode: string // Why is this not a LEC, PRA, TUT enum? title: string webTimetableInstructions: string | null } diff --git a/src/useCourses.tsx b/src/useCourses.tsx index cedd5ab..d4858fd 100644 --- a/src/useCourses.tsx +++ b/src/useCourses.tsx @@ -39,7 +39,7 @@ const useCourses = ({ sections }: Props) => { ids: coursesToGet, }, }) - }, [sections, sidebarCourse]) + }, [getCoursesById, sections, sidebarCourse]) useEffect(() => { if (!data || !data.coursesById) return diff --git a/src/useSections.tsx b/src/useSections.tsx index 919f0a3..eef31b7 100644 --- a/src/useSections.tsx +++ b/src/useSections.tsx @@ -22,16 +22,17 @@ type RemoveTimetableCourseProps = { courseId: string } -const SectionsContext = createContext< - | { - sections: { [key: string]: Array } - name: string - updateName: Function - setSections: Function - removeCourse: Function - } - | undefined ->(undefined) +type SectionsContextContent = { + sections: { [key: string]: Array } + name: string + updateName: Function + setSections: Function + removeCourse: Function +} + +const SectionsContext = createContext( + undefined +) export const SectionsProvider = ({ children, diff --git a/src/useSharePrefix.ts b/src/useSharePrefix.ts new file mode 100644 index 0000000..d182e49 --- /dev/null +++ b/src/useSharePrefix.ts @@ -0,0 +1,18 @@ +import React, { useEffect, useState } from "react" + +const useSharePrefix = (): [ + string, + React.Dispatch> +] => { + const [sharePrefix, setSharePrefix] = useState("") + + useEffect(() => { + setSharePrefix( + `${window.location.protocol}//${window.location.host}/timetable/` + ) + }, [setSharePrefix]) + + return [sharePrefix, setSharePrefix] +} + +export default useSharePrefix diff --git a/src/utils/course.ts b/src/utils/course.ts index dd0da10..b038d1d 100644 --- a/src/utils/course.ts +++ b/src/utils/course.ts @@ -2,6 +2,28 @@ import { MeetingCategoryType } from "../../components/timetable/Meeting" import { Course } from "../Course" import { UserMeeting } from "../SqrlContext" +export const getCourseLetterFromTerm = (course: Course): "F" | "S" | "Y" => { + switch (course.term) { + case "FIRST_SEMESTER": + return "F" + case "SECOND_SEMESTER": + return "S" + default: + return "Y" + } +} + +export const computeSiblingCourseId = (course: Course): string | null => { + // This is SUPER fragile. If the ID of the courses changes at all, like for supporting UTM, this will break! + const { department, numeral, suffix, term, terminal } = + breakdownCourseIdentifier(course.id) + if (term === "Y") return null + + const siblingTerm = term === "S" ? "F" : "S" + const siblingCourseId = `${department}${numeral}${suffix}-${siblingTerm}-${terminal}` + return siblingCourseId +} + export const breakdownCourseCode = (title: string) => { const firstDigitContent = title.match(/\d{3,}/g) let firstDigit = 0