Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merge : Pratice 페이지 #41

Merged
merged 13 commits into from
Feb 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4,671 changes: 2,660 additions & 2,011 deletions package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions packages/backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ SUPABASE_KEY= #''
# Node 환경 (local, production, dev)
NODE_ENV="dev"

DATABASE_URL=
DIRECT_URL=

# NestJS - Configs
ALLOWED_ORIGINS= # exmaple: https://localhost:5173,http://localhost:5173
APP_SERVER_PORT="3000"
Expand Down
2 changes: 2 additions & 0 deletions packages/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
"type": "module",
"proxy": "https://api.doit-toeic.xyz",
"dependencies": {
"@reduxjs/toolkit": "^2.2.0",
"axios": "^1.6.5",
"browserslist-to-esbuild": "^2.1.1",
"framer-motion": "^10.17.9",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.49.2",
"react-icons": "^4.12.0",
"react-redux": "^9.1.0",
"react-responsive": "^9.0.2",
"react-router-dom": "^6.21.1",
"react-spinners": "^0.13.8",
Expand Down
Binary file added packages/frontend/public/img/checkPencil.webp
Binary file not shown.
Binary file added packages/frontend/public/img/homewhite.webp
Binary file not shown.
Binary file added packages/frontend/public/img/nextarrow.webp
Binary file not shown.
Binary file added packages/frontend/public/img/outPencil.webp
Binary file not shown.
Binary file added packages/frontend/public/img/prevarrow.webp
Binary file not shown.
Binary file added packages/frontend/public/img/xmark.webp
Binary file not shown.
2 changes: 2 additions & 0 deletions packages/frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Route, Routes } from 'react-router-dom';
import Register from './pages/Register';
import Login from './pages/Login';
import Main from './pages/Main';
import Practice from './pages/Practice';

