>;
136 | readonly handlePrepareToDelete: (courseId: string) => void;
137 | }) {
138 | const {
139 | courseData,
140 | courseList,
141 | prepareToRemoveCourseId,
142 | setCourseList,
143 | handlePrepareToDelete,
144 | } = props;
145 | const sensors = useSensors(
146 | useSensor(MouseSensor),
147 | useSensor(TouchSensor),
148 | useSensor(KeyboardSensor, {
149 | coordinateGetter: sortableKeyboardCoordinates,
150 | })
151 | );
152 | return (
153 |
154 | {
159 | const { active, over } = event;
160 | if (active.id !== over?.id) {
161 | setCourseList((courseList) => {
162 | const oldIndex = courseList.indexOf(String(active.id));
163 | const newIndex = courseList.indexOf(String(over?.id));
164 |
165 | return arrayMove(courseList, oldIndex, newIndex);
166 | });
167 | }
168 | }}
169 | >
170 |
174 | {courseList.map((courseId) => {
175 | const course = courseData?.[courseId];
176 | if (!course) {
177 | return <>>;
178 | }
179 | return (
180 |
186 | );
187 | })}
188 |
189 |
190 |
191 | );
192 | }
193 |
194 | export default SortablePopover;
195 |
--------------------------------------------------------------------------------
/src/components/CourseTable/CourseTableContainer.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Table,
3 | Thead,
4 | Tbody,
5 | Tr,
6 | Th,
7 | Td,
8 | Flex,
9 | Center,
10 | Box,
11 | Text,
12 | Skeleton,
13 | Tooltip,
14 | Button,
15 | useColorModeValue,
16 | } from "@chakra-ui/react";
17 | import * as React from "react";
18 | import { useState, useCallback } from "react";
19 | import CourseTableCard from "components/CourseTable/CourseTableCard/CourseTableCard";
20 | import { weekdays_map, Weekday } from "data/mapping_table";
21 | import { hash_to_color_hex } from "utils/colorAgent";
22 | import { hoverCourseState } from "utils/hoverCourse";
23 | import { useSnapshot } from "valtio";
24 | import { reportEvent } from "utils/ga";
25 | import { TimeMap } from "@/utils/parseCourseTime";
26 | import { Course, Interval } from "types/course";
27 | import { intervals } from "constant";
28 |
29 | function HoverCourseIndicator({
30 | hoveredCourse,
31 | }: {
32 | readonly hoveredCourse: Course;
33 | }) {
34 | const course = hoveredCourse;
35 | return (
36 |
44 |
58 |
59 | );
60 | }
61 |
62 | function CourseTableContainer(props: {
63 | readonly courses: {
64 | readonly [key: string]: Course;
65 | };
66 | readonly loading: boolean;
67 | readonly courseTimeMap: TimeMap;
68 | }) {
69 | const { courses, loading, courseTimeMap } = props;
70 | const days: Weekday[] = ["1", "2", "3", "4", "5"];
71 | const intervalTextColor = useColorModeValue("gray.300", "gray.600");
72 |
73 | const [activeDayCol, setActiveDayCol] = useState<"0" | Weekday>("0");
74 | const { hoveredCourse, hoveredCourseTimeMap } = useSnapshot(hoverCourseState);
75 | const renderIntervalContent = useCallback(
76 | (days: Weekday[], interval: Interval, i: number) => {
77 | const fullWidth = days.length === 1;
78 | return days.map((day, j) => {
79 | const intervalCourseIds = courseTimeMap?.[day]?.[interval];
80 | const intervalHoveredCourseIds =
81 | hoveredCourse && hoveredCourseTimeMap?.[day]?.[interval];
82 | // hover course has been in courseTable already, show solid border in this case
83 | const isOverlapped = intervalCourseIds
84 | ? (courseTimeMap?.[day]?.[interval] ?? []).includes(
85 | hoveredCourseTimeMap?.[day]?.[interval]?.[0] ?? ""
86 | )
87 | : false;
88 | if (loading) {
89 | return (
90 |
91 |
92 |
93 |
94 | |
95 | );
96 | }
97 | // no courses & hoverCourse
98 | if (!intervalCourseIds && !intervalHoveredCourseIds) {
99 | return (
100 |
101 |
110 |
111 | {" "}
112 | {interval}
113 |
114 |
115 | |
116 | );
117 | }
118 | return (
119 |
120 |
128 | {intervalHoveredCourseIds && !isOverlapped ? (
129 |
130 | ) : null}
131 | {intervalCourseIds ? (
132 |
143 | ) : null}
144 |
145 | |
146 | );
147 | });
148 | },
149 | [
150 | courseTimeMap,
151 | courses,
152 | hoveredCourse,
153 | hoveredCourseTimeMap,
154 | loading,
155 | intervalTextColor,
156 | ]
157 | );
158 |
159 | return (
160 |
161 |
162 | {activeDayCol === "0" ? (
163 |
164 | {days.map((day, j) => {
165 | return (
166 | {
168 | setActiveDayCol(day);
169 | reportEvent("course_table", "click", "expand_day");
170 | }}
171 | cursor="pointer"
172 | key={`${day}-${j}`}
173 | >
174 |
181 |
182 | {weekdays_map[day]}
183 |
184 |
185 | |
186 | );
187 | })}
188 |
189 | ) : (
190 |
191 | {
193 | setActiveDayCol("0");
194 | reportEvent("course_table", "click", "collapse_day");
195 | }}
196 | cursor="pointer"
197 | >
198 |
205 | {weekdays_map[activeDayCol]}
206 |
207 | |
208 |
209 | )}
210 |
211 |
212 | {intervals.map((interval, i) => {
213 | if (activeDayCol === "0") {
214 | return (
215 |
216 | {renderIntervalContent(days, interval, i)}
217 |
218 | );
219 | }
220 | return (
221 |
222 | {renderIntervalContent([activeDayCol], interval, i)}
223 |
224 | );
225 | })}
226 |
227 |
228 | );
229 | }
230 |
231 | export default CourseTableContainer;
232 |
--------------------------------------------------------------------------------
/src/components/CustomIcons.tsx:
--------------------------------------------------------------------------------
1 | import { Icon, IconProps } from "@chakra-ui/react";
2 |
3 | const DiscordIcon = (props: IconProps) => (
4 |
5 |
9 |
10 | );
11 | export { DiscordIcon };
12 |
--------------------------------------------------------------------------------
/src/components/DeadlineCountdown.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Text,
3 | Flex,
4 | Progress,
5 | HStack,
6 | Button,
7 | LightMode,
8 | } from "@chakra-ui/react";
9 | import { FaClock } from "react-icons/fa";
10 | import { differenceInDays, differenceInHours } from "date-fns";
11 |
12 | const status_map = [
13 | {
14 | name: "即將開始",
15 | emoji: "⏰",
16 | color: "blue.200",
17 | },
18 | {
19 | name: "已開始",
20 | emoji: "🏎️",
21 | color: "green.200",
22 | },
23 | {
24 | name: "快結束啦!",
25 | emoji: "🏃",
26 | color: "yellow.200",
27 | },
28 | {
29 | name: "剩不到一天了",
30 | emoji: "🥵",
31 | color: "red.200",
32 | },
33 | ];
34 |
35 | const ntu_course_select_url = [
36 | "https://if192.aca.ntu.edu.tw/index.php",
37 | "https://if177.aca.ntu.edu.tw/index.php",
38 | ];
39 |
40 | interface CourseSelectionSchedule {
41 | start: number;
42 | end: number;
43 | name: string;
44 | label: string;
45 | }
46 |
47 | const course_select_schedule: CourseSelectionSchedule[] = [
48 | {
49 | name: "初選第一階段",
50 | label: "初選 一階",
51 | start: 1660698000000,
52 | end: 1660935600000,
53 | },
54 | {
55 | name: "初選第二階段",
56 | label: "二階",
57 | start: 1661302800000,
58 | end: 1661454000000,
59 | },
60 | {
61 | name: "第一週加退選",
62 | label: "加退選 W1",
63 | start: 1662339600000,
64 | end: 1662782400000,
65 | },
66 | {
67 | name: "第二週加退選",
68 | label: "W2",
69 | start: 1662944400000,
70 | end: 1663408800000,
71 | },
72 | {
73 | name: "第三週加退選",
74 | label: "W3",
75 | start: 1663549200000,
76 | end: 1663923600000,
77 | },
78 | ];
79 |
80 | const msInDay = 86400000;
81 |
82 | export const getCourseSelectSchedule = (timestamp: number) => {
83 | if (timestamp < course_select_schedule[0].start) {
84 | return { status_idx: 0, schedule_idx: 0 };
85 | }
86 | for (let i = 0; i < course_select_schedule.length; i++) {
87 | if (
88 | timestamp >= course_select_schedule[i].start &&
89 | timestamp <= course_select_schedule[i].end
90 | ) {
91 | if (course_select_schedule[i].end - timestamp <= msInDay) {
92 | return { status_idx: 3, schedule_idx: i };
93 | } else if (course_select_schedule[i].end - timestamp <= msInDay * 2) {
94 | return { status_idx: 2, schedule_idx: i };
95 | }
96 | return { status_idx: 1, schedule_idx: i };
97 | }
98 | if (
99 | i < course_select_schedule.length - 1 &&
100 | timestamp <= course_select_schedule[i + 1].start &&
101 | timestamp >= course_select_schedule[i].end
102 | ) {
103 | return { status_idx: 0, schedule_idx: i + 1 };
104 | }
105 | }
106 | return { status_idx: -1, schedule_idx: -1 };
107 | };
108 |
109 | function DeadlineCountdown() {
110 | const curr_ts = new Date().getTime();
111 | const { status_idx, schedule_idx } = getCourseSelectSchedule(curr_ts);
112 | if (status_idx === -1) {
113 | return null;
114 | }
115 | const elaspedDays = differenceInDays(
116 | status_idx === 0
117 | ? course_select_schedule[schedule_idx].start
118 | : course_select_schedule[schedule_idx].end,
119 | curr_ts
120 | );
121 | const elapsedHours =
122 | differenceInHours(
123 | status_idx === 0
124 | ? course_select_schedule[schedule_idx].start
125 | : course_select_schedule[schedule_idx].end,
126 | curr_ts
127 | ) % 24;
128 | const time_percent =
129 | status_idx === 0
130 | ? 0
131 | : (curr_ts - course_select_schedule[schedule_idx].start) /
132 | (course_select_schedule[schedule_idx].end -
133 | course_select_schedule[schedule_idx].start);
134 | const process_percent =
135 | ((time_percent + schedule_idx) / (course_select_schedule.length - 1)) * 100;
136 |
137 | return (
138 |
139 |
150 |
157 |
158 | {status_map[status_idx].emoji}{" "}
159 | {course_select_schedule[schedule_idx].name}{" "}
160 | {status_map[status_idx].name}
161 |
162 |
163 |
164 |
165 | {" "}
166 | 尚餘 {elaspedDays} 天 {elapsedHours} 時
167 |
168 |
169 |
170 |
179 |
180 | {course_select_schedule.map((item, idx) => {
181 | return (
182 |
189 | {item.label}
190 |
191 | );
192 | })}
193 |
194 |
195 |
206 |
216 |
217 |
218 |
219 | );
220 | }
221 |
222 | export default DeadlineCountdown;
223 |
--------------------------------------------------------------------------------
/src/components/FilterModals/CategoryFilterModal.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | useDisclosure,
3 | Modal,
4 | ModalOverlay,
5 | ModalContent,
6 | ModalHeader,
7 | ModalFooter,
8 | ModalBody,
9 | ModalCloseButton,
10 | Button,
11 | Heading,
12 | Divider,
13 | Flex,
14 | useBreakpointValue,
15 | useColorModeValue,
16 | } from "@chakra-ui/react";
17 | import { type_list, code_map } from "data/course_type";
18 | import FilterElement from "components/FilterModals/components/FilterElement";
19 | import React, { useMemo } from "react";
20 | import { useCourseSearchingContext } from "components/Providers/CourseSearchingProvider";
21 | import { reportEvent } from "utils/ga";
22 |
23 | export interface CategoryFilterModalProps {
24 | readonly title: string;
25 | readonly isEnabled: boolean;
26 | readonly selectedType: string[];
27 | readonly setSelectedType: (time: string[]) => void;
28 | }
29 |
30 | function CategoryFilterModal({
31 | title,
32 | isEnabled,
33 | selectedType,
34 | setSelectedType,
35 | }: CategoryFilterModalProps) {
36 | const headingColor = useColorModeValue("heading.light", "heading.dark");
37 | const { searchFilters, setSearchFilters } = useCourseSearchingContext();
38 | const { isOpen, onOpen, onClose } = useDisclosure();
39 | const modalBgColor = useColorModeValue("white", "gray.700");
40 |
41 | const onOpenModal = () => {
42 | // overwrite local states by redux store
43 | setSelectedType(searchFilters.category);
44 | onOpen();
45 | };
46 |
47 | const onCancelEditing = () => {
48 | // fire when click "X" or outside of modal
49 | // overwrite local state by redux state
50 | onClose();
51 | setSelectedType(searchFilters.category);
52 | };
53 |
54 | const onSaveEditing = () => {
55 | // fire when click "Save"
56 | // overwrite redux state by local state
57 | setSearchFilters({ ...searchFilters, category: selectedType });
58 | onClose();
59 | };
60 |
61 | const onResetEditing = () => {
62 | // fire when click "Reset"
63 | // set local state to empty array
64 | setSelectedType([]);
65 | };
66 |
67 | const modalBody = useMemo(
68 | () => (
69 |
70 | {Object.keys(code_map).map((code_map_key, index) => {
71 | const categories = type_list.filter(
72 | (type) => type.code[0] === code_map_key
73 | );
74 | return (
75 |
76 |
87 |
88 | {code_map[code_map_key].name}
89 |
90 |
91 |
92 | {categories
93 | .filter((type) => !selectedType.includes(type.id))
94 | .map((type, typeIdx) => (
95 | {
101 | setSelectedType([...selectedType, type.id]);
102 | reportEvent("filter_category", "click", type.id);
103 | }}
104 | />
105 | ))}
106 |
107 | );
108 | })}
109 |
110 | ),
111 | [selectedType, setSelectedType, headingColor, modalBgColor]
112 | );
113 |
114 | return (
115 | <>
116 |
126 | {
129 | onCancelEditing();
130 | }}
131 | size={useBreakpointValue({ base: "full", md: "xl" }) ?? "xl"}
132 | scrollBehavior="inside"
133 | >
134 |
135 |
136 |
137 | {title}
138 |
145 |
156 |
166 |
167 |
168 |
169 |
170 | {type_list
171 | .filter((type) => selectedType.includes(type.id))
172 | .map((type, index) => (
173 | {
179 | setSelectedType(
180 | selectedType.filter((id) => id !== type.id)
181 | );
182 | reportEvent("filter_category", "click", type.id);
183 | }}
184 | />
185 | ))}
186 |
187 | {modalBody}
188 |
189 |
190 |
200 |
209 |
210 |
211 |
212 | >
213 | );
214 | }
215 |
216 | export default CategoryFilterModal;
217 |
--------------------------------------------------------------------------------
/src/components/FilterModals/DeptFilterModal.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | useDisclosure,
3 | Modal,
4 | ModalOverlay,
5 | ModalContent,
6 | ModalHeader,
7 | ModalFooter,
8 | ModalBody,
9 | ModalCloseButton,
10 | Button,
11 | Heading,
12 | Divider,
13 | Flex,
14 | useBreakpointValue,
15 | useColorModeValue,
16 | } from "@chakra-ui/react";
17 | import React, { useMemo } from "react";
18 | import { college_map } from "data/college";
19 | import { deptList } from "data/department";
20 | import FilterElement from "components/FilterModals/components/FilterElement";
21 | import { useCourseSearchingContext } from "components/Providers/CourseSearchingProvider";
22 | import { reportEvent } from "utils/ga";
23 |
24 | export interface DeptFilterModalProps {
25 | readonly title: string;
26 | readonly isEnabled: boolean;
27 | readonly selectedDept: string[];
28 | readonly setSelectedDept: (time: string[]) => void;
29 | }
30 |
31 | function DeptFilterModal({
32 | title,
33 | isEnabled,
34 | selectedDept,
35 | setSelectedDept,
36 | }: DeptFilterModalProps) {
37 | const headingColor = useColorModeValue("heading.light", "heading.dark");
38 | const { searchFilters, setSearchFilters } = useCourseSearchingContext();
39 | const { isOpen, onOpen, onClose } = useDisclosure();
40 | const modalBgColor = useColorModeValue("white", "gray.700");
41 |
42 | const onOpenModal = () => {
43 | // overwrite local states by redux store
44 | setSelectedDept(searchFilters.department);
45 | onOpen();
46 | };
47 |
48 | const onCancelEditing = () => {
49 | // fire when click "X" or outside of modal
50 | // overwrite local state by redux state
51 | onClose();
52 | setSelectedDept(searchFilters.department);
53 | };
54 |
55 | const onSaveEditing = () => {
56 | // fire when click "Save"
57 | // overwrite redux state by local state
58 | setSearchFilters({ ...searchFilters, department: selectedDept });
59 | onClose();
60 | };
61 |
62 | const onResetEditing = () => {
63 | // fire when click "Reset"
64 | // set local state to empty array
65 | setSelectedDept([]);
66 | };
67 |
68 | const modalBody = useMemo(
69 | () => (
70 | <>
71 | {Object.keys(college_map).map((college_key, index) => {
72 | const departments = deptList.filter(
73 | (dept) => dept.college_id === college_key
74 | );
75 | if (departments.length === 0) {
76 | return null;
77 | }
78 | return (
79 |
80 |
91 |
92 | {college_key + " " + college_map[college_key].name}
93 |
94 |
95 |
96 | {departments
97 | .filter((dept) => !selectedDept.includes(dept.id))
98 | .map((dept, dept_index) => (
99 | {
105 | setSelectedDept([...selectedDept, dept.id]);
106 | reportEvent("filter_department", "click", dept.id);
107 | }}
108 | />
109 | ))}
110 |
111 | );
112 | })}
113 | >
114 | ),
115 | [selectedDept, setSelectedDept, headingColor, modalBgColor]
116 | );
117 |
118 | return (
119 | <>
120 |
130 | {
133 | onCancelEditing();
134 | }}
135 | size={useBreakpointValue({ base: "full", md: "xl" }) ?? "xl"}
136 | scrollBehavior="inside"
137 | >
138 |
139 |
140 |
141 | {title}
142 |
149 |
160 |
170 |
171 |
172 |
173 |
174 | {deptList
175 | .filter((dept) => selectedDept.includes(dept.id))
176 | .map((dept, index) => (
177 | {
183 | setSelectedDept(
184 | selectedDept.filter((code) => code !== dept.id)
185 | );
186 | reportEvent("filter_department", "click", dept.id);
187 | }}
188 | />
189 | ))}
190 |
191 | {modalBody}
192 |
193 |
194 |
204 |
213 |
214 |
215 |
216 | >
217 | );
218 | }
219 |
220 | export default DeptFilterModal;
221 |
--------------------------------------------------------------------------------
/src/components/FilterModals/TimeFilterModal.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | useDisclosure,
3 | Modal,
4 | ModalOverlay,
5 | ModalContent,
6 | ModalHeader,
7 | ModalFooter,
8 | ModalBody,
9 | ModalCloseButton,
10 | Button,
11 | Flex,
12 | useBreakpointValue,
13 | } from "@chakra-ui/react";
14 | import TimetableSelector from "components/FilterModals/components/TimetableSelector";
15 | import { mapStateToTimeTable } from "utils/timeTableConverter";
16 | import { useCourseSearchingContext } from "components/Providers/CourseSearchingProvider";
17 | import { reportEvent } from "utils/ga";
18 | import type { Interval } from "types/course";
19 | import { intervals } from "constant";
20 |
21 | export interface TimeFilterModalProps {
22 | readonly title: string;
23 | readonly isEnabled: boolean;
24 | readonly selectedTime: boolean[][];
25 | readonly setSelectedTime: (time: boolean[][]) => void;
26 | }
27 |
28 | function TimeFilterModal(props: TimeFilterModalProps) {
29 | const { selectedTime, setSelectedTime, isEnabled, title } = props;
30 | const { searchFilters, setSearchFilters } = useCourseSearchingContext();
31 | const { isOpen, onOpen, onClose } = useDisclosure();
32 |
33 | const saveSelectedTime = () => {
34 | // turn 15x7 2D array (selectedTime) to 7x15 array
35 | const timeTable: Interval[][] = [[], [], [], [], [], [], []];
36 | for (let i = 0; i < intervals.length; i++) {
37 | const interval = intervals[i];
38 | for (let j = 0; j < 7; j++) {
39 | if (selectedTime[i][j] === true) {
40 | timeTable[j].push(interval);
41 | }
42 | }
43 | }
44 | setSearchFilters({ ...searchFilters, time: timeTable });
45 | };
46 |
47 | const resetSelectedTime = () => {
48 | setSelectedTime([
49 | [false, false, false, false, false, false, false],
50 | [false, false, false, false, false, false, false],
51 | [false, false, false, false, false, false, false],
52 | [false, false, false, false, false, false, false],
53 | [false, false, false, false, false, false, false],
54 | [false, false, false, false, false, false, false],
55 | [false, false, false, false, false, false, false],
56 | [false, false, false, false, false, false, false],
57 | [false, false, false, false, false, false, false],
58 | [false, false, false, false, false, false, false],
59 | [false, false, false, false, false, false, false],
60 | [false, false, false, false, false, false, false],
61 | [false, false, false, false, false, false, false],
62 | [false, false, false, false, false, false, false],
63 | [false, false, false, false, false, false, false],
64 | ]);
65 | };
66 |
67 | return (
68 | <>
69 |
80 | {
83 | onClose();
84 | }}
85 | size={useBreakpointValue({ base: "full", md: "xl" }) ?? "xl"}
86 | scrollBehavior="inside"
87 | >
88 |
89 |
90 |
91 | {title}
92 |
99 |
111 |
121 |
122 |
123 |
124 |
125 |
129 |
130 |
131 |
142 |
151 |
152 |
153 |
154 | >
155 | );
156 | }
157 |
158 | export default TimeFilterModal;
159 |
--------------------------------------------------------------------------------
/src/components/FilterModals/components/FilterElement.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Button,
3 | Badge,
4 | useColorModeValue,
5 | ButtonProps,
6 | } from "@chakra-ui/react";
7 |
8 | export interface FilterElementProps extends ButtonProps {
9 | readonly id: string;
10 | readonly name: string;
11 | readonly selected: boolean;
12 | }
13 |
14 | // Buttons in filter modal
15 | function FilterElement(props: FilterElementProps) {
16 | const { id, name, selected, onClick } = props;
17 | return (
18 |
32 | );
33 | }
34 | export default FilterElement;
35 |
--------------------------------------------------------------------------------
/src/components/FilterModals/components/TimetableSelector.tsx:
--------------------------------------------------------------------------------
1 | import TableDragSelect from "react-table-drag-select";
2 | import "react-table-drag-select/style.css";
3 | import { Flex, Text } from "@chakra-ui/react";
4 | import { intervals } from "constant";
5 |
6 | function TimetableSelector({
7 | selectedTime,
8 | setSelectedTime,
9 | }: {
10 | readonly selectedTime: boolean[][];
11 | readonly setSelectedTime: (time: boolean[][]) => void;
12 | }) {
13 | const days = ["一", "二", "三", "四", "五", "六", "日"];
14 |
15 | return (
16 |
17 |
22 | {days.map((day, j) => {
23 | return (
24 |
30 | {day}
31 |
32 | );
33 | })}
34 |
35 |
36 |
43 | {intervals.map((interval, j) => {
44 | return (
45 |
51 | {interval}
52 |
53 | );
54 | })}
55 |
56 |
59 | setSelectedTime(new_time_table)
60 | }
61 | >
62 | {intervals.map((day, i) => {
63 | return (
64 |
65 | {days.map((interval, j) => {
66 | return | ;
67 | })}
68 |
69 | );
70 | })}
71 |
72 |
79 | {intervals.map((interval, j) => {
80 | return (
81 |
87 | {interval}
88 |
89 | );
90 | })}
91 |
92 |
93 |
94 | );
95 | }
96 | export default TimetableSelector;
97 |
--------------------------------------------------------------------------------
/src/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Flex,
3 | Spacer,
4 | Text,
5 | Button,
6 | ButtonGroup,
7 | HStack,
8 | Icon,
9 | Center,
10 | useColorModeValue,
11 | } from "@chakra-ui/react";
12 | import { FaCodeBranch, FaGithub, FaHeartbeat } from "react-icons/fa";
13 | import Link from "next/link";
14 | import { DiscordIcon } from "components/CustomIcons";
15 | import Image from "next/image";
16 | import { reportEvent } from "utils/ga";
17 |
18 | function Footer() {
19 | const ver = "beta (20220721)";
20 | const secondaryColor = useColorModeValue("gray.400", "gray.500");
21 | const handleOpenPage = (page: string) => {
22 | window.open(page, "_blank");
23 | };
24 | return (
25 |
38 |
39 |
40 |
47 |
48 |
49 |
50 |
51 |
52 | {ver}
53 |
54 |
55 |
56 |
57 |
84 |
111 |
138 |
139 |
140 | );
141 | }
142 | export default Footer;
143 |
--------------------------------------------------------------------------------
/src/components/GoogleAnalytics.tsx:
--------------------------------------------------------------------------------
1 | import Script from "next/script";
2 | import { useEffect } from "react";
3 | import { useRouter } from "next/router";
4 |
5 | const GA_ID = process.env.NEXT_PUBLIC_GA_ID;
6 |
7 | function GoogleAnalytics() {
8 | const router = useRouter();
9 | useEffect(() => {
10 | const handleRouteChange = (url: string) => {
11 | if (GA_ID) {
12 | window.gtag("config", GA_ID, { page_path: url });
13 | }
14 | };
15 | router.events.on("routeChangeComplete", handleRouteChange);
16 | return () => {
17 | router.events.off("routeChangeComplete", handleRouteChange);
18 | };
19 | }, [router.events]);
20 |
21 | return (
22 | <>
23 |
27 |
41 | >
42 | );
43 | }
44 |
45 | export default GoogleAnalytics;
46 |
--------------------------------------------------------------------------------
/src/components/HeaderBar.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {
3 | Flex,
4 | Heading,
5 | Button,
6 | Avatar,
7 | Menu,
8 | MenuButton,
9 | MenuList,
10 | MenuGroup,
11 | MenuItem,
12 | MenuDivider,
13 | Text,
14 | HStack,
15 | AvatarBadge,
16 | Badge,
17 | useColorModeValue,
18 | } from "@chakra-ui/react";
19 | import { ChevronRightIcon } from "@chakra-ui/icons";
20 | import Link from "next/link";
21 | import { useRouter } from "next/router";
22 | import { FaCheck, FaExclamation, FaBook, FaInfoCircle } from "react-icons/fa";
23 | import { useUser } from "@auth0/nextjs-auth0";
24 | import BeatLoader from "react-spinners/BeatLoader";
25 | import Image from "next/image";
26 | import ThemeToggleButton from "components/ThemeToggleButton";
27 | import { reportEvent } from "utils/ga";
28 |
29 | function SignInButton() {
30 | const { user, isLoading } = useUser();
31 | const router = useRouter();
32 | const textColor = useColorModeValue("gray.600", "gray.300");
33 | const loginBtnBg = useColorModeValue("yellow.300", "yellow.600");
34 |
35 | if (isLoading) {
36 | return (
37 | }
44 | isLoading
45 | />
46 | );
47 | }
48 |
49 | if (user) {
50 | return (
51 |
128 | );
129 | }
130 |
131 | return (
132 | <>
133 |
164 | }
168 | size="md"
169 | ml="10px"
170 | mr="10px"
171 | onClick={() => {
172 | reportEvent("header", "click_external", "status");
173 | router.push("/api/auth/login");
174 | }}
175 | display={{ base: "none", md: "inline-block" }}
176 | >
177 | 登入 / 註冊
178 |
179 | >
180 | );
181 | }
182 |
183 | function HeaderBar() {
184 | return (
185 |
196 |
197 |
198 |
199 |
206 |
213 | NTUCourse Neo
214 |
215 |
216 |
217 |
218 | }
223 | color={useColorModeValue("link.light", "link.dark")}
224 | >
225 | 課程
226 |
227 |
228 |
229 |
230 |
231 |
243 |
244 |
245 |
246 |
247 |
248 | );
249 | }
250 |
251 | export default HeaderBar;
252 |
--------------------------------------------------------------------------------
/src/components/HomeCard.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | Flex,
4 | Heading,
5 | Text,
6 | Image,
7 | useColorModeValue,
8 | BoxProps,
9 | } from "@chakra-ui/react";
10 |
11 | export interface HomeCardProps extends BoxProps {
12 | readonly desc: string[];
13 | readonly title: string;
14 | readonly img: string;
15 | readonly imgAtLeft?: boolean;
16 | }
17 |
18 | function HomeCard(props: HomeCardProps) {
19 | const { bg, desc, img, title, children, imgAtLeft = false } = props;
20 | const textColor = useColorModeValue("gray.500", "gray.400");
21 | return (
22 |
29 |
36 | {!imgAtLeft ? (
37 | <>>
38 | ) : (
39 |
47 | )}
48 |
55 |
62 | {title}
63 |
64 | {desc.map((item, index) => {
65 | return (
66 |
73 | {item}
74 |
75 | );
76 | })}
77 |
78 | {imgAtLeft ? (
79 | <>>
80 | ) : (
81 |
89 | )}
90 |
91 | {children}
92 |
93 | );
94 | }
95 | export default HomeCard;
96 |
--------------------------------------------------------------------------------
/src/components/Providers/CourseSearchingProvider.tsx:
--------------------------------------------------------------------------------
1 | import React, { createContext, useContext, useState } from "react";
2 | import type { SearchFieldName, FilterEnable, Filter } from "types/search";
3 |
4 | export interface SearchConfigType {
5 | show_selected_courses: boolean;
6 | only_show_not_conflicted_courses: boolean;
7 | sync_add_to_nol: boolean;
8 | strict_search_mode: boolean;
9 | }
10 |
11 | interface CourseSearchingContextType {
12 | search: string | null;
13 | setSearch: (search: string | null) => void;
14 | pageNumber: number;
15 | setPageNumber: (pageNumber: number) => void;
16 | searchResultCount: number;
17 | setSearchResultCount: (searchResultCount: number) => void;
18 | searchLoading: boolean;
19 | setSearchLoading: (searchLoading: boolean) => void;
20 | totalCount: number;
21 | setTotalCount: (totalCount: number) => void;
22 | batchSize: number;
23 | setBatchSize: (batchSize: number) => void;
24 | searchColumns: SearchFieldName[];
25 | setSearchColumns: (searchColumns: SearchFieldName[]) => void;
26 | searchSettings: SearchConfigType;
27 | setSearchSettings: (searchSettings: SearchConfigType) => void;
28 | searchFiltersEnable: FilterEnable;
29 | setSearchFiltersEnable: (searchFiltersEnable: FilterEnable) => void;
30 | searchFilters: Filter;
31 | setSearchFilters: (searchFilters: Filter) => void;
32 | searchSemester: string | null;
33 | setSearchSemester: (searchSemester: string | null) => void;
34 | fetchNextPage: () => void;
35 | dispatchSearch: (search: string | null) => void;
36 | }
37 |
38 | const CourseSearchingContext = createContext({
39 | search: "",
40 | pageNumber: 0,
41 | searchResultCount: 0,
42 | searchLoading: false,
43 | totalCount: 0,
44 | batchSize: 20,
45 | searchColumns: ["name", "teacher", "serial", "code", "identifier"],
46 | searchSettings: {
47 | show_selected_courses: false,
48 | only_show_not_conflicted_courses: false,
49 | sync_add_to_nol: false,
50 | strict_search_mode: true,
51 | },
52 | searchFiltersEnable: {
53 | time: false,
54 | department: false,
55 | category: false,
56 | enroll_method: false,
57 | },
58 | searchFilters: {
59 | time: [[], [], [], [], [], [], []],
60 | department: [],
61 | category: [],
62 | enroll_method: ["1", "2", "3"],
63 | },
64 | searchSemester: "",
65 | setSearch: () => {},
66 | setPageNumber: () => {},
67 | setSearchLoading: () => {},
68 | setSearchResultCount: () => {},
69 | setTotalCount: () => {},
70 | setBatchSize: () => {},
71 | setSearchSettings: () => {},
72 | setSearchColumns: () => {},
73 | setSearchFiltersEnable: () => {},
74 | setSearchFilters: () => {},
75 | setSearchSemester: () => {},
76 | fetchNextPage: () => {},
77 | dispatchSearch: () => {},
78 | });
79 |
80 | const CourseSearchingProvider: React.FC<{
81 | readonly children: React.ReactNode;
82 | }> = ({ children }) => {
83 | const [search, setSearch] = useState(null);
84 | const [pageNumber, setPageNumber] = useState(0);
85 | const [searchResultCount, setSearchResultCount] = useState(0);
86 | const [searchLoading, setSearchLoading] = useState(false);
87 | const [totalCount, setTotalCount] = useState(0);
88 | const [batchSize, setBatchSize] = useState(20);
89 | const [searchColumns, setSearchColumns] = useState([
90 | "name",
91 | "teacher",
92 | "serial",
93 | "code",
94 | "identifier",
95 | ]);
96 | const [searchSettings, setSearchSettings] = useState({
97 | show_selected_courses: false,
98 | only_show_not_conflicted_courses: false,
99 | sync_add_to_nol: false,
100 | strict_search_mode: true,
101 | });
102 | const [searchFiltersEnable, setSearchFiltersEnable] = useState({
103 | time: false,
104 | department: false,
105 | category: false,
106 | enroll_method: false,
107 | });
108 | const [searchFilters, setSearchFilters] = useState({
109 | time: [[], [], [], [], [], [], []],
110 | department: [],
111 | category: [],
112 | enroll_method: ["1", "2", "3"],
113 | });
114 | const [searchSemester, setSearchSemester] = useState(
115 | process.env.NEXT_PUBLIC_SEMESTER ?? null
116 | );
117 |
118 | const fetchNextPage = () => {
119 | setPageNumber(pageNumber + 1);
120 | };
121 |
122 | const dispatchSearch = (text: string | null) => {
123 | setSearch(text);
124 | setSearchResultCount(0);
125 | setPageNumber(1);
126 | };
127 |
128 | return (
129 |
157 | {children}
158 |
159 | );
160 | };
161 |
162 | function useCourseSearchingContext() {
163 | return useContext(CourseSearchingContext);
164 | }
165 |
166 | export { CourseSearchingProvider, useCourseSearchingContext };
167 |
--------------------------------------------------------------------------------
/src/components/Providers/DisplayTagsProvider.tsx:
--------------------------------------------------------------------------------
1 | import React, { createContext, useContext, useState } from "react";
2 |
3 | export type DisplayTagName = "requirement" | "slot" | "enroll_method" | "areas";
4 | export const availableTags: DisplayTagName[] = [
5 | "requirement",
6 | "slot",
7 | "enroll_method",
8 | "areas",
9 | ];
10 |
11 | interface DisplayTagsContextType {
12 | displayTags: DisplayTagName[];
13 | setDisplayTags: (displayTags: DisplayTagName[]) => void;
14 | }
15 |
16 | const DisplayTagsContext = createContext({
17 | displayTags: [],
18 | setDisplayTags: (displayTags: DisplayTagName[]) => {},
19 | });
20 |
21 | const DisplayTagsProvider: React.FC<{
22 | readonly children: React.ReactNode;
23 | }> = ({ children }) => {
24 | const [displayTags, setDisplayTags] = useState([]);
25 | return (
26 |
27 | {children}
28 |
29 | );
30 | };
31 |
32 | function useDisplayTags() {
33 | return useContext(DisplayTagsContext);
34 | }
35 |
36 | export { DisplayTagsProvider, useDisplayTags };
37 |
--------------------------------------------------------------------------------
/src/components/SkeletonRow.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Flex, Skeleton } from "@chakra-ui/react";
3 | import { useCourseSearchingContext } from "components/Providers/CourseSearchingProvider";
4 |
5 | export interface SkeletonProps {
6 | loading?: boolean;
7 | times?: number;
8 | }
9 |
10 | function SkeletonRow({ loading, times = 1 }: SkeletonProps) {
11 | const { searchLoading } = useCourseSearchingContext();
12 | const isLoading = loading ?? searchLoading;
13 |
14 | if (isLoading) {
15 | return (
16 |
17 | {[...Array(times)].map((_, index) => (
18 |
26 | ))}
27 |
28 | );
29 | }
30 |
31 | return null;
32 | }
33 | export default SkeletonRow;
34 |
--------------------------------------------------------------------------------
/src/components/ThemeToggleButton.tsx:
--------------------------------------------------------------------------------
1 | import { motion, AnimatePresence } from "framer-motion";
2 | import {
3 | Button,
4 | Icon,
5 | useColorMode,
6 | useColorModeValue,
7 | } from "@chakra-ui/react";
8 | import { SunIcon, MoonIcon } from "@chakra-ui/icons";
9 | import { reportEvent } from "utils/ga";
10 |
11 | const ThemeToggleButton = () => {
12 | const { toggleColorMode } = useColorMode();
13 | const IconColor = useColorModeValue("orange.600", "purple.700");
14 | const gtagActionLabel = useColorModeValue(
15 | "switch_dark_mode",
16 | "switch_light_mode"
17 | );
18 |
19 | return (
20 |
21 |
29 |
45 |
46 |
47 | );
48 | };
49 |
50 | export default ThemeToggleButton;
51 |
--------------------------------------------------------------------------------
/src/constant.ts:
--------------------------------------------------------------------------------
1 | import type { Interval } from "types/course";
2 |
3 | export const NCN_COURSE_TABLE_LOCAL_STORAGE_KEY =
4 | "NTU_CourseNeo_Course_Table_Key_v2";
5 | export const intervals: Interval[] = [
6 | "0",
7 | "1",
8 | "2",
9 | "3",
10 | "4",
11 | "5",
12 | "6",
13 | "7",
14 | "8",
15 | "9",
16 | "10",
17 | "A",
18 | "B",
19 | "C",
20 | "D",
21 | ];
22 |
--------------------------------------------------------------------------------
/src/data/college.ts:
--------------------------------------------------------------------------------
1 | export interface CollegeMap {
2 | [key: string]: {
3 | name: string;
4 | };
5 | }
6 |
7 | // IMPORTANT: must be aligned with DB
8 | const college_map: CollegeMap = {
9 | 0: {
10 | name: "未知學院",
11 | },
12 | 1: {
13 | name: "文學院",
14 | },
15 | 2: {
16 | name: "理學院",
17 | },
18 | 3: {
19 | name: "社會科學院",
20 | },
21 | 4: {
22 | name: "醫學院",
23 | },
24 | 5: {
25 | name: "工學院",
26 | },
27 | 6: {
28 | name: "生物資源暨農學院",
29 | },
30 | 7: {
31 | name: "管理學院",
32 | },
33 | 8: {
34 | name: "公共衛生學院",
35 | },
36 | 9: {
37 | name: "電機資訊學院",
38 | },
39 | A: {
40 | name: "法律學院",
41 | },
42 | B: {
43 | name: "生命科學院",
44 | },
45 | E: {
46 | name: "進修推廣學院",
47 | },
48 | H: {
49 | name: "學位學程",
50 | },
51 | J: {
52 | name: "產業研發碩士專班",
53 | },
54 | K: {
55 | name: "重點科技研究學院與三校聯盟",
56 | },
57 | Q: {
58 | name: "寫作教學中心",
59 | },
60 | V: {
61 | name: "國家理論科學研究中心",
62 | },
63 | Z: {
64 | name: "創新設計學院",
65 | },
66 | };
67 |
68 | const collegeIds = Object.keys(college_map);
69 |
70 | export { college_map, collegeIds };
71 |
--------------------------------------------------------------------------------
/src/data/course_select_schedule.ts:
--------------------------------------------------------------------------------
1 | export interface CourseSelectionStatus {
2 | name: string;
3 | emoji: string;
4 | color: string;
5 | }
6 |
7 | const status_map: CourseSelectionStatus[] = [
8 | {
9 | name: "即將開始",
10 | emoji: "⏰",
11 | color: "blue.200",
12 | },
13 | {
14 | name: "已開始",
15 | emoji: "🏎️",
16 | color: "green.200",
17 | },
18 | {
19 | name: "快結束啦!",
20 | emoji: "🏃",
21 | color: "yellow.200",
22 | },
23 | {
24 | name: "剩不到一天了",
25 | emoji: "🥵",
26 | color: "red.200",
27 | },
28 | ];
29 |
30 | const ntu_course_select_url = [
31 | "https://if192.aca.ntu.edu.tw/index.php",
32 | "https://if177.aca.ntu.edu.tw/index.php",
33 | ];
34 |
35 | export interface CourseSelectionSchedule {
36 | name: string;
37 | label: string;
38 | start: number;
39 | end: number;
40 | }
41 |
42 | const course_select_schedule: CourseSelectionSchedule[] = [
43 | {
44 | name: "初選第一階段",
45 | label: "初選 一階",
46 | start: 1642381200,
47 | end: 1642705200,
48 | },
49 | {
50 | name: "初選第二階段",
51 | label: "二階",
52 | start: 1642986000,
53 | end: 1643223600,
54 | },
55 | {
56 | name: "第一週加退選",
57 | label: "加退選 W1",
58 | start: 1644800400,
59 | end: 1645243200,
60 | },
61 | {
62 | name: "第二週加退選",
63 | label: "W2",
64 | start: 1645405200,
65 | end: 1645869600,
66 | },
67 | {
68 | name: "第三週加退選",
69 | label: "W3",
70 | start: 1646096400,
71 | end: 1646679600,
72 | },
73 | ];
74 |
75 | export { status_map, ntu_course_select_url, course_select_schedule };
76 |
--------------------------------------------------------------------------------
/src/data/course_type.ts:
--------------------------------------------------------------------------------
1 | export interface CourseCategory {
2 | id: string;
3 | code: string;
4 | full_name: string;
5 | }
6 |
7 | const type_list: CourseCategory[] = [
8 | {
9 | id: "chinese",
10 | code: "共",
11 | full_name: "國文領域",
12 | },
13 | {
14 | id: "english",
15 | code: "共",
16 | full_name: "英文領域",
17 | },
18 | {
19 | id: "english_adv",
20 | code: "共",
21 | full_name: "進階英文",
22 | },
23 | {
24 | id: "foreign",
25 | code: "共",
26 | full_name: "外文領域",
27 | },
28 | {
29 | id: "foreign_like",
30 | code: "共",
31 | full_name: "可充當外文領域",
32 | },
33 | {
34 | id: "shared_selective",
35 | code: "共",
36 | full_name: "共同選修課程",
37 | },
38 | {
39 | id: "g_ax",
40 | code: "AX",
41 | full_name: "不分領域通識課程",
42 | },
43 | {
44 | id: "g_a1",
45 | code: "A1",
46 | full_name: "文學與藝術領域",
47 | },
48 | {
49 | id: "g_a2",
50 | code: "A2",
51 | full_name: "歷史思維領域",
52 | },
53 | {
54 | id: "g_a3",
55 | code: "A3",
56 | full_name: "世界文明領域",
57 | },
58 | {
59 | id: "g_a4",
60 | code: "A4",
61 | full_name: "哲學與道德思考領域",
62 | },
63 | {
64 | id: "g_a5",
65 | code: "A5",
66 | full_name: "公民意識與社會分析領域",
67 | },
68 | {
69 | id: "g_a6",
70 | code: "A6",
71 | full_name: "量化分析與數學素養領域",
72 | },
73 | {
74 | id: "g_a7",
75 | code: "A7",
76 | full_name: "物質科學領域",
77 | },
78 | {
79 | id: "g_a8",
80 | code: "A8",
81 | full_name: "生命科學領域",
82 | },
83 | {
84 | id: "freshman",
85 | code: "新",
86 | full_name: "新生專題/新生講座課程",
87 | },
88 | {
89 | id: "basic",
90 | code: "基",
91 | full_name: "基本能力課程",
92 | },
93 | {
94 | id: "millitary",
95 | code: "軍",
96 | full_name: "軍訓課程",
97 | },
98 | {
99 | id: "pe_1",
100 | code: "體",
101 | full_name: "健康體適能",
102 | },
103 | {
104 | id: "pe_2",
105 | code: "體",
106 | full_name: "專項運動學群",
107 | },
108 | {
109 | id: "pe_3",
110 | code: "體",
111 | full_name: "選修體育",
112 | },
113 | {
114 | id: "pe_4",
115 | code: "體",
116 | full_name: "校隊班",
117 | },
118 | {
119 | id: "pe_5",
120 | code: "體",
121 | full_name: "進修學士班",
122 | },
123 | {
124 | id: "frequent",
125 | code: "密",
126 | full_name: "密集課程",
127 | },
128 | ];
129 |
130 | export interface CourseCategoryCodeMap {
131 | [key: string]: {
132 | name: string;
133 | };
134 | }
135 |
136 | const code_map: CourseCategoryCodeMap = {
137 | 共: {
138 | name: "共同課程",
139 | },
140 | A: {
141 | name: "通識",
142 | },
143 | 新: {
144 | name: "新生專題/新生講座",
145 | },
146 | 基: {
147 | name: "基本能力課程",
148 | },
149 | 軍: {
150 | name: "軍訓",
151 | },
152 | 體: {
153 | name: "體育",
154 | },
155 | 密: {
156 | name: "密集課程",
157 | },
158 | };
159 |
160 | export { type_list, code_map };
161 |
--------------------------------------------------------------------------------
/src/data/mapping_table.ts:
--------------------------------------------------------------------------------
1 | import {
2 | FaUserGraduate,
3 | FaCheckSquare,
4 | FaPuzzlePiece,
5 | FaLanguage,
6 | FaFileImport,
7 | } from "react-icons/fa";
8 | import { IconType } from "react-icons/lib";
9 |
10 | interface CourseInfoBaseType {
11 | name: string;
12 | logo: IconType;
13 | color: string;
14 | }
15 |
16 | interface CourseInfoType extends CourseInfoBaseType {
17 | map: Record;
18 | }
19 |
20 | export interface CourseInfoTranslateMap {
21 | requirement: CourseInfoType;
22 | slot: CourseInfoBaseType;
23 | areas: CourseInfoType<{
24 | id: string;
25 | code: string;
26 | full_name: string;
27 | }>;
28 | enroll_method: CourseInfoType;
29 | language: CourseInfoType;
30 | }
31 |
32 | // TODO: get rid of this? because the information already in v2 API
33 | const info_view_map: CourseInfoTranslateMap = {
34 | requirement: {
35 | name: "課程必選修",
36 | logo: FaCheckSquare,
37 | color: "blue",
38 | map: {
39 | preassign: "必帶",
40 | required: "必修",
41 | elective: "選修",
42 | other: "其他",
43 | },
44 | },
45 | slot: {
46 | name: "修課人數上限",
47 | logo: FaUserGraduate,
48 | color: "blue",
49 | },
50 | areas: {
51 | name: "課程領域",
52 | logo: FaPuzzlePiece,
53 | color: "blue",
54 | map: {
55 | chinese: {
56 | id: "chinese",
57 | code: "共",
58 | full_name: "國文領域",
59 | },
60 | english: {
61 | id: "english",
62 | code: "共",
63 | full_name: "英文領域",
64 | },
65 | english_adv: {
66 | id: "english_adv",
67 | code: "共",
68 | full_name: "進階英文",
69 | },
70 | foreign: {
71 | id: "foreign",
72 | code: "共",
73 | full_name: "外文領域",
74 | },
75 | foreign_like: {
76 | id: "foreign_like",
77 | code: "共",
78 | full_name: "可充當外文領域",
79 | },
80 | shared_selective: {
81 | id: "shared_selective",
82 | code: "共",
83 | full_name: "共同選修課程",
84 | },
85 | g_ax: {
86 | id: "g_ax",
87 | code: "AX",
88 | full_name: "不分領域通識課程",
89 | },
90 | g_a1: {
91 | id: "g_a1",
92 | code: "A1",
93 | full_name: "A1 文學與藝術領域",
94 | },
95 | g_a2: {
96 | id: "g_a2",
97 | code: "A2",
98 | full_name: "A2 歷史思維領域",
99 | },
100 | g_a3: {
101 | id: "g_a3",
102 | code: "A3",
103 | full_name: "A3 世界文明領域",
104 | },
105 | g_a4: {
106 | id: "g_a4",
107 | code: "A4",
108 | full_name: "A4 哲學與道德思考領域",
109 | },
110 | g_a5: {
111 | id: "g_a5",
112 | code: "A5",
113 | full_name: "A5 公民意識與社會分析領域",
114 | },
115 | g_a6: {
116 | id: "g_a6",
117 | code: "A6",
118 | full_name: "A6 量化分析與數學素養領域",
119 | },
120 | g_a7: {
121 | id: "g_a7",
122 | code: "A7",
123 | full_name: "A7 物質科學領域",
124 | },
125 | g_a8: {
126 | id: "g_a8",
127 | code: "A8",
128 | full_name: "A8 生命科學領域",
129 | },
130 | freshman: {
131 | id: "freshman",
132 | code: "新",
133 | full_name: "新生專題/新生講座課程",
134 | },
135 | basic: {
136 | id: "basic",
137 | code: "基",
138 | full_name: "基本能力課程",
139 | },
140 | military: {
141 | id: "millitary",
142 | code: "軍",
143 | full_name: "軍訓課程",
144 | },
145 | pe_1: {
146 | id: "pe_1",
147 | code: "體",
148 | full_name: "健康體適能",
149 | },
150 | pe_2: {
151 | id: "pe_2",
152 | code: "體",
153 | full_name: "專項運動學群",
154 | },
155 | pe_3: {
156 | id: "pe_3",
157 | code: "體",
158 | full_name: "選修體育",
159 | },
160 | pe_4: {
161 | id: "pe_4",
162 | code: "體",
163 | full_name: "校隊班",
164 | },
165 | pe_5: {
166 | id: "pe_5",
167 | code: "體",
168 | full_name: "進修學士班",
169 | },
170 | frequent: {
171 | id: "frequent",
172 | code: "密",
173 | full_name: "密集課程",
174 | },
175 | },
176 | },
177 | enroll_method: {
178 | name: "加簽方式",
179 | logo: FaFileImport,
180 | color: "blue",
181 | map: {
182 | 1: "無限制",
183 | 2: "授權碼",
184 | 3: "登記分發",
185 | },
186 | },
187 | language: {
188 | name: "授課語言",
189 | logo: FaLanguage,
190 | color: "blue",
191 | map: {
192 | zh_TW: "中文",
193 | en_US: "英文",
194 | },
195 | },
196 | };
197 |
198 | export type Weekday = "1" | "2" | "3" | "4" | "5" | "6" | "7";
199 | const weekdays_map: Record = {
200 | 1: "一",
201 | 2: "二",
202 | 3: "三",
203 | 4: "四",
204 | 5: "五",
205 | 6: "六",
206 | 7: "日",
207 | };
208 |
209 | const social_user_type_map = {
210 | student: "學生",
211 | teacher: "教師",
212 | course_teacher: "此課程講師",
213 | course_assistant: "此課程助教",
214 | others: "其他",
215 | };
216 |
217 | export type SocialUser = keyof typeof social_user_type_map;
218 | const socialUserTypes = Object.keys(social_user_type_map) as SocialUser[];
219 |
220 | export { info_view_map, weekdays_map, social_user_type_map, socialUserTypes };
221 |
--------------------------------------------------------------------------------
/src/hooks/useCourseTable.ts:
--------------------------------------------------------------------------------
1 | import useSWR from "swr";
2 | import { useState } from "react";
3 | import { fetchCourseTable } from "queries/courseTable";
4 | import { useToast } from "@chakra-ui/react";
5 | import { patchCourseTable } from "queries/courseTable";
6 | import type { Course } from "types/course";
7 |
8 | export default function useCourseTable(
9 | courseTableId: string | null,
10 | options?: {
11 | readonly onSuccessCallback?: (
12 | data: unknown,
13 | key: string,
14 | config: unknown
15 | ) => void;
16 | readonly onErrorCallback?: (
17 | data: unknown,
18 | key: string,
19 | config: unknown
20 | ) => void;
21 | }
22 | ) {
23 | const toast = useToast();
24 | const [isExpired, setIsExpired] = useState(false);
25 | const onSuccessCallback = options?.onSuccessCallback;
26 | const onErrorCallback = options?.onErrorCallback;
27 | const { data, error, mutate } = useSWR(
28 | courseTableId ? `/v2/course_tables/${courseTableId}` : null,
29 | async () => {
30 | const courseTableData = await fetchCourseTable(courseTableId as string);
31 | return courseTableData;
32 | },
33 | {
34 | onSuccess: async (data, key, config) => {
35 | await onSuccessCallback?.(data, key, config);
36 | setIsExpired(false);
37 | },
38 | onError: async (err, key, config) => {
39 | if (err?.response?.status === 403 || err?.response?.status === 404) {
40 | setIsExpired(true);
41 | }
42 | await onErrorCallback?.(err, key, config);
43 | },
44 | }
45 | );
46 |
47 | const addOrRemoveCourse = async (course: Course) => {
48 | const courseTable = data?.course_table;
49 | if (courseTable && courseTableId) {
50 | try {
51 | const originalCourseTableLength = courseTable?.courses?.length ?? 0;
52 | const newCourseTableData = await mutate(
53 | async (prev) => {
54 | if (courseTable.courses.map((c) => c.id).includes(course.id)) {
55 | const data = await patchCourseTable(
56 | courseTableId,
57 | courseTable.name,
58 | courseTable.user_id,
59 | courseTable.courses
60 | .map((c) => c.id)
61 | .filter((id) => id !== course.id)
62 | );
63 | return data ?? prev;
64 | } else {
65 | const data = await patchCourseTable(
66 | courseTableId,
67 | courseTable.name,
68 | courseTable.user_id,
69 | [...courseTable.courses.map((c) => c.id), course.id]
70 | );
71 | return data ?? prev;
72 | }
73 | },
74 | {
75 | revalidate: false,
76 | populateCache: true,
77 | }
78 | );
79 | const operation_str =
80 | (newCourseTableData?.course_table?.courses?.length ?? 0) >
81 | originalCourseTableLength
82 | ? "加入"
83 | : "移除";
84 | toast({
85 | title: `已${operation_str} ${course.name}`,
86 | description: `課表: ${courseTable.name}`,
87 | status: "success",
88 | duration: 3000,
89 | isClosable: true,
90 | });
91 | } catch (e) {
92 | toast({
93 | title: `新增 ${course.name} 失敗`,
94 | status: "error",
95 | duration: 3000,
96 | isClosable: true,
97 | });
98 | }
99 | } else {
100 | toast({
101 | title: `新增 ${course.name} 失敗`,
102 | status: "error",
103 | duration: 3000,
104 | isClosable: true,
105 | });
106 | }
107 | };
108 |
109 | return {
110 | courseTable: data?.course_table ?? null,
111 | isLoading:
112 | !data &&
113 | !error &&
114 | !(courseTableId === null || courseTableId === undefined),
115 | error,
116 | isExpired,
117 | mutate,
118 | addOrRemoveCourse,
119 | };
120 | }
121 |
--------------------------------------------------------------------------------
/src/hooks/useNeoLocalStorage.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { NCN_COURSE_TABLE_LOCAL_STORAGE_KEY } from "constant";
3 | import { decipherId } from "utils/cipher";
4 |
5 | export interface NeoLocalStorageType {
6 | courseTableKey: string | null;
7 | }
8 |
9 | export default function useNeoLocalStorage() {
10 | const [neoLocalStorage, setNeoLocalStorage] =
11 | useState(null);
12 | useEffect(() => {
13 | // load from local storage when client side hydrated
14 | const courseTableKey = decipherId(
15 | localStorage?.getItem(NCN_COURSE_TABLE_LOCAL_STORAGE_KEY)
16 | );
17 | setNeoLocalStorage({
18 | courseTableKey,
19 | });
20 | }, []);
21 |
22 | return {
23 | neoLocalCourseTableKey: neoLocalStorage?.courseTableKey ?? null,
24 | setNeoLocalStorage,
25 | };
26 | }
27 |
--------------------------------------------------------------------------------
/src/hooks/usePagination.ts:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 | import { useCourseSearchingContext } from "components/Providers/CourseSearchingProvider";
3 | import { useInView } from "react-intersection-observer";
4 |
5 | export default function usePagination() {
6 | const { ref, inView: reachedBottom } = useInView({
7 | /* Optional options */
8 | threshold: 0,
9 | });
10 | const { searchResultCount, totalCount, fetchNextPage } =
11 | useCourseSearchingContext();
12 |
13 | useEffect(() => {
14 | // console.log('reachedBottom: ',reachedBottom);
15 | if (reachedBottom && searchResultCount !== 0) {
16 | // fetch next batch of search results
17 | if (searchResultCount < totalCount) {
18 | fetchNextPage();
19 | }
20 | }
21 | }, [reachedBottom]); // eslint-disable-line react-hooks/exhaustive-deps
22 |
23 | return ref;
24 | }
25 |
--------------------------------------------------------------------------------
/src/hooks/useSearchResult.ts:
--------------------------------------------------------------------------------
1 | import useSWR from "swr";
2 | import { fetchSearchResult } from "queries/course";
3 | import { useCourseSearchingContext } from "components/Providers/CourseSearchingProvider";
4 | import { useToast } from "@chakra-ui/react";
5 |
6 | export default function useSearchResult(
7 | searchKeyword: string | null,
8 | pageIndex: number
9 | ) {
10 | const toast = useToast();
11 | const {
12 | searchColumns,
13 | searchSettings,
14 | searchFiltersEnable,
15 | searchFilters,
16 | batchSize,
17 | searchSemester,
18 | searchResultCount,
19 | setTotalCount,
20 | setSearchLoading,
21 | setSearchResultCount,
22 | } = useCourseSearchingContext();
23 | const { data, error, isValidating } = useSWR(
24 | `/api/search/${searchKeyword}/${pageIndex}`,
25 | async () => {
26 | if (!searchSemester) {
27 | toast({
28 | title: "獲取課程資訊失敗",
29 | description: "請選擇學期",
30 | status: "error",
31 | duration: 3000,
32 | isClosable: true,
33 | });
34 | throw new Error("Missing semester env variable");
35 | }
36 | setSearchLoading(true);
37 | const coursesData = await fetchSearchResult(
38 | searchKeyword ?? "",
39 | searchColumns,
40 | searchFiltersEnable,
41 | searchFilters,
42 | batchSize,
43 | pageIndex * batchSize,
44 | searchSemester,
45 | searchSettings.strict_search_mode
46 | );
47 | setSearchLoading(false);
48 | return coursesData;
49 | },
50 | {
51 | onSuccess: (d, k, c) => {
52 | setTotalCount(d.total_count);
53 | setSearchResultCount(searchResultCount + d?.courses?.length ?? 0);
54 | },
55 | onError: (e, k, c) => {
56 | console.log(e);
57 | toast({
58 | title: "獲取課程資訊失敗",
59 | description: "請檢查網路連線",
60 | status: "error",
61 | duration: 3000,
62 | isClosable: true,
63 | });
64 | },
65 | revalidateOnFocus: false,
66 | }
67 | );
68 |
69 | return {
70 | courses: data?.courses ?? [],
71 | isLoading: (!data && !error) || isValidating,
72 | error,
73 | };
74 | }
75 |
--------------------------------------------------------------------------------
/src/hooks/useUserInfo.ts:
--------------------------------------------------------------------------------
1 | import useSWR from "swr";
2 | import handleFetch from "utils/CustomFetch";
3 | import { useRouter } from "next/router";
4 | import { useToast } from "@chakra-ui/react";
5 | import type { User } from "types/user";
6 | import type { Course } from "types/course";
7 | import { AxiosError } from "axios";
8 |
9 | export default function useUserInfo(
10 | userId: string | null,
11 | options?: {
12 | readonly onSuccessCallback?: (
13 | data: {
14 | user: User | null;
15 | message: string;
16 | },
17 | key: string,
18 | config: unknown
19 | ) => void;
20 | readonly onErrorCallback?: (
21 | data: unknown,
22 | key: string,
23 | config: unknown
24 | ) => void;
25 | }
26 | ) {
27 | const toast = useToast();
28 | const onSuccessCallback = options?.onSuccessCallback;
29 | const onErrorCallback = options?.onErrorCallback;
30 | const router = useRouter();
31 | const { data, error, mutate } = useSWR(
32 | userId ? [`/api/user`, userId] : null,
33 | async (url) => {
34 | const userData = await handleFetch<{
35 | user: User | null;
36 | message: string;
37 | }>(url, {
38 | user_id: userId,
39 | });
40 | return userData;
41 | },
42 | {
43 | onSuccess: async (data, key, config) => {
44 | await onSuccessCallback?.(data, key, config);
45 | },
46 | onError: (err, key, config) => {
47 | if (err?.response?.status === 401) {
48 | router.push("/api/auth/login");
49 | }
50 | onErrorCallback?.(err, key, config);
51 | },
52 | }
53 | );
54 |
55 | const addOrRemoveFavorite = async (courseId: string) => {
56 | try {
57 | await mutate(
58 | async (prevUser) => {
59 | if (
60 | !prevUser?.user?.db?.favorites ||
61 | !Array.isArray(prevUser?.user?.db?.favorites)
62 | ) {
63 | return prevUser;
64 | }
65 | const favorite_list = prevUser.user.db.favorites.map((c) => c.id);
66 | if (favorite_list.includes(courseId)) {
67 | const data = await handleFetch<{
68 | favorites: Course[];
69 | message: string;
70 | }>(`/api/user/removeFavoriteCourse`, {
71 | course_id: courseId,
72 | });
73 | return {
74 | ...prevUser,
75 | user: {
76 | ...prevUser.user,
77 | db: {
78 | ...prevUser.user.db,
79 | favorites: data.favorites,
80 | },
81 | },
82 | };
83 | } else {
84 | const data = await handleFetch<{
85 | favorites: Course[];
86 | message: string;
87 | }>(`/api/user/addFavoriteCourse`, {
88 | course_id: courseId,
89 | });
90 | return {
91 | ...prevUser,
92 | user: {
93 | ...prevUser.user,
94 | db: {
95 | ...prevUser.user.db,
96 | favorites: data.favorites,
97 | },
98 | },
99 | };
100 | }
101 | },
102 | {
103 | revalidate: false,
104 | populateCache: true,
105 | }
106 | );
107 | toast({
108 | title: `更改最愛課程成功`,
109 | status: "success",
110 | duration: 3000,
111 | isClosable: true,
112 | });
113 | } catch (error) {
114 | toast({
115 | title: `更改最愛課程失敗`,
116 | description: `請稍後再試`,
117 | status: "error",
118 | duration: 3000,
119 | isClosable: true,
120 | });
121 | if ((error as AxiosError)?.response?.status === 401) {
122 | router.push("/api/auth/login");
123 | }
124 | }
125 | };
126 |
127 | return {
128 | userInfo: data?.user?.db ?? null,
129 | isLoading: !data && !error && !(userId === null || userId === undefined),
130 | error: error,
131 | mutate,
132 | addOrRemoveFavorite,
133 | };
134 | }
135 |
--------------------------------------------------------------------------------
/src/pages/404.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Flex,
3 | Text,
4 | Image,
5 | HStack,
6 | Button,
7 | useColorModeValue,
8 | } from "@chakra-ui/react";
9 | import { FaHeartbeat, FaSignInAlt } from "react-icons/fa";
10 | import Link from "next/link";
11 | import { reportEvent } from "utils/ga";
12 |
13 | export default function ErrorPage() {
14 | const handleOpenPage = (page: string) => {
15 | window.open(page, "_blank");
16 | };
17 | return (
18 |
29 |
36 |
37 | 有東西出錯了 😥
38 |
39 |
40 |
41 |
42 | 請嘗試重新
43 |
44 |
45 | }>
46 | 登入
47 |
48 |
49 |
50 |
51 | 若狀況仍未解決,請回報此問題。
52 |
53 |
54 |
55 |
67 | }
71 | onClick={() => {
72 | handleOpenPage("https://status.course.myntu.me/");
73 | reportEvent("404", "click", "view_status");
74 | }}
75 | >
76 | 服務狀態
77 |
78 |
79 |
80 | );
81 | }
82 |
--------------------------------------------------------------------------------
/src/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import { ChakraProvider, Box } from "@chakra-ui/react";
2 | import { CourseSearchingProvider } from "components/Providers/CourseSearchingProvider";
3 | import { DisplayTagsProvider } from "components/Providers/DisplayTagsProvider";
4 | import HeaderBar from "components/HeaderBar";
5 | import Footer from "components/Footer";
6 | import { UserProvider as Auth0UserProvider } from "@auth0/nextjs-auth0";
7 | import theme from "styles/theme";
8 | import "styles/nprogress.css";
9 | import { useRouter } from "next/router";
10 | import nProgress from "nprogress";
11 | import { useEffect } from "react";
12 | import GoogleAnalytics from "components/GoogleAnalytics";
13 | import type { AppProps } from "next/app";
14 |
15 | function MyApp({ Component, pageProps }: AppProps) {
16 | const router = useRouter();
17 | useEffect(() => {
18 | router.events.on("routeChangeStart", nProgress.start);
19 | router.events.on("routeChangeComplete", nProgress.done);
20 | return () => {
21 | router.events.off("routeChangeStart", nProgress.start);
22 | router.events.off("routeChangeComplete", nProgress.done);
23 | };
24 | }, [router.events]);
25 |
26 | return (
27 | <>
28 | {process.env.NEXT_PUBLIC_ENV === "prod" && }
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | >
43 | );
44 | }
45 |
46 | export default MyApp;
47 |
--------------------------------------------------------------------------------
/src/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import NextDocument, { Html, Head, Main, NextScript } from "next/document";
2 | import { ColorModeScript } from "@chakra-ui/react";
3 | import theme from "styles/theme";
4 |
5 | export default class Document extends NextDocument {
6 | render() {
7 | return (
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | );
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/pages/about.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {
3 | Avatar,
4 | Box,
5 | Divider,
6 | Flex,
7 | HStack,
8 | Spacer,
9 | Text,
10 | VStack,
11 | Icon,
12 | Button,
13 | useColorModeValue,
14 | } from "@chakra-ui/react";
15 | import { FaGithub } from "react-icons/fa";
16 | import Head from "next/head";
17 | import { reportEvent } from "utils/ga";
18 |
19 | interface TeamMember {
20 | name: string;
21 | github: string;
22 | img: string;
23 | dept: string;
24 | quote: string;
25 | desc: string;
26 | }
27 | const teams: TeamMember[] = [
28 | {
29 | name: "張博皓",
30 | img: "/img/team_avatar/jc-hiroto.png",
31 | dept: "工科海洋系 B07",
32 | quote: "WP 讚啦",
33 | github: "jc-hiroto",
34 | desc: "",
35 | },
36 | {
37 | name: "許書維",
38 | img: "/img/team_avatar/swh00tw.png",
39 | dept: "電機系 B07",
40 | quote: "WP 讚啦",
41 | github: "swh00tw",
42 | desc: "",
43 | },
44 | {
45 | name: "謝維勝",
46 | img: "/img/team_avatar/wil0408.png",
47 | dept: "電機系 B07",
48 | quote: "WP 讚啦",
49 | github: "Wil0408",
50 | desc: "",
51 | },
52 | ];
53 |
54 | function TeamMemberCard({ person }: { readonly person: TeamMember }) {
55 | return (
56 |
63 |
64 |
65 |
66 |
67 |
68 |
73 | {person.name}
74 |
75 |
80 | {person.dept}
81 |
82 |
111 |
112 |
113 |
114 |
115 | );
116 | }
117 |
118 | function AboutPage() {
119 | return (
120 | <>
121 |
122 | {`關於 | NTUCourse Neo`}
123 |
127 |
128 |
136 |
141 | 關於
142 |
143 |
144 |
151 |
152 | NTUCourse Neo
153 | 是一個專屬於台大學生的選課工具,您是否曾經覺得台大課程網搜尋課程篩選不好用,或是介面不夠精簡令人眼花撩亂,抑或是加入課表時瘋狂彈出的視窗很煩呢?
154 |
155 |
156 | We are here to help!
157 | 我們提供多樣篩選條件,讓用戶可以更快速的找到想要的課程,也提供了互動式課表,讓同學們安排下學期的課程時不用再狂切視窗了!
158 | 🥴
159 | 除此之外,還能更方便的排序選課優先順序、一鍵匯入台大課程網,大幅減少同學們花費在找課選課排志願序的時間!
160 |
161 |
162 | 希望 NTUCourse Neo 可以讓你我的選課都更加直覺,好用。
163 |
164 |
165 |
170 | 團隊
171 |
172 |
173 |
180 | {teams.map((member) => (
181 |
182 | ))}
183 |
184 |
185 | >
186 | );
187 | }
188 |
189 | export default AboutPage;
190 |
--------------------------------------------------------------------------------
/src/pages/api/auth/[...auth0].ts:
--------------------------------------------------------------------------------
1 | import { handleAuth, handleLogin } from "@auth0/nextjs-auth0";
2 |
3 | export default handleAuth({
4 | async login(req, res) {
5 | try {
6 | await handleLogin(req, res, {
7 | authorizationParams: {
8 | audience: process.env.AUTH0_SELF_API_AUDIENCE,
9 | scope: "openid profile email offline_access do:anything",
10 | },
11 | });
12 | } catch (error) {
13 | res.status(400).end("Error in login");
14 | }
15 | },
16 | });
17 |
--------------------------------------------------------------------------------
/src/pages/api/course/enrollInfo.ts:
--------------------------------------------------------------------------------
1 | import { getCourseEnrollInfo } from "queries/course";
2 | import { getAccessToken, withApiAuthRequired } from "@auth0/nextjs-auth0";
3 | import { assertNotNil } from "utils/assert";
4 | import type { NextApiRequest, NextApiResponse } from "next";
5 | import type { CourseEnrollStatus } from "types/course";
6 | import axios from "axios";
7 |
8 | export default withApiAuthRequired(async function handler(
9 | req: NextApiRequest,
10 | res: NextApiResponse<{
11 | course_id: string | null;
12 | course_status: CourseEnrollStatus | null;
13 | update_ts: string | null;
14 | message: string;
15 | }>
16 | ) {
17 | try {
18 | const { courseId } = req.body;
19 | const { accessToken } = await getAccessToken(req, res);
20 | if (!assertNotNil(courseId) || !accessToken) {
21 | res.status(400).json({
22 | course_id: null,
23 | course_status: null,
24 | update_ts: null,
25 | message: "Missing user_id",
26 | });
27 | } else {
28 | const data = await getCourseEnrollInfo(accessToken, courseId);
29 | return res.status(200).json(data);
30 | }
31 | } catch (error) {
32 | console.error(error);
33 | if (axios.isAxiosError(error)) {
34 | res.status(500).json({
35 | course_id: null,
36 | course_status: null,
37 | update_ts: null,
38 | message: error?.code ?? error.message,
39 | });
40 | } else {
41 | res.status(500).json({
42 | course_id: null,
43 | course_status: null,
44 | update_ts: null,
45 | message: "Error occur in getEnrollData",
46 | });
47 | }
48 | }
49 | });
50 |
--------------------------------------------------------------------------------
/src/pages/api/course/ntuRating.ts:
--------------------------------------------------------------------------------
1 | import { getNTURatingData } from "queries/course";
2 | import { getAccessToken, withApiAuthRequired } from "@auth0/nextjs-auth0";
3 | import { assertNotNil } from "utils/assert";
4 | import type { NextApiRequest, NextApiResponse } from "next";
5 | import type { CourseRatingData } from "types/course";
6 | import axios from "axios";
7 |
8 | export default withApiAuthRequired(async function handler(
9 | req: NextApiRequest,
10 | res: NextApiResponse<{
11 | course_id: string | null;
12 | course_rating: CourseRatingData | null;
13 | update_ts: string | null;
14 | message: string;
15 | }>
16 | ) {
17 | try {
18 | const { courseId } = req.body;
19 | const { accessToken } = await getAccessToken(req, res);
20 | if (!assertNotNil(courseId) || !accessToken) {
21 | res.status(400).json({
22 | course_id: null,
23 | course_rating: null,
24 | update_ts: null,
25 | message: "Missing user_id",
26 | });
27 | } else {
28 | const data = await getNTURatingData(accessToken, courseId);
29 | return res.status(200).json(data);
30 | }
31 | } catch (error) {
32 | console.error(error);
33 | if (axios.isAxiosError(error)) {
34 | res.status(500).json({
35 | course_id: null,
36 | course_rating: null,
37 | update_ts: null,
38 | message: error?.code ?? error.message,
39 | });
40 | } else {
41 | res.status(500).json({
42 | course_id: null,
43 | course_rating: null,
44 | update_ts: null,
45 | message: "Error occur in getRatingData",
46 | });
47 | }
48 | }
49 | });
50 |
--------------------------------------------------------------------------------
/src/pages/api/course/ptt.ts:
--------------------------------------------------------------------------------
1 | import { getPTTData } from "queries/course";
2 | import { getAccessToken, withApiAuthRequired } from "@auth0/nextjs-auth0";
3 | import { assertNotNil } from "utils/assert";
4 | import type { NextApiRequest, NextApiResponse } from "next";
5 | import type { PTTData } from "types/course";
6 | import axios from "axios";
7 |
8 | export default withApiAuthRequired(async function handler(
9 | req: NextApiRequest,
10 | res: NextApiResponse<{
11 | course_id: string | null;
12 | course_rating: PTTData | null;
13 | update_ts: string | null;
14 | message: string;
15 | }>
16 | ) {
17 | try {
18 | const { courseId, type } = req.body;
19 | const { accessToken } = await getAccessToken(req, res);
20 | if (
21 | !assertNotNil(courseId) ||
22 | !assertNotNil(type) ||
23 | !["review", "exam"].includes(type) ||
24 | !accessToken
25 | ) {
26 | res.status(400).json({
27 | course_id: null,
28 | course_rating: null,
29 | update_ts: null,
30 | message: "Missing/Wrong user_id",
31 | });
32 | } else {
33 | const data = await getPTTData(accessToken, courseId, type);
34 | return res.status(200).json(data);
35 | }
36 | } catch (error) {
37 | console.error(error);
38 | if (axios.isAxiosError(error)) {
39 | res.status(500).json({
40 | course_id: null,
41 | course_rating: null,
42 | update_ts: null,
43 | message: error?.code ?? error.message,
44 | });
45 | } else {
46 | res.status(500).json({
47 | course_id: null,
48 | course_rating: null,
49 | update_ts: null,
50 | message: "Error occur in getPTTData",
51 | });
52 | }
53 | }
54 | });
55 |
--------------------------------------------------------------------------------
/src/pages/api/social/createPost.ts:
--------------------------------------------------------------------------------
1 | import { createSocialPost } from "queries/social";
2 | import { getAccessToken, withApiAuthRequired } from "@auth0/nextjs-auth0";
3 | import { assertNotNil } from "utils/assert";
4 | import type { NextApiRequest, NextApiResponse } from "next";
5 | import axios from "axios";
6 |
7 | export default withApiAuthRequired(async function handler(
8 | req: NextApiRequest,
9 | res: NextApiResponse<{
10 | message: string;
11 | }>
12 | ) {
13 | try {
14 | const { courseId, post } = req.body;
15 | const { accessToken } = await getAccessToken(req, res);
16 | if (!assertNotNil(courseId) || !assertNotNil(post) || !accessToken) {
17 | res.status(400).json({ message: "Missing user_id" });
18 | } else {
19 | await createSocialPost(accessToken, courseId, post);
20 | return res.status(200).json({
21 | message: "Post created successfully",
22 | });
23 | }
24 | } catch (error) {
25 | console.error(error);
26 | if (axios.isAxiosError(error)) {
27 | res.status(500).json({ message: error?.code ?? error.message });
28 | } else {
29 | res.status(500).json({ message: "Error occur in deleteSocialPost" });
30 | }
31 | }
32 | });
33 |
--------------------------------------------------------------------------------
/src/pages/api/social/deletePost.ts:
--------------------------------------------------------------------------------
1 | import { deleteSocialPost } from "queries/social";
2 | import { getAccessToken, withApiAuthRequired } from "@auth0/nextjs-auth0";
3 | import { assertNotNil } from "utils/assert";
4 | import type { NextApiRequest, NextApiResponse } from "next";
5 | import axios from "axios";
6 |
7 | export default withApiAuthRequired(async function handler(
8 | req: NextApiRequest,
9 | res: NextApiResponse<{
10 | message: string;
11 | }>
12 | ) {
13 | try {
14 | const { post_id } = req.body;
15 | const { accessToken } = await getAccessToken(req, res);
16 | if (!assertNotNil(post_id) || !accessToken) {
17 | res.status(400).json({ message: "Missing user_id" });
18 | } else {
19 | await deleteSocialPost(accessToken, post_id);
20 | return res.status(200).json({ message: "Post deleted successfully" });
21 | }
22 | } catch (error) {
23 | console.error(error);
24 | if (axios.isAxiosError(error)) {
25 | res.status(500).json({ message: error?.code ?? error.message });
26 | } else {
27 | res.status(500).json({ message: "Error occur in deleteSocialPost" });
28 | }
29 | }
30 | });
31 |
--------------------------------------------------------------------------------
/src/pages/api/social/getByCourseId.ts:
--------------------------------------------------------------------------------
1 | import { getSocialPostByCourseId } from "queries/social";
2 | import { getAccessToken, withApiAuthRequired } from "@auth0/nextjs-auth0";
3 | import { assertNotNil } from "utils/assert";
4 | import type { NextApiRequest, NextApiResponse } from "next";
5 | import type { SignUpPost } from "types/course";
6 | import axios from "axios";
7 |
8 | export default withApiAuthRequired(async function handler(
9 | req: NextApiRequest,
10 | res: NextApiResponse<{
11 | posts: SignUpPost[] | null;
12 | message?: string;
13 | }>
14 | ) {
15 | try {
16 | const { course_id } = req.body;
17 | const { accessToken } = await getAccessToken(req, res);
18 | if (!assertNotNil(course_id) || !accessToken) {
19 | res.status(400).json({ posts: null, message: "Missing user_id" });
20 | } else {
21 | const post_data = await getSocialPostByCourseId(accessToken, course_id);
22 | return res.status(200).json(post_data);
23 | }
24 | } catch (error) {
25 | if (axios.isAxiosError(error) && error?.response?.status === 404) {
26 | return res.status(200).json({ posts: [] });
27 | }
28 | if (axios.isAxiosError(error)) {
29 | res
30 | .status(500)
31 | .json({ posts: null, message: error?.code ?? error.message });
32 | } else {
33 | res
34 | .status(500)
35 | .json({ posts: null, message: "Error occur in getSocialPost" });
36 | }
37 | }
38 | });
39 |
--------------------------------------------------------------------------------
/src/pages/api/social/reportPost.ts:
--------------------------------------------------------------------------------
1 | import { reportSocialPost } from "queries/social";
2 | import { getAccessToken, withApiAuthRequired } from "@auth0/nextjs-auth0";
3 | import { assertNotNil } from "utils/assert";
4 | import type { NextApiRequest, NextApiResponse } from "next";
5 | import axios from "axios";
6 |
7 | export default withApiAuthRequired(async function handler(
8 | req: NextApiRequest,
9 | res: NextApiResponse<{ message: string }>
10 | ) {
11 | try {
12 | const { post_id, content } = req.body;
13 | const { accessToken } = await getAccessToken(req, res);
14 | if (!assertNotNil(post_id) || !assertNotNil(content) || !accessToken) {
15 | res.status(400).json({ message: "Missing user_id" });
16 | } else {
17 | await reportSocialPost(accessToken, post_id, {
18 | reason: content,
19 | });
20 | return res.status(200).json({ message: "Post reported successfully" });
21 | }
22 | } catch (error) {
23 | console.error(error);
24 | if (axios.isAxiosError(error)) {
25 | res.status(500).json({ message: error?.code ?? error.message });
26 | } else {
27 | res.status(500).json({ message: "Error occur in reportSocialPost" });
28 | }
29 | }
30 | });
31 |
--------------------------------------------------------------------------------
/src/pages/api/social/votePost.ts:
--------------------------------------------------------------------------------
1 | import { voteSocialPost } from "queries/social";
2 | import { getAccessToken, withApiAuthRequired } from "@auth0/nextjs-auth0";
3 | import { assertNotNil } from "utils/assert";
4 | import type { NextApiRequest, NextApiResponse } from "next";
5 | import axios from "axios";
6 |
7 | export default withApiAuthRequired(async function handler(
8 | req: NextApiRequest,
9 | res: NextApiResponse<{
10 | message: string;
11 | }>
12 | ) {
13 | try {
14 | const { post_id, vote_type } = req.body;
15 | const { accessToken } = await getAccessToken(req, res);
16 | if (!assertNotNil(post_id) || !assertNotNil(vote_type) || !accessToken) {
17 | res.status(400).json({ message: "Missing user_id" });
18 | } else {
19 | await voteSocialPost(accessToken, post_id, vote_type);
20 | return res.status(200).json({ message: "Post voted successfully" });
21 | }
22 | } catch (error) {
23 | console.error(error);
24 | if (axios.isAxiosError(error)) {
25 | res.status(500).json({ message: error?.code ?? error.message });
26 | } else {
27 | res.status(500).json({ message: "Error occur in voteSocialPost" });
28 | }
29 | }
30 | });
31 |
--------------------------------------------------------------------------------
/src/pages/api/user/addFavoriteCourse.ts:
--------------------------------------------------------------------------------
1 | import { addFavoriteCourse } from "queries/user";
2 | import { getAccessToken, withApiAuthRequired } from "@auth0/nextjs-auth0";
3 | import { assertNotNil } from "utils/assert";
4 | import type { NextApiRequest, NextApiResponse } from "next";
5 | import type { Course } from "types/course";
6 | import axios from "axios";
7 |
8 | export default withApiAuthRequired(async function handler(
9 | req: NextApiRequest,
10 | res: NextApiResponse<{
11 | favorites: Course[];
12 | message: string;
13 | }>
14 | ) {
15 | try {
16 | const { course_id } = req.body;
17 | const { accessToken } = await getAccessToken(req, res);
18 | if (!assertNotNil(course_id) || !accessToken) {
19 | res.status(400).json({ favorites: [], message: "Missing course_id" });
20 | } else {
21 | const updatedFavoriteListData = await addFavoriteCourse(
22 | accessToken,
23 | course_id
24 | );
25 | return res.status(200).json(updatedFavoriteListData);
26 | }
27 | } catch (error) {
28 | console.error(error);
29 | if (axios.isAxiosError(error)) {
30 | res
31 | .status(500)
32 | .json({ favorites: [], message: error?.code ?? error.message });
33 | } else {
34 | res.status(500).json({
35 | favorites: [],
36 | message: "Error occur in addFavoriteCourse",
37 | });
38 | }
39 | }
40 | });
41 |
--------------------------------------------------------------------------------
/src/pages/api/user/deleteAccount.ts:
--------------------------------------------------------------------------------
1 | import { deleteUserAccount } from "queries/user";
2 | import { getAccessToken, withApiAuthRequired } from "@auth0/nextjs-auth0";
3 | import type { NextApiRequest, NextApiResponse } from "next";
4 | import axios from "axios";
5 |
6 | export default withApiAuthRequired(async function handler(
7 | req: NextApiRequest,
8 | res: NextApiResponse<{ message: string }>
9 | ) {
10 | try {
11 | const { accessToken } = await getAccessToken(req, res);
12 | if (!accessToken) {
13 | res.status(400).json({ message: "Missing accessToken" });
14 | } else {
15 | await deleteUserAccount(accessToken);
16 | return res.status(200).json({ message: "Account deleted" });
17 | }
18 | } catch (error) {
19 | console.error(error);
20 | if (axios.isAxiosError(error)) {
21 | res.status(500).json({ message: error?.code ?? error.message });
22 | } else {
23 | res.status(500).json({
24 | message: "Error occur in deleteAccount",
25 | });
26 | }
27 | }
28 | });
29 |
--------------------------------------------------------------------------------
/src/pages/api/user/deleteProfile.ts:
--------------------------------------------------------------------------------
1 | import { deleteUserProfile } from "queries/user";
2 | import { getAccessToken, withApiAuthRequired } from "@auth0/nextjs-auth0";
3 | import type { NextApiRequest, NextApiResponse } from "next";
4 | import axios from "axios";
5 |
6 | export default withApiAuthRequired(async function handler(
7 | req: NextApiRequest,
8 | res: NextApiResponse<{ message: string }>
9 | ) {
10 | try {
11 | const { accessToken } = await getAccessToken(req, res);
12 | if (!accessToken) {
13 | res.status(400).json({ message: "Missing accessToken" });
14 | } else {
15 | await deleteUserProfile(accessToken);
16 | return res.status(200).json({ message: "Profile deleted" });
17 | }
18 | } catch (error) {
19 | console.error(error);
20 | if (axios.isAxiosError(error)) {
21 | res.status(500).json({ message: error?.code ?? error.message });
22 | } else {
23 | res.status(500).json({
24 | message: "Error occur in deleteProfile",
25 | });
26 | }
27 | }
28 | });
29 |
--------------------------------------------------------------------------------
/src/pages/api/user/index.ts:
--------------------------------------------------------------------------------
1 | import { fetchUserById } from "queries/user";
2 | import { getAccessToken, withApiAuthRequired } from "@auth0/nextjs-auth0";
3 | import { assertNotNil } from "utils/assert";
4 | import type { NextApiRequest, NextApiResponse } from "next";
5 | import type { User } from "types/user";
6 | import axios from "axios";
7 |
8 | export default withApiAuthRequired(async function handler(
9 | req: NextApiRequest,
10 | res: NextApiResponse<{ user: User | null; message: string }>
11 | ) {
12 | try {
13 | const { user_id } = req.body;
14 | const { accessToken } = await getAccessToken(req, res);
15 | if (!assertNotNil(user_id) || !accessToken) {
16 | res.status(400).json({ user: null, message: "Missing user_id" });
17 | } else {
18 | const user_data = await fetchUserById(accessToken, user_id);
19 | return res.status(200).json(user_data);
20 | }
21 | } catch (error) {
22 | console.error(error);
23 | if (axios.isAxiosError(error)) {
24 | res
25 | .status(500)
26 | .json({ user: null, message: error?.code ?? error.message });
27 | } else {
28 | res.status(500).json({
29 | user: null,
30 | message: "Error occur in fetchUser",
31 | });
32 | }
33 | }
34 | });
35 |
--------------------------------------------------------------------------------
/src/pages/api/user/linkCourseTable.ts:
--------------------------------------------------------------------------------
1 | import { linkCoursetableToUser } from "queries/user";
2 | import { getAccessToken, withApiAuthRequired } from "@auth0/nextjs-auth0";
3 | import { assertNotNil } from "utils/assert";
4 | import type { NextApiRequest, NextApiResponse } from "next";
5 | import type { User } from "types/user";
6 | import axios from "axios";
7 |
8 | export default withApiAuthRequired(async function handler(
9 | req: NextApiRequest,
10 | res: NextApiResponse<{ user: User | null; message: string }>
11 | ) {
12 | try {
13 | const { table_id, user_id } = req.body;
14 | const { accessToken } = await getAccessToken(req, res);
15 | if (!assertNotNil(user_id) || !assertNotNil(table_id) || !accessToken) {
16 | res.status(400).json({ user: null, message: "Missing user_id" });
17 | } else {
18 | const user_data = await linkCoursetableToUser(
19 | accessToken,
20 | table_id,
21 | user_id
22 | );
23 | return res.status(200).json(user_data);
24 | }
25 | } catch (error) {
26 | console.error(error);
27 | if (axios.isAxiosError(error)) {
28 | res
29 | .status(500)
30 | .json({ user: null, message: error?.code ?? error.message });
31 | } else {
32 | res.status(500).json({
33 | user: null,
34 | message: "Error occur in linkCourseTable",
35 | });
36 | }
37 | }
38 | });
39 |
--------------------------------------------------------------------------------
/src/pages/api/user/patch.ts:
--------------------------------------------------------------------------------
1 | import { patchUserInfo } from "queries/user";
2 | import { getAccessToken, withApiAuthRequired } from "@auth0/nextjs-auth0";
3 | import { assertNotNil } from "utils/assert";
4 | import type { NextApiRequest, NextApiResponse } from "next";
5 | import type { User } from "types/user";
6 | import axios from "axios";
7 |
8 | export default withApiAuthRequired(async function handler(
9 | req: NextApiRequest,
10 | res: NextApiResponse<{ user: User | null; message: string }>
11 | ) {
12 | try {
13 | const { newUser } = req.body;
14 | const { accessToken } = await getAccessToken(req, res);
15 | if (!assertNotNil(newUser) || !accessToken) {
16 | res.status(400).json({ user: null, message: "Missing user_id" });
17 | } else {
18 | const userData = await patchUserInfo(accessToken, newUser);
19 | return res.status(200).json(userData);
20 | }
21 | } catch (error) {
22 | console.error(error);
23 | if (axios.isAxiosError(error)) {
24 | res
25 | .status(500)
26 | .json({ user: null, message: error?.code ?? error.message });
27 | } else {
28 | res.status(500).json({
29 | user: null,
30 | message: "Error occur in patchUser",
31 | });
32 | }
33 | }
34 | });
35 |
--------------------------------------------------------------------------------
/src/pages/api/user/register.ts:
--------------------------------------------------------------------------------
1 | import { registerNewUser } from "queries/user";
2 | import { getAccessToken, withApiAuthRequired } from "@auth0/nextjs-auth0";
3 | import { assertNotNil } from "utils/assert";
4 | import type { NextApiRequest, NextApiResponse } from "next";
5 | import type { User } from "types/user";
6 | import axios from "axios";
7 |
8 | export default withApiAuthRequired(async function handler(
9 | req: NextApiRequest,
10 | res: NextApiResponse<{ user: User | null; message: string }>
11 | ) {
12 | try {
13 | const { email } = req.body;
14 | const { accessToken } = await getAccessToken(req, res);
15 | if (!assertNotNil(email) || !accessToken) {
16 | res.status(400).json({ user: null, message: "Missing user_id" });
17 | } else {
18 | const userData = await registerNewUser(accessToken, email);
19 | return res.status(200).json(userData);
20 | }
21 | } catch (error) {
22 | console.error(error);
23 | if (axios.isAxiosError(error)) {
24 | res
25 | .status(500)
26 | .json({ user: null, message: error?.code ?? error.message });
27 | } else {
28 | res.status(500).json({
29 | user: null,
30 | message: "Error occur in registerUser",
31 | });
32 | }
33 | }
34 | });
35 |
--------------------------------------------------------------------------------
/src/pages/api/user/removeFavoriteCourse.ts:
--------------------------------------------------------------------------------
1 | import { removeFavoriteCourse } from "queries/user";
2 | import { getAccessToken, withApiAuthRequired } from "@auth0/nextjs-auth0";
3 | import { assertNotNil } from "utils/assert";
4 | import type { NextApiRequest, NextApiResponse } from "next";
5 | import type { Course } from "types/course";
6 | import axios from "axios";
7 |
8 | export default withApiAuthRequired(async function handler(
9 | req: NextApiRequest,
10 | res: NextApiResponse<{
11 | favorites: Course[];
12 | message: string;
13 | }>
14 | ) {
15 | try {
16 | const { course_id } = req.body;
17 | const { accessToken } = await getAccessToken(req, res);
18 | if (!assertNotNil(course_id) || !accessToken) {
19 | res.status(400).json({ favorites: [], message: "Missing course_id" });
20 | } else {
21 | const updatedFavoriteListData = await removeFavoriteCourse(
22 | accessToken,
23 | course_id
24 | );
25 | return res.status(200).json(updatedFavoriteListData);
26 | }
27 | } catch (error) {
28 | console.error(error);
29 | if (axios.isAxiosError(error)) {
30 | res
31 | .status(500)
32 | .json({ favorites: [], message: error?.code ?? error.message });
33 | } else {
34 | res.status(500).json({
35 | favorites: [],
36 | message: "Error occur in removeFavoriteCourse",
37 | });
38 | }
39 | }
40 | });
41 |
--------------------------------------------------------------------------------
/src/pages/user/my.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from "react";
2 | import {
3 | Flex,
4 | Text,
5 | useToast,
6 | Box,
7 | Spacer,
8 | Accordion,
9 | useColorModeValue,
10 | } from "@chakra-ui/react";
11 | import SkeletonRow from "components/SkeletonRow";
12 | import { HashLoader, BeatLoader } from "react-spinners";
13 | import CourseInfoRow from "components/CourseInfoRow";
14 | import useCourseTable from "hooks/useCourseTable";
15 | import useNeoLocalStorage from "hooks/useNeoLocalStorage";
16 | import { withPageAuthRequired, UserProfile } from "@auth0/nextjs-auth0";
17 | import Head from "next/head";
18 | import useUserInfo from "hooks/useUserInfo";
19 |
20 | export default function UserMyPage({ user }: { readonly user: UserProfile }) {
21 | const { userInfo, isLoading } = useUserInfo(user?.sub ?? null, {
22 | onErrorCallback: (e, k, c) => {
23 | toast({
24 | title: "取得用戶資料失敗.",
25 | description: "請聯繫客服(?)",
26 | status: "error",
27 | duration: 9000,
28 | isClosable: true,
29 | });
30 | },
31 | });
32 | const toast = useToast();
33 | const { neoLocalCourseTableKey } = useNeoLocalStorage();
34 | const courseTableKey = userInfo
35 | ? userInfo?.course_tables?.[0] ?? null
36 | : neoLocalCourseTableKey;
37 | const { courseTable } = useCourseTable(courseTableKey);
38 |
39 | const bgColor = useColorModeValue("white", "black");
40 | const selectedCourses = useMemo(() => {
41 | return courseTable?.courses.map((c) => c.id) ?? [];
42 | }, [courseTable]);
43 | const favoriteList = useMemo(() => userInfo?.favorites ?? [], [userInfo]);
44 |
45 | if (isLoading) {
46 | return (
47 |
48 |
55 |
56 |
57 |
58 | );
59 | }
60 |
61 | return (
62 | <>
63 |
64 | {`我的收藏 | NTUCourse Neo`}
65 |
69 |
70 |
71 |
80 |
81 | {!userInfo ? : <>>}
82 |
89 | {!userInfo
90 | ? "載入中"
91 | : `我的最愛課程 共有 ${favoriteList.length} 筆結果`}
92 |
93 |
94 |
95 |
96 | {favoriteList.map((course, index) => (
97 |
102 |
107 |
108 |
109 | ))}
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 |
118 | >
119 | );
120 | }
121 |
122 | export const getServerSideProps = withPageAuthRequired();
123 |
--------------------------------------------------------------------------------
/src/queries/axiosInstance.ts:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 |
3 | const instance = axios.create({
4 | baseURL: process.env.NEXT_PUBLIC_API_ENDPOINT,
5 | });
6 |
7 | export default instance;
8 |
--------------------------------------------------------------------------------
/src/queries/course.ts:
--------------------------------------------------------------------------------
1 | import instance from "@/queries/axiosInstance";
2 | import type { SearchFieldName, FilterEnable, Filter } from "@/types/search";
3 | import type {
4 | Course,
5 | CourseEnrollStatus,
6 | CourseRatingData,
7 | PTTData,
8 | CourseSyllabus,
9 | } from "types/course";
10 | const api_version = "v2";
11 |
12 | export const fetchSearchResult = async (
13 | searchString: string,
14 | fields: SearchFieldName[],
15 | filters_enable: FilterEnable,
16 | filter_obj: Filter,
17 | batchSize: number,
18 | offset: number,
19 | semester: string,
20 | strict_match_bool: boolean
21 | ) => {
22 | const filter = {
23 | time: filters_enable.time ? filter_obj.time : null,
24 | department:
25 | filters_enable.department && filter_obj.department.length > 0
26 | ? filter_obj.department
27 | : null,
28 | category:
29 | filters_enable.category && filter_obj.category.length > 0
30 | ? filter_obj.category
31 | : null,
32 | enroll_method:
33 | filters_enable.enroll_method && filter_obj.enroll_method.length > 0
34 | ? filter_obj.enroll_method
35 | : null,
36 | strict_match: strict_match_bool,
37 | };
38 | const { data } = await instance.post(`${api_version}/courses/search`, {
39 | keyword: searchString,
40 | fields: fields,
41 | filter: filter,
42 | batch_size: batchSize,
43 | offset: offset,
44 | semester: semester,
45 | });
46 | return data as {
47 | courses: Course[];
48 | total_count: number;
49 | };
50 | };
51 |
52 | export const fetchCourse = async (id: string) => {
53 | const { data } = await instance.get(`${api_version}/courses/${id}`);
54 | return data as {
55 | course: Course;
56 | message: string;
57 | };
58 | };
59 |
60 | export const getCourseEnrollInfo = async (token: string, course_id: string) => {
61 | const { data } = await instance.get(
62 | `${api_version}/courses/${course_id}/enrollinfo`,
63 | {
64 | headers: {
65 | Authorization: `Bearer ${token}`,
66 | },
67 | }
68 | );
69 | return data as {
70 | course_id: string;
71 | course_status: CourseEnrollStatus | null;
72 | update_ts: string;
73 | message: string;
74 | };
75 | };
76 |
77 | export const getNTURatingData = async (token: string, course_id: string) => {
78 | const { data } = await instance.get(
79 | `${api_version}/courses/${course_id}/rating`,
80 | {
81 | headers: {
82 | Authorization: `Bearer ${token}`,
83 | },
84 | }
85 | );
86 | return data as {
87 | course_id: string;
88 | course_rating: CourseRatingData | null;
89 | update_ts: string;
90 | message: string;
91 | };
92 | };
93 |
94 | type PTTRequestType = "review" | "exam";
95 | export const getPTTData = async (
96 | token: string,
97 | course_id: string,
98 | type: PTTRequestType
99 | ) => {
100 | const { data } = await instance.get(
101 | `${api_version}/courses/${course_id}/ptt/${type}`,
102 | {
103 | headers: {
104 | Authorization: `Bearer ${token}`,
105 | },
106 | }
107 | );
108 | return data as {
109 | course_id: string;
110 | course_rating: PTTData | null;
111 | update_ts: string;
112 | message: string;
113 | };
114 | };
115 |
116 | export const getCourseSyllabusData = async (course_id: string) => {
117 | const { data } = await instance.get(
118 | `${api_version}/courses/${course_id}/syllabus`
119 | );
120 | return data as {
121 | course_id: string;
122 | course_syllabus: CourseSyllabus | null;
123 | update_ts: string;
124 | message: string;
125 | };
126 | };
127 |
--------------------------------------------------------------------------------
/src/queries/courseTable.ts:
--------------------------------------------------------------------------------
1 | import instance from "@/queries/axiosInstance";
2 | import type { CourseTable } from "types/courseTable";
3 | const api_version = "v2";
4 |
5 | export const createCourseTable = async (
6 | course_table_id: string,
7 | course_table_name: string,
8 | user_id: string | null,
9 | semester: string
10 | ) => {
11 | const { data } = await instance.post(`${api_version}/course_tables/`, {
12 | id: course_table_id,
13 | name: course_table_name,
14 | user_id: user_id,
15 | semester: semester,
16 | });
17 | return data as {
18 | course_table: CourseTable;
19 | message: string;
20 | };
21 | };
22 |
23 | export const fetchCourseTable = async (course_table_id: string) => {
24 | const { data } = await instance.get(
25 | `${api_version}/course_tables/${course_table_id}`
26 | );
27 | return data as {
28 | course_table: CourseTable;
29 | message: string;
30 | };
31 | };
32 |
33 | export const patchCourseTable = async (
34 | course_table_id: string,
35 | course_table_name: string,
36 | user_id: string | null,
37 | courses: string[]
38 | ) => {
39 | // filter out "" in courses
40 | const new_courses = courses.filter((course) => course !== "");
41 | const { data } = await instance.patch(
42 | `${api_version}/course_tables/${course_table_id}`,
43 | {
44 | name: course_table_name,
45 | user_id: user_id,
46 | courses: new_courses,
47 | }
48 | );
49 | return data as {
50 | course_table: CourseTable;
51 | message: string;
52 | };
53 | };
54 |
--------------------------------------------------------------------------------
/src/queries/sendLogs.ts:
--------------------------------------------------------------------------------
1 | import instance from "@/queries/axiosInstance";
2 | const api_version = "v1";
3 |
4 | const sendLogs = async (
5 | type: "info" | "error",
6 | obj: Record
7 | ) => {
8 | if (process.env.NEXT_PUBLIC_ENV === "prod") {
9 | const { data } = await instance.post(`${api_version}/logs/${type}`, obj);
10 | return data;
11 | } else {
12 | console.log("[INFO] Logs are not sent to server in development mode.");
13 | }
14 | };
15 |
16 | export default sendLogs;
17 |
--------------------------------------------------------------------------------
/src/queries/social.ts:
--------------------------------------------------------------------------------
1 | import instance from "@/queries/axiosInstance";
2 | import { SignUpPost } from "types/course";
3 | const api_version = "v1";
4 |
5 | const getSocialPostByPostId = async (token: string, post_id: string) => {
6 | const { data } = await instance.get(
7 | `${api_version}/social/posts/${post_id}`,
8 | {
9 | headers: {
10 | Authorization: `Bearer ${token}`,
11 | },
12 | }
13 | );
14 | return data as {
15 | post: SignUpPost;
16 | };
17 | };
18 |
19 | const reportSocialPost = async (
20 | token: string,
21 | post_id: string,
22 | report: {
23 | reason: string;
24 | }
25 | ) => {
26 | await instance.post(
27 | `${api_version}/social/posts/${post_id}/report`,
28 | {
29 | report: report,
30 | },
31 | {
32 | headers: {
33 | Authorization: `Bearer ${token}`,
34 | },
35 | }
36 | );
37 | };
38 |
39 | const voteSocialPost = async (token: string, post_id: string, type: number) => {
40 | await instance.patch(
41 | `${api_version}/social/posts/${post_id}/votes`,
42 | {
43 | type: type,
44 | },
45 | {
46 | headers: {
47 | Authorization: `Bearer ${token}`,
48 | },
49 | }
50 | );
51 | };
52 |
53 | const deleteSocialPost = async (token: string, post_id: string) => {
54 | await instance.delete(`${api_version}/social/posts/${post_id}/`, {
55 | headers: {
56 | Authorization: `Bearer ${token}`,
57 | },
58 | });
59 | };
60 |
61 | export interface SignUpPostForm {
62 | type: string;
63 | content: {
64 | amount: string;
65 | when: string;
66 | rule: string;
67 | comment: string;
68 | };
69 | user_type: string;
70 | }
71 | const createSocialPost = async (
72 | token: string,
73 | course_id: string,
74 | post: SignUpPostForm
75 | ) => {
76 | await instance.post(
77 | `${api_version}/social/courses/${course_id}/posts`,
78 | {
79 | post: post,
80 | // includes: content, post_type, user_type
81 | },
82 | {
83 | headers: {
84 | Authorization: `Bearer ${token}`,
85 | },
86 | }
87 | );
88 | };
89 |
90 | const getSocialPostByCourseId = async (token: string, course_id: string) => {
91 | const { data } = await instance.get(
92 | `${api_version}/social/courses/${course_id}/posts`,
93 | {
94 | headers: {
95 | Authorization: `Bearer ${token}`,
96 | },
97 | }
98 | );
99 | return data as {
100 | posts: SignUpPost[];
101 | };
102 | };
103 |
104 | export {
105 | getSocialPostByPostId,
106 | reportSocialPost,
107 | voteSocialPost,
108 | deleteSocialPost,
109 | createSocialPost,
110 | getSocialPostByCourseId,
111 | };
112 |
--------------------------------------------------------------------------------
/src/queries/user.ts:
--------------------------------------------------------------------------------
1 | import instance from "@/queries/axiosInstance";
2 | import type { User } from "types/user";
3 | const api_version = "v2";
4 |
5 | export const deleteUserProfile = async (token: string) => {
6 | await instance.delete(`${api_version}/users/profile`, {
7 | headers: {
8 | Authorization: `Bearer ${token}`,
9 | },
10 | });
11 | };
12 |
13 | export const linkCoursetableToUser = async (
14 | token: string,
15 | course_table_id: string,
16 | user_id: string
17 | ) => {
18 | const { data } = await instance.post(
19 | `${api_version}/users/${user_id}/course_table`,
20 | { course_table_id: course_table_id },
21 | {
22 | headers: {
23 | Authorization: `Bearer ${token}`,
24 | },
25 | }
26 | );
27 | return data as {
28 | message: string;
29 | user: User;
30 | };
31 | };
32 |
33 | export const addFavoriteCourse = async (token: string, courseId: string) => {
34 | const { data } = await instance.put(
35 | `${api_version}/users/favorites/${courseId}`,
36 | {},
37 | {
38 | headers: {
39 | Authorization: `Bearer ${token}`,
40 | },
41 | }
42 | );
43 | return data as {
44 | message: string;
45 | favorites: User["db"]["favorites"];
46 | };
47 | };
48 |
49 | export const removeFavoriteCourse = async (token: string, courseId: string) => {
50 | const { data } = await instance.delete(
51 | `${api_version}/users/favorites/${courseId}`,
52 | {
53 | headers: {
54 | Authorization: `Bearer ${token}`,
55 | },
56 | }
57 | );
58 | return data as {
59 | message: string;
60 | favorites: User["db"]["favorites"];
61 | };
62 | };
63 |
64 | export type PatchUserObject = {
65 | name: string;
66 | major: string;
67 | d_major: string | null;
68 | minors: string[];
69 | };
70 | export const patchUserInfo = async (
71 | token: string,
72 | newUser: PatchUserObject
73 | ) => {
74 | const { data } = await instance.patch(
75 | `${api_version}/users/`,
76 | { user: newUser },
77 | {
78 | headers: {
79 | Authorization: `Bearer ${token}`,
80 | },
81 | }
82 | );
83 | return data as {
84 | message: string;
85 | user: User;
86 | };
87 | };
88 |
89 | export const deleteUserAccount = async (token: string) => {
90 | await instance.delete(`${api_version}/users/account`, {
91 | headers: {
92 | Authorization: `Bearer ${token}`,
93 | },
94 | });
95 | };
96 |
97 | export const fetchUserById = async (token: string, user_id: string) => {
98 | const { data } = await instance.get(`${api_version}/users/${user_id}`, {
99 | headers: {
100 | Authorization: `Bearer ${token}`,
101 | },
102 | });
103 | return data as {
104 | message: string;
105 | user: User | null; // null for initial sign up (not found in DB)
106 | };
107 | };
108 |
109 | export const registerNewUser = async (token: string, email: string) => {
110 | const { data } = await instance.post(
111 | `${api_version}/users/`,
112 | { user: { email: email } },
113 | {
114 | headers: {
115 | Authorization: `Bearer ${token}`,
116 | },
117 | }
118 | );
119 | return data as {
120 | message: string;
121 | user: User;
122 | };
123 | };
124 |
--------------------------------------------------------------------------------
/src/styles/nprogress.css:
--------------------------------------------------------------------------------
1 | /* Make clicks pass-through */
2 | #nprogress {
3 | pointer-events: none;
4 | }
5 |
6 | #nprogress .bar {
7 | background: #57d3c2;
8 |
9 | position: fixed;
10 | z-index: 999;
11 | top: 64px;
12 | left: 0;
13 |
14 | width: 100%;
15 | height: 2px;
16 | }
17 |
18 | /* Fancy blur effect */
19 | #nprogress .peg {
20 | display: block;
21 | position: absolute;
22 | right: 0px;
23 | width: 100px;
24 | height: 100%;
25 | box-shadow: 0 0 10px 3px #85d3c9, 0 0 5px #57d3c2;
26 | opacity: 1;
27 |
28 | -webkit-transform: rotate(3deg) translate(0px, -4px);
29 | -ms-transform: rotate(3deg) translate(0px, -4px);
30 | transform: rotate(3deg) translate(0px, -4px);
31 | }
32 |
33 | /* Remove these to get rid of the spinner */
34 | /* #nprogress .spinner {
35 | display: block;
36 | position: fixed;
37 | z-index: 1031;
38 | top: 15px;
39 | right: 15px;
40 | }
41 |
42 | #nprogress .spinner-icon {
43 | width: 18px;
44 | height: 18px;
45 | box-sizing: border-box;
46 |
47 | border: solid 2px transparent;
48 | border-top-color: #29d;
49 | border-left-color: #29d;
50 | border-radius: 50%;
51 |
52 | -webkit-animation: nprogress-spinner 400ms linear infinite;
53 | animation: nprogress-spinner 400ms linear infinite;
54 | } */
55 |
56 | .nprogress-custom-parent {
57 | overflow: hidden;
58 | position: relative;
59 | }
60 |
61 | .nprogress-custom-parent #nprogress .spinner,
62 | .nprogress-custom-parent #nprogress .bar {
63 | position: absolute;
64 | }
65 |
66 | @-webkit-keyframes nprogress-spinner {
67 | 0% {
68 | -webkit-transform: rotate(0deg);
69 | }
70 | 100% {
71 | -webkit-transform: rotate(360deg);
72 | }
73 | }
74 | @keyframes nprogress-spinner {
75 | 0% {
76 | transform: rotate(0deg);
77 | }
78 | 100% {
79 | transform: rotate(360deg);
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/src/styles/theme.ts:
--------------------------------------------------------------------------------
1 | import { extendTheme } from "@chakra-ui/react";
2 |
3 | const config = {
4 | initialColorMode: "light",
5 | useSystemColorMode: true,
6 | };
7 |
8 | const colors = {
9 | link: {
10 | light: "#2B6CB0",
11 | dark: "#90CDF4",
12 | },
13 | heading: {
14 | light: "#2D3748",
15 | dark: "#E2E8F0",
16 | },
17 | text: {
18 | light: "#4A5568",
19 | dark: "#CBD5E0",
20 | },
21 | card: {
22 | light: "#EDF2F7",
23 | dark: "#131720",
24 | },
25 | teal: {
26 | light: "#81E6D9",
27 | dark: "#1A202C",
28 | },
29 | headerBar: {
30 | light: "#EDF2F7",
31 | dark: "#1A202C",
32 | },
33 | };
34 |
35 | const theme = extendTheme({ config, colors });
36 |
37 | export default theme;
38 |
--------------------------------------------------------------------------------
/src/types/course.ts:
--------------------------------------------------------------------------------
1 | import { SocialUser } from "data/mapping_table";
2 |
3 | export interface Course {
4 | id: string;
5 | serial: string | null;
6 | code: string;
7 | identifier: string;
8 | name: string;
9 | semester: string;
10 | teacher: string | null;
11 | limitation: string | null;
12 | note: string | null;
13 | cool_url: string | null;
14 | credits: number | null;
15 | can_be_selective: boolean;
16 | is_half_year: boolean;
17 | slot: number;
18 | enroll_method: number;
19 | intensive_weeks: readonly number[];
20 | departments_raw: readonly string[];
21 | class: string | null;
22 | syllabus_url: string | null;
23 | requirement: "preassign" | "required" | "elective" | "other";
24 | language: "zh_TW" | "en_US";
25 | provider: "ntu" | "ntust" | "ntnu" | "other";
26 | areas: readonly Area[];
27 | departments: readonly Department[];
28 | schedules: readonly Schedule[];
29 | specialties: readonly CourseSpecialty[]; // TODO
30 | prerequisites: readonly string[]; // TODO
31 | prerequisite_of: readonly string[]; // TODO
32 | }
33 |
34 | export interface Area {
35 | area_id: string;
36 | area: {
37 | name: string;
38 | };
39 | }
40 |
41 | export interface Department {
42 | id: string;
43 | college_id: string | null;
44 | name_alt: string | null;
45 | name_full: string;
46 | name_short: string | null;
47 | }
48 |
49 | export interface CourseSpecialty {
50 | id: string;
51 | name: string;
52 | }
53 |
54 | export type Interval =
55 | | "0"
56 | | "1"
57 | | "2"
58 | | "3"
59 | | "4"
60 | | "5"
61 | | "6"
62 | | "7"
63 | | "8"
64 | | "9"
65 | | "10"
66 | | "A"
67 | | "B"
68 | | "C"
69 | | "D";
70 |
71 | export interface Schedule {
72 | weekday: 1 | 2 | 3 | 4 | 5 | 6 | 7;
73 | interval: Interval;
74 | location: string;
75 | }
76 |
77 | export type CourseEnrollStatus = {
78 | enrolled: string;
79 | enrolled_other: string;
80 | fetch_ts: number;
81 | registered: string;
82 | remain: string;
83 | };
84 | // from ntu rating
85 | export type CourseRatingData = {
86 | breeze: number;
87 | count: number;
88 | quality: number;
89 | sweety: number;
90 | url: string;
91 | workload: number;
92 | };
93 | export const syllabusFieldSource = {
94 | intro: "概述",
95 | objective: "目標",
96 | requirement: "要求",
97 | office_hour: "Office Hour",
98 | material: "參考書目",
99 | specify: "指定閱讀",
100 | };
101 | export type SyllabusFieldName = keyof typeof syllabusFieldSource;
102 | export const syllabusFields = Object.keys(
103 | syllabusFieldSource
104 | ) as SyllabusFieldName[];
105 | export type CourseSyllabus = {
106 | grade:
107 | | null
108 | | {
109 | color: string | null;
110 | comment: string;
111 | title: string;
112 | value: number;
113 | }[];
114 | syllabus: Record;
115 | };
116 | export type PTTData = PTTArticle[];
117 | export type SignUpPostData = SignUpPost[] | null;
118 |
119 | export interface SignUpPost {
120 | content: {
121 | amount: number;
122 | comment: string;
123 | rule: string;
124 | when: string;
125 | _id: string;
126 | };
127 | course_id: string;
128 | create_ts: string;
129 | is_owner: boolean;
130 | self_vote_status: number;
131 | type: string;
132 | upvotes: number;
133 | downvotes: number;
134 | user_type: SocialUser;
135 | _id: string;
136 | }
137 |
138 | interface PTTArticle {
139 | aid: string;
140 | author: string;
141 | date: string;
142 | title: string;
143 | url: string;
144 | }
145 |
--------------------------------------------------------------------------------
/src/types/courseTable.ts:
--------------------------------------------------------------------------------
1 | import { Course } from "types/course";
2 |
3 | export interface CourseTable {
4 | courses: Course[];
5 | id: string;
6 | name: string;
7 | semester: string;
8 | expire_ts: null | string;
9 | user_id: string | null;
10 | }
11 |
--------------------------------------------------------------------------------
/src/types/search.ts:
--------------------------------------------------------------------------------
1 | import { Interval } from "types/course";
2 |
3 | export const FilterSource = {
4 | time: null,
5 | department: null,
6 | category: null,
7 | enroll_method: null,
8 | };
9 | export type FilterName = keyof typeof FilterSource;
10 | export type FilterEnable = Record;
11 | export type EnrollMethod = "1" | "2" | "3";
12 | export type Filter = {
13 | category: string[]; // TODO: refactor to ENUM
14 | department: string[]; // TODO: refactor to ENUM
15 | enroll_method: EnrollMethod[];
16 | time: Interval[][];
17 | };
18 |
19 | export type SearchFieldName =
20 | | "name"
21 | | "teacher"
22 | | "serial"
23 | | "code"
24 | | "identifier";
25 |
--------------------------------------------------------------------------------
/src/types/user.ts:
--------------------------------------------------------------------------------
1 | import { Department, Course } from "types/course";
2 |
3 | export interface User {
4 | db: {
5 | id: string;
6 | name: string;
7 | email: string;
8 | year: number;
9 | course_tables: string[];
10 | d_major: Department | null;
11 | major: Department | null;
12 | minors: Department[];
13 | favorites: Course[];
14 | history_courses: Course[]; // TODO
15 | student_id: null | string; // TODO
16 | };
17 | }
18 |
--------------------------------------------------------------------------------
/src/utils/CustomFetch.ts:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 |
3 | // used for hitting self-hosted API in Next.js
4 | async function handleFetch(route: string, payload: object): Promise {
5 | const response = await axios.post(route, payload);
6 | return response.data;
7 | }
8 |
9 | export default handleFetch;
10 |
--------------------------------------------------------------------------------
/src/utils/assert.ts:
--------------------------------------------------------------------------------
1 | export function assertNotNil(
2 | value: T | null | undefined,
3 | message?: string
4 | ): boolean {
5 | if (value === null || value === undefined) {
6 | console.log(message ?? `${value} is null or undefined`);
7 | return false;
8 | }
9 | return true;
10 | }
11 |
--------------------------------------------------------------------------------
/src/utils/cipher.ts:
--------------------------------------------------------------------------------
1 | import AES from "crypto-js/aes";
2 | import { enc } from "crypto-js";
3 |
4 | const password = process.env.NEXT_PUBLIC_COURSE_TABLE_SECRET;
5 |
6 | export function cipherId(id: string) {
7 | if (!password) {
8 | return null;
9 | }
10 | const ciphertext = AES.encrypt(id, password);
11 | return ciphertext.toString();
12 | }
13 |
14 | export function decipherId(id: string | null) {
15 | if (!id) return null;
16 | if (!password) {
17 | return null;
18 | }
19 | return AES.decrypt(id, password).toString(enc.Utf8);
20 | }
21 |
--------------------------------------------------------------------------------
/src/utils/colorAgent.ts:
--------------------------------------------------------------------------------
1 | import ColorHash from "color-hash";
2 | import RandomColor from "randomcolor";
3 |
4 | const hash_to_color_hex = (
5 | str: string,
6 | lightness: number,
7 | saturation = 0.5
8 | ) => {
9 | const colorhash = new ColorHash({
10 | lightness: lightness,
11 | saturation: saturation,
12 | });
13 | return colorhash.hex(str);
14 | };
15 |
16 | const hash_to_color_hex_with_hue = (
17 | str: string,
18 | hue: {
19 | min: number;
20 | max: number;
21 | }
22 | ) => {
23 | const colorhash = new ColorHash({ hue: hue });
24 | return colorhash.hex(str);
25 | };
26 |
27 | const random_color_hex = () => {
28 | return RandomColor();
29 | };
30 |
31 | export { hash_to_color_hex, hash_to_color_hex_with_hue, random_color_hex };
32 |
--------------------------------------------------------------------------------
/src/utils/ga.ts:
--------------------------------------------------------------------------------
1 | export const reportEvent = (
2 | event_category: string,
3 | eventAction: string,
4 | event_label: string
5 | ) => {
6 | if (process.env.NEXT_PUBLIC_ENV === "prod") {
7 | window.gtag("event", eventAction, { event_category, event_label });
8 | }
9 | };
10 |
--------------------------------------------------------------------------------
/src/utils/getNolUrls.ts:
--------------------------------------------------------------------------------
1 | import type { Course } from "types/course";
2 |
3 | const getNolAddUrl = (course: Course) => {
4 | const d_id = "T010"; // TODO: move to parameter?
5 | return `https://nol.ntu.edu.tw/nol/coursesearch/myschedule.php?add=${course.serial}&ddd=${d_id}`;
6 | };
7 | const getNolUrl = (course: Course) => {
8 | if (course.syllabus_url && course.syllabus_url.length > 0) {
9 | return `https://nol.ntu.edu.tw/nol/coursesearch/${course.syllabus_url}`;
10 | }
11 | const lang = "CH";
12 | const base_url = "https://nol.ntu.edu.tw/nol/coursesearch/print_table.php?";
13 | const course_id = course.identifier.replace("E", "");
14 | const dept_id = course.departments?.[0]?.id ?? "T010";
15 | const class_id = course?.class ?? "";
16 | const params = `course_id=${course_id.slice(0, 3)}%20${course_id.slice(
17 | 3
18 | )}&class=${class_id}&ser_no=${course.serial}&semester=${course.semester.slice(
19 | 0,
20 | 3
21 | )}-${course.semester.slice(3, 1)}&dpt_code=${dept_id}&lang=${lang}`;
22 | return base_url + params;
23 | };
24 |
25 | export { getNolAddUrl, getNolUrl };
26 |
--------------------------------------------------------------------------------
/src/utils/hoverCourse.ts:
--------------------------------------------------------------------------------
1 | import { proxy } from "valtio";
2 | import { parseCourseTime, TimeMap } from "utils/parseCourseTime";
3 | import type { Course } from "types/course";
4 |
5 | interface HoverCourseState {
6 | hoveredCourse: Course | null;
7 | hoveredCourseTimeMap: TimeMap | null;
8 | }
9 |
10 | const hoverCourseState = proxy({
11 | hoveredCourse: null,
12 | hoveredCourseTimeMap: null,
13 | });
14 |
15 | const setHoveredCourseData = (course: Course | null) => {
16 | if (course === null) {
17 | hoverCourseState.hoveredCourse = null;
18 | hoverCourseState.hoveredCourseTimeMap = null;
19 | } else {
20 | const hoverCourseTime = parseCourseTime(course, {});
21 | hoverCourseState.hoveredCourse = course;
22 | hoverCourseState.hoveredCourseTimeMap = hoverCourseTime;
23 | }
24 | };
25 |
26 | export { setHoveredCourseData, hoverCourseState };
27 |
--------------------------------------------------------------------------------
/src/utils/openPage.ts:
--------------------------------------------------------------------------------
1 | function openPage(url: string, closeAfterOneSecond = false) {
2 | if (window) {
3 | const wnd = window.open(url, "_blank");
4 | if (closeAfterOneSecond) {
5 | setTimeout(() => {
6 | wnd?.close();
7 | }, 1000);
8 | }
9 | }
10 | }
11 |
12 | export default openPage;
13 |
--------------------------------------------------------------------------------
/src/utils/parseCourseSchedule.ts:
--------------------------------------------------------------------------------
1 | import _ from "lodash";
2 | import type { Course } from "types/course";
3 | import { weekdays_map as numberToDay } from "data/mapping_table";
4 |
5 | export default function parseCourseSchedlue(course: Course): string {
6 | const schedules = course.schedules;
7 | const scheduleGroupByDayAndLocation = _.groupBy(schedules, (schedule) => {
8 | return `${numberToDay[schedule.weekday]}@${schedule.location}`;
9 | });
10 |
11 | return Object.entries(scheduleGroupByDayAndLocation)
12 | .map(([key, scheduleGroup]) => {
13 | if (key === "") {
14 | return null;
15 | }
16 | const [weekday, location] = key.split("@");
17 | return `${weekday} ${scheduleGroup
18 | .map((s) => s?.interval ?? null)
19 | .filter((x) => x !== null)
20 | .join(", ")} (${location})`;
21 | })
22 | .filter((x) => x !== null)
23 | .join(", ");
24 | }
25 |
--------------------------------------------------------------------------------
/src/utils/parseCourseTime.ts:
--------------------------------------------------------------------------------
1 | import type { Course, Interval } from "types/course";
2 | import type { Weekday } from "data/mapping_table";
3 |
4 | export type SingleDayTimeMap = Partial>;
5 | export type TimeMap = Partial>;
6 |
7 | // parse course object to timeMap object
8 | const parseCourseTime = (course: Course, initTimeMap: TimeMap) => {
9 | const timeMap = Object.assign({}, initTimeMap);
10 | course.schedules.forEach((schedule) => {
11 | const weekday = schedule.weekday;
12 | const interval = schedule.interval;
13 | if (!timeMap?.[weekday]) {
14 | timeMap[weekday] = {};
15 | }
16 | if (!timeMap?.[weekday]?.[interval]) {
17 | (timeMap[weekday] as { [key: string]: string[] })[interval] = [];
18 | }
19 | (
20 | (timeMap[weekday] as { [key: string]: string[] })[interval] as string[]
21 | ).push(course.id);
22 | });
23 | return timeMap;
24 | };
25 |
26 | const parseCoursesToTimeMap = (courses: { [key: string]: Course }): TimeMap => {
27 | const parsed: string[] = [];
28 | let timeMap: TimeMap = {};
29 | Object.keys(courses).forEach((key) => {
30 | if (parsed.includes(courses[key].id)) {
31 | return;
32 | }
33 | timeMap = parseCourseTime(courses[key], timeMap);
34 | parsed.push(courses[key].id);
35 | });
36 | return timeMap;
37 | };
38 |
39 | export { parseCourseTime, parseCoursesToTimeMap };
40 |
--------------------------------------------------------------------------------
/src/utils/timeTableConverter.ts:
--------------------------------------------------------------------------------
1 | import { Interval } from "types/course";
2 | import _ from "lodash";
3 |
4 | const mapStateToTimeTable = (time_state: Interval[][]) => {
5 | if (_.isEqual(time_state, [[], [], [], [], [], [], []])) {
6 | return new Array(15).fill(0).map((x) => new Array(7).fill(false));
7 | } else {
8 | const time_table = new Array(15)
9 | .fill(0)
10 | .map((x) => new Array(7).fill(false));
11 | for (let i = 0; i < 7; i++) {
12 | const day = time_state[i];
13 | for (let j = 0; j < day.length; j++) {
14 | switch (day[j]) {
15 | case "0":
16 | time_table[0][i] = true;
17 | break;
18 | case "1":
19 | time_table[1][i] = true;
20 | break;
21 | case "2":
22 | time_table[2][i] = true;
23 | break;
24 | case "3":
25 | time_table[3][i] = true;
26 | break;
27 | case "4":
28 | time_table[4][i] = true;
29 | break;
30 | case "5":
31 | time_table[5][i] = true;
32 | break;
33 | case "6":
34 | time_table[6][i] = true;
35 | break;
36 | case "7":
37 | time_table[7][i] = true;
38 | break;
39 | case "8":
40 | time_table[8][i] = true;
41 | break;
42 | case "9":
43 | time_table[9][i] = true;
44 | break;
45 | case "10":
46 | time_table[10][i] = true;
47 | break;
48 | case "A":
49 | time_table[11][i] = true;
50 | break;
51 | case "B":
52 | time_table[12][i] = true;
53 | break;
54 | case "C":
55 | time_table[13][i] = true;
56 | break;
57 | case "D":
58 | time_table[14][i] = true;
59 | break;
60 | default:
61 | break;
62 | }
63 | }
64 | }
65 | return time_table;
66 | }
67 | };
68 |
69 | const mapStateToIntervals = (time_state: Interval[][]) => {
70 | let res = 0;
71 | for (let i = 0; i < time_state.length; i++) {
72 | res += time_state[i].length;
73 | }
74 | return res;
75 | };
76 |
77 | export { mapStateToTimeTable, mapStateToIntervals };
78 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": "./src/",
4 | "paths": {
5 | "@/*": ["./*"]
6 | },
7 | "target": "es5",
8 | "lib": ["dom", "dom.iterable", "esnext"],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "strict": true,
12 | "forceConsistentCasingInFileNames": true,
13 | "noEmit": true,
14 | "incremental": true,
15 | "esModuleInterop": true,
16 | "module": "esnext",
17 | "moduleResolution": "node",
18 | "resolveJsonModule": true,
19 | "isolatedModules": true,
20 | "jsx": "preserve",
21 | "strictNullChecks": true
22 | },
23 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
24 | "exclude": ["node_modules"]
25 | }
26 |
--------------------------------------------------------------------------------