diff --git a/src/api/types/problem.ts b/src/api/types/problem.ts index 1f64728..47ba560 100644 --- a/src/api/types/problem.ts +++ b/src/api/types/problem.ts @@ -9,23 +9,28 @@ interface Subject { name: string; } +interface Chocice { + choice: string; +} + interface Problem { problemId: string; examInfo: ExamInfo; subject: Subject; isBookmark: boolean; description: string; - choices: string[]; + choices: Chocice[]; answerNumber: number; theory: string; solution: string; } /** - * Backend의 ProblemType에서 이론, 해설 보기 선택을 위한 viewSolution, viewTheory가 추가된 타입입니다. + * Backend의 ProblemType에서 이론, 해설 보기 선택을 위한 viewSolution, viewTheory, 문제 번호를 위한 problemNumber가 추가된 타입입니다. */ interface ProblemViewType extends Problem { chooseNumber: number; viewSolution: boolean; viewTheory: boolean; + problemNumber: number; } diff --git a/src/app/exam/[problems]/page.tsx b/src/app/exam/[problems]/page.tsx new file mode 100644 index 0000000..69a08aa --- /dev/null +++ b/src/app/exam/[problems]/page.tsx @@ -0,0 +1,453 @@ +"use client"; + +import Appbar from "@/src/components/Appbar"; +import { globalTheme } from "@/src/components/globalStyle"; +import useProblems from "@/src/hooks/useProblems"; +import { + Box, + Button, + CircularProgress, + Container, + Drawer, + Grid, + Modal, + ThemeProvider, + Typography, + useMediaQuery, +} from "@mui/material"; +import { useTheme } from "@mui/material/styles"; +import { useRouter } from "next/navigation"; +import { useEffect, useRef, useState } from "react"; +import ExamHeader from "../components/examHeader"; +import Omr from "../components/omrUI"; +import ProblemUI from "../components/problemUI"; +import SmallOmrUI from "../components/smallOmrUI"; +import StudyTime from "../components/studyTime"; + +const ExamPage = () => { + const { getProblems, loading, error } = useProblems(); + const [problem, setProblem] = useState(null); + const [problems, setProblems] = useState([]); + const [problemNumber, setProblemNumber] = useState(1); + const [submitModalopen, setSubmitModalOpen] = useState(false); + const [omrModalopen, setOmrModalOpen] = useState(false); + const router = useRouter(); + + useEffect(() => { + if (!getProblems) return; + setProblems(getProblems); + setProblem(getProblems[problemNumber - 1]); + }, [getProblems]); + + useEffect(() => { + if (problem && problem.problemId == problems[problemNumber - 1].problemId) return; + setProblem(problems[problemNumber - 1]); + }, [problemNumber, problems]); + + const nextProblem = () => { + setProblemNumber(problemNumber + 1); + }; + + const prevProblem = () => { + setProblemNumber(problemNumber - 1); + }; + + const chooseAnswer = (number: number, chooseProblemNumber = problemNumber) => { + setProblem(prev => { + if (!prev) return null; + return { ...prev, chooseNumber: number }; + }); + problems[chooseProblemNumber - 1].chooseNumber = number; + }; + + const sendResult = () => { + localStorage.setItem("problems", JSON.stringify(problems)); + router.push("/result"); + }; + + const handleSubmitModal = () => { + setSubmitModalOpen(!submitModalopen); + }; + + const handleOmrModal = () => { + setOmrModalOpen(!omrModalopen); + }; + + const studyBoxRef = useRef(null); + const [studyBoxWidth, setStudyBoxWidth] = useState(0); + + useEffect(() => { + if (studyBoxRef.current) { + setStudyBoxWidth(studyBoxRef.current.offsetWidth); + } + }, [loading]); + + const theme = useTheme(); + const isSm = useMediaQuery(theme.breakpoints.down("sm")); + const isMd = useMediaQuery(theme.breakpoints.down("md")); + + if (loading) { + return ( + + + + + + + + ); + } + + if (error) { + return ( + + + Error + + + ); + } + return ( + <> + + + + + + + + {isSm ? ( + <> + + + {problem?.subject.name} + + + + + + + ) : ( + <> + + {problem?.subject.name} + + + + + + + )} + + + + + + + {problem ? ( + <> + + + ) : ( +
Loading problem...
+ )} +
+ + {!isMd && ( + + )} + +
+
+ + + + + + + + {problemNumber}/{problems?.length} + + + + + +
+ + + + 결과를 제출하시겠습니까? + + + 결과를 제출하면 다시 돌아올 수 없습니다 + + + + + + + + + + + + +
+ + ); +}; + +export default ExamPage; diff --git a/src/app/exam/components/examHeader.tsx b/src/app/exam/components/examHeader.tsx new file mode 100644 index 0000000..ca111bc --- /dev/null +++ b/src/app/exam/components/examHeader.tsx @@ -0,0 +1,46 @@ +import { Box, Typography } from "@mui/material"; + +const ExamHeader = () => { + const certificate = JSON.parse(localStorage.getItem("certificate")!); + return ( + <> + + + 시험 모드 + + + {certificate.name} + + + + ); +}; +export default ExamHeader; diff --git a/src/app/exam/components/omrUI.tsx b/src/app/exam/components/omrUI.tsx new file mode 100644 index 0000000..6ebade6 --- /dev/null +++ b/src/app/exam/components/omrUI.tsx @@ -0,0 +1,110 @@ +import { globalTheme } from "@/src/components/globalStyle"; +import { Box, Grid, ThemeProvider, Typography } from "@mui/material"; +import { Bs1Circle, Bs2Circle, Bs3Circle, Bs4Circle, Bs5Circle } from "react-icons/bs"; + +interface OmrProps { + handleOmrModal: () => void; + setProblemNumber: (number: number) => void; + problems: ProblemViewType[]; + chooseAnswer: (number: number, chooseProblemNumber: number) => void; +} + +const Omr: React.FC = ({ + handleOmrModal, + setProblemNumber, + problems, + chooseAnswer, +}: OmrProps) => { + const circles = [Bs1Circle, Bs2Circle, Bs3Circle, Bs4Circle, Bs5Circle]; + return ( + + + + + OMR 표기란 + + + {problems.map((problem, index) => ( + + + { + setProblemNumber(index + 1); + }} + > + {problem.problemNumber} + + + + {problem.choices.map((choice, idx) => ( + { + setProblemNumber(index + 1); + chooseAnswer(idx + 1, index + 1); + }} + > + {circles[idx].call(null, { size: 20 })} + + ))} + + + ))} + + + ); +}; +export default Omr; diff --git a/src/app/exam/components/problemUI.tsx b/src/app/exam/components/problemUI.tsx index 6442dd0..7185605 100644 --- a/src/app/exam/components/problemUI.tsx +++ b/src/app/exam/components/problemUI.tsx @@ -2,6 +2,9 @@ import { Box, Container, Grid, Typography } from "@mui/material"; import { memo, useEffect, useState } from "react"; import { Bs1Circle, Bs2Circle, Bs3Circle, Bs4Circle, Bs5Circle } from "react-icons/bs"; +import { FaRegBookmark } from "react-icons/fa"; +import { FaBookmark } from "react-icons/fa6"; +import { PiSirenFill } from "react-icons/pi"; import Markdown from "react-markdown"; import rehypeKatex from "rehype-katex"; import rehypeRaw from "rehype-raw"; @@ -15,7 +18,7 @@ const ProblemUI: React.FC<{ const circles = [Bs1Circle, Bs2Circle, Bs3Circle, Bs4Circle, Bs5Circle]; const [colors, setColors] = useState(["white", "white", "white", "white", "white"]); const changeColor = () => { - if (problem.chooseNumber === 0) { + if (problem.chooseNumber === -1) { setColors(["white", "white", "white", "white", "white"]); } else { setColors(() => { @@ -28,6 +31,18 @@ const ProblemUI: React.FC<{ useEffect(() => { changeColor(); }, [problem.chooseNumber]); + /** + * @todo 북마크 기능 + */ + const bookmarking = () => { + problem.isBookmark = !problem.isBookmark; + }; + /** + * @todo 신고하기 기능 + */ + const alerting = () => { + alert("신고하기 기능은 준비중입니다."); + }; return ( <> @@ -40,17 +55,62 @@ const ProblemUI: React.FC<{ flexDirection: "column", }} > - + - 1번 + {problem.isBookmark ? : } + + + + + + + + + + {problem.problemNumber}. + + + ({problem.examInfo.description}) + + @@ -64,7 +124,15 @@ const ProblemUI: React.FC<{ width: "100%", }} > - {content.children} + + {content.children} + ), img: ({ node, ...content }) => ( @@ -89,7 +157,7 @@ const ProblemUI: React.FC<{ - + {problem.choices.map((choice, idx) => ( {circles[idx].call(null, { size: 20 })} - - {choice} + ( + + + {content.children} + + + ), + }} + > + {choice.choice} diff --git a/src/app/exam/components/smallOmrUI.tsx b/src/app/exam/components/smallOmrUI.tsx new file mode 100644 index 0000000..5ab83bc --- /dev/null +++ b/src/app/exam/components/smallOmrUI.tsx @@ -0,0 +1,124 @@ +import { globalTheme } from "@/src/components/globalStyle"; +import { Box, Grid, ThemeProvider, Typography } from "@mui/material"; +import { Bs1Circle, Bs2Circle, Bs3Circle, Bs4Circle, Bs5Circle } from "react-icons/bs"; + +interface OmrProps { + handleOmrModal: () => void; + setProblemNumber: (number: number) => void; + problems: ProblemViewType[]; + chooseAnswer: (number: number, chooseProblemNumber: number) => void; +} + +const SmallOmrUI: React.FC = ({ + handleOmrModal, + setProblemNumber, + problems, + chooseAnswer, +}: OmrProps) => { + const circles = [Bs1Circle, Bs2Circle, Bs3Circle, Bs4Circle, Bs5Circle]; + return ( + + + + + + OMR 표기란 + + + + + {problems.map((problem, index) => ( + + + + { + setProblemNumber(index + 1); + handleOmrModal(); + }} + > + {problem.problemNumber} + + + + {problem.choices.map((choice, idx) => ( + { + chooseAnswer(idx + 1, index + 1); + setProblemNumber(index + 1); + handleOmrModal(); + }} + > + {circles[idx].call(null, { size: 20 })} + + ))} + + + + ))} + + + + ); +}; +export default SmallOmrUI; diff --git a/src/app/exam/components/studyTime.tsx b/src/app/exam/components/studyTime.tsx new file mode 100644 index 0000000..0044357 --- /dev/null +++ b/src/app/exam/components/studyTime.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { Typography } from "@mui/material"; +import { useEffect, useState } from "react"; + +const StudyTime = () => { + const [time, setTime] = useState(0); + const [viewTime, setViewTime] = useState("00분 00초"); + useEffect(() => { + const interval = setInterval(() => { + setTime(time => time + 1); + }, 1000); + return () => clearInterval(interval); + }, [time]); + + const secondsToMMSS = (seconds: number) => { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = seconds % 60; + return `${minutes < 10 ? `${minutes}` : minutes}분 ${ + remainingSeconds < 10 ? `0${remainingSeconds}` : remainingSeconds + }초`; + }; + + useEffect(() => { + setViewTime(secondsToMMSS(time)); + }, [time]); + return ( + <> + + 경과시간: {viewTime} + + + ); +}; + +export default StudyTime; diff --git a/src/app/exam/page.tsx b/src/app/exam/page.tsx deleted file mode 100644 index 6281d07..0000000 --- a/src/app/exam/page.tsx +++ /dev/null @@ -1,256 +0,0 @@ -"use client"; - -import { getProblems } from "@/src/api/types/apis/problem"; -import Appbar from "@/src/components/Appbar"; -import { Box, Button, Container, Grid, Modal, Typography } from "@mui/material"; -import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; -import ProblemUI from "./components/problemUI"; - -const ExamPage = () => { - const [problem, setProblem] = useState(null); - const [problems, setProblems] = useState([]); - const [problemNumber, setProblemNumber] = useState(1); - const [time, setTime] = useState(0); - const [viewTime, setViewTime] = useState("00분 00초"); - const [open, setOpen] = useState(false); - const router = useRouter(); - - useEffect(() => { - const interval = setInterval(() => { - setTime(time => time + 1); - }, 1000); - return () => clearInterval(interval); - }, [time]); - - const secondsToMMSS = (seconds: number) => { - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - return `${minutes < 10 ? `0${minutes}` : minutes}분 ${ - remainingSeconds < 10 ? `0${remainingSeconds}` : remainingSeconds - }초`; - }; - - useEffect(() => { - setViewTime(secondsToMMSS(time)); - }, [time]); - - // useEffect(() => { - // const fetchProblems = async () => { - // const fetchedProblems = await getProblems(); - // setProblems(fetchedProblems); - // setProblem(fetchedProblems[0]); - // }; - - // fetchProblems(); - // }, []); - - useEffect(() => { - setProblem(problems[problemNumber - 1]); - }, [problemNumber]); - - const nextProblem = () => { - setProblemNumber(problemNumber + 1); - }; - - const prevProblem = () => { - setProblemNumber(problemNumber - 1); - }; - - const chooseAnswer = (number: number) => { - setProblem(prev => { - if (!prev) return null; - return { ...prev, chooseNumber: number }; - }); - problems[problemNumber - 1].chooseNumber = number; - }; - - const sendResult = () => { - localStorage.setItem("problems", JSON.stringify(problems)); - router.push("/result"); - }; - - const handleModal = () => { - setOpen(!open); - }; - - return ( - <> - - - - - - - - - {problem ? ( - <> - - - - {problem.subject.name} - - - {problem.examInfo.description} 시험 - - - - - - - - - {viewTime} - - - - - ) : ( -
Loading problem...
- )} - - - {problemNumber}/100 - - -
- - - -
-
-
- - - - 결과를 제출하시겠습니까? - - - 결과를 제출하면 다시 돌아올 수 없습니다 - - - - - - - - - ); -}; - -export default ExamPage; diff --git a/src/app/learning/components/MakeProblemSet.tsx b/src/app/learning/components/MakeProblemSet.tsx index 8c26f17..82d91e4 100644 --- a/src/app/learning/components/MakeProblemSet.tsx +++ b/src/app/learning/components/MakeProblemSet.tsx @@ -39,7 +39,7 @@ function valuetext(value: number) { const MakeProblemSetUI = () => { const { certificateInfo, loading, error } = useCertificateInfo(); const [questionsCount, setQuestionsCount] = useState(20); - const [selectedSubjects, setSelectedSubjects] = useState([]); + const [selectedSubjects, setSelectedSubjects] = useState([]); const [selectedExam, setSelectedExam] = useState(""); const [selectedExamId, setSelectedExamId] = useState("0"); const [numberOfQuestions, setNumberOfQuestions] = useState(0); @@ -60,14 +60,29 @@ const MakeProblemSetUI = () => { const handleSubjectChange = (event: React.ChangeEvent) => { const value = event.target.value; - setSelectedSubjects(prev => - prev.includes(value) ? prev.filter(subject => subject !== value) : [...prev, value] - ); + setSelectedSubjects(prevSelectedSubjects => { + // subject.name이 value와 일치하는지 확인하는 함수 + const isSelected = prevSelectedSubjects.some(subject => subject.name === value); + + // 만약 isSelected가 true이면 해당 항목을 제거한 배열을 반환 + if (isSelected) { + return prevSelectedSubjects.filter(subject => subject.name !== value); + } else { + // isSelected가 false이면 해당 항목을 추가한 배열을 반환 + const selectedSubject = certificateInfo?.subjects.find(subject => subject.name === value); + if (selectedSubject) { + return [...prevSelectedSubjects, selectedSubject]; + } else { + // 만약 해당하는 subject.name을 찾지 못했을 경우 기존 배열을 반환 + return prevSelectedSubjects; + } + } + }); }; useEffect(() => { if (certificateInfo === undefined) return; - const subjects = certificateInfo?.subjects.map(subject => subject.name) || []; + const subjects = certificateInfo?.subjects; setSelectedSubjects(subjects); }, [certificateInfo]); @@ -77,13 +92,13 @@ const MakeProblemSetUI = () => { const examId = selectedExamId; let path; if (examId === "0") { - path = `/study/certificate-id=${certificateId}&subjectids=${selectedSubjects.join( - "," - )}&count=${questionsCount}`; + path = `/study/certificate-id=${certificateId}&subject-id=${selectedSubjects + .map(subject => subject.subjectId) + .join(",")}&count=${questionsCount}`; } else { - path = `/study/certificate-id=${certificateId}&exam-id=${examId}&subjectids=${selectedSubjects.join( - "," - )}&count=${questionsCount}`; + path = `/study/certificate-id=${certificateId}&exam-id=${examId}&subject-id=${selectedSubjects + .map(subject => subject.subjectId) + .join(",")}&count=${questionsCount}`; } router.push(path); }; @@ -93,13 +108,13 @@ const MakeProblemSetUI = () => { const examId = selectedExamId; let path; if (examId === "0") { - path = `/exam/certificate-id=${certificateId}&subjectids=${selectedSubjects.join( - "," - )}&count=${questionsCount}`; + path = `/exam/certificate-id=${certificateId}&subject-id=${selectedSubjects + .map(subject => subject.subjectId) + .join(",")}&count=${questionsCount}`; } else { - path = `/exam/certificate-id=${certificateId}&exam-id=${examId}&subjectids=${selectedSubjects.join( - "," - )}&count=${questionsCount}`; + path = `/exam/certificate-id=${certificateId}&exam-id=${examId}&subject-id=${selectedSubjects + .map(subject => subject.subjectId) + .join(",")}&count=${questionsCount}`; } router.push(path); }; @@ -172,7 +187,7 @@ const MakeProblemSetUI = () => { { handleExamChange(e); setSelectedExamId(exam.examId); @@ -242,7 +257,7 @@ const MakeProblemSetUI = () => { key={subject.subjectId} control={ { backgroundColor: "white", }, }} + onClick={gotoExamMode} > void; diff --git a/src/app/login/components/GoogleButton.tsx b/src/app/login/components/GoogleButton.tsx index 2e21a7d..cf551a9 100644 --- a/src/app/login/components/GoogleButton.tsx +++ b/src/app/login/components/GoogleButton.tsx @@ -19,7 +19,7 @@ const GoogleButton = () => { localStorage.setItem("refreshToken", res.refreshToken); window.location.href = "/"; } else { - // todo : error handling + // todo : 회원 탈퇴 } }; return ( diff --git a/src/app/page.tsx b/src/app/page.tsx index d876441..0747874 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,5 @@ "use client"; -import { Box, Container, Grid, Typography, Paper } from "@mui/material"; +import { Container } from "@mui/material"; import Appbar from "../components/Appbar"; import MainHeader from "../components/MainHeader"; diff --git a/src/app/study/[problems]/page.tsx b/src/app/study/[problems]/page.tsx index 2af06a8..815fea6 100644 --- a/src/app/study/[problems]/page.tsx +++ b/src/app/study/[problems]/page.tsx @@ -19,7 +19,6 @@ import Appbar from "@/src/components/Appbar"; import { useRouter } from "next/navigation"; import StudyTime from "../components/studyTime"; import useProblems from "@/src/hooks/useProblems"; -import { getProblems } from "@/src/api/types/apis/problem"; import { globalTheme } from "@/src/components/globalStyle"; import StudyHeader from "../components/studyHeader"; import SolutionUI from "../components/solutionUI"; @@ -27,7 +26,7 @@ import { useTheme } from "@mui/material/styles"; import { IoMdClose } from "react-icons/io"; const StudyPage = () => { - const { Problems, loading, error } = useProblems(); + const { getProblems, loading, error } = useProblems(); const [problem, setProblem] = useState(null); const [problems, setProblems] = useState([]); const [problemNumber, setProblemNumber] = useState(1); @@ -40,10 +39,10 @@ const StudyPage = () => { setTabValue(prev => prev ^ 1); }; useEffect(() => { - const problems = getProblems(); - setProblems(problems); - setProblem(problems[problemNumber - 1]); - }, []); + if (!getProblems) return; + setProblems(getProblems); + setProblem(getProblems[problemNumber - 1]); + }, [getProblems]); useEffect(() => { if (problem && problem.problemId == problems[problemNumber - 1].problemId) return; @@ -311,21 +310,22 @@ const StudyPage = () => { sx={{ flex: 1 }} /> - {tabValue === 0 ? ( - - ) : ( - - )} + {problem && + (tabValue === 0 ? ( + + ) : ( + + ))}
diff --git a/src/app/study/components/problemUI.tsx b/src/app/study/components/problemUI.tsx index 148dda7..06f32b6 100644 --- a/src/app/study/components/problemUI.tsx +++ b/src/app/study/components/problemUI.tsx @@ -9,7 +9,6 @@ import Markdown from "react-markdown"; import rehypeKatex from "rehype-katex"; import rehypeRaw from "rehype-raw"; import remarkMath from "remark-math"; -import SolutionUI from "./solutionUI"; const ProblemUI: React.FC<{ props: ProblemViewType; @@ -19,7 +18,7 @@ const ProblemUI: React.FC<{ const circles = [Bs1Circle, Bs2Circle, Bs3Circle, Bs4Circle, Bs5Circle]; const [colors, setColors] = useState(["white", "white", "white", "white", "white"]); const changeColor = () => { - if (problem.chooseNumber === 0) { + if (problem.chooseNumber === -1) { setColors(["white", "white", "white", "white", "white"]); return; } @@ -106,7 +105,7 @@ const ProblemUI: React.FC<{ }} marginRight={1} > - 1. + {problem.problemNumber}. ), + // span: ({ node, ...content }) => + // node?.properties.className == "katex-html" ? ( + // <> + // ) : ( + // {content.children} + // ), img: ({ node, ...content }) => ( - {choice} + {choice.choice} diff --git a/src/components/Appbar.tsx b/src/components/Appbar.tsx index ae0e92b..04f3b93 100644 --- a/src/components/Appbar.tsx +++ b/src/components/Appbar.tsx @@ -13,7 +13,7 @@ import { Toolbar, Typography, } from "@mui/material"; -import React from "react"; +import React, { useEffect, useState } from "react"; import { globalTheme } from "./globalStyle"; import { IoMdClose } from "react-icons/io"; import { IoPersonCircleSharp } from "react-icons/io5"; @@ -24,14 +24,14 @@ const logoStyle = { cursor: "pointer", }; const Appbar = () => { - const [open, setOpen] = React.useState(false); - const [isLogin, setIsLogin] = React.useState(false); + const [open, setOpen] = useState(false); + const [isLogin, setIsLogin] = useState(false); const toggleDrawer = (newOpen: boolean) => () => { setOpen(newOpen); }; - React.useEffect(() => { + useEffect(() => { const token = localStorage.getItem("accessToken"); if (token) { setIsLogin(true); diff --git a/src/hooks/useProblems.tsx b/src/hooks/useProblems.tsx index ed4f1dc..d466a8c 100644 --- a/src/hooks/useProblems.tsx +++ b/src/hooks/useProblems.tsx @@ -3,20 +3,26 @@ import { useEffect, useState } from "react"; import { mainfetch } from "../api/apis/mainFetch"; const useProblems = () => { - const [Problems, setProblems] = useState(); + const [getProblems, setgetProblems] = useState(); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const path = usePathname().split("/study/")[1]; + const path = usePathname().split("/exam/")[1] ?? usePathname().split("/study/")[1]; useEffect(() => { const fetchProblems = async () => { try { const response = await mainfetch("/problems/set?" + path, { method: "GET" }, false); const data = await response.json(); // 선택 정답을 추가한 데이터 - const modifyData = data.map((problem: Problem) => { - return { ...problem, chooseNumber: 0, viewSolution: false, viewTheory: false }; + const newProblems = data.map((problem: Problem, index: number) => { + return { + ...problem, + chooseNumber: -1, + viewSolution: false, + viewTheory: false, + problemNumber: index + 1, + }; }); - setProblems(modifyData); + setgetProblems(newProblems); } catch (err: any) { setError(err.message); } finally { @@ -27,6 +33,6 @@ const useProblems = () => { fetchProblems(); }, []); - return { Problems, loading, error }; + return { getProblems, loading, error }; }; export default useProblems;