Next.jsでクイズアプリを作っていきます。Typescriptも使用していきます。バージョンによってエラーが起きる可能性がありので下記のバージョンを確認してください。
- next@12.1.0
- styled-components@5.3.3
- typescript@4.6.2
参考動画
こちらの動画はReactでクイズ作ってます。Reactで作りたい場合はこちらの動画を見てください。またこの動画では画像を使用していますがここでは使用せずcssを使っていきます。
外部API
こちらのAPIはAPIキーがいらず登録もしなくていいので簡単に使用できます。
では早速作っていきましょう。
yarn create next-app --typescript
中身はこんな感じにしていきます。またアプリを作るときstylesは省略していきますので詳しくはコードを確認してください。
.
┝ node_modules
┝ public
┝ src
│ ┝ components
│ ┝ api
│ ┝ Questions
│ ┝ utils
│ ┝ pages
│ ┝ styles
┝ .eslintrc.json
┝ .gitignore
┝ next-nev.d.ts
┝ next.config.js
┝ README.md
┝ package.json
┝ tsconfig.json
└ yarn.lock
インストール
yarn add styled-components
yarn add -D @types/styled-components
最初にtscofig.jsox
を少し変えていきます。
{
"compilerOptions": {
"baseUrl": "src",
},
}
これで../
を減らせます。
- _app.tsx
import 'styles/globals.css'
import Layout from 'components/Layout'
import type { AppProps } from 'next/app'
function MyApp({ Component, pageProps }: AppProps) {
return (
<Layout>
<Component {...pageProps} />
</Layout>
)
}
export default MyApp
- _document.tsx
import { Html, Head, Main, NextScript } from 'next/document'
const MyDocument = () => {
return (
<Html lang="ja-JP">
<Head>
<meta name="application-name" content="MyTemplate" />
<meta name="description" content="" />
<link
href="https://fonts.googleapis.com/css2?family=Catamaran:wght@700&family=Fascinate+Inline&display=swap"
rel="stylesheet"
/>
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
)
}
export default MyDocument
- Layout.tsx
import Head from 'next/head'
import { ReactNode } from 'react'
type Props = {
children?: ReactNode
}
const Layout = ({ children }: Props) => {
return (
<div>
<Head>
<title>MyTemplate</title>
</Head>
<div className="content">{children}</div>
<footer className=""></footer>
</div>
)
}
export default Layout
- index.tsx
import type { NextPage } from 'next'
import QuestionCard from 'components/Questions/QuestionCard'
import styles from 'styles/Home.module.css'
const Home: NextPage= () => {
const startTrivia = async () => {}
const nextQuestion = () => {}
const checkAnswer = (e: any) => {}
return (
<div className={styles.container}>
<h1>Next.js quiz</h1>
<button className={styles.start}>
start
</button>
<p className={styles.score}>Score:{score}</p>
<p>Loading Questons...</p>
// <QuestionCard/>
<button>Next Question</button>
</div>
)
}
export default Home
QuestionCard.tsx
import styles from 'styles/QuestionCard.module.css'
import { ButtonWrapper } from './styled'
const QuestionCard = () => {
return (
<div className={styles.container}>
<p>Question: </p>
<p/>
<div>
<ButtonWrapper>
<button>
<span/>
</button>
</ButtonWrapper>
</div>
</div>
)
}
cssやstyledの中身はコードを確認してコピペしてください。
localhostを確認できましたか?
- utiles.ts
これはクイズをランダムに表示させるための必要なものです。
export const shuffleArray = (array: any[]) =>
[...array].sort(() => Math.random() - 0.5)
utilesはAPIに持っていきます。
- API.ts
import { shuffleArray } from '../utils/utils'
export type Question = {
category: string
correct_answer: string
difficulty: string
incorrect_answers: string[]
question: string
type: string
}
// 難易度
export enum Difficulty {
EASY = 'easy',
MEDIUM = 'medium',
HARD = 'hard',
}
export type QuestionsState = Question & { answers: string[] }
export const fetchQuizQuestions = async (
amount: number,
difficulty: Difficulty,
): Promise<QuestionsState[]> => {
// amount はクイズの問題を表示する数を入れます。数の設定はindex.tsx でします。
// difficulty は難易度これも設定はindex.tsx でします。
// type=multiple は複数の種類の問題
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,
// ここでshuffleArrayでランダムに問題を表示させます
answers: shuffleArray([
...question.incorrect_answers,
question.correct_answer,
]),
}))
}
Open Trivia DB では難易度、数、問題のタイプなど色んな組み合わせができますAPIで設定できるので、是非自分で設定してみてください。
ではindex.tsxでスタート時の動きを書いていきます
- index.tsx
import { useState } from 'react'
import { fetchQuizQuestions } from '../components/api/API'
:
// types
import { QuestionsState, Difficulty } from '../components/api/API'
export type AnswerObject = {
question: string
answer: string
correct: boolean
correctAnswer: string
}
// クイズの出題数
const TOTAL_QUESTIONS = 10
const Home: NextPage = () => {
const [loading, setLoading] = useState(false)
const [questions, setQuestions] = useState<QuestionsState[]>([])
const [number, setNumber] = useState(0)
const [userAnswers, setUserAnswers] = useState<AnswerObject[]>([])
const [score, setScore] = useState(0)
const [gameOver, setGameOver] = useState(true)
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 nextQuestion = () => {}
const checkAnswer = (e: any) => {}
return (
<div className={styles.container}>
<h1>Next.js quiz</h1>
{gameOver || userAnswers.length === TOTAL_QUESTIONS ? (
<button className={styles.start} onClick={startTrivia}>
start
</button>
) : null}
// !gameOverで間違いたらスコアをカウントしない
{!gameOver ? <p className={styles.score}>Score:{score}</p> : null}
{loading ? <p>Loading Questons...</p> : null}
:
</div>
)
}
startTrivia ではstartボタンを押したときにクイズのデータを持ってきてsetScore
で0から始めsetNumber
でカウントしていきます。最後にsetLoading(false)
にしましょう。
checkAnswer の中身を書いていきます。
const checkAnswer = (e: any) => {
if (!gameOver) {
const answer = e.currentTarget.value
// 正解かの確認をしています
const correct = questions[number].correct_answer === answer
// 答えが正しければスコアを追加
if (correct) setScore((prev) => prev + 1)
// ユーザーの回答を保存します
const answerObject = {
question: questions[number].question,
answer,
correct,
correctAnswer: questions[number].correct_answer,
}
setUserAnswers((prev) => [...prev, answerObject])
}
}
return(
:
:
{!loading && !gameOver && (
<QuestionCard
questionNr={number + 1}
totalQuestions={TOTAL_QUESTIONS}
question={questions[number].question}
answers={questions[number].answers}
userAnswer={userAnswers ? userAnswers[number] : undefined}
callback={checkAnswer}
/>
)}
)
checkAnswer の変数は QuestionCard に渡します。
nextQuestion を書いていきます。
const nextQuestion = () => {
// 10問までNext Questionボタンを押せるようにします。
const nextQ = number + 1
if (nextQ === TOTAL_QUESTIONS) {
setGameOver(true)
} else {
setNumber(nextQ)
}
}
return (
:
:
{!gameOver &&
!loading &&
userAnswers.length === number + 1 &&
number !== TOTAL_QUESTIONS - 1 ? (
<button onClick={nextQuestion}>Next Question</button>
) : null}
)
index.tsx全体のコード
import { useState } from 'react'
import type { NextPage } from 'next'
import { fetchQuizQuestions } from '../components/api/API'
// components
import QuestionCard from 'components/Questions/QuestionCard'
// styles
import styles from 'styles/Home.module.css'
// types
import { QuestionsState, Difficulty } from '../components/api/API'
export type AnswerObject = {
question: string
answer: string
correct: boolean
correctAnswer: string
}
const TOTAL_QUESTIONS = 10
const Home: NextPage = () => {
const [loading, setLoading] = useState(false)
const [questions, setQuestions] = useState<QuestionsState[]>([])
const [number, setNumber] = useState(0)
const [userAnswers, setUserAnswers] = useState<AnswerObject[]>([])
const [score, setScore] = useState(0)
const [gameOver, setGameOver] = useState(true)
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: any) => {
if (!gameOver) {
const answer = e.currentTarget.value
const correct = questions[number].correct_answer === answer
if (correct) setScore((prev) => prev + 1)
const answerObject = {
question: questions[number].question,
answer,
correct,
correctAnswer: questions[number].correct_answer,
}
setUserAnswers((prev) => [...prev, answerObject])
}
}
const nextQuestion = () => {
const nextQ = number + 1
if (nextQ === TOTAL_QUESTIONS) {
setGameOver(true)
} else {
setNumber(nextQ)
}
}
return (
<div className={styles.container}>
<h1>Next.js quiz</h1>
{gameOver || userAnswers.length === TOTAL_QUESTIONS ? (
<button className={styles.start} onClick={startTrivia}>
start
</button>
) : null}
{!gameOver ? <p className={styles.score}>Score:{score}</p> : null}
{loading ? <p>Loading Questons...</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 onClick={nextQuestion}>Next Question</button>
) : null}
</div>
)
}
export default Home
QuestionCard を作れば完成です。
import { AnswerObject } from 'pages/index'
// styles
import styles from 'styles/QuestionCard.module.css'
import { ButtonWrapper } from './styled'
type Props = {
question: string
answers: string[]
callback: (e: React.MouseEvent<HTMLButtonElement>) => void
userAnswer: AnswerObject | undefined
questionNr: number
totalQuestions: number
}
const QuestionCard = ({
question,
answers,
callback,
userAnswer,
questionNr,
totalQuestions,
}: Props) => {
return (
<div className={styles.container}>
<p>
{' '}
Question: {questionNr} / {totalQuestions}
</p>
<p dangerouslySetInnerHTML={{ __html: question }} />
<div>
{answers.map((answer) => (
<ButtonWrapper
key={answer}
correct={userAnswer?.correctAnswer === answer}
userClicked={userAnswer?.answer === answer}
>
<button
disabled={userAnswer ? true : false}
value={answer}
onClick={callback}
>
<span dangerouslySetInnerHTML={{ __html: answer }} />
</button>
</ButtonWrapper>
))}
</div>
</div>
)
}
export default QuestionCard
http://localhost:3000 を開いて確かめてください。