-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Browse files
Browse the repository at this point in the history
* 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로 타입 통일
- Loading branch information
1 parent
6d275a3
commit 7f4b6e0
Showing
9 changed files
with
311 additions
and
166 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 ( | ||
<> | ||
<Quiz | ||
key={currentQuizIndex} | ||
quizData={mockQuizData[currentQuizIndex]} | ||
handleAnimationEnd={handleAnimationEnd} | ||
/> | ||
{isLoading ? ( | ||
<QuizLoading /> | ||
) : ( | ||
<div> | ||
<QuizHeader /> | ||
<QuizBackground easyPercentage={easyPercentage} /> | ||
<QuizBox reactionStats={reactionStats} setReactionStats={setReactionStats} /> | ||
</div> | ||
)} | ||
</> | ||
); | ||
} |
This file was deleted.
Oops, something went wrong.
18 changes: 18 additions & 0 deletions
18
packages/client/src/pages/quiz-session/ui/QuizBackground.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
interface QuizBackgroundProps { | ||
easyPercentage: number; | ||
} | ||
|
||
export default function QuizBackground({ easyPercentage }: QuizBackgroundProps) { | ||
return ( | ||
<div className="fixed inset-0 -z-10"> | ||
<div | ||
className="absolute inset-0 transition-colors duration-1000" | ||
style={{ | ||
background: `linear-gradient(to bottom, | ||
rgba(16, 185, 129, ${easyPercentage / 200}) 0%, | ||
rgba(239, 68, 68, ${(100 - easyPercentage) / 200}) 100%)`, | ||
}} | ||
/> | ||
</div> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<SetStateAction<ReactionData>>; | ||
} | ||
|
||
export default function QuizBox({ reactionStats, setReactionStats }: QuizBoxProps) { | ||
const [selectedAnswer, setSelectedAnswer] = useState<number[]>([]); | ||
const [hasSubmitted, setHasSubmitted] = useState(false); | ||
const easyButtonRef = useRef<HTMLButtonElement>(null); | ||
const hardButtonRef = useRef<HTMLButtonElement>(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 ( | ||
<> | ||
<div className="relative z-10 p-6 max-w-4xl mx-auto mb-8"> | ||
<div className="bg-white/80 backdrop-blur-sm rounded-2xl shadow-lg p-6 mb-12"> | ||
<div className="flex items-center justify-between mb-6"> | ||
<span className="text-sm text-gray-500">Question 1/10</span> | ||
<span className="text-sm text-gray-500">난이도</span> | ||
</div> | ||
{/* 문제 */} | ||
<div className="mb-8"> | ||
<h2 className="text-xl font-bold mb-4"> | ||
Python의 기본 자료형에 대한 설명으로 올바른 것은? | ||
</h2> | ||
<p className="text-gray-600"> | ||
다음 중 Python의 기본 자료형(Data Type)에 대한 설명으로 가장 적절한 것을 고르시오. | ||
</p> | ||
</div> | ||
{/* 선택지 */} | ||
<div className="space-y-4"> | ||
{[ | ||
'문자열(string)은 변경 가능한(mutable) 자료형이다.', | ||
'튜플(tuple)은 변경 불가능한(immutable) 자료형이다.', | ||
'리스트(list)는 변경 불가능한(immutable) 자료형이다.', | ||
'딕셔너리(dictionary)는 정렬된 자료형이다.', | ||
].map((answer, idx) => ( | ||
<button | ||
key={idx} | ||
onClick={() => handleSelectAnswer(idx)} | ||
className={`w-full p-4 text-left rounded-xl border transition-all ${ | ||
selectedAnswer.includes(idx) | ||
? 'border-blue-500 bg-blue-50' | ||
: 'border-gray-200 hover:border-blue-200' | ||
}`} | ||
> | ||
<span className="font-medium mr-3">{String.fromCharCode(65 + idx)}.</span> | ||
{answer} | ||
</button> | ||
))} | ||
</div> | ||
</div> | ||
|
||
{/* 제출 버튼 */} | ||
<div className="flex justify-between items-center"> | ||
<button | ||
ref={easyButtonRef} | ||
onClick={() => handleReaction('easy')} | ||
className={`relative w-[149px] px-4 py-2 rounded-full transition-all flex items-center justify-center space-x-2 ${ | ||
reactionStats.easy > 0 | ||
? 'bg-secondary/90 text-white' | ||
: 'bg-white border hover:border-secondary' | ||
}`} | ||
> | ||
<span className="text-2xl">😊</span> | ||
<span>쉬워요 ({reactionStats.easy})</span> | ||
</button> | ||
<button | ||
onClick={handleSubmit} | ||
disabled={selectedAnswer.length === 0 || hasSubmitted} | ||
className={`px-8 py-3 rounded-full font-medium transition-all ${ | ||
selectedAnswer.length > 0 && !hasSubmitted | ||
? 'bg-primary/90 text-white hover:bg-primary/90' | ||
: 'bg-gray-100 text-gray-400 cursor-not-allowed' | ||
}`} | ||
> | ||
{hasSubmitted ? '제출 완료' : '제출하기'} | ||
</button> | ||
<button | ||
ref={hardButtonRef} | ||
onClick={() => handleReaction('hard')} | ||
className={`relative w-[160px] px-4 py-2 rounded-full transition-all flex items-center justify-center space-x-2 ${ | ||
reactionStats.hard > 0 | ||
? 'bg-red-500/90 text-white' | ||
: 'bg-white border hover:border-red-400' | ||
}`} | ||
> | ||
<span className="text-2xl ">🤔</span> | ||
<span>어려워요 ({reactionStats.hard})</span> | ||
</button> | ||
</div> | ||
</div> | ||
</> | ||
); | ||
} |
Oops, something went wrong.