React Quiz app Tutorial
위 영상을 복습하는 글이다.
QuestionCard.tsx
import React from 'react'; import {AnswerObject} from '../App'; import {Wrapper, ButtonWrapper} from './QuestionCard.styles'; ** styled-components import ** type Props = { question: string; answers: string[]; callback: (e: React.MouseEvent<HTMLButtonElement>) => void; ** e는 MouseEvent중에서도 HTMLButtonElement 타입이다. ** userAnswer: AnswerObject | undefined; questionNr: number; totalQuestions: number; } ** QuestionCard component로 넘겨줄 Props의 타입을 정의한다. ** const QuestionCard: React.FC<Props> = ({ ** Specifing props ** question, answers, callback, userAnswer, questionNr, totalQuestions }) => ( <Wrapper> <p className="number"> Question:{questionNr} / {totalQuestions} </p> <p dangerouslySetInnerHTML={{__html: question}} /> <div> {answers.map((answer) => ( <ButtonWrapper key={answer} correct={userAnswer?.correctAnswer === answer} // optinal chaining userClicked={userAnswer?.answer === answer}> <button disabled={userAnswer ? true : false} value={answer} onClick={callback}> <span dangerouslySetInnerHTML={{ __html: answer }} /> **dangerouslySetInnerHTML InnerHTML과 같은 것이다.** </button> </ButtonWrapper> ))} </div> </Wrapper> ); export default QuestionCard;
QuestionCard.styles.ts
import styled from 'styled-components'; export const Wrapper = styled.div` max-width: 1100px; background: #ebfeff; border-radius:10px; border: 2px solid #0085a3; padding: 20px; box-shadow: 0px 5px 10px rgba(0,0,0,0.25); text-align: center; p { font-size: 1rem; } ` type ButtonWrapperProps = { correct: boolean; userClicked: boolean; } **ButtonWrapper styled-component로 전달해줄 Props의 type이다.** export const ButtonWrapper = styled.div<ButtonWrapperProps>` transition: all 0.3 ease; :hover { opacity: 0.8; } button { cursor: pointer; user-select: none; font-size: 0.8rem; width: 100%; height: 40px; margin: 5px 0; background: ${({correct, userClicked}) => correct ? 'linear-gradient(90deg, #56ffa4, #59bc86)' : !correct && userClicked ? 'linear-gradient(90deg, #ff5656, #c16868)' : 'linear-gradient(90deg, #56ccff, #6eafb4)'}; **correct,userClicked의 상태에 따라 색이 달라진다.** border: 3px solid #fff; box-shadow: 1px 2px 0px rgba(0, 0, 0, 0.1); border-radius: 10px; color: #fff; text-shadow: 0px 1px 0px rgba(0, 0, 0, 0.25); } `;
API.tsx
import { shuffleArray } from './utils'; export type Question = { category: string; correct_answer: string; difficulty: string; incorrect_answers: string[]; question: string; type: string; }; **JSON 형태로 받아온 데이터의 type을 정의한다.** export enum Difficulty { EASY = "easy", MEDIUM = "medium", HARD = "hard", } export type QuestionsState = Question & { answers: string[] }; **&을 활용해 type을 정의한다.** export const fetchQuizQuestions = async (amount: number, difficulty: Difficulty): Promise<QuestionsState[]> => { const endpoint = `https://opentdb.com/api.php?amount=${amount}&difficulty=${difficulty}&type=multiple`; const data = await (await fetch(endpoint)).json(); return data.results.map((question: Question) => ({ ...question, answers: shuffleArray([...question.incorrect_answers, question.correct_answer]) })) };
data.result(Question[])
question(Question)
utils.ts
export const shuffleArray = (array: any[]) => [...array].sort(() => Math.random() - 0.5);
App.tsx
import React ,{useState} from 'react'; import QuestionCard from './components/QuestionCard'; import {fetchQuizQuestions, QuestionsState, Difficulty} from './API'; import {GlobalStyle, Wrapper} from './App.styles'; export type AnswerObject = { question: string; answer: string; correct: boolean; correctAnswer: string; } **정답의 type을 정의한다.** const TOTAL_QUESTIONS = 10; **질문의 총개수이다.** const App = () => { const [loading, setLoading] = useState(false); **loading상태를 나타낸다.** const [questions, setQuestions] = useState<QuestionsState[]>([]); **<QuestionState[]>는 questions의 타입을 specifing 한다.** const [number, setNumber] = useState(0); const [userAnswers, setUserAnswers] = useState<AnswerObject[]>([]); const [score, setScore] = useState(0); const [gameOver, setGameOver] = useState(true); console.log(questions); const startTrivia = async () => { setLoading(true); setGameOver(false); const newQuestions = await fetchQuizQuestions( TOTAL_QUESTIONS, Difficulty.EASY ); setQuestions(newQuestions); setScore(0); setUserAnswers([]); setNumber(0); setLoading(false); }; const checkAnswer = (e: React.MouseEvent<HTMLButtonElement>) => { if(!gameOver) { const answer = e.currentTarget.value; const correct = questions[number].correct_answer === answer; if(correct) setScore(prev => prev + 1); //Save answer in the array for user answers const answerObject = { question: questions[number].question, answer, // answer: answer correct, correctAnswer: questions[number].correct_answer, }; setUserAnswers(prev => [...prev, answerObject]); } } const nextQuestion = () => { const nextQuestion = number + 1; if(nextQuestion === TOTAL_QUESTIONS){ setGameOver(true); } else { setNumber(nextQuestion); } } return ( <> <GlobalStyle /> <Wrapper className="App"> <h1>REACT QUIZ</h1> {gameOver || userAnswers.length === TOTAL_QUESTIONS ? ( // ||는 or을 의미한다. <button className="start" onClick={startTrivia}> Start </button> ) : null} {!gameOver ? <p className="score">Score: {score}</p> : null} {loading ? <p>Loading Question ...</p> : null} {!loading && !gameOver && ( <QuestionCard questionNr={number + 1} totalQuestions={TOTAL_QUESTIONS} question={questions[number].question} answers={questions[number].answers} userAnswer={userAnswers ? userAnswers[number] : undefined} callback={checkAnswer} /> )} {!gameOver && !loading && userAnswers.length === number + 1 && number !== TOTAL_QUESTIONS - 1 ? ( <button className="next" onClick={nextQuestion}> Next Question </button> ) : null} </Wrapper> </> ); }; export default App;
App.styles.ts
import styled, {createGlobalStyle} from 'styled-components'; import BGImage from './images/aditya-chinchure-YfAn4arFq74-unsplash.jpg'; export const GlobalStyle = createGlobalStyle` **app전체에 적용할 스타일이다.** html { height : 100%; } body { background-image:url(${BGImage}); background-size: cover; margin: 0; padding: 0 20px; display: flex; justify-content: center; } * { box-sizing: border-box; font-family: 'Catamaran', sans-serif; } `; export const Wrapper = styled.div` display: flex; flex-direction: column; align-items: center; > p { color: #fff; } ** forward arrow 모든 p tag에 스타일링 되는 것을 피하기 위해서 사용한다. ** .score { color:#fff; font-size: 2rem; margin:0; } h1 { font-family: Fascinate Inline , Haettenschweiler, 'Arial Narrow Bold', sans-serif; background-image: linear-gradient(180deg, #fff, #87f1ff); background-size: 100%; background-clip: text; -webkit-background-clip: text; -webkit-text-fill-color: transparent; -moz-background--clip:text; -moz-text-fill-color: transparent; filter: drop-shadow(2px 2px #0085a3); **흐림 효과나 색상 변형 등 그래픽 효과를 요소에 적용한다** font-size: 70px; text-align: center; font-weight: 400; margin: 20px; } .start, .next { cursor: pointer; background: linear-gradient(190deg, #fff, #ffcc91); border: 2px solid #d38558; box-shadow: 0px 5px 10px rgba(0,0,0,0.25); border-radius: 10px; margin: 20px 0; padding: 0 40px; } .start { max-width: 200px; } `