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 styles from './QuizCard.module.css';
import CloseIcon from '/src/assets/icons/close.svg?react'; import CloseIcon from '/src/assets/icons/close.svg?react';
import PlusIcon from '/src/assets/icons/plus.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 }) { export default function QuizCard({ quiz, updateQuiz, deleteQuiz }) {
// TODO:
const [question, setQuestion] = useState(quiz.question || ''); const [question, setQuestion] = useState(quiz.question || '');
const [answer, setAnswer] = useState(Number(quiz.answer) || ''); 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 [image, setImage] = useState(quiz.image || null);
const [imagePreview, setImagePreview] = useState( const [imagePreview, setImagePreview] = useState(quiz.image ? `${STATIC_URL}${quiz.image}` : null);
quiz.image ? `${import.meta.env.VITE_STATIC_URL}${quiz.image}` : null const [quizType, setQuizType] = useState('단답식');
);
const clearImage = () => {
setImage(null);
setImagePreview(null);
};
const handleChoiceChange = (num, content) => { const handleChoiceChange = (num, content) => {
const updatedChoices = choices.map((choice) => (choice.num === num ? { ...choice, content } : choice)); const updatedChoices = choices.map((choice) => (choice.num === num ? { ...choice, content } : choice));
setChoices(updatedChoices); setChoices(updatedChoices);
@ -19,32 +25,36 @@ export default function QuizCard({ quiz, updateQuiz, deleteQuiz }) {
}; };
const handleAddChoice = () => { const handleAddChoice = () => {
if (choices.length < 4) { if (choices.length >= 4) {
const newChoice = { num: choices.length + 1, content: '' }; return;
const updatedChoices = [...choices, newChoice];
setChoices(updatedChoices);
updateQuiz(quiz.id, { ...quiz, question, answer, choices: updatedChoices, image });
} }
const newChoice = { num: choices.length + 1, content: '' };
const updatedChoices = [...choices, newChoice];
setChoices(updatedChoices);
updateQuiz(quiz.id, { ...quiz, question, answer, choices: updatedChoices, image });
}; };
const handlePopChoice = () => { const handlePopChoice = () => {
if (choices.length > 0) { if (choices.length <= 1) {
const updatedChoices = choices.slice(0, -1); return;
setChoices(updatedChoices);
if (updatedChoices.length < answer) {
setAnswer('');
}
updateQuiz(quiz.id, { ...quiz, question, answer, choices: updatedChoices, image });
} }
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 handleFileChange = (e) => {
const file = e.target.files[0] ?? null; const file = e.target.files[0];
if (!file || !file.type.startsWith('image/')) { if (!file) {
return;
}
if (!file.type.startsWith('image/')) {
alert('이미지 파일만 업로드 해주세요'); alert('이미지 파일만 업로드 해주세요');
e.target.value = null; e.target.value = null;
setImage(null); clearImage();
setImagePreview(null);
return; return;
} }
setImage(file); setImage(file);
@ -61,15 +71,31 @@ export default function QuizCard({ quiz, updateQuiz, deleteQuiz }) {
}; };
const handleChoiceSelect = (choiceContent) => { const handleChoiceSelect = (choiceContent) => {
console.log(choiceContent);
setAnswer(choiceContent); setAnswer(choiceContent);
updateQuiz(quiz.id, { ...quiz, question, answer: choiceContent, choices, image }); updateQuiz(quiz.id, { ...quiz, question, answer: choiceContent, choices, image });
}; };
useEffect(() => {
quizType === '단답식' ? setAnswer('') : setAnswer(1);
}, [quizType]);
return ( return (
<div className={styles.card}> <div className={styles.card}>
<div className={styles.header}> <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 <button
className={`${styles.cardRemove}`} className={`${styles.cardRemove}`}
onClick={() => deleteQuiz(quiz.id)} onClick={() => deleteQuiz(quiz.id)}
@ -77,103 +103,103 @@ export default function QuizCard({ quiz, updateQuiz, deleteQuiz }) {
<CloseIcon /> <CloseIcon />
</button> </button>
</div> </div>
<label htmlFor={`file-input-${quiz.id}`}> <div className={styles.content}>
{imagePreview ? ( {imagePreview ? (
<img <div className={styles.imageArea}>
src={imagePreview} <label htmlFor={`file-input-${quiz.id}`}>
alt="Preview" <img
className={styles.imagePreview} src={imagePreview}
/> alt="Preview"
) : ( className={styles.imagePreview}
<div className={styles.imagePreview}> />
<PlusIcon /> </label>
<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) => (
<button <button
type="button" type="button"
key={index + 1} onClick={clearImage}
onClick={() => handleChoiceSelect(index + 1)}
className={`${styles.choiceButton} ${answer === index + 1 ? styles.selected : ''}`}
> >
{index + 1} <CloseIcon />
</button> </button>
))} </div>
</div> ) : (
) : ( <label htmlFor={`file-input-${quiz.id}`}>
<input <div className={styles.imagePreview}>
type="text" <PlusIcon />
value={answer} <span>퀴즈 이미지 추가</span>
maxLength={200} </div>
onChange={(e) => { </label>
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> <div className={styles.answerArea}>
{choices.map?.((choice, idx) => ( <Toggle
<div active={quizType}
className={styles.choiceDiv} setActive={setQuizType}
key={idx} choices={['단답식', '객관식']}
>
<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}`}
/> />
<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>
</div> </div>
); );
} }

View File

@ -1,31 +1,81 @@
.card { .card {
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 16px 12px;
width: 416px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 20px;
border-radius: 8px;
border: 1px solid var(--border-color);
padding: 20px;
} }
.header { .header {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
justify-content: space-between; 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; font-size: 24px;
line-height: 1.2; line-height: 1.2;
font-weight: 700; 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 { .label {
color: var(--text-color); color: var(--text-color);
font-size: 14px; font-size: 12px;
line-height: 1.4; line-height: 1.4;
font-weight: 400; 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 { .imagePreview {
@ -37,7 +87,6 @@
align-items: center; align-items: center;
width: 295px; width: 295px;
height: 220px; height: 220px;
margin: 10px auto;
border-radius: 8px; border-radius: 8px;
background-color: var(--background-secondary); background-color: var(--background-secondary);
object-fit: contain; object-fit: contain;
@ -49,8 +98,15 @@
display: none; display: none;
} }
.answerArea {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.input { .input {
padding: 14px; padding: 12px;
background: var(--background); background: var(--background);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 8px; border-radius: 8px;
@ -69,7 +125,7 @@
.choiceInput { .choiceInput {
flex-grow: 1; flex-grow: 1;
padding: 7px; padding: 12px;
} }
.input::placeholder { .input::placeholder {
@ -77,6 +133,7 @@
} }
.buttonsWrapper { .buttonsWrapper {
align-self: end;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 8px; gap: 8px;
@ -85,27 +142,28 @@
.button { .button {
display: flex; display: flex;
align-items: center; align-items: center;
padding: 12px 16px; padding: 8px 12px;
font-size: 16px; font-size: 16px;
line-height: 1.4; line-height: 1.4;
font-weight: 700; font-weight: 500;
align-self: end; align-self: end;
border-radius: 8px; border-radius: 8px;
cursor: pointer; cursor: pointer;
} }
.add { .add {
border: 1px solid var(--primary-color); border: none;
background-color: var(--primary-color); background-color: var(--primary-color);
color: var(--on-primary); color: var(--on-primary);
stroke: var(--on-primary); stroke: var(--on-primary);
} }
.remove { .remove {
border: 1px solid var(--blue100); /* border: 1px solid var(--blue100); */
background-color: var(--blue100); border: none;
color: var(--info-color); background-color: var(--background-tertiary);
stroke: var(--info-color); color: var(--text-color-);
stroke: var(--text-color-);
} }
.cardRemove { .cardRemove {
@ -119,22 +177,27 @@
.choicesWrapper { .choicesWrapper {
display: flex; display: flex;
flex-wrap: wrap; flex-direction: column;
gap: 8px; gap: 8px;
margin-top: 8px; margin-top: 8px;
} }
.choice {
display: flex;
gap: 8px;
}
.choiceButton { .choiceButton {
padding: 10px 16px; padding: 12px 16px;
background-color: var(--background-secondary); background-color: var(--background-secondary);
border: 1px solid var(--background-tertiary); border: 1px solid var(--background-tertiary);
border-radius: 4px; border-radius: 6px;
cursor: pointer; cursor: pointer;
transition: transition:
background-color 0.25s, background-color 0.1s,
border-color 0.25s, border-color 0.1s,
stroke 0.25s, stroke 0.1s,
color 0.25s; color 0.1s;
} }
.choiceButton:hover { .choiceButton:hover {
@ -152,3 +215,7 @@
border: 1px solid var(--primary-color); border: 1px solid var(--primary-color);
background-color: 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]); }, [initialValue]);
const handleAddQuiz = () => { 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); setQuizId(quizId + 1);
}; };
@ -49,7 +49,7 @@ export default function QuizsetForm({ headerTitle, topic, to, onSubmit, initialV
className={styles.form} className={styles.form}
onSubmit={(e) => onSubmit(e, title, quizzes)} onSubmit={(e) => onSubmit(e, title, quizzes)}
> >
<label className={styles.label}>퀴즈셋 제목</label> <label className={styles.label}>제목</label>
<input <input
className={styles.input} className={styles.input}
type="text" type="text"
@ -58,7 +58,7 @@ export default function QuizsetForm({ headerTitle, topic, to, onSubmit, initialV
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
placeholder="퀴즈셋 제목을 입력해주세요" placeholder="퀴즈셋 제목을 입력해주세요"
/> />
<div className={styles.grid}> <div className={styles.quizList}>
{quizzes.map((quiz) => ( {quizzes.map((quiz) => (
<QuizCard <QuizCard
key={quiz.id} key={quiz.id}

View File

@ -63,18 +63,20 @@
font-weight: 500; font-weight: 500;
} }
.grid { .quizList {
display: grid; /* display: grid;
grid-template-columns: repeat(auto-fill, 440px); grid-template-columns: repeat(auto-fill, 440px); */
display: flex;
flex-direction: column;
gap: 20px; gap: 20px;
justify-content: start; justify-content: start;
margin-bottom: 40px; margin-bottom: 40px;
} }
.addCard { .addCard {
width: 440px; /* width: 440px; */
cursor: pointer; cursor: pointer;
height: 592px; height: 120px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
@ -83,6 +85,7 @@
stroke: var(--text-color); stroke: var(--text-color);
border-radius: 8px; border-radius: 8px;
gap: 10px; gap: 10px;
transition: background-color 0.1s;
} }
.addCard:hover { .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; --whiteOpacity900: #fff;
/* semantic colors */ /* semantic colors */
--primary-color: #05f; --primary-color: #36f;
--accent-color: #f50; --accent-color: #f50;
--background: #fff; --background: #fff;