function App() {
return (
Expand All @@ -14,6 +15,7 @@ function App() {
<Route path="/register" element={<Register />} />
<Route path="/login" element={<Login />} />
<Route path="/main" element={<Main />} />
<Route path="/practice" element={<Practice />} />
</Routes>
</div>
);
Expand Down
6 changes: 6 additions & 0 deletions packages/frontend/src/apis/problem/FetchGetProblem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { httpClientForCredentials } from '../auth/FetchLogIn';

export const FetchGetProblem = async () => {
const response = await httpClientForCredentials.get('/toeic/7');
return response.data;
};
44 changes: 44 additions & 0 deletions packages/frontend/src/components/practice/PracticeModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { PracticeModalProps } from '@/types/PracticeModalProps';
import React from 'react';
import { useNavigate } from 'react-router-dom';
import * as M from '../../style/components/practice/PracticeModalCSS';

function PracticeModal({
type,
setIsOpen,
img_path,
color,
title,
sub_title,
btn_text,
}: PracticeModalProps) {
const navigate = useNavigate();
return (
<>
<M.ModalOverlay>
<M.ModalContent>
<M.XmarkImg onClick={setIsOpen} src={`/img/xmark.webp`} />
<M.ModalBox>
<M.PencilImg src={img_path} />
<M.Title>{title}</M.Title>
<M.SubTitle>{sub_title}</M.SubTitle>
<M.Btn
onClick={() => {
if (type === 'check') {
navigate('/result');
} else if (type === 'out') {
navigate('/main');
}
}}
$color={color}
>
{btn_text}
</M.Btn>
</M.ModalBox>
</M.ModalContent>
</M.ModalOverlay>
</>
);
}

export default PracticeModal;
10 changes: 7 additions & 3 deletions packages/frontend/src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import React, { Suspense, lazy } from 'react';
import { Provider } from 'react-redux';
import { store } from './store';
import ReactDOM from 'react-dom/client';
import './index.css';
import * as serviceWorkerRegistration from './serviceWorkerRegistration';
Expand All @@ -13,9 +15,11 @@ const root = ReactDOM.createRoot(
);
root.render(
<Suspense fallback={<Loading />}>
<BrowserRouter>
<LazyApp />
</BrowserRouter>
<Provider store={store}>
<BrowserRouter>
<LazyApp />
</BrowserRouter>
</Provider>
</Suspense>,
);

Expand Down
6 changes: 5 additions & 1 deletion packages/frontend/src/pages/Main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,11 @@ function Main() {
<ProblemBox>
<Title>문제풀기</Title>
<ContBox>
<DescBox>
<DescBox
onClick={() => {
navigate('/practice');
}}
>
<IconBox color="#395aa2">
<Img size="21px" src={`/img/connectIcon.webp`} alt="connect" />
</IconBox>
Expand Down
217 changes: 217 additions & 0 deletions packages/frontend/src/pages/Practice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import React, { useEffect, useState } from 'react';
import { useAppDispatch, useAppSelector } from '../redux/hook';
import { selectChoice } from '../redux/_reducers/choices';
import { fetchGetProblem } from '@/redux/_reducers/problem';
import Loading from './Loading';
import PracticeModal from '@/components/practice/PracticeModal';
import * as P from '../style/pages/PracticeCSS';

function Practice() {
// redux problem slice의 isLoading
const isLoading = useAppSelector((state) => state.problem.isLoading);
// redux problem slice의 problem
const data = useAppSelector((state) => state.problem.problem);
// 문제의 인덱스
const [questionIndex, setQuestionIndex] = useState(0);
// 문제의 마지막 index
const lastIndex = data.questions.length - 1;
// 이전 버튼의 disable 속성의 bool값
const [isPrevDisabled, setIsPrevDisabled] = useState(false);
// 다음 버튼의 disable 속성의 bool값
const [isNextDisabled, setIsNextDisabled] = useState(true);

const dispatch = useAppDispatch();

useEffect(() => {
// 문제 요청 get api
dispatch(fetchGetProblem());
// 문제의 index값이 0일때 이전 버튼 비활성화
questionIndex === 0 ? setIsPrevDisabled(true) : setIsPrevDisabled(false);
// 문제의 index값이 마지막일때 다음 버튼 비활성화
questionIndex === lastIndex
? setIsNextDisabled(true)
: setIsNextDisabled(false);
}, [questionIndex, isLoading]);

// 현재 페이지의 문제 data
const problemdata = data.questions[questionIndex];
const problem = problemdata.content;
const choices = problemdata.choice;

// choice의 문자열을 "(" 을 기준으로 배열로 만든다.
const choicesArray = choices.match(/\([^)]+\) [^\s]+/g);

// redux에서 choicesArray를 가져온다.
const ChoicesArray = useAppSelector((state) => state.choices.choicesArray);
// choicesArray중 지금 문제의 번호와 같은 객체를 찾아 currentChoice에 넣는다.
const currentChoice = ChoicesArray.find(
(item) => item.questionIndex === questionIndex,
);
// 선택했던 답의 index 번호
const currentChoiceIndex = currentChoice?.choiceIndex;

const extracAnswer = (answer: string): string => {
// 정규 표현식을 사용하여 "()"와 같은 패턴을 찾습니다.
const pattern = /\((\w+)\)/;
const match = answer.match(pattern);
if (match) {
// 정규 표현식에 일치하는 그룹을 추출하여 리턴합니다.
return match[1];
} else {
return '';
}
};

// 정답 확인 함수
const checkCorrect = (exAnswer: string) => {
if (exAnswer === problemdata.answer) {
return true;
} else {
return false;
}
};

// choice를 클릭했을때 문제 번호와 답, 답의 index 저장
const clickChoice = (answer: string, i: number) => {
const exAnswer = extracAnswer(answer);
const isCorrect = checkCorrect(exAnswer);
// 마지막 문제가 아닐때
if (questionIndex !== lastIndex) {
dispatch(
selectChoice({
questionIndex,
answer: exAnswer,
choiceIndex: i,
isCorrect,
}),
);
setQuestionIndex((prev) => prev + 1);
} else {
const exAnswer = extracAnswer(answer);
const isCorrect = checkCorrect(exAnswer);
dispatch(
selectChoice({
questionIndex,
answer: exAnswer,
choiceIndex: i,
isCorrect,
}),
);
setIsOpenCheckModal(true);
}
};

// 마지막 문제 클릭시 나오는 모달창의 isopen
const [isOpenCheckModal, setIsOpenCheckModal] = useState(false);
// 홈 버튼을 눌렀을때 나오는 모달창의 isopen
const [isOpenOutModal, setIsOpenOutModal] = useState(false);

return (
<P.Wrapper>
{isLoading ? (
<Loading />
) : (
<>
{isOpenCheckModal && (
<PracticeModal
type="check"
setIsOpen={() => {
setIsOpenCheckModal(false);
}}
img_path="/img/checkPencil.webp"
color="#35DE73"
title="마지막 문제입니다."
sub_title="답안지를 제출하겠습니까?"
btn_text="제출하기"
/>
)}
{isOpenOutModal && (
<PracticeModal
type="out"
setIsOpen={() => setIsOpenOutModal(false)}
img_path="/img/outPencil.webp"
color="#FF5573"
title="이대로 나갈 건가요?"
sub_title="풀었던 문제들은 저장되지 않아요."
btn_text="나가기"
/>
)}
<P.HomeImg
onClick={() => {
setIsOpenOutModal(true);
}}
src={`/img/homewhite.webp`}
/>
{/* 버튼영역 */}
<P.BtnBox>
<P.PrevBtn
disabled={isPrevDisabled}
$isActive={isPrevDisabled}
onClick={() => {
setQuestionIndex((prev) => prev - 1);
}}
>
<P.Btn $isopen={isOpenCheckModal || isOpenOutModal}>
<P.Arrow src={`/img/prevarrow.webp`} alt="prevarrow" />
</P.Btn>
<div>이전 문제</div>
</P.PrevBtn>
<P.NextBtn
// 비활성화 속성
disabled={isNextDisabled}
// 비활성화 boolean값 styled에서 쓰기위한 속성추가
$isActive={isNextDisabled}
onClick={() => {
setQuestionIndex((prev) => prev + 1);
}}
>
<div>다음 문제</div>
<P.Btn $isopen={isOpenCheckModal || isOpenOutModal}>
<P.Arrow src={`/img/nextarrow.webp`} alt="nextarrow" />
</P.Btn>
</P.NextBtn>
</P.BtnBox>
<P.ContentBox>
<P.Box>
{/* 번호와 즐겨찾기 영역 */}
<P.ContHeader>
<P.ProblemNum>{questionIndex + 1}</P.ProblemNum>
<svg
xmlns="http://www.w3.org/2000/svg"
width="25"
height="25"
viewBox="0 0 21 19"
fill="none"
>
<path
d="M9.65693 1.32159C10.0501 0.705335 10.9499 0.705335 11.3431 1.32159L13.9879 5.46762C14.1347 5.69776 14.3692 5.85787 14.637 5.91082L19.6229 6.89662C20.4026 7.05078 20.6996 8.00901 20.1437 8.57705L16.8469 11.9459C16.6371 12.1603 16.5346 12.4575 16.5677 12.7555L17.0752 17.3325C17.1586 18.0841 16.4096 18.6527 15.708 18.3704L10.8733 16.4252C10.6338 16.3288 10.3662 16.3288 10.1267 16.4252L5.29197 18.3704C4.59038 18.6527 3.84144 18.0841 3.92479 17.3325L4.43234 12.7555C4.4654 12.4575 4.36291 12.1603 4.15314 11.9459L0.856323 8.57706C0.30042 8.00901 0.59736 7.05078 1.37707 6.89662L6.363 5.91082C6.63079 5.85787 6.8653 5.69776 7.0121 5.46762L9.65693 1.32159Z"
fill="#D9D9D9"
/>
</svg>
</P.ContHeader>
<P.Problem>{problem}</P.Problem>
</P.Box>
{/* 선택영역 */}
<P.ChoiceBox>
{/* 문자열을 배열로 만든 선택 배열을 map으로 나열 */}
{choicesArray?.map((choice, i) => (
<P.Choice
style={{
backgroundColor:
i === currentChoiceIndex ? '#fff741' : '#eee',
}}
onClick={() => clickChoice(choice, i)}
key={i}
>
{choice}
</P.Choice>
))}
</P.ChoiceBox>
</P.ContentBox>
</>
)}
</P.Wrapper>
);
}

export default Practice;
35 changes: 35 additions & 0 deletions packages/frontend/src/redux/_reducers/choices.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { Choice } from '../../types/Choice';

interface DataState {
choicesArray: Choice[];
}

const initialState: DataState = {
choicesArray: [],
};

export const choicesSlice = createSlice({
name: 'choices',
initialState,
reducers: {
selectChoice: (state, action: PayloadAction<Choice>) => {
const existingChoice = state.choicesArray.find(
(choice) => choice.questionIndex === action.payload.questionIndex,
);

if (existingChoice) {
// 이미 선택된 Choice가 있다면 해당 객체의 answer,choiceIndex를 갱신
existingChoice.answer = action.payload.answer;
existingChoice.choiceIndex = action.payload.choiceIndex;
existingChoice.isCorrect = action.payload.isCorrect;
} else {
// 없으면 기존 state에 새로운 Choice를 추가
state.choicesArray = [...state.choicesArray, action.payload];
}
},
},
});

export const { selectChoice } = choicesSlice.actions;
export default choicesSlice.reducer;
Loading