feat: 퀴즈 생성 카드에 토글 추가

This commit is contained in:
jhynsoo 2024-08-13 13:06:02 +09:00
parent 72aa60cefb
commit bcdb8cb31b
8 changed files with 290 additions and 147 deletions

View File

@ -1,17 +1,23 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import styles from './QuizCard.module.css';
import CloseIcon from '/src/assets/icons/close.svg?react';
import PlusIcon from '/src/assets/icons/plus.svg?react';
import { Toggle } from '../Toggle';
import { STATIC_URL } from '../../constants';
export default function QuizCard({ quiz, updateQuiz, deleteQuiz }) {
// TODO:
const [question, setQuestion] = useState(quiz.question || '');
const [answer, setAnswer] = useState(Number(quiz.answer) || '');
const [choices, setChoices] = useState(quiz.choices || []);
const [choices, setChoices] = useState(quiz.choices || [{ num: 1, content: '' }]);
const [image, setImage] = useState(quiz.image || null);
const [imagePreview, setImagePreview] = useState(
quiz.image ? `${import.meta.env.VITE_STATIC_URL}${quiz.image}` : null
);
const [imagePreview, setImagePreview] = useState(quiz.image ? `${STATIC_URL}${quiz.image}` : null);
const [quizType, setQuizType] = useState('단답식');
const clearImage = () => {
setImage(null);
setImagePreview(null);
};
const handleChoiceChange = (num, content) => {
const updatedChoices = choices.map((choice) => (choice.num === num ? { ...choice, content } : choice));
setChoices(updatedChoices);
@ -19,32 +25,36 @@ export default function QuizCard({ quiz, updateQuiz, deleteQuiz }) {
};
const handleAddChoice = () => {
if (choices.length < 4) {
const newChoice = { num: choices.length + 1, content: '' };
const updatedChoices = [...choices, newChoice];
setChoices(updatedChoices);
updateQuiz(quiz.id, { ...quiz, question, answer, choices: updatedChoices, image });
if (choices.length >= 4) {
return;
}
const newChoice = { num: choices.length + 1, content: '' };
const updatedChoices = [...choices, newChoice];
setChoices(updatedChoices);
updateQuiz(quiz.id, { ...quiz, question, answer, choices: updatedChoices, image });
};
const handlePopChoice = () => {
if (choices.length > 0) {
const updatedChoices = choices.slice(0, -1);
setChoices(updatedChoices);
if (updatedChoices.length < answer) {
setAnswer('');
}
updateQuiz(quiz.id, { ...quiz, question, answer, choices: updatedChoices, image });
if (choices.length <= 1) {
return;
}
const updatedChoices = choices.slice(0, -1);
setChoices(updatedChoices);
if (updatedChoices.length < answer) {
setAnswer(1);
}
updateQuiz(quiz.id, { ...quiz, question, answer, choices: updatedChoices, image });
};
const handleFileChange = (e) => {
const file = e.target.files[0] ?? null;
if (!file || !file.type.startsWith('image/')) {
const file = e.target.files[0];
if (!file) {
return;
}
if (!file.type.startsWith('image/')) {
alert('이미지 파일만 업로드 해주세요');
e.target.value = null;
setImage(null);
setImagePreview(null);
clearImage();
return;
}
setImage(file);
@ -61,15 +71,31 @@ export default function QuizCard({ quiz, updateQuiz, deleteQuiz }) {
};
const handleChoiceSelect = (choiceContent) => {
console.log(choiceContent);
setAnswer(choiceContent);
updateQuiz(quiz.id, { ...quiz, question, answer: choiceContent, choices, image });
};
useEffect(() => {
quizType === '단답식' ? setAnswer('') : setAnswer(1);
}, [quizType]);
return (
<div className={styles.card}>
<div className={styles.header}>
<span className={styles.heading}>퀴즈 생성 카드</span>
<div className={styles.titleGroup}>
<span>Q.</span>
<input
type="text"
value={question}
maxLength={200}
autoFocus
onChange={(e) => {
setQuestion(e.target.value);
updateQuiz(quiz.id, { ...quiz, question: e.target.value, answer, choices, image });
}}
placeholder="질문 내용을 입력하세요"
/>
</div>
<button
className={`${styles.cardRemove}`}
onClick={() => deleteQuiz(quiz.id)}
@ -77,103 +103,103 @@ export default function QuizCard({ quiz, updateQuiz, deleteQuiz }) {
<CloseIcon />
</button>
</div>
<label htmlFor={`file-input-${quiz.id}`}>
<div className={styles.content}>
{imagePreview ? (
<img
src={imagePreview}
alt="Preview"
className={styles.imagePreview}
/>
) : (
<div className={styles.imagePreview}>
<PlusIcon />
<span>퀴즈 이미지 추가</span>
</div>
)}
</label>
<input
id={`file-input-${quiz.id}`}
type="file"
accept="image/*"
onChange={handleFileChange}
className={styles.hiddenInput}
/>
<label className={styles.label}>질문</label>
<input
type="text"
value={question}
maxLength={200}
onChange={(e) => {
setQuestion(e.target.value);
updateQuiz(quiz.id, { ...quiz, question: e.target.value, answer, choices, image });
}}
className={styles.input}
placeholder="질문 내용을 입력하세요"
/>
<label className={styles.label}>정답</label>
{choices.length > 0 ? (
<div className={styles.choicesWrapper}>
{choices.map((choice, index) => (
<div className={styles.imageArea}>
<label htmlFor={`file-input-${quiz.id}`}>
<img
src={imagePreview}
alt="Preview"
className={styles.imagePreview}
/>
</label>
<button
type="button"
key={index + 1}
onClick={() => handleChoiceSelect(index + 1)}
className={`${styles.choiceButton} ${answer === index + 1 ? styles.selected : ''}`}
onClick={clearImage}
>
{index + 1}
<CloseIcon />
</button>
))}
</div>
) : (
<input
type="text"
value={answer}
maxLength={200}
onChange={(e) => {
setAnswer(e.target.value);
updateQuiz(quiz.id, { ...quiz, question, answer: e.target.value, choices, image });
}}
className={styles.input}
placeholder="정답을 입력하세요"
/>
)}
<div>
<span>Tip: 선택지를 넣지 않는다면 단답형 문제가 됩니다</span>
</div>
<div className={styles.buttonsWrapper}>
<button
type="button"
onClick={handleAddChoice}
className={`${styles.button} ${styles.add}`}
>
선택지 추가하기
</button>
{choices.length > 0 && (
<button
type="button"
onClick={handlePopChoice}
className={`${styles.button} ${styles.remove}`}
>
선택지 줄이기
</button>
</div>
) : (
<label htmlFor={`file-input-${quiz.id}`}>
<div className={styles.imagePreview}>
<PlusIcon />
<span>퀴즈 이미지 추가</span>
</div>
</label>
)}
</div>
{choices.map?.((choice, idx) => (
<div
className={styles.choiceDiv}
key={idx}
>
<label>선택지 {choice.num} </label>
<input
className={`${styles.input} ${styles.choiceInput}`}
type="text"
maxLength={200}
value={choice.content}
onChange={(e) => handleChoiceChange(choice.num, e.target.value)}
placeholder={`Choice ${choice.num}`}
<div className={styles.answerArea}>
<Toggle
active={quizType}
setActive={setQuizType}
choices={['단답식', '객관식']}
/>
<input
id={`file-input-${quiz.id}`}
type="file"
accept="image/*"
onChange={handleFileChange}
className={styles.hiddenInput}
/>
<div className={styles.choicesWrapper}>
<div className={styles.label}>정답</div>
{quizType === '객관식' ? (
<>
{choices.map((choice, index) => (
<div
key={index}
className={styles.choice}
>
<button
type="button"
onClick={() => handleChoiceSelect(index + 1)}
className={`${styles.choiceButton} ${answer === index + 1 ? styles.selected : ''}`}
>
{index + 1}
</button>
<input
className={`${styles.input} ${styles.choiceInput}`}
type="text"
maxLength={200}
value={choice.content}
onChange={(e) => handleChoiceChange(choice.num, e.target.value)}
placeholder={`보기 ${choice.num}`}
/>
</div>
))}
<div className={styles.buttonsWrapper}>
<button
type="button"
onClick={handlePopChoice}
className={`${styles.button} ${styles.remove} ${choices.length <= 1 ? styles.hidden : ''}`}
>
보기 줄이기
</button>
<button
type="button"
onClick={handleAddChoice}
className={`${styles.button} ${styles.add} ${choices.length >= 4 ? styles.hidden : ''}`}
>
보기 추가
</button>
</div>
</>
) : (
<input
type="text"
value={answer}
maxLength={200}
onChange={(e) => {
setAnswer(e.target.value);
updateQuiz(quiz.id, { ...quiz, question, answer: e.target.value, choices, image });
}}
className={styles.input}
placeholder="정답을 입력하세요"
/>
)}
</div>
</div>
))}
</div>
</div>
);
}

View File

@ -1,31 +1,81 @@
.card {
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 16px 12px;
width: 416px;
display: flex;
flex-direction: column;
gap: 8px;
gap: 20px;
border-radius: 8px;
border: 1px solid var(--border-color);
padding: 20px;
}
.header {
display: flex;
flex-direction: row;
justify-content: space-between;
gap: 20px;
}
.heading {
.titleGroup {
flex-grow: 1;
display: flex;
align-items: center;
gap: 8px;
color: var(--text-color);
font-size: 24px;
line-height: 1.2;
font-weight: 700;
& > input {
flex-grow: 1;
border: 0;
border-radius: 8px;
padding: 8px;
font-size: 24px;
line-height: 1.2;
font-weight: 700;
&::placeholder {
color: var(--text-color-tertiary);
}
}
}
.content {
display: flex;
gap: 20px;
align-items: start;
}
.label {
color: var(--text-color);
font-size: 14px;
font-size: 12px;
line-height: 1.4;
font-weight: 400;
margin-bottom: 4px;
}
.imageArea {
position: relative;
& > button {
display: flex;
align-items: center;
position: absolute;
top: 4px;
right: 4px;
padding: 4px;
background-color: transparent;
stroke: var(--error-color);
border: none;
border-radius: 8px;
cursor: pointer;
transition:
background-color 0.1s,
stroke 0.1s;
&:hover {
background-color: var(--error-color);
stroke: var(--on-primary);
}
}
}
.imagePreview {
@ -37,7 +87,6 @@
align-items: center;
width: 295px;
height: 220px;
margin: 10px auto;
border-radius: 8px;
background-color: var(--background-secondary);
object-fit: contain;
@ -49,8 +98,15 @@
display: none;
}
.answerArea {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.input {
padding: 14px;
padding: 12px;
background: var(--background);
border: 1px solid var(--border-color);
border-radius: 8px;
@ -69,7 +125,7 @@
.choiceInput {
flex-grow: 1;
padding: 7px;
padding: 12px;
}
.input::placeholder {
@ -77,6 +133,7 @@
}
.buttonsWrapper {
align-self: end;
display: flex;
flex-direction: row;
gap: 8px;
@ -85,27 +142,28 @@
.button {
display: flex;
align-items: center;
padding: 12px 16px;
padding: 8px 12px;
font-size: 16px;
line-height: 1.4;
font-weight: 700;
font-weight: 500;
align-self: end;
border-radius: 8px;
cursor: pointer;
}
.add {
border: 1px solid var(--primary-color);
border: none;
background-color: var(--primary-color);
color: var(--on-primary);
stroke: var(--on-primary);
}
.remove {
border: 1px solid var(--blue100);
background-color: var(--blue100);
color: var(--info-color);
stroke: var(--info-color);
/* border: 1px solid var(--blue100); */
border: none;
background-color: var(--background-tertiary);
color: var(--text-color-);
stroke: var(--text-color-);
}
.cardRemove {
@ -119,22 +177,27 @@
.choicesWrapper {
display: flex;
flex-wrap: wrap;
flex-direction: column;
gap: 8px;
margin-top: 8px;
}
.choice {
display: flex;
gap: 8px;
}
.choiceButton {
padding: 10px 16px;
padding: 12px 16px;
background-color: var(--background-secondary);
border: 1px solid var(--background-tertiary);
border-radius: 4px;
border-radius: 6px;
cursor: pointer;
transition:
background-color 0.25s,
border-color 0.25s,
stroke 0.25s,
color 0.25s;
background-color 0.1s,
border-color 0.1s,
stroke 0.1s,
color 0.1s;
}
.choiceButton:hover {
@ -152,3 +215,7 @@
border: 1px solid var(--primary-color);
background-color: var(--primary-color);
}
.hidden {
display: none;
}

View File

@ -20,7 +20,7 @@ export default function QuizsetForm({ headerTitle, topic, to, onSubmit, initialV
}, [initialValue]);
const handleAddQuiz = () => {
setQuizzes([...quizzes, { id: quizId, question: '', answer: '', choices: [], image: null }]);
setQuizzes([...quizzes, { id: quizId, question: '', answer: '', choices: [{ num: 1, content: '' }], image: null }]);
setQuizId(quizId + 1);
};
@ -49,7 +49,7 @@ export default function QuizsetForm({ headerTitle, topic, to, onSubmit, initialV
className={styles.form}
onSubmit={(e) => onSubmit(e, title, quizzes)}
>
<label className={styles.label}>퀴즈셋 제목</label>
<label className={styles.label}>제목</label>
<input
className={styles.input}
type="text"
@ -58,7 +58,7 @@ export default function QuizsetForm({ headerTitle, topic, to, onSubmit, initialV
onChange={(e) => setTitle(e.target.value)}
placeholder="퀴즈셋 제목을 입력해주세요"
/>
<div className={styles.grid}>
<div className={styles.quizList}>
{quizzes.map((quiz) => (
<QuizCard
key={quiz.id}

View File

@ -63,18 +63,20 @@
font-weight: 500;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, 440px);
.quizList {
/* display: grid;
grid-template-columns: repeat(auto-fill, 440px); */
display: flex;
flex-direction: column;
gap: 20px;
justify-content: start;
margin-bottom: 40px;
}
.addCard {
width: 440px;
/* width: 440px; */
cursor: pointer;
height: 592px;
height: 120px;
display: flex;
flex-direction: column;
justify-content: center;
@ -83,6 +85,7 @@
stroke: var(--text-color);
border-radius: 8px;
gap: 10px;
transition: background-color 0.1s;
}
.addCard:hover {

View File

@ -0,0 +1,18 @@
import styles from './Toggle.module.css';
export default function Toggle({ choices, active, setActive }) {
return (
<div className={styles.toggle}>
{choices?.map((choice) => (
<button
key={choice}
type="button"
className={choice === active ? styles.active : ''}
onClick={() => setActive(choice)}
>
{choice}
</button>
))}
</div>
);
}

View File

@ -0,0 +1,28 @@
.toggle {
display: flex;
justify-content: stretch;
align-items: center;
gap: 8px;
padding: 8px 16px;
border-radius: 8px;
border: 1px solid var(--border-color);
background-color: var(--background);
& > button {
border: none;
background-color: var(--background);
width: 100%;
border-radius: 8px;
padding: 8px 16px;
color: var(--text-color);
font-size: 16px;
line-height: 1.4;
font-weight: 500;
cursor: pointer;
&.active {
color: var(--on-primary);
background-color: var(--primary-color);
}
}
}

View File

@ -0,0 +1 @@
export { default as Toggle } from './Toggle';

View File

@ -112,7 +112,7 @@
--whiteOpacity900: #fff;
/* semantic colors */
--primary-color: #05f;
--primary-color: #36f;
--accent-color: #f50;
--background: #fff;