From f610a1c08fc60bc8f426c2590a41a8790593c9cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=ED=95=98=EC=9D=80?= Date: Wed, 10 Sep 2025 00:05:31 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=ED=95=99=EC=8A=B5=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/fetchProblems.ts | 46 +++++ src/assets/icons/button/floating-check.svg | 3 + src/assets/icons/button/floating-next.svg | 3 + src/components/chapter-page/Tooltip.tsx | 19 +- src/components/lesson-page/FloatingButton.tsx | 40 ++++ src/components/lesson-page/Header.tsx | 32 ++++ src/components/lesson-page/LessonProgress.tsx | 10 + src/components/lesson-page/OptionItem.tsx | 64 +++++++ src/components/lesson-page/OptionsList.tsx | 26 +++ .../lesson-page/problem/AnswerInput.tsx | 34 ++++ .../lesson-page/problem/AnswerSection.tsx | 15 ++ src/components/modal/LessonQuitModal.tsx | 83 +++++--- src/context/ProblemContext.tsx | 124 ++++++++++++ src/hooks/useAnswerState.tsx | 53 ++++++ src/hooks/useAuthState.tsx | 1 + src/layouts/NoHeaderLayout.tsx | 2 +- src/pages/ChapterDetailPage.tsx | 8 +- src/pages/MainPage.tsx | 3 +- src/pages/ProblemPage.tsx | 177 +++++++++++------- src/routes/router.tsx | 2 +- src/types/@common/problem.ts | 28 +++ src/types/api/problem-response.ts | 12 ++ src/types/transformers/transformProblems.ts | 30 +++ src/utils/cn.ts | 6 + src/utils/formatTime.ts | 7 + src/utils/parseAnswers.ts | 20 ++ 26 files changed, 744 insertions(+), 104 deletions(-) create mode 100644 src/api/fetchProblems.ts create mode 100644 src/assets/icons/button/floating-check.svg create mode 100644 src/assets/icons/button/floating-next.svg create mode 100644 src/components/lesson-page/FloatingButton.tsx create mode 100644 src/components/lesson-page/Header.tsx create mode 100644 src/components/lesson-page/LessonProgress.tsx create mode 100644 src/components/lesson-page/OptionItem.tsx create mode 100644 src/components/lesson-page/OptionsList.tsx create mode 100644 src/components/lesson-page/problem/AnswerInput.tsx create mode 100644 src/components/lesson-page/problem/AnswerSection.tsx create mode 100644 src/context/ProblemContext.tsx create mode 100644 src/hooks/useAnswerState.tsx create mode 100644 src/hooks/useAuthState.tsx create mode 100644 src/types/@common/problem.ts create mode 100644 src/types/api/problem-response.ts create mode 100644 src/types/transformers/transformProblems.ts create mode 100644 src/utils/cn.ts create mode 100644 src/utils/formatTime.ts create mode 100644 src/utils/parseAnswers.ts diff --git a/src/api/fetchProblems.ts b/src/api/fetchProblems.ts new file mode 100644 index 0000000..e58f5ae --- /dev/null +++ b/src/api/fetchProblems.ts @@ -0,0 +1,46 @@ +import { ApiError } from '../types/@common/api'; +import type { Problem } from '../types/@common/problem'; +import { transformProblems } from '../types/transformers/transformProblems'; + +export default async function fetchProblems(lessonId: string): Promise { + const accessToken = localStorage.getItem('accessToken'); + + try { + const response = await fetch(`https://grav-it.inuappcenter.kr/api/v1/learning/${lessonId}/problems`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + }); + + if (!response.ok) { + let errorData: ApiError; + + try { + const errorResponse = await response.json(); + errorData = new ApiError( + errorResponse.message || `HTTP ${response.status}: ${response.statusText}`, + errorResponse.error || 'HTTP_ERROR' + ); + } catch { + // JSON 파싱 실패 시 기본 에러 메시지 + errorData = new ApiError(`HTTP ${response.status}: ${response.statusText}`, 'HTTP_ERROR'); + } + throw errorData; + } + + // 성공 응답 파싱 + const data = await response.json(); + console.log('서버 응답:', data); // 디버깅용 + + return transformProblems(data); + } catch (error) { + // 네트워크 에러나 기타 예외 처리 + if (error instanceof ApiError) { + throw error; + } + + throw new ApiError('네트워크 연결을 확인해주세요.', 'NETWORK_ERROR'); + } +} diff --git a/src/assets/icons/button/floating-check.svg b/src/assets/icons/button/floating-check.svg new file mode 100644 index 0000000..717c723 --- /dev/null +++ b/src/assets/icons/button/floating-check.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/button/floating-next.svg b/src/assets/icons/button/floating-next.svg new file mode 100644 index 0000000..4d0ae1a --- /dev/null +++ b/src/assets/icons/button/floating-next.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/chapter-page/Tooltip.tsx b/src/components/chapter-page/Tooltip.tsx index ab0becd..8cb9e31 100644 --- a/src/components/chapter-page/Tooltip.tsx +++ b/src/components/chapter-page/Tooltip.tsx @@ -1,16 +1,29 @@ import { Link } from 'react-router-dom'; -function Tooltip({ chapterName, chapterNumber, xp }: { chapterName: string; chapterNumber: number; xp: number }) { +function Tooltip({ + chapterName, + chapterId, + unitId, + xp, +}: { + chapterName: string; + unitId: number; + xp: number; + chapterId: string; +}) { return (
{/* 툴팁 꼬리 */}
{/* 툴팁 몸통 */}
-

{`${chapterName}: ${chapterNumber}챕터`}

+

{`${chapterName}: ${unitId}챕터`}

학습 시작하기 (+{xp}xp) diff --git a/src/components/lesson-page/FloatingButton.tsx b/src/components/lesson-page/FloatingButton.tsx new file mode 100644 index 0000000..60a6f9d --- /dev/null +++ b/src/components/lesson-page/FloatingButton.tsx @@ -0,0 +1,40 @@ +import { cn } from '../../utils/cn'; +import CheckIcon from '../../assets/icons/button/floating-check.svg?react'; +import NextIcon from '../../assets/icons/button/floating-next.svg?react'; +import { useProblemContext } from '../../context/ProblemContext'; + +export default function FloatingButton() { + const { answerState, handleClickNext, checkAnswer } = useProblemContext(); + + function handleClickFloatingBtn() { + switch (answerState) { + case 'ANSWERING': + return null; + case 'SELECTED': + checkAnswer(); + break; + case 'WRONG': + handleClickNext(); + break; + case 'CORRECT': + handleClickNext(); + } + } + + return ( + + ); +} diff --git a/src/components/lesson-page/Header.tsx b/src/components/lesson-page/Header.tsx new file mode 100644 index 0000000..bea4a78 --- /dev/null +++ b/src/components/lesson-page/Header.tsx @@ -0,0 +1,32 @@ +import XIcon from '@/assets/icons/x.svg?react'; +import TimerIcon from '@/assets/icons/timer.svg?react'; +import { useEffect, useRef, useState } from 'react'; +import formatTime from '../../utils/formatTime'; + +export default function Header({ handleOpenModal, chapterName }: { handleOpenModal: () => void; chapterName: string }) { + const startTimeRef = useRef(Date.now()); + const [elapsedTime, setElapsedTime] = useState('00:00'); + const timerRef = useRef | null>(null); + + useEffect(() => { + timerRef.current = setInterval(() => { + if (!startTimeRef.current) return; + const currentTime = Date.now() - startTimeRef.current; + + setElapsedTime(formatTime(currentTime)); + }, 1000); + }, []); + + return ( +
+ +

{chapterName}

+
+ +

{elapsedTime}

+
+
+ ); +} diff --git a/src/components/lesson-page/LessonProgress.tsx b/src/components/lesson-page/LessonProgress.tsx new file mode 100644 index 0000000..b7c38bc --- /dev/null +++ b/src/components/lesson-page/LessonProgress.tsx @@ -0,0 +1,10 @@ +export default function LessonProgress({ progress }: { progress: string }) { + return ( +
+
+
+ ); +} diff --git a/src/components/lesson-page/OptionItem.tsx b/src/components/lesson-page/OptionItem.tsx new file mode 100644 index 0000000..d3ef872 --- /dev/null +++ b/src/components/lesson-page/OptionItem.tsx @@ -0,0 +1,64 @@ +import { useProblemContext } from '../../context/ProblemContext'; +import { cn } from '../../utils/cn'; +import WrongIcon from '@/assets/icons/x.svg?react'; +import CorrectIcon from '@/assets/icons/button/floating-check.svg?react'; + +interface OptionItemProps { + number: number; + text: string; + isSelected: boolean; + isCorrectAnswer: boolean; + onClick: (idx: number) => void; +} + +function OptionItem({ number, text, isSelected, onClick, isCorrectAnswer }: OptionItemProps) { + const { changeCurrentAnswer, answerState } = useProblemContext(); + const isSubmitted = answerState === 'CORRECT' || answerState === 'WRONG'; + + const renderButtonContent = () => { + if (!isSubmitted) { + return number; + } + + // 제출된 상태에서의 처리 + if (isCorrectAnswer) { + return ; + } else if (isSelected && answerState === 'WRONG') { + return ; + } + + return number; + }; + + return ( +
  • { + if (answerState === 'WRONG' || answerState == 'CORRECT') return; + if (isSelected) { + changeCurrentAnswer(''); + } else { + onClick(number); + } + }} + className={cn( + 'flex flex-row gap-4 items-center w-full p-4 cursor-pointer', + isSelected && 'bg-gray-300', + isSubmitted && 'cursor-default' + )} + > + + {renderButtonContent()} + + {text} +
  • + ); +} + +export default OptionItem; diff --git a/src/components/lesson-page/OptionsList.tsx b/src/components/lesson-page/OptionsList.tsx new file mode 100644 index 0000000..dc568f0 --- /dev/null +++ b/src/components/lesson-page/OptionsList.tsx @@ -0,0 +1,26 @@ +import { useProblemContext } from '../../context/ProblemContext'; +import type { SelectableProblem } from '../../types/@common/problem'; +import OptionItem from './OptionItem'; + +export default function OptionsList({ problem }: { problem: SelectableProblem }) { + const { changeCurrentAnswer, currentAnswer } = useProblemContext(); + + return ( +
    +
      + {problem.options.map((option, idx) => { + return ( + changeCurrentAnswer((idx + 1).toString())} + number={idx + 1} + text={option} + /> + ); + })} +
    +
    + ); +} diff --git a/src/components/lesson-page/problem/AnswerInput.tsx b/src/components/lesson-page/problem/AnswerInput.tsx new file mode 100644 index 0000000..231ed86 --- /dev/null +++ b/src/components/lesson-page/problem/AnswerInput.tsx @@ -0,0 +1,34 @@ +import { cn } from '../../../utils/cn'; +import { useProblemContext } from '../../../context/ProblemContext'; +import type { DescriptiveProblem } from '../../../types/@common/problem'; + +export default function AnswerInput({ problem }: { problem: DescriptiveProblem }) { + const { answerState, currentAnswer, changeCurrentAnswer } = useProblemContext(); + + const isSubmitted = answerState === 'CORRECT' || answerState === 'WRONG'; + + const isCorrect = answerState === 'CORRECT'; + return ( +
    + changeCurrentAnswer(e.target.value)} + className={cn( + 'w-full h-[73px] bg-white rounded-lg border border-gray-200 pl-6 text-gray-600 text-2xl font-medium focus:outline-none focus:border-gray-400', + isCorrect && isSubmitted ? 'border-[#00A80B] text-[#00A80B]' : '', + !isCorrect && isSubmitted ? 'border-red-400 text-red-400' : '' + )} + placeholder="정답을 입력해주세요." + /> + {!isCorrect && isSubmitted ? ( + + 정답: {problem.correctAnswers.join(', ')} + + ) : null} + {isCorrect && isSubmitted ? ( + 정답입니다! + ) : null} +
    + ); +} diff --git a/src/components/lesson-page/problem/AnswerSection.tsx b/src/components/lesson-page/problem/AnswerSection.tsx new file mode 100644 index 0000000..c7cac12 --- /dev/null +++ b/src/components/lesson-page/problem/AnswerSection.tsx @@ -0,0 +1,15 @@ +import { useProblemContext } from '../../../context/ProblemContext'; +import OptionsList from '../OptionsList'; +import AnswerInput from './AnswerInput'; + +export default function AnswerSection() { + const { currentProblem } = useProblemContext(); + + if (currentProblem?.optionType === 'descriptive') { + return ; + } else if (currentProblem?.optionType === 'selectable') { + return ; + } else { + return <>; + } +} diff --git a/src/components/modal/LessonQuitModal.tsx b/src/components/modal/LessonQuitModal.tsx index 8214d5b..f900720 100644 --- a/src/components/modal/LessonQuitModal.tsx +++ b/src/components/modal/LessonQuitModal.tsx @@ -1,35 +1,68 @@ -import Modal from '../@common/modal/Modal'; +import type { Ref } from 'react'; +// import Modal from '../@common/modal/Modal'; import RabbitIcon from '@/assets/mascot/mascot-sad.svg?react'; +import { useNavigate } from 'react-router-dom'; -function LessonQuitModal({ isOpen, onClose }: { isOpen: boolean; onClose: () => void }) { - const handleContinue = () => { +function LessonQuitModal({ + onClose, + modalRef, +}: { + isOpen: boolean; + onClose: () => void; + modalRef: Ref; +}) { + const navigate = useNavigate(); + const handleQuit = () => { onClose(); + navigate(-1); }; - function handleQuit() { - onClose(); - } + return ( - - -
    -

    지금까지 푼 내역이

    -

    모두 사라져요!

    -
    + +
    +
    e.stopPropagation()} + > + +
    +

    + 지금까지 푼 내역이 +
    + 모두 사라져요! +

    +
    -
    -

    자료구조 학습 출제가 중단됩니다.

    -

    정말 학습을 그만두시나요?

    +

    + 자료구조 학습 출제가 중단됩니다. +
    + 정말 학습을 그만두시나요? +

    + + +
    - - - +
    ); } diff --git a/src/context/ProblemContext.tsx b/src/context/ProblemContext.tsx new file mode 100644 index 0000000..c06a3b3 --- /dev/null +++ b/src/context/ProblemContext.tsx @@ -0,0 +1,124 @@ +import { createContext, useContext, useState, useCallback, type ReactNode } from 'react'; +import type { Problem, SelectableProblem } from '../types/@common/problem'; + +interface ProblemContextType { + // 상태 + userAnswers: boolean[]; + answerState: AnswerState; + currentProblemIdx: number; + currentProblem: Problem | undefined; + currentAnswer: string; + isLastProblem: boolean; + totalProblemsLength: number; + + // 액션 + setCurrentAnswer: (answer: string) => void; + checkAnswer: () => boolean; + handleClickNext: () => void; + resetQuiz: () => void; + changeCurrentAnswer: (newAnswer: string) => void; +} + +type AnswerState = 'ANSWERING' | 'SELECTED' | 'WRONG' | 'CORRECT'; + +const ProblemContext = createContext(null); + +function isSelectableProblem(problem: Problem): problem is SelectableProblem { + return problem.optionType === 'selectable'; +} + +export function ProblemProvider({ problems, children }: { problems: Problem[] | undefined; children: ReactNode }) { + const [userAnswers, setUserAnswers] = useState([]); + const [answerState, setAnswerState] = useState('ANSWERING'); + const [currentAnswer, setCurrentAnswer] = useState(''); + + const currentProblemIdx = userAnswers.length; + const currentProblem = problems?.[currentProblemIdx]; + const isLastProblem = currentProblemIdx === (problems?.length ?? 0) - 1; + const totalProblemsLength: number = problems?.length || 0; + + const checkAnswer = useCallback((): boolean => { + if (!currentProblem || currentAnswer.trim() === '') { + return false; + } + + let isCorrectAnswer = false; + + if (isSelectableProblem(currentProblem)) { + // 객관식 문제 + const selectedIndex = Number(currentAnswer); + isCorrectAnswer = currentProblem.correctAnswerIndex === selectedIndex; + } else { + // 주관식 문제 + isCorrectAnswer = currentProblem.correctAnswers.includes(currentAnswer.trim()); + } + + // AnswerState 변경 + const newAnswerState: AnswerState = isCorrectAnswer ? 'CORRECT' : 'WRONG'; + setAnswerState(newAnswerState); + + return isCorrectAnswer; + }, [currentProblem, currentAnswer]); + + const handleClickNext = useCallback(() => { + if (answerState === 'ANSWERING' || answerState === 'SELECTED') { + return; + } + + { + /* TODO 모달이 열리게 만들어야 함 */ + } + if (isLastProblem) { + return; + } + + const isCorrect = answerState === 'CORRECT'; + setUserAnswers((prev) => [...prev, isCorrect]); + + setCurrentAnswer(''); + setAnswerState('ANSWERING'); + }, [answerState, isLastProblem]); + + const resetQuiz = useCallback(() => { + setUserAnswers([]); + setCurrentAnswer(''); + setAnswerState('ANSWERING'); + }, []); + + const changeCurrentAnswer = useCallback( + (newAnswer: string) => { + if (answerState === 'ANSWERING' && newAnswer.trim() !== '') { + setAnswerState('SELECTED'); + } else if (newAnswer === '' && answerState === 'SELECTED') { + setAnswerState('ANSWERING'); + } + setCurrentAnswer(newAnswer); + }, + [answerState] + ); + + const contextValue: ProblemContextType = { + userAnswers, + answerState, + currentProblemIdx, + totalProblemsLength, + currentAnswer, + isLastProblem, + currentProblem, + setCurrentAnswer, + checkAnswer, + handleClickNext, + changeCurrentAnswer, + resetQuiz, + }; + + return {children}; +} + +export function useProblemContext() { + const context = useContext(ProblemContext); + if (!context) { + throw new Error('useProblemContext must be used within a ProblemProvider'); + } + return context; +} diff --git a/src/hooks/useAnswerState.tsx b/src/hooks/useAnswerState.tsx new file mode 100644 index 0000000..a8a833c --- /dev/null +++ b/src/hooks/useAnswerState.tsx @@ -0,0 +1,53 @@ +// import { useState } from 'react'; +// import { useProblemContext } from '../context/ProblemContext'; + +// type ButtonType = 'ANSWERING' | 'SELECTED' | 'NEXT'; + +// export function useAnswerState() { +// const [currentAnswer, setCurrentAnswer] = useState(''); +// const [problemState, setProblemState] = useState('ANSWERING'); +// const [isCorrect, setIsCorrect] = useState(false); +// const { checkAnswer } = useProblemContext(); + +// function handleAnswerChange(answer: string) { +// setCurrentAnswer(answer); +// if (answer === '' && problemState === 'SELECTED') { +// setProblemState('ANSWERING'); +// } else if (answer !== '' && problemState === 'ANSWERING') { +// setProblemState('SELECTED'); +// } +// } + +// function handleSelectOption(idx: number) { +// const newAnswer = idx.toString(); +// if (newAnswer === currentAnswer) { +// setCurrentAnswer(''); +// setProblemState('ANSWERING'); +// } else { +// setCurrentAnswer(newAnswer); +// setProblemState('SELECTED'); +// } +// } + +// function handleCheckAnswer() { +// const result = checkAnswer(currentAnswer); +// setIsCorrect(result); +// setProblemState('NEXT'); +// } + +// function resetAnswer() { +// setCurrentAnswer(''); +// setProblemState('ANSWERING'); +// setIsCorrect(false); +// } + +// return { +// currentAnswer, +// problemState, +// isCorrect, +// handleAnswerChange, +// handleSelectOption, +// handleCheckAnswer, +// resetAnswer, +// }; +// } diff --git a/src/hooks/useAuthState.tsx b/src/hooks/useAuthState.tsx new file mode 100644 index 0000000..5348942 --- /dev/null +++ b/src/hooks/useAuthState.tsx @@ -0,0 +1 @@ +// This file is currently empty - placeholder for future auth state management \ No newline at end of file diff --git a/src/layouts/NoHeaderLayout.tsx b/src/layouts/NoHeaderLayout.tsx index 944e8da..e61d226 100644 --- a/src/layouts/NoHeaderLayout.tsx +++ b/src/layouts/NoHeaderLayout.tsx @@ -3,7 +3,7 @@ import ScrollToTop from '../components/@common/scroll/ScrollToTop'; function NoHeaderLayout() { return ( -
    +
    diff --git a/src/pages/ChapterDetailPage.tsx b/src/pages/ChapterDetailPage.tsx index 5dd16cf..f3e2ded 100644 --- a/src/pages/ChapterDetailPage.tsx +++ b/src/pages/ChapterDetailPage.tsx @@ -82,7 +82,9 @@ function ChapterDetailPage() { {unit.name} - {tooltip === index && } + {tooltip === index && ( + + )} ); } @@ -101,7 +103,9 @@ function ChapterDetailPage() { }} > {unit.name} - {tooltip === index && } + {tooltip === index && ( + + )} ); })} diff --git a/src/pages/MainPage.tsx b/src/pages/MainPage.tsx index d6f56ad..e744a68 100644 --- a/src/pages/MainPage.tsx +++ b/src/pages/MainPage.tsx @@ -31,7 +31,8 @@ function MainPage() {

    - 현재 {nickname}님의 티어는 {league || '브론즈'} + 현재 {nickname}님의 티어는{' '} + {league || '브론즈'} 입니다!

    diff --git a/src/pages/ProblemPage.tsx b/src/pages/ProblemPage.tsx index fdd3466..6f50d26 100644 --- a/src/pages/ProblemPage.tsx +++ b/src/pages/ProblemPage.tsx @@ -1,90 +1,125 @@ -import { useState } from 'react'; -import XIcon from '@/assets/icons/x.svg?react'; -import TimerIcon from '@/assets/icons/timer.svg?react'; +import { useRef, useState } from 'react'; +import { useLocation, useParams } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; import ClipBoardIcon from '@/assets/icons/clipboard.svg?react'; import LessonQuitModal from '../components/modal/LessonQuitModal'; +import { ProblemProvider, useProblemContext } from '../context/ProblemContext'; +import fetchProblems from '../api/fetchProblems'; +import AnswerSection from '../components/lesson-page/problem/AnswerSection'; +import FloatingButton from '../components/lesson-page/FloatingButton'; +import Header from '../components/lesson-page/Header'; +import LessonProgress from '../components/lesson-page/LessonProgress'; -function ProblemPage() { +function ProblemContent() { const [isModalOpen, setIsModalOpen] = useState(false); + const modalRef = useRef(null); + const { currentProblem, totalProblemsLength, currentProblemIdx } = useProblemContext(); + const location = useLocation(); + const chapterName = (location.state as { chapterName?: string })?.chapterName || '자료구조'; + + if (!currentProblem || totalProblemsLength === 0) { + return <>; + } - function handleOnClose() { + const progress: string = `${((currentProblemIdx + 1) / totalProblemsLength) * 100}%`; + + const handleOpenModal = () => { + if (isModalOpen) return; setIsModalOpen(true); + modalRef.current?.showModal(); + }; + + const handleCloseModal = () => { + modalRef.current?.close(); + setIsModalOpen(false); + }; + + if (!currentProblem) { + return
    Loading...
    ; } + return ( <> - {isModalOpen && } -
    -
    - -

    자료구조

    -
    - -

    00:07

    -
    -
    -
    -
    -
    -
    -
    -

    +

    +
    + + + +
    +
    +

    -

    1/10

    -

    -

    빈칸에 들어갈 단어를 고르세요

    -
    - OOO는 넣고 또 넣고, 위에 넣고 또 위에 넣고 꺼내면 위에 거부터 꺼내고 또 꺼내면 그 아래 거 - 꺼내고 다시 넣으면 또 위에 올라가고 다시 꺼내면 또 위에 거부터 빠지는 구조예요. 이런 거 보면 - 사람들은 다 말하죠. “이거 완전 ( ) 아냐?” OOO는 넣고 또 넣고, 위에 넣고 또 위에 넣고 꺼내면 - 위에 거부터 꺼내고 또 꺼내면 그 아래 거 꺼내고 다시 넣으면 또 위에 올라가고 다시 꺼내면 또 - 위에 거부터 빠지는 구조예요. 이런 거 보면 사람들은 다 말하죠. “이거 완전 ( ) 아냐?” OOO는 - 넣고 또 넣고, 위에 넣고 또 위에 넣고 꺼내면 위에 거부터 + + {currentProblemIdx + 1}/{totalProblemsLength} + +

    +
    + {currentProblem.question}
    -
    -
      -
    1. - - 1 - - FIFO -
    2. - -
    3. - - 2 - - DFS -
    4. - -
    5. - - 3 - - LIFO -
    6. - -
    7. - - 4 - - 해시맵 -
    8. - -
    9. - - 5 - - -
    10. -
    -
    + +
    + +
    ); } +function ProblemPage() { + const { lessonId } = useParams<{ lessonId: string }>(); + + const { + data: problems, + isLoading, + error, + } = useQuery({ + queryKey: ['problems', lessonId], + queryFn: () => fetchProblems(lessonId!), + enabled: !!lessonId, + }); + + if (!lessonId) { + return
    Invalid lesson ID
    ; + } + + if (isLoading) { + return ( +
    +
    +
    +

    문제를 불러오는 중...

    +
    +
    + ); + } + + if (error) { + return ( +
    +
    +

    문제를 불러오는 중 오류가 발생했습니다.

    + +
    +
    + ); + } + + if (problems) { + console.log(problems); + } + + return ( + + + + ); +} + export default ProblemPage; diff --git a/src/routes/router.tsx b/src/routes/router.tsx index 655ffe6..11bdf6e 100644 --- a/src/routes/router.tsx +++ b/src/routes/router.tsx @@ -115,7 +115,7 @@ const router = createBrowserRouter([ element: , children: [ { - path: 'lesson', + path: 'lesson/:chapterId/:lessonId', element: ( diff --git a/src/types/@common/problem.ts b/src/types/@common/problem.ts new file mode 100644 index 0000000..9a50eda --- /dev/null +++ b/src/types/@common/problem.ts @@ -0,0 +1,28 @@ +type ProblemType = 'FILL_BLANK' | 'SELECT_DESCRIPTION'; + +export interface BaseProblem { + id: number; + question: string; + type: ProblemType; +} + +export interface SelectableProblem extends BaseProblem { + optionType: 'selectable'; + options: string[]; + correctAnswerIndex: number; +} + +export interface DescriptiveProblem extends BaseProblem { + optionType: 'descriptive'; + correctAnswers: string[]; +} + +export type Problem = SelectableProblem | DescriptiveProblem; + +export function isSelectableProblem(problem: Problem): problem is SelectableProblem { + return problem.optionType === 'selectable'; +} + +export function isDescriptiveProblem(problem: Problem): problem is DescriptiveProblem { + return problem.optionType === 'descriptive'; +} diff --git a/src/types/api/problem-response.ts b/src/types/api/problem-response.ts new file mode 100644 index 0000000..79669bf --- /dev/null +++ b/src/types/api/problem-response.ts @@ -0,0 +1,12 @@ +export interface ProblemRaw { + problemId: number; + problemType: string; + question: string; + options: string; + answer: string; +} + +export interface ProblemResponse { + problems: ProblemRaw[]; + totalProblems: number; +} diff --git a/src/types/transformers/transformProblems.ts b/src/types/transformers/transformProblems.ts new file mode 100644 index 0000000..bd7294b --- /dev/null +++ b/src/types/transformers/transformProblems.ts @@ -0,0 +1,30 @@ +import { parseOptions, parsePossibleAnswers } from '../../utils/parseAnswers'; +import type { BaseProblem, Problem } from '../@common/problem'; +import type { ProblemRaw, ProblemResponse } from '../api/problem-response'; + +export function transformProblems(response: ProblemResponse): Problem[] { + return response.problems.map((problem) => transformProblem(problem)); +} + +function transformProblem(problem: ProblemRaw): Problem { + const base: BaseProblem = { + id: problem.problemId, + question: problem.question, + type: problem.problemType === 'FILL_BLANK' ? 'FILL_BLANK' : 'SELECT_DESCRIPTION', + }; + + if (problem.options !== '-') { + return { + ...base, + optionType: 'selectable', + options: parseOptions(problem.options), + correctAnswerIndex: Number(problem.answer) || 0, + }; + } else { + return { + ...base, + optionType: 'descriptive', + correctAnswers: parsePossibleAnswers(problem.answer), + }; + } +} diff --git a/src/utils/cn.ts b/src/utils/cn.ts new file mode 100644 index 0000000..4834418 --- /dev/null +++ b/src/utils/cn.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} \ No newline at end of file diff --git a/src/utils/formatTime.ts b/src/utils/formatTime.ts new file mode 100644 index 0000000..0513a34 --- /dev/null +++ b/src/utils/formatTime.ts @@ -0,0 +1,7 @@ +const formatTime = (ms: number) => { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + + return `${minutes.toString().padStart(2, '0')}:${(seconds % 60).toString().padStart(2, '0')}`; +}; +export default formatTime; diff --git a/src/utils/parseAnswers.ts b/src/utils/parseAnswers.ts new file mode 100644 index 0000000..1eb382e --- /dev/null +++ b/src/utils/parseAnswers.ts @@ -0,0 +1,20 @@ +export function parseOptions(optionsString: string): string[] { + if (optionsString === '-') { + return []; + } + + return optionsString + .split(';') + .map((option) => { + const trimmed = option.trim(); + return trimmed.replace(/^\d+\.\s*/, ''); + }) + .filter((option) => option.length > 0); +} + +export function parsePossibleAnswers(optionsString: string): string[] { + if (optionsString === '-') { + return []; + } + return optionsString.split(',').map((c) => c.trim()); +}