From 7f4b6e06189e5ff3062ab7ce521334610a410d1e Mon Sep 17 00:00:00 2001 From: ByeongChan Choi <77400298+chan-byeong@users.noreply.github.com> Date: Tue, 19 Nov 2024 20:22:48 +0900 Subject: [PATCH] =?UTF-8?q?[FE=20-=20#73]=20=EC=B0=B8=EC=97=AC=EC=9E=90=20?= =?UTF-8?q?=ED=80=B4=EC=A6=88=20=ED=92=80=EC=9D=B4=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20UI=20=EA=B5=AC=ED=98=84=20=EB=B0=8F=20=EC=86=8C?= =?UTF-8?q?=EC=BC=93=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=20(#83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: floatUp 키프레임 추가 * fix: Header UI 수정 * feat: Quiz 풀이 페이지 배경 컴포넌트 * fix: 기존 코드 제거 * feat: QuizLoading 페이지 UI 구현 - mock data 바꿔야 함 - UI 일부 수정 필요 * feat: Quiz 풀이 페이지 헤더 - 제출자 , 남은 시간 표기 * feat: Quiz 박스 UI 구현 - 문제, 선지, 제출 버튼 * feat: QuizSession UI 구현 * fix: 에러 메세지 토스트 추가 * fix: ReactionData로 타입 통일 --- .../client/src/pages/quiz-session/index.tsx | 134 +++++++-------- .../client/src/pages/quiz-session/ui/Quiz.tsx | 51 ------ .../pages/quiz-session/ui/QuizBackground.tsx | 18 ++ .../src/pages/quiz-session/ui/QuizBox.tsx | 155 ++++++++++++++++++ .../src/pages/quiz-session/ui/QuizForm.tsx | 42 ----- .../src/pages/quiz-session/ui/QuizHeader.tsx | 34 ++++ .../src/pages/quiz-session/ui/QuizLoading.tsx | 31 ++++ .../client/src/shared/ui/header/Header.tsx | 2 +- packages/client/tailwind.config.js | 10 ++ 9 files changed, 311 insertions(+), 166 deletions(-) delete mode 100644 packages/client/src/pages/quiz-session/ui/Quiz.tsx create mode 100644 packages/client/src/pages/quiz-session/ui/QuizBackground.tsx create mode 100644 packages/client/src/pages/quiz-session/ui/QuizBox.tsx delete mode 100644 packages/client/src/pages/quiz-session/ui/QuizForm.tsx create mode 100644 packages/client/src/pages/quiz-session/ui/QuizHeader.tsx create mode 100644 packages/client/src/pages/quiz-session/ui/QuizLoading.tsx diff --git a/packages/client/src/pages/quiz-session/index.tsx b/packages/client/src/pages/quiz-session/index.tsx index 78e7ec9c..c6d5d628 100644 --- a/packages/client/src/pages/quiz-session/index.tsx +++ b/packages/client/src/pages/quiz-session/index.tsx @@ -1,84 +1,74 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; -import Quiz from './ui/Quiz'; -import { useNavigate } from 'react-router-dom'; +import { getQuizSocket } from '@/shared/utils/socket'; +import QuizBackground from './ui/QuizBackground'; +import QuizBox from './ui/QuizBox'; +import QuizHeader from './ui/QuizHeader'; +import QuizLoading from './ui/QuizLoading'; +import { toastController } from '@/features/toast/model/toastController'; -interface Quizes { - title: string; - choices: { - content: string; - isAnswer: boolean; - }[]; -} +export default function QuizSession() { + const socket = getQuizSocket(); + const toast = toastController(); + const [isLoading, setIsLoading] = useState(true); + const [reactionStats, setReactionStats] = useState({ + easy: 0, + hard: 0, + }); + const [quiz, setQuiz] = useState(null); -const mockQuizData: Quizes[] = [ - { - title: '임시 퀴즈 문제1', - choices: [ - { - content: '천마총', - isAnswer: false, - }, - { - content: '왕릉', - isAnswer: false, - }, - { - content: '석굴암', - isAnswer: true, - }, - { - content: '불국사', - isAnswer: false, - }, - ], - }, - { - title: '임시 퀴즈 문제2', - choices: [ - { - content: '천마총', - isAnswer: false, - }, - { - content: '왕릉', - isAnswer: false, - }, - { - content: '석굴암', - isAnswer: true, - }, - { - content: '불국사', - isAnswer: false, - }, - ], - }, -]; + const totalReactions = reactionStats.easy + reactionStats.hard; + const easyPercentage = totalReactions ? (reactionStats.easy / totalReactions) * 100 : 50; -export default function QuizSession() { - //TODO: 퀴즈 정보는 React-query를 활용해서 브라우저 캐시에서 가져온다. + useEffect(() => { + const quizPromise = new Promise((resolve, reject) => { + const handleShowQuiz = (data: any) => { + setQuiz(data); + resolve(data); + }; + + socket.on('show quiz', handleShowQuiz); + + const timer = setTimeout(() => { + reject(new Error('Timeout')); + }, 2000); + + return () => { + socket.off('show quiz', handleShowQuiz); + clearTimeout(timer); + }; + }); + + const timerPromise = new Promise((resolve) => { + setTimeout(resolve, 2000); + }); - const [currentQuizIndex, setCurrentQuizIndex] = useState(0); - const navigate = useNavigate(); + Promise.all([quizPromise, timerPromise]) + .then(() => { + setIsLoading(false); + }) + .catch(() => { + toast.error('문제 로딩에 실패했습니다.'); + setIsLoading(false); + }); - const handleAnimationEnd = () => { - // TODO: 타이머 종료 시 다음 퀴즈 페이지로 이동하는 이벤트 emit - setCurrentQuizIndex((pre) => pre + 1); - if (currentQuizIndex === mockQuizData.length - 1) { - // TODO: Host 여부에 따라 페이지 변경 - // HOST - navigate('/questions) - navigate('/quiz/question'); - } - }; + socket.emit('timeout', (response: any) => { + console.log(response); + }); + }, []); + console.log(quiz); return ( <> - + {isLoading ? ( + + ) : ( +
+ + + +
+ )} ); } diff --git a/packages/client/src/pages/quiz-session/ui/Quiz.tsx b/packages/client/src/pages/quiz-session/ui/Quiz.tsx deleted file mode 100644 index 01174ce8..00000000 --- a/packages/client/src/pages/quiz-session/ui/Quiz.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { useState } from 'react'; - -import ProgressBar from '@/shared/ui/progress-bar/ProgressBar'; -import CustomButton from '@/shared/ui/buttons/CustomButton'; -import QuizForm from './QuizForm'; - -// TODO: 제출하기 버튼 API 연동 -// TODO: 타이머 종료 시 다음 퀴즈 페이지로 이동 - -type Choice = { - content: string; - isAnswer: boolean; -}; - -interface QuizProps { - quizData: { - title: string; - choices: Choice[]; - }; - handleAnimationEnd: () => void; -} - -export default function Quiz({ quizData, handleAnimationEnd }: QuizProps) { - const [selectedOptions, setSelectedOptions] = useState([]); - - const handleToggle = (index: number) => { - setSelectedOptions((prev) => { - if (prev.includes(index)) { - return prev.filter((item) => item !== index); - } - return [...prev, index]; - }); - }; - - return ( -
- handleAnimationEnd()} - /> -
- -
- {}} /> -
-
-
- ); -} diff --git a/packages/client/src/pages/quiz-session/ui/QuizBackground.tsx b/packages/client/src/pages/quiz-session/ui/QuizBackground.tsx new file mode 100644 index 00000000..1d96a3cf --- /dev/null +++ b/packages/client/src/pages/quiz-session/ui/QuizBackground.tsx @@ -0,0 +1,18 @@ +interface QuizBackgroundProps { + easyPercentage: number; +} + +export default function QuizBackground({ easyPercentage }: QuizBackgroundProps) { + return ( +
+
+
+ ); +} diff --git a/packages/client/src/pages/quiz-session/ui/QuizBox.tsx b/packages/client/src/pages/quiz-session/ui/QuizBox.tsx new file mode 100644 index 00000000..0c643400 --- /dev/null +++ b/packages/client/src/pages/quiz-session/ui/QuizBox.tsx @@ -0,0 +1,155 @@ +import { Dispatch, SetStateAction, useState, useRef, useEffect, useCallback } from 'react'; + +import { getQuizSocket } from '@/shared/utils/socket'; +interface ReactionData { + easy: number; + hard: number; +} + +interface QuizBoxProps { + reactionStats: ReactionData; + setReactionStats: Dispatch>; +} + +export default function QuizBox({ reactionStats, setReactionStats }: QuizBoxProps) { + const [selectedAnswer, setSelectedAnswer] = useState([]); + const [hasSubmitted, setHasSubmitted] = useState(false); + const easyButtonRef = useRef(null); + const hardButtonRef = useRef(null); + const socket = getQuizSocket(); + + const handleSelectAnswer = (idx: number) => { + setSelectedAnswer((prev) => { + if (prev.includes(idx)) { + return prev.filter((i) => i !== idx); + } + return [...prev, idx]; + }); + }; + + const handleSubmit = () => { + socket.emit('submit answer', { selectAnswer: selectedAnswer }); + console.log(selectedAnswer); + setHasSubmitted(true); + }; + + const handleReaction = (reaction: 'easy' | 'hard') => { + setReactionStats({ ...reactionStats, [reaction]: reactionStats[reaction] + 1 }); + handleFloatUp(reaction); + socket.emit('emoji', { reaction }); + }; + + const handleFloatUp = (reaction: 'easy' | 'hard') => { + const buttonRef = reaction === 'easy' ? easyButtonRef : hardButtonRef; + + const emoji = document.createElement('div'); + emoji.textContent = reaction === 'easy' ? '😊' : '🤔'; + emoji.className = 'fixed left-4 text-2xl absolute animate-[floatUp_1s_ease-in-out_forwards]'; + buttonRef.current?.appendChild(emoji); + + setTimeout(() => { + emoji.remove(); + }, 1000); + }; + + const handleReactionUpdate = useCallback((data: ReactionData) => { + setReactionStats(data); + }, []); + + const handleSubmitUpdate = useCallback(() => { + // 제출자에게 제출 완료에 대한 피드백 보여주기 + }, []); + + useEffect(() => { + socket.on('emoji', handleReactionUpdate); + + socket.on('submit answer', handleSubmitUpdate); + + return () => { + socket.off('emoji', handleReactionUpdate); + }; + }, []); + + return ( + <> +
+
+
+ Question 1/10 + 난이도 +
+ {/* 문제 */} +
+

+ Python의 기본 자료형에 대한 설명으로 올바른 것은? +

+

+ 다음 중 Python의 기본 자료형(Data Type)에 대한 설명으로 가장 적절한 것을 고르시오. +

+
+ {/* 선택지 */} +
+ {[ + '문자열(string)은 변경 가능한(mutable) 자료형이다.', + '튜플(tuple)은 변경 불가능한(immutable) 자료형이다.', + '리스트(list)는 변경 불가능한(immutable) 자료형이다.', + '딕셔너리(dictionary)는 정렬된 자료형이다.', + ].map((answer, idx) => ( + + ))} +
+
+ + {/* 제출 버튼 */} +
+ + + +
+
+ + ); +} diff --git a/packages/client/src/pages/quiz-session/ui/QuizForm.tsx b/packages/client/src/pages/quiz-session/ui/QuizForm.tsx deleted file mode 100644 index bf4a7c93..00000000 --- a/packages/client/src/pages/quiz-session/ui/QuizForm.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import ToggleButton from '@/shared/ui/buttons/ToggleButton'; - -type Choice = { - content: string; - isAnswer: boolean; -}; - -interface QuizProps { - title: string; - choices: Choice[]; -} - -interface QuizFormProps { - selectedOptions: number[]; - onToggle: (index: number) => void; - quizData: QuizProps; -} - -export default function QuizForm({ selectedOptions, onToggle, quizData }: QuizFormProps) { - return ( -
-

{quizData.title}

-

- {quizData.choices.map((option, index) => ( -

onToggle(index)} - > - - {option.content} -
- ))} -

-
- ); -} diff --git a/packages/client/src/pages/quiz-session/ui/QuizHeader.tsx b/packages/client/src/pages/quiz-session/ui/QuizHeader.tsx new file mode 100644 index 00000000..83849401 --- /dev/null +++ b/packages/client/src/pages/quiz-session/ui/QuizHeader.tsx @@ -0,0 +1,34 @@ +import { useEffect, useState } from 'react'; + +import { getQuizSocket } from '@/shared/utils/socket'; + +export default function QuizHeader() { + const socket = getQuizSocket(); + const [submitStatus, setSubmitStatus] = useState<{ count: number; total: number }>({ + count: 0, + total: 0, + }); + + const handleSubmitStatus = (status: { count: number; total: number }) => { + setSubmitStatus(status); + }; + + useEffect(() => { + socket.on('submit status', handleSubmitStatus); + + return () => { + socket.off('submit status', handleSubmitStatus); + }; + }, []); + + return ( +
+
+
+ {submitStatus.count} / {submitStatus.total}명 제출 +
+
남은 시간
+
+
+ ); +} diff --git a/packages/client/src/pages/quiz-session/ui/QuizLoading.tsx b/packages/client/src/pages/quiz-session/ui/QuizLoading.tsx new file mode 100644 index 00000000..789c0c59 --- /dev/null +++ b/packages/client/src/pages/quiz-session/ui/QuizLoading.tsx @@ -0,0 +1,31 @@ +export default function QuizLoading() { + return ( +
+
+
+ Leader Board +
+ +
+
+
+ 2 +
+
+
+ 1 +
+
+
+ 3 +
+
+ +
+ 나는 몇 등? + #10 +
+
+
+ ); +} diff --git a/packages/client/src/shared/ui/header/Header.tsx b/packages/client/src/shared/ui/header/Header.tsx index 831ac09b..0dc33682 100644 --- a/packages/client/src/shared/ui/header/Header.tsx +++ b/packages/client/src/shared/ui/header/Header.tsx @@ -9,7 +9,7 @@ interface HeaderProps { export default function Header({ classTitle }: HeaderProps) { //TODO: 로그인 상태 관리 return ( -
+
diff --git a/packages/client/tailwind.config.js b/packages/client/tailwind.config.js index 689d7263..0ac3ce6b 100644 --- a/packages/client/tailwind.config.js +++ b/packages/client/tailwind.config.js @@ -65,6 +65,16 @@ export default { opacity: 0, }, }, + floatUp: { + '0%': { + transform: 'translateY(0)', + opacity: 1, + }, + '100%': { + transform: 'translateY(-100px)', + opacity: 0, + }, + }, }, }, },