Skip to content

Commit

Permalink
문제 페이지 작업하기 (#21)
Browse files Browse the repository at this point in the history
* chore: 마크다운 관련 패키지 설치 및 설정

* chore: 새로운 테이블로부터 Supabase 타입 생성

* feat: 퀴즈 상세 데이터 가져오기

* feat: choice-form 작성

* feat: choice-form mutate 작성

* chore: zod 설치

* feat: 제출 처리하는 라우트 핸들러 작성

* chore: Supabase 제네릭 타입 전역적으로 설정

* feat: 제출 처리 화면 로직 작성

* feat: LoadingSpinner 컴포넌트 작성

* chore: Supabase 데이터베이스 타입 생성

* feat: 풀이 성공시 disalbed 처리

* refactor: answerChoice의 여러 eq를 하나의 match로 통합

* fix: getQuiz 함수에 조인 multiple choice 문제 해결

* style: errorMessage 조건부 렌더링에 && 사용

* chore: 마크다운 영역에만 non-reset css 적용

* chore: 데이터베이스 타입 업데이트

* style: 데이터 패칭 병렬 처리

* refactor: join 쿼리 수정
  • Loading branch information
bbearcookie authored Dec 21, 2023
1 parent 7bbe2ec commit 93f9645
Show file tree
Hide file tree
Showing 12 changed files with 6,449 additions and 3,174 deletions.
57 changes: 57 additions & 0 deletions app/api/quiz-submission/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { cookies } from 'next/headers';
import { createClient } from '@/utils/supabase/server';
import { NextRequest } from 'next/server';
import { z } from 'zod';

export async function POST(request: NextRequest) {
const cookieStore = cookies();
const supabase = createClient(cookieStore);

const validateBody = await request.json().then((body) =>
z
.object({
quizId: z.number(),
choiceId: z.number(),
})
.safeParse(body)
);

if (!validateBody.success) {
return Response.json(
{ error: '필드가 올바르지 않습니다.' },
{ status: 400 }
);
}

const { quizId, choiceId } = validateBody.data;

const {
data: { user },
} = await supabase.auth.getUser();

if (!user) {
return Response.json({ error: '로그인이 필요합니다.' }, { status: 401 });
}

const { data: answerChoice } = await supabase
.from('choices')
.select(`*`)
.match({ quiz_id: quizId, answer: true })
.single();

await supabase.from('quizsubmissions').upsert({
user_id: user?.id,
quiz_id: quizId,
success: choiceId === answerChoice?.id,
updated_at: new Date().toISOString(),
});

if (choiceId !== answerChoice?.id) {
return Response.json(
{ error: '틀렸어요! 다시 고민해주세요.' },
{ status: 400 }
);
}

return Response.json({ data: '정답!' });
}
70 changes: 70 additions & 0 deletions app/quizzes/[id]/choice-form.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
'use client';

import React, { useState } from 'react';
import { useGetChoicesOfQuiz, postQuizSubmission } from '@/hooks/quiz';
import { useMutation } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import Button from '@/components/common/buttons/button';
import LoadingSpinner from '@/components/common/loading-spinner/loading-spinner';

export default function ChoiceForm({ quizId }: { quizId: number }) {
const { data: choices } = useGetChoicesOfQuiz(quizId);
const [errorMessage, setErrorMessage] = useState('');
const router = useRouter();

const { mutate, isPending, isSuccess } = useMutation({
mutationFn: postQuizSubmission,
onSuccess: () => router.push(`/quizzes/${quizId}/answer`),
onError: (error) => setErrorMessage(error.message),
});

const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();

const formData = new FormData(e.currentTarget);

const choice = choices?.find(
(choice) => choice.id === Number(formData.get('choice'))
);

if (!choice) {
setErrorMessage('선택지를 선택해주세요.');
return;
}

mutate({
quizId,
choiceId: choice.id,
});
};

return (
<form onSubmit={handleSubmit}>
{choices?.map((choice) => (
<label className="flex gap-2" key={choice.id} htmlFor={`${choice.id}`}>
<input
id={`${choice.id}`}
value={`${choice.id}`}
type="radio"
name="choice"
onChange={() => setErrorMessage('')}
/>
<p className="my-1.5">{choice.description}</p>
</label>
))}
{errorMessage && <p className="my-2 text-red-500">{errorMessage}</p>}
<Button
className="mt-4 flex h-10 w-full items-center justify-center disabled:bg-blue-500"
disabled={isPending || isSuccess}
>
{isSuccess ? (
'성공!'
) : isPending ? (
<LoadingSpinner size="lg" weight="sm" />
) : (
'제출하기'
)}
</Button>
</form>
);
}
72 changes: 62 additions & 10 deletions app/quizzes/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,74 @@ import {
QueryClient,
dehydrate,
} from '@tanstack/react-query';
import { Quiz } from './quiz';
import { getQuiz } from '@/hooks/quiz';
import { getQuiz, getChoicesOfQuiz } from '@/hooks/quiz';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { dracula } from 'react-syntax-highlighter/dist/cjs/styles/prism';
import Markdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import Link from 'next/link';
import ChoiceForm from './choice-form';

