diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..09995c1 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,50 @@ +name: Deploy + +on: + push: + branches: [main] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v2 + with: + version: 10 + + - uses: actions/setup-node@v4 + with: + node-version: 22.x + cache: pnpm + + - name: Install deps + run: pnpm install + + - name: Build + run: pnpm run build + + # 404.html 생성 - SPA 라우팅 지원 + - name: Create 404.html for SPA routing + run: cp dist/index.html dist/404.html + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: dist + + deploy: + needs: build + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + + steps: + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index a547bf3..4272c64 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,8 @@ yarn-error.log* pnpm-debug.log* lerna-debug.log* +/*.md + node_modules dist dist-ssr diff --git a/src/SearchDialog.tsx b/src/SearchDialog.tsx index 593951f..e5b0639 100644 --- a/src/SearchDialog.tsx +++ b/src/SearchDialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, useMemo, useCallback, memo } from "react"; import { Box, Button, @@ -33,6 +33,7 @@ import { useScheduleContext } from "./ScheduleContext.tsx"; import { Lecture } from "./types.ts"; import { parseSchedule } from "./utils.ts"; import axios from "axios"; +import { AxiosResponse } from "axios"; import { DAY_LABELS } from "./constants.ts"; interface Props { @@ -45,12 +46,12 @@ interface Props { } interface SearchOption { - query?: string, - grades: number[], - days: string[], - times: number[], - majors: string[], - credits?: number, + query?: string; + grades: number[]; + days: string[]; + times: number[]; + majors: string[]; + credits?: number; } const TIME_SLOTS = [ @@ -82,18 +83,59 @@ const TIME_SLOTS = [ const PAGE_SIZE = 100; -const fetchMajors = () => axios.get('/schedules-majors.json'); -const fetchLiberalArts = () => axios.get('/schedules-liberal-arts.json'); +// 테이블 행 컴포넌트 - React.memo로 최적화 +const LectureRow = memo(({ lecture, onAdd }: { lecture: Lecture; onAdd: (lecture: Lecture) => void }) => { + return ( + + {lecture.id} + {lecture.grade} + {lecture.title} + {lecture.credits} + + + + + + + ); +}); -// 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()), -]); +LectureRow.displayName = 'LectureRow'; + +const fetchMajors = (() => { + let cache: Promise> | null = null; + return () => { + if (!cache) cache = axios.get("/schedules-majors.json"); + return cache; + }; +})(); +const fetchLiberalArts = (() => { + let cache: Promise> | null = null; + return () => { + if (!cache) cache = axios.get("/schedules-liberal-arts.json"); + return cache; + }; +})(); + +const fetchAllLectures = async () => { + const [majors, liberalArts] = await Promise.all([ + fetchMajors(), + fetchLiberalArts(), + ]); + return [majors, liberalArts]; +}; // TODO: 이 컴포넌트에서 불필요한 연산이 발생하지 않도록 다양한 방식으로 시도해주세요. const SearchDialog = ({ searchInfo, onClose }: Props) => { @@ -104,77 +146,103 @@ const SearchDialog = ({ searchInfo, onClose }: Props) => { const [lectures, setLectures] = useState([]); const [page, setPage] = useState(1); const [searchOptions, setSearchOptions] = useState({ - query: '', + 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 filteredLectures = useMemo(() => { + const { query = "", credits, grades, days, times, majors } = searchOptions; + + return lectures.filter((lecture) => { + if (query) { + const lowerQuery = query.toLowerCase(); + const matchesQuery = + lecture.title.toLowerCase().includes(lowerQuery) || + lecture.id.toLowerCase().includes(lowerQuery); + if (!matchesQuery) return false; + } + + if (grades.length > 0 && !grades.includes(lecture.grade)) { + return false; + } + + if (majors.length > 0 && !majors.includes(lecture.major)) { + return false; + } + + if (credits && !lecture.credits.startsWith(String(credits))) { + return false; + } + + if (days.length > 0 || times.length > 0) { + const schedules = lecture.schedule + ? parseSchedule(lecture.schedule) + : []; + + if (days.length > 0) { + const matchesDay = schedules.some((s) => days.includes(s.day)); + if (!matchesDay) return false; } - const schedules = lecture.schedule ? parseSchedule(lecture.schedule) : []; - return schedules.some(s => days.includes(s.day)); - }) - .filter(lecture => { - if (times.length === 0) { - return true; + + if (times.length > 0) { + const matchesTime = schedules.some((s) => + s.range.some((time) => times.includes(time)) + ); + if (!matchesTime) return false; } - const schedules = lecture.schedule ? parseSchedule(lecture.schedule) : []; - return schedules.some(s => s.range.some(time => times.includes(time))); - }); - } + } - const filteredLectures = getFilteredLectures(); + return true; + }); + }, [lectures, searchOptions]); 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 allMajors = useMemo(() => { + return [...new Set(lectures.map((lecture) => lecture.major))]; + }, [lectures]); - const changeSearchOption = (field: keyof SearchOption, value: SearchOption[typeof field]) => { - setPage(1); - setSearchOptions(({ ...searchOptions, [field]: value })); - loaderWrapperRef.current?.scrollTo(0, 0); - }; + const changeSearchOption = useCallback( + (field: keyof SearchOption, value: SearchOption[typeof field]) => { + setPage(1); + setSearchOptions((prev) => ({ ...prev, [field]: value })); + loaderWrapperRef.current?.scrollTo(0, 0); + }, + [] + ); - const addSchedule = (lecture: Lecture) => { - if (!searchInfo) return; + const addSchedule = useCallback( + (lecture: Lecture) => { + if (!searchInfo) return; - const { tableId } = searchInfo; + const { tableId } = searchInfo; - const schedules = parseSchedule(lecture.schedule).map(schedule => ({ - ...schedule, - lecture - })); + const schedules = parseSchedule(lecture.schedule).map((schedule) => ({ + ...schedule, + lecture, + })); - setSchedulesMap(prev => ({ - ...prev, - [tableId]: [...prev[tableId], ...schedules] - })); + setSchedulesMap((prev) => ({ + ...prev, + [tableId]: [...prev[tableId], ...schedules], + })); - onClose(); - }; + onClose(); + }, + [searchInfo, setSchedulesMap, onClose] + ); useEffect(() => { const start = performance.now(); - console.log('API 호출 시작: ', start) - fetchAllLectures().then(results => { + 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)); - }) + console.log("모든 API 호출 완료 ", end); + console.log("API 호출에 걸린 시간(ms): ", end - start); + setLectures(results.flatMap((result) => result.data)); + }); }, []); useEffect(() => { @@ -186,9 +254,9 @@ const SearchDialog = ({ searchInfo, onClose }: Props) => { } const observer = new IntersectionObserver( - entries => { + (entries) => { if (entries[0].isIntersecting) { - setPage(prevPage => Math.min(lastPage, prevPage + 1)); + setPage((prevPage) => Math.min(lastPage, prevPage + 1)); } }, { threshold: 0, root: $loaderWrapper } @@ -200,20 +268,20 @@ const SearchDialog = ({ searchInfo, onClose }: Props) => { }, [lastPage]); useEffect(() => { - setSearchOptions(prev => ({ + setSearchOptions((prev) => ({ ...prev, days: searchInfo?.day ? [searchInfo.day] : [], times: searchInfo?.time ? [searchInfo.time] : [], - })) + })); setPage(1); }, [searchInfo]); return ( - + 수업 검색 - + @@ -222,7 +290,7 @@ const SearchDialog = ({ searchInfo, onClose }: Props) => { changeSearchOption('query', e.target.value)} + onChange={(e) => changeSearchOption("query", e.target.value)} /> @@ -230,7 +298,9 @@ const SearchDialog = ({ searchInfo, onClose }: Props) => { 학점