Skip to content

Commit

Permalink
[FE - #73] 참여자 퀴즈 풀이 페이지 UI 구현 및 소켓 기능 구현 (#83)
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
chan-byeong authored Nov 19, 2024
1 parent 6d275a3 commit 7f4b6e0
Show file tree
Hide file tree
Showing 9 changed files with 311 additions and 166 deletions.
134 changes: 62 additions & 72 deletions packages/client/src/pages/quiz-session/index.tsx
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>
)}
</>
);
}
51 changes: 0 additions & 51 deletions packages/client/src/pages/quiz-session/ui/Quiz.tsx

This file was deleted.

18 changes: 18 additions & 0 deletions packages/client/src/pages/quiz-session/ui/QuizBackground.tsx
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>
);
}
155 changes: 155 additions & 0 deletions packages/client/src/pages/quiz-session/ui/QuizBox.tsx
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>
</>
);
}
Loading

0 comments on commit 7f4b6e0

Please sign in to comment.