From 70523572d32ba8736f34eff0912f35995e9b9f12 Mon Sep 17 00:00:00 2001 From: godzz733 Date: Mon, 29 Jul 2024 15:17:19 +0900 Subject: [PATCH] =?UTF-8?q?Feat(API):=20=EB=B6=81=EB=A7=88=ED=81=AC,=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83,=20=EB=A6=AC=ED=94=84?= =?UTF-8?q?=EB=A0=88=EC=8B=9C=20API=20=EC=97=B0=EB=8F=99=20=20(#26)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Feat(API): 북마크, 로그아웃, 리프레시 API 연동 - 북마크 페이지, 공부모드, 시험모드에서 북마크를 할 수 있음 - 로그아웃 시 서버에도 로그아웃 요청을 보냄 - 현재 token이 있고 만료 되었을 때 자동으로 갱신을 요청함 * Fix: build error - type 에러로 인한 build 에러 해결 --- src/api/apis/interceptor.ts | 3 +- src/api/apis/mainFetch.ts | 1 + src/api/types/apis/problem.ts | 40 ---- src/api/types/bookmark.ts | 4 +- src/api/types/certificate.ts | 4 +- src/api/types/problem.ts | 8 +- src/app/bookmark/components/bookMarkMain.tsx | 143 +++++++------ .../components/mobileBookMarkMain.tsx | 158 +++++++------- src/app/bookmark/components/problemList.tsx | 200 +++++++++++------- src/app/exam/[problems]/page.tsx | 36 ++-- src/app/exam/components/examHeader.tsx | 6 +- src/app/exam/components/examInfoUI.tsx | 4 +- src/app/exam/components/examMainUI.tsx | 99 ++++++--- src/app/exam/components/problemUI.tsx | 15 +- .../learning/components/MakeProblemSet.tsx | 6 +- src/app/learning/components/selectExamUI.tsx | 4 +- src/app/learning/components/statusBox.tsx | 2 +- src/app/study/[problems]/page.tsx | 9 +- src/app/study/components/problemUI.tsx | 9 +- src/app/study/components/studyHeader.tsx | 9 +- src/app/study/components/studyInfoUI.tsx | 4 +- src/app/study/components/studyMainUI.tsx | 59 +++++- src/components/Appbar.tsx | 10 +- src/components/scrollAppbar.tsx | 10 +- src/hooks/useBookmarks.ts | 38 ++++ src/hooks/useProblems.tsx | 8 +- 26 files changed, 523 insertions(+), 366 deletions(-) delete mode 100644 src/api/types/apis/problem.ts create mode 100644 src/hooks/useBookmarks.ts diff --git a/src/api/apis/interceptor.ts b/src/api/apis/interceptor.ts index a6af210..aaff2bf 100644 --- a/src/api/apis/interceptor.ts +++ b/src/api/apis/interceptor.ts @@ -1,5 +1,5 @@ export const refreshTokenInterceptor = async () => { - const url = "/auth/reissue"; + const url = process.env.NEXT_PUBLIC_SERVER_URL + "/auth/reissue"; const accessToken = localStorage.getItem("accessToken"); const refreshToken = localStorage.getItem("refreshToken"); // todo : accessToken 또는 refreshToken 없을 경우 로그인 페이지로 이동 @@ -18,6 +18,7 @@ export const refreshTokenInterceptor = async () => { if (!token) { localStorage.removeItem("accessToken"); localStorage.removeItem("refreshToken"); + window.location.href = "/login"; return new Error("토큰 재발급 실패"); } localStorage.setItem("accessToken", token); diff --git a/src/api/apis/mainFetch.ts b/src/api/apis/mainFetch.ts index 171c7d4..301d9d5 100644 --- a/src/api/apis/mainFetch.ts +++ b/src/api/apis/mainFetch.ts @@ -29,6 +29,7 @@ export const mainfetch = async ( const accessToken = localStorage.getItem("accessToken"); const refreshToken = localStorage.getItem("refreshToken"); if (!accessToken || !refreshToken) { + window.location.href = "/login"; throw new Error("Access token or refresh token is missing"); } headers.Authorization = `Bearer ${accessToken}`; diff --git a/src/api/types/apis/problem.ts b/src/api/types/apis/problem.ts deleted file mode 100644 index 6e3ad07..0000000 --- a/src/api/types/apis/problem.ts +++ /dev/null @@ -1,40 +0,0 @@ -let problems = []; - -for (let i = 0; i < 20; i++) { - let newProblem = { - problemId: `${i + 1}`, - examInfo: { - examId: `${i + 1}`, - description: `${2020 + i}년 ${i}회차`, - }, - subject: { - subjectId: `${i + 1}`, - name: `소프트웨어 설계 ${i + 1}`, - sequence: i + 1, - }, - isBookmark: true, - description: `인터페이스 구현 검증도구 중 아래에서 설명하는 것은?
`, - choices: [ - `빌드 검증 ${i + 1}
`, - `동료 검토 ${i + 1}
`, - `워크 스루 ${i + 1}
`, - `개발자 검토 ${i + 1}
`, - ], - answerNumber: (i % 4) + 1, - theory: `검토회의 전에 요구사항 명세서를 미리 배포하여 사전 검토한 후 짧은 검토 회의를 통해 오류를 조기에 검출하는데 목적을 두는 요구 사항 검토 방법은? ${ - i + 1 - }
`, - solution: `검토회의 전에 요구사항 명세서를 미리 배포하여 사전 검토한 후 짧은 검토 회의를 통해 오류를 조기에 검출하는데 목적을 두는 요구 사항 검토 방법은? ${ - i + 1 - }
`, - }; - problems.push(newProblem); -} -const getProblems = () => { - const newProblems = problems.map(problem => { - return { ...problem, chooseNumber: 0, viewSolution: false, viewTheory: false }; - }); - return newProblems; -}; - -export { getProblems }; diff --git a/src/api/types/bookmark.ts b/src/api/types/bookmark.ts index ccced6c..a4ccd27 100644 --- a/src/api/types/bookmark.ts +++ b/src/api/types/bookmark.ts @@ -1,7 +1,7 @@ interface BookMarkProblem { - problemId: string; + problemId: number; examInfo: ExamInfo; - subject: Subject; + subjectInfo: Subject; isBookmark: boolean; description: string; } diff --git a/src/api/types/certificate.ts b/src/api/types/certificate.ts index 84e98d8..b0ab51f 100644 --- a/src/api/types/certificate.ts +++ b/src/api/types/certificate.ts @@ -1,12 +1,12 @@ // 자격증 타입 정의 interface CertificateType { - certificateId: string; + certificateId: number; name: string; } // 자격증 정보 타입 정의 interface CertificateInfo { - certificateId: string; + certificateId: number; name: string; exams: ExamInfo[]; subjects: Subject[]; diff --git a/src/api/types/problem.ts b/src/api/types/problem.ts index 47ba560..cef9712 100644 --- a/src/api/types/problem.ts +++ b/src/api/types/problem.ts @@ -1,10 +1,10 @@ interface ExamInfo { - examId: string; + examId: number; description: string; } interface Subject { - subjectId: string; + subjectId: number; sequence: number; name: string; } @@ -14,9 +14,9 @@ interface Chocice { } interface Problem { - problemId: string; + problemId: number; examInfo: ExamInfo; - subject: Subject; + subjectInfo: Subject; isBookmark: boolean; description: string; choices: Chocice[]; diff --git a/src/app/bookmark/components/bookMarkMain.tsx b/src/app/bookmark/components/bookMarkMain.tsx index 3b955a4..d295add 100644 --- a/src/app/bookmark/components/bookMarkMain.tsx +++ b/src/app/bookmark/components/bookMarkMain.tsx @@ -1,4 +1,8 @@ "use client"; +import { mainfetch } from "@/src/api/apis/mainFetch"; +import { NoHoverButton } from "@/src/components/elements/styledElements"; +import { globalTheme } from "@/src/components/globalStyle"; +import useBookmarks from "@/src/hooks/useBookmarks"; import useCertificateInfo from "@/src/hooks/useCertificateInfo"; import { Box, Button, Grid, SelectChangeEvent, ThemeProvider, Typography } from "@mui/material"; import { useEffect, useState } from "react"; @@ -6,17 +10,23 @@ import BookMarkModal from "./bookmarkModal"; import ExamChoice from "./examChoice"; import BookmarkProblemList from "./problemList"; import SubjectChoice from "./subjectChoice"; -import { globalTheme } from "@/src/components/globalStyle"; -import { NoHoverButton } from "@/src/components/elements/styledElements"; const BookMarkMain = () => { const { certificateInfo, loading, error } = useCertificateInfo(); const [selectedExam, setSelectedExam] = useState("전체 회차"); const [problems, setProblems] = useState([]); - const [selectedProblems, setSelectedProblems] = useState([]); + const [selectedProblems, setSelectedProblems] = useState([]); const [selectedSubjects, setSelectedSubjects] = useState([]); const [isModalOpen, setisModalOpen] = useState(false); - + const [selectedExamId, setSelectedExamId] = useState(0); + const [selectedSubjectsId, setSelectedSubjectsId] = useState([]); + const [page, setPage] = useState(0); + const [isProcessing, setIsProcessing] = useState(false); + const { bookmarkedProblems, totalPage } = useBookmarks({ + selectedExamId, + selectedSubjectsId, + page, + }); const handleModalOpen = () => { setisModalOpen(prev => !prev); }; @@ -27,74 +37,49 @@ const BookMarkMain = () => { const gotoExamMode = () => { // 시험 모드로 이동하는 함수 }; + useEffect(() => { - const t: BookMarkProblem[] = [ - { - problemId: "1", - examInfo: { - examId: "1", - description: "2022년 1,2회", - }, - subject: { - subjectId: "1", - sequence: 2, - name: "소프트웨어 설계", - }, - isBookmark: true, - description: "UML 다이어그램 중 순차 다이어그램에 대한 설명으로 틀린 것은?", - }, - { - problemId: "2", - examInfo: { - examId: "1", - description: "2022년 1,2회", - }, - subject: { - subjectId: "1", - sequence: 2, - name: "소프트웨어 설계", - }, - isBookmark: true, - description: "UML 다이어그램 중 순차 다이어그램에 대한 설명으로 틀린 것은?", - }, - { - problemId: "3", - examInfo: { - examId: "1", - description: "2022년 1,2회", - }, - subject: { - subjectId: "1", - sequence: 2, - name: "소프트웨어 설계", - }, - isBookmark: true, - description: "UML 다이어그램 중 순차 다이어그램에 대한 설명으로 틀린 것은?", - }, - ]; - setProblems(t); - }, []); - const selectProblem = (problemId: string) => { + setProblems(bookmarkedProblems); + }, [bookmarkedProblems]); + const selectProblem = (problemId: number) => { if (selectedProblems.includes(problemId)) { setSelectedProblems(selectedProblems.filter(id => id !== problemId)); } else { setSelectedProblems([...selectedProblems, problemId]); } }; - /** - * - * @param problemId - * 북마크 삭제 api 추가 예정 - */ - const handleBookmark = (problemId: string) => { - // 북마크 삭제 api 추가 예정 - const handledProblems = problems.map(problem => { - if (problem.problemId === problemId) { - return { ...problem, isBookmark: !problem.isBookmark }; - } - return problem; - }); - setProblems(handledProblems); + + const handleBookmark = async (problemId: number) => { + if (isProcessing) return; + + setIsProcessing(true); + + try { + const targetProblem = problems.find(problem => problem.problemId === problemId); + if (!targetProblem) throw new Error("Problem not found"); + const method = targetProblem.isBookmark ? "DELETE" : "POST"; + const endpoint = "/bookmarks"; + + await mainfetch( + endpoint, + { + method, + body: { + problemId, + }, + }, + true + ); + + const handledProblems = problems.map(problem => + problem.problemId === problemId ? { ...problem, isBookmark: !problem.isBookmark } : problem + ); + + setProblems(handledProblems); + } catch (error) { + } finally { + setIsProcessing(false); // 처리 완료 + } }; const selectAllProblems = () => { @@ -108,6 +93,10 @@ const BookMarkMain = () => { const handleExamChoice = (event: SelectChangeEvent) => { setSelectedExam(event.target.value as string); + const examId = certificateInfo!.exams.find( + exam => exam.description === event.target.value + )!.examId; + setSelectedExamId(examId); }; useEffect(() => { @@ -115,28 +104,36 @@ const BookMarkMain = () => { const subjects = certificateInfo.subjects; setSelectedSubjects(subjects); setSelectedExam(certificateInfo.exams[0].description); + setSelectedSubjectsId(subjects.map(subject => subject.subjectId)); }, [certificateInfo]); const handleSubjectChoice = (event: React.ChangeEvent) => { const value = event.target.value; - setSelectedSubjects(prevSelectedSubjects => { - // subject.name이 value와 일치하는지 확인하는 함수 - const isSelected = prevSelectedSubjects.some(subject => subject.name === value); + const getNewSelectedSubjects = () => { + const isSelected = selectedSubjects.some(subject => subject.name === value); // 만약 isSelected가 true이면 해당 항목을 제거한 배열을 반환 if (isSelected) { - return prevSelectedSubjects.filter(subject => subject.name !== value); + return selectedSubjects.filter(subject => subject.name !== value); } else { // isSelected가 false이면 해당 항목을 추가한 배열을 반환 const selectedSubject = certificateInfo?.subjects.find(subject => subject.name === value); if (selectedSubject) { - return [...prevSelectedSubjects, selectedSubject]; + return [...selectedSubjects, selectedSubject]; } else { // 만약 해당하는 subject.name을 찾지 못했을 경우 기존 배열을 반환 - return prevSelectedSubjects; + return selectedSubjects; } } - }); + }; + const newSelectedSubjects = getNewSelectedSubjects(); + setSelectedSubjects(newSelectedSubjects); + // const newSelectedSubjectsId = []; + setSelectedSubjectsId(newSelectedSubjects.map(subject => subject.subjectId)); + }; + + const handleChangePage = (page: number) => { + setPage(page); }; if (loading) { @@ -269,6 +266,8 @@ const BookMarkMain = () => { { - const { certificateInfo, loading, error } = useCertificateInfo(); - const [selectedExam, setSelectedExam] = useState("전체 회차"); - const [problems, setProblems] = useState([]); - const [selectedProblems, setSelectedProblems] = useState([]); - const [selectedSubjects, setSelectedSubjects] = useState([]); - const [isModalOpen, setisModalOpen] = useState(false); const [isSliderOpen, setIsSliderOpen] = useState(false); const handleSliderOpen = () => { setIsSliderOpen(prev => !prev); }; + const [isModalOpen, setisModalOpen] = useState(false); + const { certificateInfo, loading, error } = useCertificateInfo(); + const [selectedExam, setSelectedExam] = useState("전체 회차"); + const [problems, setProblems] = useState([]); + const [selectedProblems, setSelectedProblems] = useState([]); + const [selectedSubjects, setSelectedSubjects] = useState([]); + const [selectedExamId, setSelectedExamId] = useState(0); + const [selectedSubjectsId, setSelectedSubjectsId] = useState([]); + const [page, setPage] = useState(0); + const [isProcessing, setIsProcessing] = useState(false); + const { bookmarkedProblems, totalPage } = useBookmarks({ + selectedExamId, + selectedSubjectsId, + page, + }); const handleModalOpen = () => { setisModalOpen(prev => !prev); }; @@ -30,74 +49,49 @@ const MobileBookMarkMain = () => { const gotoExamMode = () => { // 시험 모드로 이동하는 함수 }; + useEffect(() => { - const t: BookMarkProblem[] = [ - { - problemId: "1", - examInfo: { - examId: "1", - description: "2022년 1,2회", - }, - subject: { - subjectId: "1", - sequence: 2, - name: "소프트웨어 설계", - }, - isBookmark: true, - description: "UML 다이어그램 중 순차 다이어그램에 대한 설명으로 틀린 것은?", - }, - { - problemId: "2", - examInfo: { - examId: "1", - description: "2022년 1,2회", - }, - subject: { - subjectId: "1", - sequence: 2, - name: "소프트웨어 설계", - }, - isBookmark: true, - description: "UML 다이어그램 중 순차 다이어그램에 대한 설명으로 틀린 것은?", - }, - { - problemId: "3", - examInfo: { - examId: "1", - description: "2022년 1,2회", - }, - subject: { - subjectId: "1", - sequence: 2, - name: "소프트웨어 설계", - }, - isBookmark: true, - description: "UML 다이어그램 중 순차 다이어그램에 대한 설명으로 틀린 것은?", - }, - ]; - setProblems(t); - }, []); - const selectProblem = (problemId: string) => { + setProblems(bookmarkedProblems); + }, [bookmarkedProblems]); + const selectProblem = (problemId: number) => { if (selectedProblems.includes(problemId)) { setSelectedProblems(selectedProblems.filter(id => id !== problemId)); } else { setSelectedProblems([...selectedProblems, problemId]); } }; - /** - * - * @param problemId - * 북마크 삭제 api 추가 예정 - */ - const handleBookmark = (problemId: string) => { - // 북마크 삭제 api 추가 예정 - const handledProblems = problems.map(problem => { - if (problem.problemId === problemId) { - return { ...problem, isBookmark: !problem.isBookmark }; - } - return problem; - }); - setProblems(handledProblems); + + const handleBookmark = async (problemId: number) => { + if (isProcessing) return; + + setIsProcessing(true); + + try { + const targetProblem = problems.find(problem => problem.problemId === problemId); + if (!targetProblem) throw new Error("Problem not found"); + const method = targetProblem.isBookmark ? "DELETE" : "POST"; + const endpoint = "/bookmarks"; + + await mainfetch( + endpoint, + { + method, + body: { + problemId, + }, + }, + true + ); + + const handledProblems = problems.map(problem => + problem.problemId === problemId ? { ...problem, isBookmark: !problem.isBookmark } : problem + ); + + setProblems(handledProblems); + } catch (error) { + } finally { + setIsProcessing(false); // 처리 완료 + } }; const selectAllProblems = () => { @@ -111,6 +105,10 @@ const MobileBookMarkMain = () => { const handleExamChoice = (event: SelectChangeEvent) => { setSelectedExam(event.target.value as string); + const examId = certificateInfo!.exams.find( + exam => exam.description === event.target.value + )!.examId; + setSelectedExamId(examId); }; useEffect(() => { @@ -118,28 +116,35 @@ const MobileBookMarkMain = () => { const subjects = certificateInfo.subjects; setSelectedSubjects(subjects); setSelectedExam(certificateInfo.exams[0].description); + setSelectedSubjectsId(subjects.map(subject => subject.subjectId)); }, [certificateInfo]); const handleSubjectChoice = (event: React.ChangeEvent) => { const value = event.target.value; - setSelectedSubjects(prevSelectedSubjects => { - // subject.name이 value와 일치하는지 확인하는 함수 - const isSelected = prevSelectedSubjects.some(subject => subject.name === value); + const getNewSelectedSubjects = () => { + const isSelected = selectedSubjects.some(subject => subject.name === value); // 만약 isSelected가 true이면 해당 항목을 제거한 배열을 반환 if (isSelected) { - return prevSelectedSubjects.filter(subject => subject.name !== value); + return selectedSubjects.filter(subject => subject.name !== value); } else { // isSelected가 false이면 해당 항목을 추가한 배열을 반환 const selectedSubject = certificateInfo?.subjects.find(subject => subject.name === value); if (selectedSubject) { - return [...prevSelectedSubjects, selectedSubject]; + return [...selectedSubjects, selectedSubject]; } else { // 만약 해당하는 subject.name을 찾지 못했을 경우 기존 배열을 반환 - return prevSelectedSubjects; + return selectedSubjects; } } - }); + }; + const newSelectedSubjects = getNewSelectedSubjects(); + setSelectedSubjects(newSelectedSubjects); + // const newSelectedSubjectsId = []; + setSelectedSubjectsId(newSelectedSubjects.map(subject => subject.subjectId)); + }; + const handleChangePage = (page: number) => { + setPage(page); }; if (loading) { @@ -288,12 +293,15 @@ const MobileBookMarkMain = () => { + void; - handleBookmark: (problemId: string) => void; + selectedProblems: number[]; + selectProblem: (problemId: number) => void; + handleBookmark: (problemId: number) => void; + totalPage: number; + handleChangePage: (page: number) => void; } const BookmarkProblemList: React.FC = ({ @@ -20,99 +22,137 @@ const BookmarkProblemList: React.FC = ({ selectedProblems, selectProblem, handleBookmark, + totalPage, + handleChangePage, }: BookmarkProblemListProps) => { return ( - - {problems.map(problem => ( - - - + + {problems.map(problem => ( + + - selectProblem(problem.problemId)} > - - - selectProblem(problem.problemId)} + > + - {problem.examInfo.description} ({problem.subject.name}) - - ( - - + + + {problem.examInfo.description} ({problem.subjectInfo.name}) + + ( + - {content.children} - - - ), - img: ({ node, ...content }) => <>, - }} - > - {problem.description} - + + {content.children} + + + ), + img: ({ node, ...content }) => <>, + }} + > + {problem.description} + + - - { - handleBookmark(problem.problemId); - }} - > - {problem.isBookmark ? ( - - ) : ( - - )} - - - - - ))} + { + handleBookmark(problem.problemId); + }} + > + {problem.isBookmark ? ( + + ) : ( + + )} + + + + + ))} + + + { + handleChangePage(page - 1); + }} + /> + ); diff --git a/src/app/exam/[problems]/page.tsx b/src/app/exam/[problems]/page.tsx index 4bac16b..83b037f 100644 --- a/src/app/exam/[problems]/page.tsx +++ b/src/app/exam/[problems]/page.tsx @@ -1,27 +1,19 @@ -import { mainfetch } from "@/src/api/apis/mainFetch"; +"use client"; import Appbar from "@/src/components/Appbar"; import { Box } from "@mui/material"; -import ExamHeader from "../components/examHeader"; import ExamMainUI from "../components/examMainUI"; +import useProblems from "@/src/hooks/useProblems"; +import dynamic from "next/dynamic"; -const getProblemFetch = async () => { - const path = "certificate-id=1&subject-id=1,2,3,4,5&count=20"; - const response = await mainfetch("/problems/set?" + path, { method: "GET" }, false); - const data = await response.json(); - const newProblems = data.map((problem: Problem, index: number) => { - return { - ...problem, - chooseNumber: -1, - viewSolution: false, - viewTheory: false, - problemNumber: index + 1, - }; - }); - return newProblems; -}; +const ExamHeader = dynamic(() => import("@/src/app/exam/components/examHeader"), { + loading: () =>

Header Loading

, +}); -const studyPage = async () => { - const getProblems: ProblemViewType[] = await getProblemFetch(); +const ExamPage = async () => { + const { getProblems, certificateInfo, loading, error } = useProblems(); + if (loading) { + return
로딩중...
; + } return ( { }} > - - + + ); }; -export default studyPage; +export default ExamPage; diff --git a/src/app/exam/components/examHeader.tsx b/src/app/exam/components/examHeader.tsx index 5dfc2fe..3924975 100644 --- a/src/app/exam/components/examHeader.tsx +++ b/src/app/exam/components/examHeader.tsx @@ -1,9 +1,7 @@ "use client"; import { Box, Typography } from "@mui/material"; -const ExamHeader = () => { - const certificate = JSON.parse(localStorage.getItem("certificate")!); - +const ExamHeader = ({ certificateName }: { certificateName: string }) => { return ( <> { }} color="white" > - {certificate.name} + {certificateName} diff --git a/src/app/exam/components/examInfoUI.tsx b/src/app/exam/components/examInfoUI.tsx index 23a29e8..72cee68 100644 --- a/src/app/exam/components/examInfoUI.tsx +++ b/src/app/exam/components/examInfoUI.tsx @@ -50,7 +50,7 @@ const ExamInfoUI: React.FC = ({ }} > - {problem?.subject.sequence}과목 {problem?.subject.name} + {problem?.subjectInfo.sequence}과목 {problem?.subjectInfo.name} @@ -75,7 +75,7 @@ const ExamInfoUI: React.FC = ({ ) : ( <> - {problem?.subject.sequence}과목 {problem?.subject.name} + {problem?.subjectInfo.sequence}과목 {problem?.subjectInfo.name} = ({ getProblems }) => { - // const { getProblems, loading, error } = useProblems(); +const ExamMainUI: React.FC = ({ getProblems, loading, error }) => { const [problem, setProblem] = useState(null); const [problems, setProblems] = useState([]); const [problemNumber, setProblemNumber] = useState(1); @@ -36,6 +37,7 @@ const ExamMainUI: React.FC = ({ getProblems }) => { const [omrModalopen, setOmrModalOpen] = useState(false); const [viewTime, setViewTime] = useState(""); const [solvedProblemsNumber, setSolvedProblemsNumber] = useState(""); + const [isProcessing, setIsProcessing] = useState(false); const router = useRouter(); useEffect(() => { @@ -108,30 +110,72 @@ const ExamMainUI: React.FC = ({ getProblems }) => { } }, [problems]); + const handleBookmark = useCallback( + async (problemId: number) => { + if (isProcessing) return; + if (localStorage.getItem("accessToken") === null) { + return; + } + + setIsProcessing(true); + + try { + const targetProblem = problems.find(problem => problem.problemId === problemId); + if (!targetProblem) throw new Error("Problem not found"); + const method = targetProblem.isBookmark ? "DELETE" : "POST"; + const endpoint = "/bookmarks"; + + await mainfetch( + endpoint, + { + method, + body: { + problemId, + }, + }, + true + ); + + setProblems(prevProblems => + prevProblems.map(problem => + problem.problemId === problemId + ? { ...problem, isBookmark: !problem.isBookmark } + : problem + ) + ); + } catch (error) { + // 에러 처리 로직 추가 (예: console.error 또는 사용자에게 알림) + } finally { + setIsProcessing(false); + } + }, + [isProcessing, problems, mainfetch] + ); + const theme = useTheme(); const isMd = useMediaQuery(theme.breakpoints.down(960)); - // if (loading) { - // return ( - // - // - // - // - // - // - // - // ); - // } - - // if (error) { - // return ( - // - // - // Error - // - // - // ); - // } + if (loading) { + return ( + + + + + + + + ); + } + + if (error) { + return ( + + + Error + + + ); + } return ( <> @@ -169,7 +213,12 @@ const ExamMainUI: React.FC = ({ getProblems }) => { {problem ? ( <> - + ) : (
Loading problem...
diff --git a/src/app/exam/components/problemUI.tsx b/src/app/exam/components/problemUI.tsx index 82f2c9c..2f2c556 100644 --- a/src/app/exam/components/problemUI.tsx +++ b/src/app/exam/components/problemUI.tsx @@ -14,7 +14,8 @@ const ProblemUI: React.FC<{ problem: ProblemViewType; chooseAnswer: (number: number) => void; isMd: boolean; -}> = memo(({ problem, chooseAnswer, isMd }) => { + handleBookmark: (problemId: number) => void; +}> = memo(({ problem, chooseAnswer, isMd, handleBookmark }) => { const [colors, setColors] = useState(["white", "white", "white", "white", "white"]); const changeColor = () => { if (problem.chooseNumber === -1) { @@ -27,17 +28,11 @@ const ProblemUI: React.FC<{ }); } }; - /** - * @todo 북마크 기능 - */ - const bookmarking = () => { - problem.isBookmark = !problem.isBookmark; - }; /** * @todo 신고하기 기능 */ const alerting = () => { - alert("신고하기 기능은 준비중입니다."); + alert("신고되었습니다."); }; useEffect(() => { changeColor(); @@ -62,7 +57,9 @@ const ProblemUI: React.FC<{ }, marginRight: 1, }} - onClick={bookmarking} + onClick={() => { + handleBookmark(problem.problemId); + }} > {problem.isBookmark ? ( diff --git a/src/app/learning/components/MakeProblemSet.tsx b/src/app/learning/components/MakeProblemSet.tsx index 46aac18..1110903 100644 --- a/src/app/learning/components/MakeProblemSet.tsx +++ b/src/app/learning/components/MakeProblemSet.tsx @@ -28,7 +28,7 @@ const MakeProblemSetUI = () => { const [questionsCount, setQuestionsCount] = useState(20); const [selectedSubjects, setSelectedSubjects] = useState([]); const [selectedExam, setSelectedExam] = useState(""); - const [selectedExamId, setSelectedExamId] = useState("0"); + const [selectedExamId, setSelectedExamId] = useState(0); const [numberOfQuestions, setNumberOfQuestions] = useState(0); const handleExamChange = (event: SelectChangeEvent) => { setSelectedExam(event.target.value as string); @@ -78,7 +78,7 @@ const MakeProblemSetUI = () => { const certificateId = certificateInfo?.certificateId; const examId = selectedExamId; let path; - if (examId === "0") { + if (examId === 0) { path = `/study/certificate-id=${certificateId}&subject-id=${selectedSubjects .map(subject => subject.subjectId) .join(",")}&count=${questionsCount}`; @@ -94,7 +94,7 @@ const MakeProblemSetUI = () => { const certificateId = certificateInfo?.certificateId; const examId = selectedExamId; let path; - if (examId === "0") { + if (examId === 0) { path = `/exam/certificate-id=${certificateId}&subject-id=${selectedSubjects .map(subject => subject.subjectId) .join(",")}&count=${questionsCount}`; diff --git a/src/app/learning/components/selectExamUI.tsx b/src/app/learning/components/selectExamUI.tsx index 6b807ff..fbe2963 100644 --- a/src/app/learning/components/selectExamUI.tsx +++ b/src/app/learning/components/selectExamUI.tsx @@ -2,8 +2,8 @@ import { Box, FormControlLabel, List, ListItem, Radio, Typography } from "@mui/m interface SelectExamUIProps { exams: ExamInfo[]; - selectedExamId: string; - setSelectedExamId: (examId: string) => void; + selectedExamId: number; + setSelectedExamId: (examId: number) => void; handleExamChange: (e: any) => void; leftBoxHeight: number; } diff --git a/src/app/learning/components/statusBox.tsx b/src/app/learning/components/statusBox.tsx index 20ee6d5..5bc868e 100644 --- a/src/app/learning/components/statusBox.tsx +++ b/src/app/learning/components/statusBox.tsx @@ -35,7 +35,7 @@ const StatusBox = () => { const { certificates } = useCertificates(); const [anchorEl, setAnchorEl] = useState(null); const [selectedCertificate, setSelectedCertificate] = useState({ - certificateId: "", + certificateId: 0, name: "먼저 자격증을 골라주세요!", }); const theme = useTheme(); diff --git a/src/app/study/[problems]/page.tsx b/src/app/study/[problems]/page.tsx index 9b468e7..362d452 100644 --- a/src/app/study/[problems]/page.tsx +++ b/src/app/study/[problems]/page.tsx @@ -4,12 +4,17 @@ import Appbar from "@/src/components/Appbar"; import { MiddleBoxColumn } from "@/src/components/elements/styledElements"; import StudyMainUI from "../components/studyMainUI"; import dynamic from "next/dynamic"; +import useProblems from "@/src/hooks/useProblems"; const StudyHeader = dynamic(() => import("@/src/app/study/components/studyHeader"), { loading: () =>

Header Loading

, }); const studyPage = () => { + const { getProblems, certificateInfo, loading, error } = useProblems(); + if (loading) { + return
로딩중...
; + } return ( { }} > - - + + ); }; diff --git a/src/app/study/components/problemUI.tsx b/src/app/study/components/problemUI.tsx index 8cba747..94a9df5 100644 --- a/src/app/study/components/problemUI.tsx +++ b/src/app/study/components/problemUI.tsx @@ -14,7 +14,8 @@ const ProblemUI: React.FC<{ problem: ProblemViewType; chooseAnswer: (number: number) => void; isMd: boolean; -}> = memo(({ problem, chooseAnswer, isMd }) => { + handleBookmark: (problemId: number) => void; +}> = memo(({ problem, chooseAnswer, isMd, handleBookmark }) => { const [colors, setColors] = useState(["white", "white", "white", "white", "white"]); const changeColor = () => { if (problem.chooseNumber === -1) { @@ -46,7 +47,7 @@ const ProblemUI: React.FC<{ * @todo 신고하기 기능 */ const alerting = () => { - alert("신고하기 기능은 준비중입니다."); + alert("신고되었습니다."); }; useEffect(() => { changeColor(); @@ -71,7 +72,9 @@ const ProblemUI: React.FC<{ }, marginRight: 1, }} - onClick={bookmarking} + onClick={() => { + handleBookmark(problem.problemId); + }} > {problem.isBookmark ? ( diff --git a/src/app/study/components/studyHeader.tsx b/src/app/study/components/studyHeader.tsx index 8535770..f3912ce 100644 --- a/src/app/study/components/studyHeader.tsx +++ b/src/app/study/components/studyHeader.tsx @@ -1,12 +1,7 @@ "use client"; import { Box, Typography } from "@mui/material"; -import { useEffect, useState } from "react"; -const StudyHeader = () => { - const [certificate, setCertificate] = useState({ name: "" }); - useEffect(() => { - setCertificate(JSON.parse(localStorage.getItem("certificate")!)); - }, []); +const StudyHeader = ({ certificateName }: { certificateName: string }) => { return ( <> { }} color="white" > - {certificate?.name} + {certificateName} diff --git a/src/app/study/components/studyInfoUI.tsx b/src/app/study/components/studyInfoUI.tsx index a19f990..6037a45 100644 --- a/src/app/study/components/studyInfoUI.tsx +++ b/src/app/study/components/studyInfoUI.tsx @@ -50,7 +50,7 @@ const StudyInfoUI: React.FC = ({ }} > - {problem?.subject.sequence}과목 {problem?.subject.name} + {problem?.subjectInfo.sequence}과목 {problem?.subjectInfo.name}
@@ -75,7 +75,7 @@ const StudyInfoUI: React.FC = ({ ) : ( <> - {problem?.subject.sequence}과목 {problem?.subject.name} + {problem?.subjectInfo.sequence}과목 {problem?.subjectInfo.name} { - const { getProblems, loading, error } = useProblems(); +interface StudyMainUIProps { + getProblems: ProblemViewType[]; + loading: boolean; + error: string | null; +} + +const StudyMainUI: React.FC = ({ getProblems, loading, error }) => { const [problem, setProblem] = useState(null); const [problems, setProblems] = useState([]); const [problemNumber, setProblemNumber] = useState(1); @@ -34,6 +40,7 @@ const StudyMainUI = () => { const [omrModalopen, setOmrModalOpen] = useState(false); const [solvedProblemsNumber, setSolvedProblemsNumber] = useState(""); const [viewTime, setViewTime] = useState(""); + const [isProcessing, setIsProcessing] = useState(false); const router = useRouter(); const handleChange = (event: React.SyntheticEvent, newValue: number) => { @@ -131,6 +138,47 @@ const StudyMainUI = () => { }); }; + const handleBookmark = useCallback( + async (problemId: number) => { + if (isProcessing) return; + if (localStorage.getItem("accessToken") === null) { + return; + } + + setIsProcessing(true); + + try { + const targetProblem = problems.find(problem => problem.problemId === problemId); + if (!targetProblem) throw new Error("Problem not found"); + const method = targetProblem.isBookmark ? "DELETE" : "POST"; + const endpoint = "/bookmarks"; + + await mainfetch( + endpoint, + { + method, + body: { + problemId, + }, + }, + true + ); + + setProblems(prevProblems => + prevProblems.map(problem => + problem.problemId === problemId + ? { ...problem, isBookmark: !problem.isBookmark } + : problem + ) + ); + } catch (error) { + } finally { + setIsProcessing(false); + } + }, + [isProcessing, problems, mainfetch] + ); + const theme = useTheme(); const isMd = useMediaQuery(theme.breakpoints.down(960)); @@ -180,7 +228,12 @@ const StudyMainUI = () => { {problem ? ( <> - + ) : (
Loading problem...
diff --git a/src/components/Appbar.tsx b/src/components/Appbar.tsx index 2c9cfb1..584629d 100644 --- a/src/components/Appbar.tsx +++ b/src/components/Appbar.tsx @@ -6,6 +6,7 @@ import useAppbarState from "../hooks/useAppbarState"; import AppbarDrawer from "./appbarDrawer"; import AppbarToolbarUI from "./appbarToolbarUI"; import { globalTheme } from "./globalStyle"; +import { mainfetch } from "../api/apis/mainFetch"; const Appbar = () => { const { isLogin, certificate, focusTap } = useAppbarState(); const [open, setOpen] = useState(false); @@ -25,9 +26,16 @@ const Appbar = () => { setOpen(newOpen); }; - const handleLogout = () => { + const handleLogout = async () => { localStorage.removeItem("accessToken"); localStorage.removeItem("refreshToken"); + await mainfetch( + "/auth/logout", + { + method: "POST", + }, + true + ); window.location.href = "/"; }; diff --git a/src/components/scrollAppbar.tsx b/src/components/scrollAppbar.tsx index 766803f..7d7a0cf 100644 --- a/src/components/scrollAppbar.tsx +++ b/src/components/scrollAppbar.tsx @@ -6,6 +6,7 @@ import useAppbarState from "../hooks/useAppbarState"; import AppbarDrawer from "./appbarDrawer"; import AppbarToolbarUI from "./appbarToolbarUI"; import { globalTheme } from "./globalStyle"; +import { mainfetch } from "../api/apis/mainFetch"; const ScrollAppbar = ({ isScroll }: { isScroll?: number }) => { const { isLogin, certificate, focusTap } = useAppbarState(); const [open, setOpen] = useState(false); @@ -35,9 +36,16 @@ const ScrollAppbar = ({ isScroll }: { isScroll?: number }) => { setOpen(newOpen); }; - const handleLogout = () => { + const handleLogout = async () => { localStorage.removeItem("accessToken"); localStorage.removeItem("refreshToken"); + await mainfetch( + "/auth/logout", + { + method: "POST", + }, + true + ); window.location.href = "/"; }; diff --git a/src/hooks/useBookmarks.ts b/src/hooks/useBookmarks.ts new file mode 100644 index 0000000..7036a10 --- /dev/null +++ b/src/hooks/useBookmarks.ts @@ -0,0 +1,38 @@ +import { useEffect, useState } from "react"; +import { mainfetch } from "../api/apis/mainFetch"; + +interface BookMarkProblemsProps { + selectedExamId: number; + selectedSubjectsId: number[]; + page: number; +} +const useBookmarks = (props: BookMarkProblemsProps) => { + const [bookmarkedProblems, setBookmarkedProblems] = useState([]); + const [totalPage, setTotalPage] = useState(0); + useEffect(() => { + const fetchBookmarks = async (examId: number, subjectIds: number[], page: number) => { + let path = ""; + if (subjectIds.length === 0) { + setBookmarkedProblems([]); + return; + } + if (examId == 0) { + path = `/problems/bookmarked?&subject-id=${subjectIds}&page=${page}`; + } else { + path = `/problems/bookmarked?exam-id=${examId}&subject-id=${subjectIds}&page=${page}`; + } + const response = await mainfetch(path, { method: "GET" }, true); + if (response.ok) { + const data = await response.json(); + setBookmarkedProblems(data.problems); + setTotalPage(data.totalPage); + } else { + new Error("Failed to fetch bookmarks"); + } + }; + fetchBookmarks(props.selectedExamId, props.selectedSubjectsId, props.page); + }, [props.selectedExamId, props.selectedSubjectsId, props.page]); + return { bookmarkedProblems, totalPage }; +}; + +export default useBookmarks; diff --git a/src/hooks/useProblems.tsx b/src/hooks/useProblems.tsx index d466a8c..d2bc8ad 100644 --- a/src/hooks/useProblems.tsx +++ b/src/hooks/useProblems.tsx @@ -3,7 +3,8 @@ import { useEffect, useState } from "react"; import { mainfetch } from "../api/apis/mainFetch"; const useProblems = () => { - const [getProblems, setgetProblems] = useState(); + const [getProblems, setgetProblems] = useState(); + const [certificateInfo, setCertificateInfo] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const path = usePathname().split("/exam/")[1] ?? usePathname().split("/study/")[1]; @@ -13,7 +14,7 @@ const useProblems = () => { const response = await mainfetch("/problems/set?" + path, { method: "GET" }, false); const data = await response.json(); // 선택 정답을 추가한 데이터 - const newProblems = data.map((problem: Problem, index: number) => { + const newProblems = data.problems.map((problem: Problem, index: number) => { return { ...problem, chooseNumber: -1, @@ -23,6 +24,7 @@ const useProblems = () => { }; }); setgetProblems(newProblems); + setCertificateInfo(data.certificateInfo); } catch (err: any) { setError(err.message); } finally { @@ -33,6 +35,6 @@ const useProblems = () => { fetchProblems(); }, []); - return { getProblems, loading, error }; + return { getProblems, certificateInfo, loading, error }; }; export default useProblems;