export default async function Page() {
export default async function Page({ params }: { params: { id: string } }) {
const queryClient = new QueryClient();
const quizId = Number(params.id) ?? 0;

const quiz = await queryClient.fetchQuery({
queryKey: ['quiz'],
queryFn: getQuiz,
});
const [quiz] = await Promise.all([
queryClient.fetchQuery({
queryKey: ['quiz', quizId],
queryFn: () => getQuiz(quizId),
}),
queryClient.fetchQuery({
queryKey: ['quiz', quizId, 'choices'],
queryFn: () => getChoicesOfQuiz(quizId),
}),
]);

return (
<HydrationBoundary state={dehydrate(queryClient)}>
<div>나는 서버 컴포넌트에서 불러온 값!</div>
<div>{JSON.stringify(quiz)}</div>
<Quiz />
<div className="p-4">
<section className="mb-10">
<h2 className="text-2xl font-bold">출제자</h2>
{/* TODO: 추후 상세 유저 페이지 라우팅 경로로 변경하기 */}
<Link className="underline" href={`/users/${quiz?.users?.id}`}>
{quiz?.users?.name}
</Link>
</section>

<section className="mb-10">
<h2 className="text-2xl font-bold">문제</h2>
<Markdown
className="prose"
remarkPlugins={[remarkGfm]}
components={{
code({ className, children, ...rest }) {
const match = /language-(\w+)/.exec(className || '');
return match ? (
<SyntaxHighlighter
{...rest}
PreTag="div"
language={match[1]}
style={dracula}
ref={null}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<code {...rest} className={className}>
{children}
</code>
);
},
}}
>
{quiz?.description}
</Markdown>
</section>

<section className="mb-10">
<ChoiceForm quizId={quizId} />
</section>
</div>
</HydrationBoundary>
);
}
5 changes: 2 additions & 3 deletions app/quizzes/[id]/quiz.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@

import { useGetQuiz } from '@/hooks/quiz';

export function Quiz() {
const { data: quiz } = useGetQuiz();
export function Quiz({ id }: { id: number }) {
const { data: quiz } = useGetQuiz(id);

console.log(quiz);
return (
<div>
<div>나는 클라이언트 컴포넌트에서 불러온 값!</div>
Expand Down
43 changes: 43 additions & 0 deletions components/common/loading-spinner/loading-spinner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { cva, type VariantProps } from 'class-variance-authority';
import { forwardRef, type Ref } from 'react';
import { twMerge } from 'tailwind-merge';

type LoadingSpinnerProps = React.HTMLAttributes<HTMLElement> &
VariantProps<typeof loadingSpinner>;

const loadingSpinner = cva(
[
'animate-spin rounded-full border-solid border-current border-t-transparent',
],
{
variants: {
size: {
sm: 'h-2 w-2',
md: 'h-4 w-4',
lg: 'h-6 w-6',
xl: 'h-8 w-8',
'2xl': 'h-12 w-12',
'3xl': 'h-16 w-16',
},
weight: {
sm: 'border-2',
md: 'border-4',
lg: 'border-8',
},
},
defaultVariants: { size: 'md', weight: 'sm' },
}
);

export default forwardRef(function LoadingSpinner(
{ size, weight, className, ...props }: LoadingSpinnerProps,
forwardedRef: Ref<HTMLDivElement>
) {
return (
<div
ref={forwardedRef}
className={twMerge(loadingSpinner({ size, weight, className }))}
{...props}
/>
);
});
56 changes: 51 additions & 5 deletions hooks/quiz.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,63 @@ import { createClient } from '@/utils/supabase/client';
import { SupabaseClient } from '@supabase/supabase-js';
import { useSuspenseQuery } from '@tanstack/react-query';

export async function getQuiz() {
export async function getQuiz(id: number) {
const supabase: SupabaseClient<Database> = createClient();

const { data } = await supabase.from('quizzes').select('*');
const { data } = await supabase
.from('quizzes')
.select(`*, users!quizzes_user_id_fkey(id, name)`)
.eq('id', id)
.limit(1)
.single();

return data;
}

export function useGetQuiz() {
export async function getChoicesOfQuiz(quizId: number) {
const supabase: SupabaseClient<Database> = createClient();

const { data } = await supabase
.from('choices')
.select(`id, quiz_id, description`)
.eq('quiz_id', quizId);

return data;
}

export async function postQuizSubmission(params: {
quizId: number;
choiceId: number;
}) {
const { quizId, choiceId } = params;

const res = await fetch('/api/quiz-submission', {
method: 'POST',
body: JSON.stringify({
quizId,
choiceId,
}),
});

const json = await res.json();

if (!res.ok) {
throw new Error(json.error);
}

return json;
}

export function useGetQuiz(id: number) {
return useSuspenseQuery({
queryKey: ['quiz', id],
queryFn: () => getQuiz(id),
});
}

export function useGetChoicesOfQuiz(quizId: number) {
return useSuspenseQuery({
queryKey: ['quiz'],
queryFn: getQuiz,
queryKey: ['quiz', quizId, 'choices'],
queryFn: () => getChoicesOfQuiz(quizId),
});
}
Loading

0 comments on commit 93f9645

Please sign in to comment.