diff --git a/src/App.tsx b/src/App.tsx index 664bf6d..7c3a27e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,15 +1,14 @@ import { ChakraProvider } from "@chakra-ui/react"; import { ScheduleProvider } from "./ScheduleContext.tsx"; -import { ScheduleTables } from "./ScheduleTables.tsx"; +import { ScheduleTables } from "./components/ScheduleTables/index.tsx"; import ScheduleDndProvider from "./ScheduleDndProvider.tsx"; function App() { - return ( - + diff --git a/src/ScheduleContext.tsx b/src/ScheduleContext.tsx index 529f0dd..cf3b224 100644 --- a/src/ScheduleContext.tsx +++ b/src/ScheduleContext.tsx @@ -1,28 +1,58 @@ -import React, { createContext, PropsWithChildren, useContext, useState } from "react"; -import { Schedule } from "./types.ts"; -import dummyScheduleMap from "./dummyScheduleMap.ts"; +/* eslint-disable react-refresh/only-export-components */ +import React, { + createContext, + PropsWithChildren, + useContext, + useState, +} from "react"; +import dummyScheduleMap from "./dummyScheduleMap"; +import { Schedule } from "./types"; -interface ScheduleContextType { - schedulesMap: Record; - setSchedulesMap: React.Dispatch>>; -} +type SchedulesMap = Record; +type SetSchedulesMap = React.Dispatch>; -const ScheduleContext = createContext(undefined); +const ScheduleQueryContext = createContext(undefined); +const ScheduleCommandContext = createContext( + undefined +); -export const useScheduleContext = () => { - const context = useContext(ScheduleContext); - if (context === undefined) { - throw new Error('useSchedule must be used within a ScheduleProvider'); +ScheduleQueryContext.displayName = "ScheduleQueryContext"; +ScheduleCommandContext.displayName = "ScheduleCommandContext"; + +const useContextSafely = ( + context: React.Context, + contextName: string +): T => { + const value = useContext(context); + if (value === undefined) { + throw new Error( + `${contextName}를 사용하려면 ScheduleProvider 내부에서 사용해야 합니다` + ); } - return context; + return value; }; +export const useScheduleQueryContext = () => + useContextSafely(ScheduleQueryContext, "Schedule Query"); + +export const useScheduleCommandContext = () => + useContextSafely(ScheduleCommandContext, "Schedule Command"); + +export const useScheduleContext = () => ({ + schedulesMap: useScheduleQueryContext(), + setSchedulesMap: useScheduleCommandContext(), +}); + export const ScheduleProvider = ({ children }: PropsWithChildren) => { - const [schedulesMap, setSchedulesMap] = useState>(dummyScheduleMap); + const [schedulesMap, setSchedulesMap] = useState( + () => dummyScheduleMap + ); return ( - - {children} - + + + {children} + + ); }; diff --git a/src/ScheduleDndProvider.tsx b/src/ScheduleDndProvider.tsx index ca15f52..811569b 100644 --- a/src/ScheduleDndProvider.tsx +++ b/src/ScheduleDndProvider.tsx @@ -1,7 +1,13 @@ -import { DndContext, Modifier, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; -import { PropsWithChildren } from "react"; -import { CellSize, DAY_LABELS } from "./constants.ts"; -import { useScheduleContext } from "./ScheduleContext.tsx"; +import { + DndContext, + Modifier, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { memo, PropsWithChildren } from "react"; +import { CellSize, DAY_LABELS } from "./constants"; +import { useScheduleCommandContext } from "./ScheduleContext"; function createSnapModifier(): Modifier { return ({ transform, containerNodeRect, draggingNodeRect }) => { @@ -17,19 +23,30 @@ function createSnapModifier(): Modifier { const maxX = containerRight - right; const maxY = containerBottom - bottom; - - return ({ + return { ...transform, - x: Math.min(Math.max(Math.round(transform.x / CellSize.WIDTH) * CellSize.WIDTH, minX), maxX), - y: Math.min(Math.max(Math.round(transform.y / CellSize.HEIGHT) * CellSize.HEIGHT, minY), maxY), - }) + x: Math.min( + Math.max( + Math.round(transform.x / CellSize.WIDTH) * CellSize.WIDTH, + minX + ), + maxX + ), + y: Math.min( + Math.max( + Math.round(transform.y / CellSize.HEIGHT) * CellSize.HEIGHT, + minY + ), + maxY + ), + }; }; } -const modifiers = [createSnapModifier()] +const modifiers = [createSnapModifier()]; -export default function ScheduleDndProvider({ children }: PropsWithChildren) { - const { schedulesMap, setSchedulesMap } = useScheduleContext(); +const ScheduleDndProvider = memo(({ children }: PropsWithChildren) => { + const setSchedulesMap = useScheduleCommandContext(); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { @@ -38,34 +55,45 @@ export default function ScheduleDndProvider({ children }: PropsWithChildren) { }) ); - // eslint-disable-next-line @typescript-eslint/no-explicit-any const handleDragEnd = (event: any) => { const { active, delta } = event; const { x, y } = delta; - const [tableId, index] = active.id.split(':'); - const schedule = schedulesMap[tableId][index]; - const nowDayIndex = DAY_LABELS.indexOf(schedule.day as typeof DAY_LABELS[number]) - const moveDayIndex = Math.floor(x / 80); - const moveTimeIndex = Math.floor(y / 30); + const [tableId, index] = active.id.split(":"); - setSchedulesMap({ - ...schedulesMap, - [tableId]: schedulesMap[tableId].map((targetSchedule, targetIndex) => { - if (targetIndex !== Number(index)) { - return { ...targetSchedule } - } - return { - ...targetSchedule, - day: DAY_LABELS[nowDayIndex + moveDayIndex], - range: targetSchedule.range.map(time => time + moveTimeIndex), - } - }) - }) + setSchedulesMap((prev) => { + const schedule = prev[tableId][index]; + const nowDayIndex = DAY_LABELS.indexOf( + schedule.day as (typeof DAY_LABELS)[number] + ); + const moveDayIndex = Math.floor(x / 80); + const moveTimeIndex = Math.floor(y / 30); + return { + ...prev, + [tableId]: prev[tableId].map((targetSchedule, targetIndex) => { + if (targetIndex !== Number(index)) { + return { ...targetSchedule }; + } + return { + ...targetSchedule, + day: DAY_LABELS[nowDayIndex + moveDayIndex], + range: targetSchedule.range.map((time) => time + moveTimeIndex), + }; + }), + }; + }); }; return ( - + {children} ); -} +}); + +ScheduleDndProvider.displayName = "ScheduleDndProvider"; + +export default ScheduleDndProvider; diff --git a/src/ScheduleTable.tsx b/src/ScheduleTable.tsx deleted file mode 100644 index ea17b6a..0000000 --- a/src/ScheduleTable.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { - Box, - Button, - Flex, - Grid, - GridItem, - Popover, - PopoverArrow, - PopoverBody, - PopoverCloseButton, - PopoverContent, - PopoverTrigger, - Text, -} from "@chakra-ui/react"; -import { CellSize, DAY_LABELS, 분 } from "./constants.ts"; -import { Schedule } from "./types.ts"; -import { fill2, parseHnM } from "./utils.ts"; -import { useDndContext, useDraggable } from "@dnd-kit/core"; -import { CSS } from "@dnd-kit/utilities"; -import { ComponentProps, Fragment } from "react"; - -interface Props { - tableId: string; - schedules: Schedule[]; - onScheduleTimeClick?: (timeInfo: { day: string, time: number }) => void; - onDeleteButtonClick?: (timeInfo: { day: string, time: number }) => void; -} - -const TIMES = [ - ...Array(18) - .fill(0) - .map((v, k) => v + k * 30 * 분) - .map((v) => `${parseHnM(v)}~${parseHnM(v + 30 * 분)}`), - - ...Array(6) - .fill(18 * 30 * 분) - .map((v, k) => v + k * 55 * 분) - .map((v) => `${parseHnM(v)}~${parseHnM(v + 50 * 분)}`), -] as const; - -const ScheduleTable = ({ tableId, schedules, onScheduleTimeClick, onDeleteButtonClick }: Props) => { - - const getColor = (lectureId: string): string => { - const lectures = [...new Set(schedules.map(({ lecture }) => lecture.id))]; - const colors = ["#fdd", "#ffd", "#dff", "#ddf", "#fdf", "#dfd"]; - return colors[lectures.indexOf(lectureId) % colors.length]; - }; - - const dndContext = useDndContext(); - - const getActiveTableId = () => { - const activeId = dndContext.active?.id; - if (activeId) { - return String(activeId).split(":")[0]; - } - return null; - } - - const activeTableId = getActiveTableId(); - - return ( - - - - - 교시 - - - {DAY_LABELS.map((day) => ( - - - {day} - - - ))} - {TIMES.map((time, timeIndex) => ( - - 17 ? 'gray.200' : 'gray.100'} - > - - {fill2(timeIndex + 1)} ({time}) - - - {DAY_LABELS.map((day) => ( - 17 ? 'gray.100' : 'white'} - cursor="pointer" - _hover={{ bg: 'yellow.100' }} - onClick={() => onScheduleTimeClick?.({ day, time: timeIndex + 1 })} - /> - ))} - - ))} - - - {schedules.map((schedule, index) => ( - onDeleteButtonClick?.({ - day: schedule.day, - time: schedule.range[0], - })} - /> - ))} - - ); -}; - -const DraggableSchedule = ({ - id, - data, - bg, - onDeleteButtonClick -}: { id: string; data: Schedule } & ComponentProps & { - onDeleteButtonClick: () => void -}) => { - const { day, range, room, lecture } = data; - const { attributes, setNodeRef, listeners, transform } = useDraggable({ id }); - const leftIndex = DAY_LABELS.indexOf(day as typeof DAY_LABELS[number]); - const topIndex = range[0] - 1; - const size = range.length; - - return ( - - - - {lecture.title} - {room} - - - event.stopPropagation()}> - - - - 강의를 삭제하시겠습니까? - - - - - ); -} - -export default ScheduleTable; diff --git a/src/ScheduleTables.tsx b/src/ScheduleTables.tsx deleted file mode 100644 index 44dbd7a..0000000 --- a/src/ScheduleTables.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { Button, ButtonGroup, Flex, Heading, Stack } from "@chakra-ui/react"; -import ScheduleTable from "./ScheduleTable.tsx"; -import { useScheduleContext } from "./ScheduleContext.tsx"; -import SearchDialog from "./SearchDialog.tsx"; -import { useState } from "react"; - -export const ScheduleTables = () => { - const { schedulesMap, setSchedulesMap } = useScheduleContext(); - const [searchInfo, setSearchInfo] = useState<{ - tableId: string; - day?: string; - time?: number; - } | null>(null); - - const disabledRemoveButton = Object.keys(schedulesMap).length === 1; - - const duplicate = (targetId: string) => { - setSchedulesMap(prev => ({ - ...prev, - [`schedule-${Date.now()}`]: [...prev[targetId]] - })) - }; - - const remove = (targetId: string) => { - setSchedulesMap(prev => { - delete prev[targetId]; - return { ...prev }; - }) - }; - - return ( - <> - - {Object.entries(schedulesMap).map(([tableId, schedules], index) => ( - - - 시간표 {index + 1} - - - - - - - setSearchInfo({ tableId, ...timeInfo })} - onDeleteButtonClick={({ day, time }) => setSchedulesMap((prev) => ({ - ...prev, - [tableId]: prev[tableId].filter(schedule => schedule.day !== day || !schedule.range.includes(time)) - }))} - /> - - ))} - - setSearchInfo(null)}/> - - ); -} diff --git a/src/SearchDialog.tsx b/src/SearchDialog.tsx deleted file mode 100644 index 593951f..0000000 --- a/src/SearchDialog.tsx +++ /dev/null @@ -1,378 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { - Box, - Button, - Checkbox, - CheckboxGroup, - FormControl, - FormLabel, - HStack, - Input, - Modal, - ModalBody, - ModalCloseButton, - ModalContent, - ModalHeader, - ModalOverlay, - Select, - Stack, - Table, - Tag, - TagCloseButton, - TagLabel, - Tbody, - Td, - Text, - Th, - Thead, - Tr, - VStack, - Wrap, -} from "@chakra-ui/react"; -import { useScheduleContext } from "./ScheduleContext.tsx"; -import { Lecture } from "./types.ts"; -import { parseSchedule } from "./utils.ts"; -import axios from "axios"; -import { DAY_LABELS } from "./constants.ts"; - -interface Props { - searchInfo: { - tableId: string; - day?: string; - time?: number; - } | null; - onClose: () => void; -} - -interface SearchOption { - query?: string, - grades: number[], - days: string[], - times: number[], - majors: string[], - credits?: number, -} - -const TIME_SLOTS = [ - { id: 1, label: "09:00~09:30" }, - { id: 2, label: "09:30~10:00" }, - { id: 3, label: "10:00~10:30" }, - { id: 4, label: "10:30~11:00" }, - { id: 5, label: "11:00~11:30" }, - { id: 6, label: "11:30~12:00" }, - { id: 7, label: "12:00~12:30" }, - { id: 8, label: "12:30~13:00" }, - { id: 9, label: "13:00~13:30" }, - { id: 10, label: "13:30~14:00" }, - { id: 11, label: "14:00~14:30" }, - { id: 12, label: "14:30~15:00" }, - { id: 13, label: "15:00~15:30" }, - { id: 14, label: "15:30~16:00" }, - { id: 15, label: "16:00~16:30" }, - { id: 16, label: "16:30~17:00" }, - { id: 17, label: "17:00~17:30" }, - { id: 18, label: "17:30~18:00" }, - { id: 19, label: "18:00~18:50" }, - { id: 20, label: "18:55~19:45" }, - { id: 21, label: "19:50~20:40" }, - { id: 22, label: "20:45~21:35" }, - { id: 23, label: "21:40~22:30" }, - { id: 24, label: "22:35~23:25" }, -]; - -const PAGE_SIZE = 100; - -const fetchMajors = () => axios.get('/schedules-majors.json'); -const fetchLiberalArts = () => axios.get('/schedules-liberal-arts.json'); - -// TODO: 이 코드를 개선해서 API 호출을 최소화 해보세요 + Promise.all이 현재 잘못 사용되고 있습니다. 같이 개선해주세요. -const fetchAllLectures = async () => await Promise.all([ - (console.log('API Call 1', performance.now()), await fetchMajors()), - (console.log('API Call 2', performance.now()), await fetchLiberalArts()), - (console.log('API Call 3', performance.now()), await fetchMajors()), - (console.log('API Call 4', performance.now()), await fetchLiberalArts()), - (console.log('API Call 5', performance.now()), await fetchMajors()), - (console.log('API Call 6', performance.now()), await fetchLiberalArts()), -]); - -// TODO: 이 컴포넌트에서 불필요한 연산이 발생하지 않도록 다양한 방식으로 시도해주세요. -const SearchDialog = ({ searchInfo, onClose }: Props) => { - const { setSchedulesMap } = useScheduleContext(); - - const loaderWrapperRef = useRef(null); - const loaderRef = useRef(null); - const [lectures, setLectures] = useState([]); - const [page, setPage] = useState(1); - const [searchOptions, setSearchOptions] = useState({ - query: '', - grades: [], - days: [], - times: [], - majors: [], - }); - - const getFilteredLectures = () => { - const { query = '', credits, grades, days, times, majors } = searchOptions; - return lectures - .filter(lecture => - lecture.title.toLowerCase().includes(query.toLowerCase()) || - lecture.id.toLowerCase().includes(query.toLowerCase()) - ) - .filter(lecture => grades.length === 0 || grades.includes(lecture.grade)) - .filter(lecture => majors.length === 0 || majors.includes(lecture.major)) - .filter(lecture => !credits || lecture.credits.startsWith(String(credits))) - .filter(lecture => { - if (days.length === 0) { - return true; - } - const schedules = lecture.schedule ? parseSchedule(lecture.schedule) : []; - return schedules.some(s => days.includes(s.day)); - }) - .filter(lecture => { - if (times.length === 0) { - return true; - } - const schedules = lecture.schedule ? parseSchedule(lecture.schedule) : []; - return schedules.some(s => s.range.some(time => times.includes(time))); - }); - } - - const filteredLectures = getFilteredLectures(); - const lastPage = Math.ceil(filteredLectures.length / PAGE_SIZE); - const visibleLectures = filteredLectures.slice(0, page * PAGE_SIZE); - const allMajors = [...new Set(lectures.map(lecture => lecture.major))]; - - const changeSearchOption = (field: keyof SearchOption, value: SearchOption[typeof field]) => { - setPage(1); - setSearchOptions(({ ...searchOptions, [field]: value })); - loaderWrapperRef.current?.scrollTo(0, 0); - }; - - const addSchedule = (lecture: Lecture) => { - if (!searchInfo) return; - - const { tableId } = searchInfo; - - const schedules = parseSchedule(lecture.schedule).map(schedule => ({ - ...schedule, - lecture - })); - - setSchedulesMap(prev => ({ - ...prev, - [tableId]: [...prev[tableId], ...schedules] - })); - - onClose(); - }; - - useEffect(() => { - const start = performance.now(); - console.log('API 호출 시작: ', start) - fetchAllLectures().then(results => { - const end = performance.now(); - console.log('모든 API 호출 완료 ', end) - console.log('API 호출에 걸린 시간(ms): ', end - start) - setLectures(results.flatMap(result => result.data)); - }) - }, []); - - useEffect(() => { - const $loader = loaderRef.current; - const $loaderWrapper = loaderWrapperRef.current; - - if (!$loader || !$loaderWrapper) { - return; - } - - const observer = new IntersectionObserver( - entries => { - if (entries[0].isIntersecting) { - setPage(prevPage => Math.min(lastPage, prevPage + 1)); - } - }, - { threshold: 0, root: $loaderWrapper } - ); - - observer.observe($loader); - - return () => observer.unobserve($loader); - }, [lastPage]); - - useEffect(() => { - setSearchOptions(prev => ({ - ...prev, - days: searchInfo?.day ? [searchInfo.day] : [], - times: searchInfo?.time ? [searchInfo.time] : [], - })) - setPage(1); - }, [searchInfo]); - - return ( - - - - 수업 검색 - - - - - - 검색어 - changeSearchOption('query', e.target.value)} - /> - - - - 학점 - - - - - - - 학년 - changeSearchOption('grades', value.map(Number))} - > - - {[1, 2, 3, 4].map(grade => ( - {grade}학년 - ))} - - - - - - 요일 - changeSearchOption('days', value as string[])} - > - - {DAY_LABELS.map(day => ( - {day} - ))} - - - - - - - - 시간 - changeSearchOption('times', values.map(Number))} - > - - {searchOptions.times.sort((a, b) => a - b).map(time => ( - - {time}교시 - changeSearchOption('times', searchOptions.times.filter(v => v !== time))}/> - - ))} - - - {TIME_SLOTS.map(({ id, label }) => ( - - - {id}교시({label}) - - - ))} - - - - - - 전공 - changeSearchOption('majors', values as string[])} - > - - {searchOptions.majors.map(major => ( - - {major.split("

").pop()} - changeSearchOption('majors', searchOptions.majors.filter(v => v !== major))}/> - - ))} - - - {allMajors.map(major => ( - - - {major.replace(/

/gi, ' ')} - - - ))} - - - - - - 검색결과: {filteredLectures.length}개 - - - - - - - - - - - - - - -
과목코드학년과목명학점전공시간
- - - - - {visibleLectures.map((lecture, index) => ( - - - - - - - - ))} - -
{lecture.id}{lecture.grade}{lecture.title}{lecture.credits} - - - -
- - -
- - - - - ); -}; - -export default SearchDialog; \ No newline at end of file diff --git a/src/apis.ts b/src/apis.ts new file mode 100644 index 0000000..2e81cb1 --- /dev/null +++ b/src/apis.ts @@ -0,0 +1,17 @@ +import { Lecture } from "./types"; +import { requestWithCache } from "./utils"; + +export const fetchMajors = () => + requestWithCache("./schedules-majors.json"); +export const fetchLiberalArts = () => + requestWithCache("./schedules-liberal-arts.json"); + +export const fetchAllLectures = async () => + await Promise.all([ + (console.log("API Call 1", performance.now()), fetchMajors()), + (console.log("API Call 2", performance.now()), fetchLiberalArts()), + (console.log("API Call 3", performance.now()), fetchMajors()), + (console.log("API Call 4", performance.now()), fetchLiberalArts()), + (console.log("API Call 5", performance.now()), fetchMajors()), + (console.log("API Call 6", performance.now()), fetchLiberalArts()), + ]); diff --git a/src/components/ScheduleTables/DraggableSchedule.tsx b/src/components/ScheduleTables/DraggableSchedule.tsx new file mode 100644 index 0000000..42938c2 --- /dev/null +++ b/src/components/ScheduleTables/DraggableSchedule.tsx @@ -0,0 +1,59 @@ +import { + useCallbackRef, + Popover, + PopoverTrigger, + PopoverContent, + PopoverArrow, + PopoverCloseButton, + PopoverBody, + Button, + Text, +} from "@chakra-ui/react"; +import { useDraggable } from "@dnd-kit/core"; +import { ComponentProps, useState } from "react"; +import { ScheduleItem } from "./ScheduleItem"; +import { CSS } from "@dnd-kit/utilities"; + +type DraggableScheduleProps = ComponentProps & { + onDeleteButtonClick: () => void; +}; + +export const DraggableSchedule = (props: DraggableScheduleProps) => { + const { id, data, bg, onDeleteButtonClick } = props; + + const [enabledPopover, setEnabledPopover] = useState(false); + const { attributes, setNodeRef, listeners, transform } = useDraggable({ id }); + const enablePopover = useCallbackRef(() => setEnabledPopover(true)); + + const item = ( + + ); + + if (!enabledPopover) { + return item; + } + + return ( + + {item} + event.stopPropagation()}> + + + + 강의를 삭제하시겠습니까? + + + + + ); +}; diff --git a/src/components/ScheduleTables/ScheduleGrid.tsx b/src/components/ScheduleTables/ScheduleGrid.tsx new file mode 100644 index 0000000..62c06cc --- /dev/null +++ b/src/components/ScheduleTables/ScheduleGrid.tsx @@ -0,0 +1,62 @@ +import { Fragment, memo } from "react"; +import { Flex, Grid, GridItem, Text } from "@chakra-ui/react"; +import { fill2 } from "../../utils.ts"; +import { SearchInfo } from "../../types.ts"; +import { DAY_LABELS, CellSize, TIMES } from "../../constants.ts"; + +interface ScheduleGridProps { + onClick: (timeInfo: Required>) => void; +} + +export const ScheduleGrid = memo(({ onClick }: ScheduleGridProps) => ( + + + + 교시 + + + {DAY_LABELS.map((day) => ( + + + {day} + + + ))} + {TIMES.map((time, timeIndex) => ( + + 17 ? "gray.200" : "gray.100"} + > + + + {fill2(timeIndex + 1)} ({time}) + + + + {DAY_LABELS.map((day) => ( + 17 ? "gray.100" : "white"} + cursor="pointer" + _hover={{ bg: "yellow.100" }} + onClick={() => onClick({ day, time: timeIndex + 1 })} + /> + ))} + + ))} + +)); + +ScheduleGrid.displayName = "ScheduleGrid"; diff --git a/src/components/ScheduleTables/ScheduleItem.tsx b/src/components/ScheduleTables/ScheduleItem.tsx new file mode 100644 index 0000000..b5b1ba4 --- /dev/null +++ b/src/components/ScheduleTables/ScheduleItem.tsx @@ -0,0 +1,39 @@ +import { ComponentPropsWithRef, memo } from "react"; +import { Box } from "@chakra-ui/react"; +import { Schedule } from "../../types"; +import { DAY_LABELS, CellSize } from "../../constants"; + +export const ScheduleItem = memo( + ({ + data, + bg, + ref, + ...props + }: { data: Schedule } & ComponentPropsWithRef) => { + const { day, range, room, lecture } = data; + const leftIndex = DAY_LABELS.indexOf(day as (typeof DAY_LABELS)[number]); + const topIndex = range[0] - 1; + const size = range.length; + + return ( + +

{lecture.title}

+

{room}

+ + ); + } +); + +ScheduleItem.displayName = "ScheduleItem"; diff --git a/src/components/ScheduleTables/ScheduleTable.tsx b/src/components/ScheduleTables/ScheduleTable.tsx new file mode 100644 index 0000000..121015e --- /dev/null +++ b/src/components/ScheduleTables/ScheduleTable.tsx @@ -0,0 +1,69 @@ +import { Box, useCallbackRef } from "@chakra-ui/react"; +import { useDndContext } from "@dnd-kit/core"; +import { useCallback, useMemo } from "react"; +import { Schedule } from "../../types"; +import { ScheduleGrid } from "./ScheduleGrid"; +import { SCHEDULE_COLORS } from "../../constants"; +import { DraggableSchedule } from "./DraggableSchedule"; + +interface ScheduleTableProps { + tableId: string; + schedules: Schedule[]; + onScheduleTimeClick: (timeInfo: { day: string; time: number }) => void; + onDeleteButtonClick?: (timeInfo: { day: string; time: number }) => void; +} + +const ScheduleTable = (props: ScheduleTableProps) => { + const { tableId, schedules, onScheduleTimeClick, onDeleteButtonClick } = + props; + + const getScheduleColor = (lectureId: string): string => { + const lectures = [...new Set(schedules.map(({ lecture }) => lecture.id))]; + return SCHEDULE_COLORS[ + lectures.indexOf(lectureId) % SCHEDULE_COLORS.length + ]; + }; + + const dndContext = useDndContext(); + + const activeTableId = useMemo(() => { + const activeId = dndContext.active?.id; + + if (activeId) { + return String(activeId).split(":")[0]; + } + + return null; + }, [dndContext.active]); + + const handleDeleteButtonClick = useCallback( + (schedule: Schedule) => { + onDeleteButtonClick?.({ + day: schedule.day, + time: schedule.range[0], + }); + }, + [onDeleteButtonClick] + ); + + return ( + + + {schedules.map((schedule, index) => ( + handleDeleteButtonClick(schedule)} + /> + ))} + + ); +}; + +export default ScheduleTable; diff --git a/src/components/ScheduleTables/ScheduleTableContainer.tsx b/src/components/ScheduleTables/ScheduleTableContainer.tsx new file mode 100644 index 0000000..974f46e --- /dev/null +++ b/src/components/ScheduleTables/ScheduleTableContainer.tsx @@ -0,0 +1,83 @@ +import { SearchInfo } from "../../types.ts"; +import { ComponentProps, memo } from "react"; +import { Button, ButtonGroup, Flex, Heading, Stack } from "@chakra-ui/react"; +import ScheduleTable from "./ScheduleTable.tsx"; +import ScheduleDndProvider from "../../ScheduleDndProvider.tsx"; + +type TimeInfo = Omit; + +type ScheduleTableContainerProps = Omit< + ComponentProps, + "onScheduleTimeClick" | "onDeleteButtonClick" +> & { + index: number; + disabledRemoveButton: boolean; + onAddButtonClick: (tableId: string) => void; + onDuplicateButtonClick: (tableId: string) => void; + onDeleteTableButtonClick: (tableId: string) => void; + onScheduleTimeClick: (value: SearchInfo) => void; + onDeleteButtonClick: (value: Required) => void; +}; + +export const ScheduleTableContainer = memo( + (props: ScheduleTableContainerProps) => { + const { + index, + tableId, + disabledRemoveButton, + onAddButtonClick, + onDuplicateButtonClick, + onScheduleTimeClick, + onDeleteButtonClick, + onDeleteTableButtonClick, + ...restProps + } = props; + + const handleScheduleTimeClick = (timeInfo: TimeInfo) => + onScheduleTimeClick({ tableId, ...timeInfo }); + const handleDeleteButtonClick = (timeInfo: Required) => + onDeleteButtonClick({ tableId, ...timeInfo }); + + return ( + + + + 시간표 {index + 1} + + + + + + + + + + + + ); + } +); + +ScheduleTableContainer.displayName = "ScheduleTableContainer"; diff --git a/src/components/ScheduleTables/index.tsx b/src/components/ScheduleTables/index.tsx new file mode 100644 index 0000000..991b336 --- /dev/null +++ b/src/components/ScheduleTables/index.tsx @@ -0,0 +1,71 @@ +import { Flex, useCallbackRef } from "@chakra-ui/react"; +import { useState } from "react"; +import { SearchInfo } from "../../types"; +import { ScheduleTableContainer } from "./ScheduleTableContainer"; +import { + useScheduleCommandContext, + useScheduleQueryContext, +} from "../../ScheduleContext"; +import { SearchDialog } from "../SearchDialog"; + +export const ScheduleTables = () => { + const setSchedulesMap = useScheduleCommandContext(); + const schedulesMap = useScheduleQueryContext(); + const [searchInfo, setSearchInfo] = useState(null); + + const disabledRemoveButton = Object.keys(schedulesMap).length === 1; + + const add = useCallbackRef((tableId: string) => setSearchInfo({ tableId })); + + const duplicate = useCallbackRef((targetId: string) => { + setSchedulesMap((prev) => ({ + ...prev, + [`schedule-${Date.now()}`]: [...prev[targetId]], + })); + }); + + const remove = useCallbackRef((targetId: string) => { + setSchedulesMap((prev) => { + delete prev[targetId]; + return { ...prev }; + }); + }); + + const deleteSchedule = useCallbackRef( + ({ tableId, day, time }: Required) => + setSchedulesMap((prev) => ({ + ...prev, + [tableId]: prev[tableId].filter( + (schedule) => schedule.day !== day || !schedule.range.includes(time) + ), + })) + ); + + return ( + <> + + {Object.entries(schedulesMap).map(([tableId, schedules], index) => ( + + ))} + + + {!!searchInfo && ( + setSearchInfo(null)} + /> + )} + + ); +}; diff --git a/src/components/SearchDialog/CreditsSelect.tsx b/src/components/SearchDialog/CreditsSelect.tsx new file mode 100644 index 0000000..c3ef9b2 --- /dev/null +++ b/src/components/SearchDialog/CreditsSelect.tsx @@ -0,0 +1,28 @@ +import { FormControl, FormLabel, Select } from "@chakra-ui/react"; +import { memo } from "react"; + +interface CreditsSelectProps { + value: number | undefined; + onChange: (value: number | undefined) => void; +} + +export const CreditsSelect = memo(({ value, onChange }: CreditsSelectProps) => { + return ( + + 학점 + + + ); +}); + +CreditsSelect.displayName = "CreditsSelect"; diff --git a/src/components/SearchDialog/DaysCheckbox.tsx b/src/components/SearchDialog/DaysCheckbox.tsx new file mode 100644 index 0000000..e8b2bb3 --- /dev/null +++ b/src/components/SearchDialog/DaysCheckbox.tsx @@ -0,0 +1,36 @@ +import { + Checkbox, + CheckboxGroup, + FormControl, + FormLabel, + HStack, +} from "@chakra-ui/react"; +import { memo } from "react"; +import { DAY_LABELS } from "../../constants.ts"; + +interface DaysCheckboxProps { + value: string[]; + onChange: (value: string[]) => void; +} + +export const DaysCheckbox = memo(({ value, onChange }: DaysCheckboxProps) => { + return ( + + 요일 + onChange(selected as string[])} + > + + {DAY_LABELS.map((day) => ( + + {day} + + ))} + + + + ); +}); + +DaysCheckbox.displayName = "DaysCheckbox"; diff --git a/src/components/SearchDialog/GradesCheckbox.tsx b/src/components/SearchDialog/GradesCheckbox.tsx new file mode 100644 index 0000000..8e17937 --- /dev/null +++ b/src/components/SearchDialog/GradesCheckbox.tsx @@ -0,0 +1,37 @@ +import { + Checkbox, + CheckboxGroup, + FormControl, + FormLabel, + HStack, +} from "@chakra-ui/react"; +import { memo } from "react"; + +interface GradesCheckboxProps { + value: number[]; + onChange: (value: number[]) => void; +} + +export const GradesCheckbox = memo( + ({ value, onChange }: GradesCheckboxProps) => { + return ( + + 학년 + onChange(selected.map(Number))} + > + + {[1, 2, 3, 4].map((grade) => ( + + {grade}학년 + + ))} + + + + ); + } +); + +GradesCheckbox.displayName = "GradesCheckbox"; diff --git a/src/components/SearchDialog/MajorCheckbox.tsx b/src/components/SearchDialog/MajorCheckbox.tsx new file mode 100644 index 0000000..e852f5a --- /dev/null +++ b/src/components/SearchDialog/MajorCheckbox.tsx @@ -0,0 +1,24 @@ +import { Box, Checkbox, Stack } from "@chakra-ui/react"; +import { memo } from "react"; + +export const MajorsCheckbox = memo(({ majors }: { majors: string[] }) => { + return ( + + {majors.map((major) => ( + + + {major.replace(/

/gi, " ")} + + + ))} + + ); +}); diff --git a/src/components/SearchDialog/MajorsFilter.tsx b/src/components/SearchDialog/MajorsFilter.tsx new file mode 100644 index 0000000..d20fe5f --- /dev/null +++ b/src/components/SearchDialog/MajorsFilter.tsx @@ -0,0 +1,49 @@ +import { + CheckboxGroup, + FormControl, + FormLabel, + Tag, + TagCloseButton, + TagLabel, + Wrap, +} from "@chakra-ui/react"; +import { memo } from "react"; +import { MajorsCheckbox } from "./MajorCheckbox.tsx"; + +interface MajorsFilterProps { + value: string[]; + onChange: (value: string[]) => void; + allMajors: string[]; +} + +export const MajorsFilter = memo( + ({ value, onChange, allMajors }: MajorsFilterProps) => { + const handleRemoveMajor = (major: string) => { + onChange(value.filter((v) => v !== major)); + }; + + return ( + + 전공 + onChange(values as string[])} + > + + {value.map((major) => ( + + {major.split("

").pop()} + handleRemoveMajor(major)} /> + + ))} + + + + + + ); + } +); + +MajorsFilter.displayName = "MajorsFilter"; diff --git a/src/components/SearchDialog/SearchInput.tsx b/src/components/SearchDialog/SearchInput.tsx new file mode 100644 index 0000000..b61729e --- /dev/null +++ b/src/components/SearchDialog/SearchInput.tsx @@ -0,0 +1,22 @@ +import { FormControl, FormLabel, Input } from "@chakra-ui/react"; +import { memo } from "react"; + +interface SearchInputProps { + value?: string; + onChange: (value: string) => void; +} + +export const SearchInput = memo(({ value, onChange }: SearchInputProps) => { + return ( + + 검색어 + onChange(e.target.value)} + /> + + ); +}); + +SearchInput.displayName = "SearchInput"; diff --git a/src/components/SearchDialog/SearchItem.tsx b/src/components/SearchDialog/SearchItem.tsx new file mode 100644 index 0000000..c0b05d0 --- /dev/null +++ b/src/components/SearchDialog/SearchItem.tsx @@ -0,0 +1,26 @@ +import { memo } from "react"; +import { Lecture } from "../../types"; + +interface SearchItemProps { + addSchedule: (lecture: Lecture) => void; + lecture: Lecture; +} + +export const SearchItem = memo((props: SearchItemProps) => { + const { addSchedule, lecture } = props; + const { id, grade, title, credits, major, schedule } = lecture; + + return ( + + {id} + {grade} + {title} + {credits} + + + + + + + ); +}); diff --git a/src/components/SearchDialog/TimesCheckbox.tsx b/src/components/SearchDialog/TimesCheckbox.tsx new file mode 100644 index 0000000..afedcb5 --- /dev/null +++ b/src/components/SearchDialog/TimesCheckbox.tsx @@ -0,0 +1,66 @@ +import { + Box, + Checkbox, + CheckboxGroup, + FormControl, + FormLabel, + Stack, + Tag, + TagCloseButton, + TagLabel, + Wrap, +} from "@chakra-ui/react"; +import { memo } from "react"; +import { TIME_SLOTS } from "../../constants.ts"; + +interface TimesCheckboxProps { + value: number[]; + onChange: (value: number[]) => void; +} + +export const TimesCheckbox = memo(({ value, onChange }: TimesCheckboxProps) => { + const handleRemoveTime = (time: number) => { + onChange(value.filter((v) => v !== time)); + }; + + return ( + + 시간 + onChange(values.map(Number))} + > + + {value + .sort((a, b) => a - b) + .map((time) => ( + + {time}교시 + handleRemoveTime(time)} /> + + ))} + + + {TIME_SLOTS.map(({ id, label }) => ( + + + {id}교시({label}) + + + ))} + + + + ); +}); + +TimesCheckbox.displayName = "TimesCheckbox"; diff --git a/src/components/SearchDialog/index.tsx b/src/components/SearchDialog/index.tsx new file mode 100644 index 0000000..1d94b6d --- /dev/null +++ b/src/components/SearchDialog/index.tsx @@ -0,0 +1,148 @@ +import { + Box, + HStack, + Modal, + ModalBody, + ModalCloseButton, + ModalContent, + ModalHeader, + ModalOverlay, + Table, + Tbody, + Text, + Th, + Thead, + Tr, + VStack, +} from "@chakra-ui/react"; + +import { SearchItem } from "./SearchItem.tsx"; +import { + useAddSchedule, + useInfiniteLectures, + useLectures, + useSearchOption, +} from "../../hooks"; +import { SearchDialogProps } from "./types.ts"; +import { SearchInput } from "./SearchInput.tsx"; +import { CreditsSelect } from "./CreditsSelect.tsx"; +import { GradesCheckbox } from "./GradesCheckbox.tsx"; +import { DaysCheckbox } from "./DaysCheckbox.tsx"; +import { TimesCheckbox } from "./TimesCheckbox.tsx"; +import { MajorsFilter } from "./MajorsFilter.tsx"; + +export const SearchDialog = (props: SearchDialogProps) => { + const { searchInfo, onDialogClose } = props; + + const searchOptions = useSearchOption({ + searchInfo, + onChange: () => infiniteLectures.reset(), + }); + const lectures = useLectures({ searchOptions: searchOptions.values }); + const infiniteLectures = useInfiniteLectures({ items: lectures.items }); + const addSchedule = useAddSchedule({ + tableId: searchInfo?.tableId, + onComplete: onDialogClose, + }); + + if (!searchInfo) { + return null; + } + + return ( + + + + 수업 검색 + + + + + + + + + + + + + + + + + + + 검색결과: {lectures.items.length}개 + + + + + + + + + + + + + +
과목코드학년과목명학점전공시간
+ + + + + {infiniteLectures.items.map((lecture, index) => ( + + ))} + +
+ + +
+
+
+
+
+ ); +}; diff --git a/src/components/SearchDialog/types.ts b/src/components/SearchDialog/types.ts new file mode 100644 index 0000000..61a59a2 --- /dev/null +++ b/src/components/SearchDialog/types.ts @@ -0,0 +1,6 @@ +import { SearchInfo } from "../../types"; + +export interface SearchDialogProps { + searchInfo: SearchInfo | null; + onDialogClose: () => void; +} diff --git a/src/constants.ts b/src/constants.ts index c7c2e46..21743dc 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,3 +1,5 @@ +import { parseHnM } from "./utils.ts"; + export const DAY_LABELS = ["월", "화", "수", "목", "금", "토"] as const; export const CellSize = { @@ -7,3 +9,44 @@ export const CellSize = { export const 초 = 1000; export const 분 = 60 * 초; + +export const TIME_SLOTS = [ + { id: 1, label: "09:00~09:30" }, + { id: 2, label: "09:30~10:00" }, + { id: 3, label: "10:00~10:30" }, + { id: 4, label: "10:30~11:00" }, + { id: 5, label: "11:00~11:30" }, + { id: 6, label: "11:30~12:00" }, + { id: 7, label: "12:00~12:30" }, + { id: 8, label: "12:30~13:00" }, + { id: 9, label: "13:00~13:30" }, + { id: 10, label: "13:30~14:00" }, + { id: 11, label: "14:00~14:30" }, + { id: 12, label: "14:30~15:00" }, + { id: 13, label: "15:00~15:30" }, + { id: 14, label: "15:30~16:00" }, + { id: 15, label: "16:00~16:30" }, + { id: 16, label: "16:30~17:00" }, + { id: 17, label: "17:00~17:30" }, + { id: 18, label: "17:30~18:00" }, + { id: 19, label: "18:00~18:50" }, + { id: 20, label: "18:55~19:45" }, + { id: 21, label: "19:50~20:40" }, + { id: 22, label: "20:45~21:35" }, + { id: 23, label: "21:40~22:30" }, + { id: 24, label: "22:35~23:25" }, +]; + +export const TIMES = [ + ...Array(18) + .fill(0) + .map((v, k) => v + k * 30 * 분) + .map((v) => `${parseHnM(v)}~${parseHnM(v + 30 * 분)}`), + + ...Array(6) + .fill(18 * 30 * 분) + .map((v, k) => v + k * 55 * 분) + .map((v) => `${parseHnM(v)}~${parseHnM(v + 50 * 분)}`), +] as const; + +export const SCHEDULE_COLORS = ["#fdd", "#ffd", "#dff", "#ddf", "#fdf", "#dfd"]; diff --git a/src/dummyScheduleMap.ts b/src/dummyScheduleMap.ts index debdcb7..574c1bd 100644 --- a/src/dummyScheduleMap.ts +++ b/src/dummyScheduleMap.ts @@ -1,746 +1,495 @@ export default { "schedule-1": [ { - "day": "월", - "range": [ - 1, - 2, - 3, - 4, - 5, - 6 - ], - "room": "2공521", - "lecture": { - "id": "529540", - "title": "SW융합코딩1", - "credits": "3(0)", - "major": "SW융합대학

SW융합학부

SW융합경제경영전공", - "schedule": "월1~6(2공521)", - "grade": 1 - } - }, - { - "day": "화", - "range": [ - 1, - 2, - 3 - ], - "room": "미디어509", - "lecture": { - "id": "527790", - "title": "객체지향프로그래밍(SW)", - "credits": "3(0)", - "major": "SW융합대학

SW융합학부

SW융합경제경영전공", - "schedule": "화1~3(미디어509)

목1~3(미디어509)", - "grade": 2 - } - }, - { - "day": "목", - "range": [ - 1, - 2, - 3 - ], - "room": "미디어509", - "lecture": { - "id": "527790", - "title": "객체지향프로그래밍(SW)", - "credits": "3(0)", - "major": "SW융합대학

SW융합학부

SW융합경제경영전공", - "schedule": "화1~3(미디어509)

목1~3(미디어509)", - "grade": 2 - } - }, - { - "day": "수", - "range": [ - 1, - 2, - 3, - 4, - 5, - 6 - ], - "room": "소프트304", - "lecture": { - "id": "540970", - "title": "파이썬프로그래밍(SW융합)", - "credits": "3(0)", - "major": "SW융합대학

SW융합학부

SW융합경제경영전공", - "schedule": "수1~6(소프트304)", - "grade": 2 - } - }, - { - "day": "금", - "range": [ - 1, - 2, - 3, - 4, - 5, - 6 - ], - "room": "2공524", - "lecture": { - "id": "359210", - "title": "선형대수", - "credits": "3(0)", - "major": "SW융합대학

SW융합학부

SW융합경제경영전공", - "schedule": "금1~6(2공524)", - "grade": 2 - } - }, - { - "day": "목", - "range": [ - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13, - 14, - 15, - 16, - 17, - 18 - ], - "room": "소프트414", - "lecture": { - "id": "548310", - "title": "실무중심종합설계프로젝트(티맥스)", - "credits": "3(0)", - "major": "SW융합대학

SW융합학부

SW융합경제경영전공", - "schedule": "목1~18(소프트414)

토1~18(소프트414)", - "grade": 3 - } - }, - { - "day": "토", - "range": [ - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11, - 12, - 13, - 14, - 15, - 16, - 17, - 18 - ], - "room": "소프트414", - "lecture": { - "id": "548310", - "title": "실무중심종합설계프로젝트(티맥스)", - "credits": "3(0)", - "major": "SW융합대학

SW융합학부

SW융합경제경영전공", - "schedule": "목1~18(소프트414)

토1~18(소프트414)", - "grade": 3 - } - } + day: "월", + range: [1, 2, 3, 4, 5, 6], + room: "2공521", + lecture: { + id: "529540", + title: "SW융합코딩1", + credits: "3(0)", + major: "SW융합대학

SW융합학부

SW융합경제경영전공", + schedule: "월1~6(2공521)", + grade: 1, + }, + }, + { + day: "화", + range: [1, 2, 3], + room: "미디어509", + lecture: { + id: "527790", + title: "객체지향프로그래밍(SW)", + credits: "3(0)", + major: "SW융합대학

SW융합학부

SW융합경제경영전공", + schedule: "화1~3(미디어509)

목1~3(미디어509)", + grade: 2, + }, + }, + { + day: "목", + range: [1, 2, 3], + room: "미디어509", + lecture: { + id: "527790", + title: "객체지향프로그래밍(SW)", + credits: "3(0)", + major: "SW융합대학

SW융합학부

SW융합경제경영전공", + schedule: "화1~3(미디어509)

목1~3(미디어509)", + grade: 2, + }, + }, + { + day: "수", + range: [1, 2, 3, 4, 5, 6], + room: "소프트304", + lecture: { + id: "540970", + title: "파이썬프로그래밍(SW융합)", + credits: "3(0)", + major: "SW융합대학

SW융합학부

SW융합경제경영전공", + schedule: "수1~6(소프트304)", + grade: 2, + }, + }, + { + day: "금", + range: [1, 2, 3, 4, 5, 6], + room: "2공524", + lecture: { + id: "359210", + title: "선형대수", + credits: "3(0)", + major: "SW융합대학

SW융합학부

SW융합경제경영전공", + schedule: "금1~6(2공524)", + grade: 2, + }, + }, + { + day: "목", + range: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18], + room: "소프트414", + lecture: { + id: "548310", + title: "실무중심종합설계프로젝트(티맥스)", + credits: "3(0)", + major: "SW융합대학

SW융합학부

SW융합경제경영전공", + schedule: "목1~18(소프트414)

토1~18(소프트414)", + grade: 3, + }, + }, + { + day: "토", + range: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18], + room: "소프트414", + lecture: { + id: "548310", + title: "실무중심종합설계프로젝트(티맥스)", + credits: "3(0)", + major: "SW융합대학

SW융합학부

SW융합경제경영전공", + schedule: "목1~18(소프트414)

토1~18(소프트414)", + grade: 3, + }, + }, ], "schedule-2": [ { - "day": "월", - "range": [ - 1, - 2, - 3, - 4, - 5, - 6 - ], - "room": "국제205_PC", - "lecture": { - "id": "525770", - "title": "자료구조기초및실습", - "credits": "3(0)", - "major": "SW융합대학

SW융합학부

SW융합경제경영전공", - "schedule": "월1~6(국제205_PC)", - "grade": 2 - } - }, - { - "day": "화", - "range": [ - 1, - 2, - 3, - 4, - 5, - 6 - ], - "room": "소프트227", - "lecture": { - "id": "372460", - "title": "알고리즘", - "credits": "3(0)", - "major": "SW융합대학

SW융합학부

SW융합경제경영전공", - "schedule": "화1~6(소프트227)", - "grade": 3 - } - }, - { - "day": "수", - "range": [ - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9 - ], - "room": "2공524", - "lecture": { - "id": "388600", - "title": "인공지능", - "credits": "3(0)", - "major": "SW융합대학

SW융합학부

SW융합경제경영전공", - "schedule": "수1~9(2공524)", - "grade": 3 - } - }, - { - "day": "목", - "range": [ - 1, - 2, - 3, - 4, - 5, - 6 - ], - "room": "소프트516", - "lecture": { - "id": "524820", - "title": "오픈소스SW활용", - "credits": "3(0)", - "major": "SW융합대학

SW융합학부

SW융합경제경영전공", - "schedule": "목1~6(소프트516)", - "grade": 3 - } - }, - { - "day": "금", - "range": [ - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11 - ], - "room": "소프트414", - "lecture": { - "id": "548300", - "title": "인공지능입문및실습(티맥스)", - "credits": "3(0)", - "major": "SW융합대학

SW융합학부

SW융합경제경영전공", - "schedule": "금1~11(소프트414)", - "grade": 3 - } - }, - { - "day": "토", - "range": [ - 1, - 2 - ], - "room": "", - "lecture": { - "id": "451150", - "title": "노래-목소리3", - "credits": "1(0)", - "major": "음악·예술대학

공연영화학부 뮤지컬전공", - "schedule": "토1~2", - "grade": 3 - } - } + day: "월", + range: [1, 2, 3, 4, 5, 6], + room: "국제205_PC", + lecture: { + id: "525770", + title: "자료구조기초및실습", + credits: "3(0)", + major: "SW융합대학

SW융합학부

SW융합경제경영전공", + schedule: "월1~6(국제205_PC)", + grade: 2, + }, + }, + { + day: "화", + range: [1, 2, 3, 4, 5, 6], + room: "소프트227", + lecture: { + id: "372460", + title: "알고리즘", + credits: "3(0)", + major: "SW융합대학

SW융합학부

SW융합경제경영전공", + schedule: "화1~6(소프트227)", + grade: 3, + }, + }, + { + day: "수", + range: [1, 2, 3, 4, 5, 6, 7, 8, 9], + room: "2공524", + lecture: { + id: "388600", + title: "인공지능", + credits: "3(0)", + major: "SW융합대학

SW융합학부

SW융합경제경영전공", + schedule: "수1~9(2공524)", + grade: 3, + }, + }, + { + day: "목", + range: [1, 2, 3, 4, 5, 6], + room: "소프트516", + lecture: { + id: "524820", + title: "오픈소스SW활용", + credits: "3(0)", + major: "SW융합대학

SW융합학부

SW융합경제경영전공", + schedule: "목1~6(소프트516)", + grade: 3, + }, + }, + { + day: "금", + range: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], + room: "소프트414", + lecture: { + id: "548300", + title: "인공지능입문및실습(티맥스)", + credits: "3(0)", + major: "SW융합대학

SW융합학부

SW융합경제경영전공", + schedule: "금1~11(소프트414)", + grade: 3, + }, + }, + { + day: "토", + range: [1, 2], + room: "", + lecture: { + id: "451150", + title: "노래-목소리3", + credits: "1(0)", + major: "음악·예술대학

공연영화학부 뮤지컬전공", + schedule: "토1~2", + grade: 3, + }, + }, ], "schedule-3": [ { - "day": "월", - "range": [ - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11 - ], - "room": "소프트414", - "lecture": { - "id": "548290", - "title": "운영체제및실습(티맥스)", - "credits": "3(0)", - "major": "SW융합대학

SW융합학부

SW융합경제경영전공", - "schedule": "월1~11(소프트414)", - "grade": 4 - } - }, - { - "day": "화", - "range": [ - 1, - 2, - 3, - 4, - 5, - 6, - 7, - 8, - 9, - 10, - 11 - ], - "room": "소프트414", - "lecture": { - "id": "548280", - "title": "데이터베이스와SQL실습(티맥스)", - "credits": "3(0)", - "major": "SW융합대학

SW융합학부

SW융합경제경영전공", - "schedule": "화1~11(소프트414)", - "grade": 4 - } - }, - { - "day": "수", - "range": [ - 1, - 2, - 3, - 4, - 5, - 6 - ], - "room": "소프트406", - "lecture": { - "id": "366770", - "title": "시스템분석및설계", - "credits": "3(0)", - "major": "SW융합대학

SW융합학부

SW융합바이오전공", - "schedule": "수1~6(소프트406)", - "grade": 4 - } - }, - { - "day": "목", - "range": [ - 1, - 2, - 3, - 4, - 5, - 6 - ], - "room": "미디어403", - "lecture": { - "id": "539800", - "title": "캡스톤디자인(정보통계)", - "credits": "3(0)", - "major": "SW융합대학

정보통계학과", - "schedule": "목1~6(미디어403)", - "grade": 4 - } - }, - { - "day": "금", - "range": [ - 1, - 2 - ], - "room": "치114", - "lecture": { - "id": "394090", - "title": "임상보철학2", - "credits": "1(0)", - "major": "치과대학

치의학과", - "schedule": "금1~2(치114)", - "grade": 4 - } - }, - { - "day": "토", - "range": [ - 1, - 2, - 3, - 4, - 5, - 6 - ], - "room": "", - "lecture": { - "id": "550040", - "title": "반도체기초공학및산업의이해", - "credits": "3(0)", - "major": "공과대학

반도체WAVE융합전공", - "schedule": "토1~6", - "grade": 4 - } - } + day: "월", + range: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], + room: "소프트414", + lecture: { + id: "548290", + title: "운영체제및실습(티맥스)", + credits: "3(0)", + major: "SW융합대학

SW융합학부

SW융합경제경영전공", + schedule: "월1~11(소프트414)", + grade: 4, + }, + }, + { + day: "화", + range: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11], + room: "소프트414", + lecture: { + id: "548280", + title: "데이터베이스와SQL실습(티맥스)", + credits: "3(0)", + major: "SW융합대학

SW융합학부

SW융합경제경영전공", + schedule: "화1~11(소프트414)", + grade: 4, + }, + }, + { + day: "수", + range: [1, 2, 3, 4, 5, 6], + room: "소프트406", + lecture: { + id: "366770", + title: "시스템분석및설계", + credits: "3(0)", + major: "SW융합대학

SW융합학부

SW융합바이오전공", + schedule: "수1~6(소프트406)", + grade: 4, + }, + }, + { + day: "목", + range: [1, 2, 3, 4, 5, 6], + room: "미디어403", + lecture: { + id: "539800", + title: "캡스톤디자인(정보통계)", + credits: "3(0)", + major: "SW융합대학

정보통계학과", + schedule: "목1~6(미디어403)", + grade: 4, + }, + }, + { + day: "금", + range: [1, 2], + room: "치114", + lecture: { + id: "394090", + title: "임상보철학2", + credits: "1(0)", + major: "치과대학

치의학과", + schedule: "금1~2(치114)", + grade: 4, + }, + }, + { + day: "토", + range: [1, 2, 3, 4, 5, 6], + room: "", + lecture: { + id: "550040", + title: "반도체기초공학및산업의이해", + credits: "3(0)", + major: "공과대학

반도체WAVE융합전공", + schedule: "토1~6", + grade: 4, + }, + }, ], "schedule-4": [ { - "day": "월", - "range": [ - 11, - 12, - 13, - 14, - 15, - 16 - ], - "room": "예323", - "lecture": { - "id": "343070", - "title": "문학사세미나", - "credits": "3(0)", - "major": "예술대학

문예창작과", - "schedule": "월11~16(예323)", - "grade": 1 - } - }, - { - "day": "화", - "range": [ - 3, - 4, - 5, - 6, - 7, - 8 - ], - "room": "예018", - "lecture": { - "id": "361960", - "title": "소설창작세미나1", - "credits": "3(0)", - "major": "예술대학

문예창작과", - "schedule": "화3~8(예018)", - "grade": 3 - } - }, - { - "day": "수", - "range": [ - 3, - 4, - 5, - 6, - 7, - 8 - ], - "room": "예술관D동207", - "lecture": { - "id": "533510", - "title": "영상문학의이론과창작", - "credits": "3(0)", - "major": "예술대학

문예창작과", - "schedule": "수3~8(예술관D동207)", - "grade": 2 - } - }, - { - "day": "목", - "range": [ - 3, - 4, - 5, - 6, - 7, - 8 - ], - "room": "예술관D동308", - "lecture": { - "id": "533520", - "title": "비평창작연습", - "credits": "3(0)", - "major": "예술대학

문예창작과", - "schedule": "목3~8(예술관D동308)", - "grade": 3 - } - }, - { - "day": "금", - "range": [ - 3, - 4, - 5, - 6, - 7, - 8 - ], - "room": "예술관D동308", - "lecture": { - "id": "481130", - "title": "소설창작연습", - "credits": "3(0)", - "major": "예술대학

문예창작과", - "schedule": "금3~8(예술관D동308)", - "grade": 2 - } - } + day: "월", + range: [11, 12, 13, 14, 15, 16], + room: "예323", + lecture: { + id: "343070", + title: "문학사세미나", + credits: "3(0)", + major: "예술대학

문예창작과", + schedule: "월11~16(예323)", + grade: 1, + }, + }, + { + day: "화", + range: [3, 4, 5, 6, 7, 8], + room: "예018", + lecture: { + id: "361960", + title: "소설창작세미나1", + credits: "3(0)", + major: "예술대학

문예창작과", + schedule: "화3~8(예018)", + grade: 3, + }, + }, + { + day: "수", + range: [3, 4, 5, 6, 7, 8], + room: "예술관D동207", + lecture: { + id: "533510", + title: "영상문학의이론과창작", + credits: "3(0)", + major: "예술대학

문예창작과", + schedule: "수3~8(예술관D동207)", + grade: 2, + }, + }, + { + day: "목", + range: [3, 4, 5, 6, 7, 8], + room: "예술관D동308", + lecture: { + id: "533520", + title: "비평창작연습", + credits: "3(0)", + major: "예술대학

문예창작과", + schedule: "목3~8(예술관D동308)", + grade: 3, + }, + }, + { + day: "금", + range: [3, 4, 5, 6, 7, 8], + room: "예술관D동308", + lecture: { + id: "481130", + title: "소설창작연습", + credits: "3(0)", + major: "예술대학

문예창작과", + schedule: "금3~8(예술관D동308)", + grade: 2, + }, + }, ], "schedule-5": [ { - "day": "월", - "range": [ - 3, - 4, - 5, - 6 - ], - "room": "의228", - "lecture": { - "id": "432030", - "title": "해부학", - "credits": "2(0)", - "major": "간호대학

간호학과", - "schedule": "월3~6(의228)", - "grade": 1 - } - }, - { - "day": "화", - "range": [ - 1, - 2, - 3 - ], - "room": "의228", - "lecture": { - "id": "323070", - "title": "기본간호학1", - "credits": "3(0)", - "major": "간호대학

간호학과", - "schedule": "화1~3(의228)

목10~12(의228)", - "grade": 2 - } - }, - { - "day": "목", - "range": [ - 7, - 8, - 9 - ], - "room": "의228", - "lecture": { - "id": "323070", - "title": "기본간호학1", - "credits": "3(0)", - "major": "간호대학

간호학과", - "schedule": "화1~3(의228)

목10~12(의228)", - "grade": 2 - } - }, - { - "day": "수", - "range": [ - 1, - 2, - 3, - 4 - ], - "room": "의230", - "lecture": { - "id": "411690", - "title": "지역사회간호학3", - "credits": "2(0)", - "major": "간호대학

간호학과", - "schedule": "수1~4(의230)", - "grade": 4 - } - }, - { - "day": "금", - "range": [ - 1, - 2, - 3 - ], - "room": "인521", - "lecture": { - "id": "409440", - "title": "중급일본어강독1", - "credits": "3(0)", - "major": "외국어대학

아시아중동학부 일본학전공", - "schedule": "화15~17(인521)

목8~10(인424)", - "grade": 2 - } - }, - { - "day": "목", - "range": [ - 1, - 2, - 3 - ], - "room": "인424", - "lecture": { - "id": "409440", - "title": "중급일본어강독1", - "credits": "3(0)", - "major": "외국어대학

아시아중동학부 일본학전공", - "schedule": "화15~17(인521)

목8~10(인424)", - "grade": 2 - } - } + day: "월", + range: [3, 4, 5, 6], + room: "의228", + lecture: { + id: "432030", + title: "해부학", + credits: "2(0)", + major: "간호대학

간호학과", + schedule: "월3~6(의228)", + grade: 1, + }, + }, + { + day: "화", + range: [1, 2, 3], + room: "의228", + lecture: { + id: "323070", + title: "기본간호학1", + credits: "3(0)", + major: "간호대학

간호학과", + schedule: "화1~3(의228)

목10~12(의228)", + grade: 2, + }, + }, + { + day: "목", + range: [7, 8, 9], + room: "의228", + lecture: { + id: "323070", + title: "기본간호학1", + credits: "3(0)", + major: "간호대학

간호학과", + schedule: "화1~3(의228)

목10~12(의228)", + grade: 2, + }, + }, + { + day: "수", + range: [1, 2, 3, 4], + room: "의230", + lecture: { + id: "411690", + title: "지역사회간호학3", + credits: "2(0)", + major: "간호대학

간호학과", + schedule: "수1~4(의230)", + grade: 4, + }, + }, + { + day: "금", + range: [1, 2, 3], + room: "인521", + lecture: { + id: "409440", + title: "중급일본어강독1", + credits: "3(0)", + major: "외국어대학

아시아중동학부 일본학전공", + schedule: "화15~17(인521)

목8~10(인424)", + grade: 2, + }, + }, + { + day: "목", + range: [1, 2, 3], + room: "인424", + lecture: { + id: "409440", + title: "중급일본어강독1", + credits: "3(0)", + major: "외국어대학

아시아중동학부 일본학전공", + schedule: "화15~17(인521)

목8~10(인424)", + grade: 2, + }, + }, ], "schedule-6": [ { - "day": "화", - "range": [ - 9, - 10 - ], - "room": "음악133", - "lecture": { - "id": "471870", - "title": "연주A", - "credits": "1(0)", - "major": "음악·예술대학

음악학부 기악전공(피아노)", - "schedule": "화9~10(음악133)", - "grade": 1 - } - }, - { - "day": "토", - "range": [ - 3, - 4 - ], - "room": "", - "lecture": { - "id": "502420", - "title": "피아노실기A", - "credits": "1(0)", - "major": "음악·예술대학

음악학부 기악전공(피아노)", - "schedule": "토3~4", - "grade": 1 - } - }, - { - "day": "토", - "range": [ - 7, - 8 - ], - "room": "", - "lecture": { - "id": "502420", - "title": "피아노실기A", - "credits": "1(0)", - "major": "음악·예술대학

음악학부 기악전공(피아노)", - "schedule": "토7~8", - "grade": 1 - } - }, - { - "day": "월", - "range": [ - 13, - 14, - 15, - 16 - ], - "room": "음악104", - "lecture": { - "id": "318720", - "title": "국악사1", - "credits": "2(0)", - "major": "음악·예술대학

음악학부 기악전공", - "schedule": "월13~16(음악104)", - "grade": 2 - } - }, - { - "day": "목", - "range": [ - 9, - 10, - 11, - 12 - ], - "room": "음악106", - "lecture": { - "id": "358200", - "title": "서양음악사1", - "credits": "2(0)", - "major": "음악·예술대학

음악학부 기악전공", - "schedule": "목9~12(음악106)", - "grade": 2 - } - }, - { - "day": "화", - "range": [ - 1, - 2, - 3, - 4 - ], - "room": "음악105", - "lecture": { - "id": "367110", - "title": "시창청음", - "credits": "2(0)", - "major": "음악·예술대학

음악학부 기악전공", - "schedule": "화5~8(음악105)", - "grade": 1 - } - }, - { - "day": "금", - "range": [ - 5, - 6, - 7, - 8 - ], - "room": "음악105", - "lecture": { - "id": "358200", - "title": "서양음악사1", - "credits": "2(0)", - "major": "음악·예술대학

음악학부 기악전공", - "schedule": "금5~8(음악105)", - "grade": 2 - } - } - ] -} + day: "화", + range: [9, 10], + room: "음악133", + lecture: { + id: "471870", + title: "연주A", + credits: "1(0)", + major: "음악·예술대학

음악학부 기악전공(피아노)", + schedule: "화9~10(음악133)", + grade: 1, + }, + }, + { + day: "토", + range: [3, 4], + room: "", + lecture: { + id: "502420", + title: "피아노실기A", + credits: "1(0)", + major: "음악·예술대학

음악학부 기악전공(피아노)", + schedule: "토3~4", + grade: 1, + }, + }, + { + day: "토", + range: [7, 8], + room: "", + lecture: { + id: "502420", + title: "피아노실기A", + credits: "1(0)", + major: "음악·예술대학

음악학부 기악전공(피아노)", + schedule: "토7~8", + grade: 1, + }, + }, + { + day: "월", + range: [13, 14, 15, 16], + room: "음악104", + lecture: { + id: "318720", + title: "국악사1", + credits: "2(0)", + major: "음악·예술대학

음악학부 기악전공", + schedule: "월13~16(음악104)", + grade: 2, + }, + }, + { + day: "목", + range: [9, 10, 11, 12], + room: "음악106", + lecture: { + id: "358200", + title: "서양음악사1", + credits: "2(0)", + major: "음악·예술대학

음악학부 기악전공", + schedule: "목9~12(음악106)", + grade: 2, + }, + }, + { + day: "화", + range: [1, 2, 3, 4], + room: "음악105", + lecture: { + id: "367110", + title: "시창청음", + credits: "2(0)", + major: "음악·예술대학

음악학부 기악전공", + schedule: "화5~8(음악105)", + grade: 1, + }, + }, + { + day: "금", + range: [5, 6, 7, 8], + room: "음악105", + lecture: { + id: "358200", + title: "서양음악사1", + credits: "2(0)", + major: "음악·예술대학

음악학부 기악전공", + schedule: "금5~8(음악105)", + grade: 2, + }, + }, + ], +}; diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..a2d5bf3 --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,5 @@ +export * from "./useLectures.ts"; +export * from "./useInfiniteLectures.ts"; +export * from "./useSearchOption.ts"; +export * from "./useAddSchedule.ts"; +export * from "./useIntersectionObserver.ts"; diff --git a/src/hooks/useAddSchedule.ts b/src/hooks/useAddSchedule.ts new file mode 100644 index 0000000..61df318 --- /dev/null +++ b/src/hooks/useAddSchedule.ts @@ -0,0 +1,33 @@ +import { useScheduleCommandContext } from "../ScheduleContext.tsx"; +import { Lecture } from "../types.ts"; +import { parseSchedule } from "../utils.ts"; +import { useCallbackRef } from "@chakra-ui/react"; + +interface UseAddScheduleProps { + tableId?: string; + onComplete?: () => void; +} + +export const useAddSchedule = (props: UseAddScheduleProps) => { + const { tableId, onComplete } = props; + + const setSchedulesMap = useScheduleCommandContext(); + + return useCallbackRef((lecture: Lecture) => { + if (!tableId) { + return; + } + + const schedules = parseSchedule(lecture.schedule).map((schedule) => ({ + ...schedule, + lecture, + })); + + setSchedulesMap((prev) => ({ + ...prev, + [tableId]: [...prev[tableId], ...schedules], + })); + + onComplete?.(); + }); +}; diff --git a/src/hooks/useInfiniteLectures.ts b/src/hooks/useInfiniteLectures.ts new file mode 100644 index 0000000..ade8ccf --- /dev/null +++ b/src/hooks/useInfiniteLectures.ts @@ -0,0 +1,44 @@ +import { useRef, useState } from "react"; +import { useIntersectionObserver } from "./useIntersectionObserver"; + +interface UseInfiniteLecturesProps { + items: T[]; +} + +const PAGE_SIZE = 100; + +export const useInfiniteLectures = (props: UseInfiniteLecturesProps) => { + const { items: originItems } = props; + + const [page, setPage] = useState(1); + const ref = useRef(null); + const wrapperRef = useRef(null); + + const lastPage = Math.ceil(originItems.length / PAGE_SIZE); + + const items = originItems.slice(0, page * PAGE_SIZE); + + const reset = () => { + setPage(1); + wrapperRef.current?.scrollTo(0, 0); + }; + + useIntersectionObserver({ + ref, + options: { root: wrapperRef.current }, + onIntersect: ([entry]) => { + const $loader = ref.current; + const $loaderWrapper = wrapperRef.current; + + if (!$loader || !$loaderWrapper) { + return; + } + + if (entry.isIntersecting) { + setPage((prevPage) => Math.min(lastPage, prevPage + 1)); + } + }, + }); + + return { items, ref, wrapperRef, reset }; +}; diff --git a/src/hooks/useIntersectionObserver.ts b/src/hooks/useIntersectionObserver.ts new file mode 100644 index 0000000..7539b26 --- /dev/null +++ b/src/hooks/useIntersectionObserver.ts @@ -0,0 +1,33 @@ +import { RefObject, useEffect } from "react"; + +type UseIntersectionObserverProps = { + ref: RefObject; + onIntersect?: (entries: IntersectionObserverEntry[]) => void; + options: IntersectionObserverInit; +}; + +export const useIntersectionObserver = ( + props: UseIntersectionObserverProps +) => { + const { ref, onIntersect, options } = props; + + useEffect(() => { + const $el = ref.current; + if (!$el) return undefined; + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) { + onIntersect?.(entries); + } + }, + { threshold: 0, root: $el, ...options } + ); + + observer.observe($el); + + return () => observer.unobserve($el); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [onIntersect]); +}; diff --git a/src/hooks/useLectures.ts b/src/hooks/useLectures.ts new file mode 100644 index 0000000..3f09f08 --- /dev/null +++ b/src/hooks/useLectures.ts @@ -0,0 +1,71 @@ +import { useEffect, useMemo, useState } from "react"; +import { Lecture, SearchOptions } from "../types"; +import { parseSchedule } from "../utils"; +import { fetchAllLectures } from "../apis"; + +interface UseLecturesProps { + searchOptions: SearchOptions; +} + +export const useLectures = (props: UseLecturesProps) => { + const { searchOptions } = props; + + const [lectures, setLectures] = useState([]); + + useEffect(() => { + const start = performance.now(); + console.log("API 호출 시작: ", start); + fetchAllLectures().then((results) => { + const end = performance.now(); + console.log("모든 API 호출 완료 ", end); + console.log("API 호출에 걸린 시간(ms): ", end - start); + setLectures(results.flatMap((result) => result.data)); + }); + }, []); + + const items = useMemo(() => { + const { query = "", credits, grades, days, times, majors } = searchOptions; + return lectures + .filter( + (lecture) => + lecture.title.toLowerCase().includes(query.toLowerCase()) || + lecture.id.toLowerCase().includes(query.toLowerCase()) + ) + .filter( + (lecture) => grades.length === 0 || grades.includes(lecture.grade) + ) + .filter( + (lecture) => majors.length === 0 || majors.includes(lecture.major) + ) + .filter( + (lecture) => !credits || lecture.credits.startsWith(String(credits)) + ) + .filter((lecture) => { + if (days.length === 0) { + return true; + } + const schedules = lecture.schedule + ? parseSchedule(lecture.schedule) + : []; + return schedules.some((s) => days.includes(s.day)); + }) + .filter((lecture) => { + if (times.length === 0) { + return true; + } + const schedules = lecture.schedule + ? parseSchedule(lecture.schedule) + : []; + return schedules.some((s) => + s.range.some((time) => times.includes(time)) + ); + }); + }, [lectures, searchOptions]); + + const allMajors = useMemo( + () => [...new Set(lectures.map((lecture) => lecture.major))], + [lectures] + ); + + return { items, allMajors }; +}; diff --git a/src/hooks/useSearchOption.ts b/src/hooks/useSearchOption.ts new file mode 100644 index 0000000..9d5d1e2 --- /dev/null +++ b/src/hooks/useSearchOption.ts @@ -0,0 +1,60 @@ +import { useEffect, useState } from "react"; +import { SearchInfo, SearchOptions } from "../types"; +import { useCallbackRef } from "@chakra-ui/react"; + +interface UseSearchOptionProps { + searchInfo: SearchInfo | null; + onChange?: () => void; +} + +export const useSearchOption = (props: UseSearchOptionProps) => { + const { searchInfo, onChange } = props; + + const [values, setValues] = useState({ + query: "", + grades: [], + days: [], + times: [], + majors: [], + }); + + const changeValue = ( + field: K, + value: SearchOptions[K] + ) => { + setValues({ ...values, [field]: value }); + onChange?.(); + }; + + const makeChangeFunction = + (field: K) => + (value: SearchOptions[K]) => + changeValue(field, value); + + const changeQuery = useCallbackRef(makeChangeFunction("query" as const)); + const changeGrades = useCallbackRef(makeChangeFunction("grades" as const)); + const changeDays = useCallbackRef(makeChangeFunction("days" as const)); + const changeTimes = useCallbackRef(makeChangeFunction("times" as const)); + const changeMajors = useCallbackRef(makeChangeFunction("majors" as const)); + const changeCredits = useCallbackRef(makeChangeFunction("credits" as const)); + + useEffect(() => { + setValues((prev) => ({ + ...prev, + days: searchInfo?.day ? [searchInfo.day] : [], + times: searchInfo?.time ? [searchInfo.time] : [], + })); + onChange?.(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchInfo]); + + return { + values, + query: { value: values.query, change: changeQuery }, + grades: { value: values.grades, change: changeGrades }, + days: { value: values.days, change: changeDays }, + times: { value: values.times, change: changeTimes }, + majors: { value: values.majors, change: changeMajors }, + credits: { value: values.credits, change: changeCredits }, + }; +}; diff --git a/src/main.tsx b/src/main.tsx index 96533e7..a7f34ca 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,6 +4,6 @@ import { createRoot } from "react-dom/client"; createRoot(document.getElementById("root")!).render( - + -) +); diff --git a/src/types.ts b/src/types.ts index 16118bf..5f4ea98 100644 --- a/src/types.ts +++ b/src/types.ts @@ -8,8 +8,23 @@ export interface Lecture { } export interface Schedule { - lecture: Lecture + lecture: Lecture; day: string; - range: number[] + range: number[]; room?: string; } + +export interface SearchOptions { + query?: string; + grades: number[]; + days: string[]; + times: number[]; + majors: string[]; + credits?: number; +} + +export interface SearchInfo { + tableId: string; + day?: string; + time?: number; +} diff --git a/src/utils.ts b/src/utils.ts index 8b6eb66..f4f6365 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,3 +1,5 @@ +import axios, { AxiosResponse } from "axios"; + export const fill2 = (n: number) => `0${n}`.substr(-2); export const parseHnM = (current: number) => { @@ -11,12 +13,11 @@ const getTimeRange = (value: string): number[] => { return Array(end - start + 1) .fill(start) .map((v, k) => v + k); -} +}; export const parseSchedule = (schedule: string) => { - const schedules = schedule.split('

'); - return schedules.map(schedule => { - + const schedules = schedule.split("

"); + return schedules.map((schedule) => { const reg = /^([가-힣])(\d+(~\d+)?)(.*)/; const [day] = schedule.split(/(\d+)/); @@ -28,3 +29,19 @@ export const parseSchedule = (schedule: string) => { return { day, range, room }; }); }; + +export const requestWithCache = (() => { + const cache = new Map(); + + return async function (...args: Parameters) { + const key = args[0]; + + if (!cache.has(key)) { + const response = axios.get(...args); + + cache.set(key, response); + } + + return cache.get(key) as AxiosResponse; + }; +})();