feat: Qna와 Answer 수정 삭제 생성 기능 추가

This commit is contained in:
minwucho 2024-08-02 16:26:36 +09:00
parent ebf5b15daa
commit bdcd01acb7
20 changed files with 560 additions and 24 deletions

View File

@ -25,6 +25,7 @@ const MyInfoChangePage = lazy(async () => await import('./pages/MyInfoChangePage
const PasswordChangePage = lazy(async () => await import('./pages/PasswordChangePage')); const PasswordChangePage = lazy(async () => await import('./pages/PasswordChangePage'));
const LearningLecturesPage = lazy(async () => await import('./pages/LearningLecturesPage')); const LearningLecturesPage = lazy(async () => await import('./pages/LearningLecturesPage'));
const LectureCreatePage = lazy(async () => await import('./pages/LectureCreatePage')); const LectureCreatePage = lazy(async () => await import('./pages/LectureCreatePage'));
const EditQuestionPage = lazy(async () => await import('./pages/EditQuestionPage'));
const router = createBrowserRouter([ const router = createBrowserRouter([
{ {
@ -97,7 +98,20 @@ const router = createBrowserRouter([
}, },
{ {
path: ':questionId', path: ':questionId',
element: <QuestionDetailPage />, children: [
{
index: true,
element: <QuestionDetailPage />,
},
{
path: 'edit',
element: <EditQuestionPage />,
},
],
},
{
path: ':questionId/edit',
element: <EditQuestionPage />,
}, },
{ {
path: 'write', path: 'write',

View File

@ -2,29 +2,81 @@ import BackIcon from '/src/assets/icons/back.svg?react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import styles from './ArticleDetail.module.css'; import styles from './ArticleDetail.module.css';
import ArticleDetailAnswer from './ArticleDetailAnswer/ArticleDetailAnswer'; import ArticleDetailAnswer from './ArticleDetailAnswer/ArticleDetailAnswer';
import ArticleDetailAnswerInput from './ArticleDetailAnswer/ArticleDetailAnswerInput';
import EditIcon from '/src/assets/icons/edit.svg?react';
import { useState, useEffect } from 'react';
export default function ArticleDetail({ topic, title, author = null, content, answer = null }) { export default function ArticleDetail({ topic, title, author = null, content, answer = null, onDelete }) {
// TODO: const [submittedAnswer, setSubmittedAnswer] = useState(answer);
const [isEditing, setIsEditing] = useState(false);
useEffect(() => {
setSubmittedAnswer(answer);
}, [answer]);
const handleAnswerSubmit = (newAnswer) => {
setSubmittedAnswer(newAnswer);
setIsEditing(false);
};
const handleEditClick = () => {
setIsEditing(true);
};
const handleDeleteSubmit = () => {
setSubmittedAnswer(null);
setIsEditing(false);
};
return ( return (
<div className={styles.articleDetail}> <div className={styles.articleDetail}>
<header className={styles.header}> <header className={styles.header}>
<Link <div className={styles.headerInside}>
to={'..'} <Link
className={styles.goBack} to={'..'}
> className={styles.goBack}
<BackIcon /> >
<span>{topic}</span> <BackIcon />
</Link> <span>{topic}</span>
<div> </Link>
<h1 className={styles.title}>{title}</h1> <div>
{author && <span className={styles.author}>{author}</span>} <h1 className={styles.title}>{title}</h1>
{author && <span className={styles.author}>{author}</span>}
</div>
</div> </div>
<Link
type="button"
className={styles.editButton}
to={'edit'}
state={{ title: title, content: content, answer: answer }}
>
<EditIcon className={styles.icon} />
<span>수정하기</span>
</Link>
<button
type="button"
className={styles.deleteButton}
onClick={onDelete}
>
삭제하기
</button>
</header> </header>
<div> <div>
<p className={styles.content}>{content}</p> <p className={styles.content}>{content}</p>
</div> </div>
{answer && <ArticleDetailAnswer answer={answer} />} {/* TODO: 이 부분에서 answer 만든다음 뒤로가기로 나갔다가 돌아오면 0.1초 정도 input 칸이 보였다가 answer 로 바뀜. 수정필요 */}
{submittedAnswer && !isEditing ? (
<ArticleDetailAnswer
answer={submittedAnswer}
onEditClick={handleEditClick}
onDeleteSubmit={handleDeleteSubmit}
/>
) : (
<ArticleDetailAnswerInput
onSubmit={handleAnswerSubmit}
initialAnswer={submittedAnswer}
/>
)}
</div> </div>
); );
} }

View File

@ -11,6 +11,12 @@
} }
.header { .header {
display: flex;
justify-content: space-between;
align-items: start;
}
.headerInside {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: start; align-items: start;
@ -49,3 +55,39 @@
margin: 0; margin: 0;
white-space: pre-line; white-space: pre-line;
} }
.icon {
stroke: var(--text-color);
}
.editButton {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
background: var(--background);
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 14px;
line-height: 1.4;
font-weight: 700;
color: var(--text-color);
cursor: pointer;
}
.deleteButton {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border: 1px solid var(--error-color);
background-color: var(--error-color);
color: var(--on-primary);
stroke: var(--error-color);
font-size: 16px;
line-height: 1.4;
font-weight: 700;
align-self: end;
border-radius: 8px;
cursor: pointer;
}

View File

@ -1,14 +1,42 @@
import styles from './ArticleDetailAnswer.module.css'; import styles from './ArticleDetailAnswer.module.css';
import ReplyIcon from '/src/assets/icons/reply.svg?react'; import ReplyIcon from '/src/assets/icons/reply.svg?react';
import { useParams } from 'react-router-dom';
import { useAnswerDelete } from '../../../../hooks/api/useAnswerDelete';
export default function ArticleDetailAnswer({ answer, onEditClick, onDeleteSubmit }) {
const { questionId } = useParams();
const { answerDelete } = useAnswerDelete();
const handleDeleteSubmit = async (e) => {
e.preventDefault();
await answerDelete(questionId);
onDeleteSubmit();
};
export default function ArticleDetailAnswer({ answer }) {
return ( return (
<section className={styles.answer}> <>
<div className={styles.answerHeader}> <section className={styles.answer}>
<ReplyIcon /> <div className={styles.answerHeader}>
<div className={styles.author}>선생님의 답변</div> <ReplyIcon />
</div> <div className={styles.author}>선생님의 답변</div>
<p className={styles.content}>{answer}</p> </div>
</section> <p className={styles.content}>{answer}</p>
<button
type="button"
className={styles.deleteButton}
onClick={handleDeleteSubmit}
>
<div>삭제</div>
</button>
<button
type="button"
className={styles.editButton}
onClick={onEditClick}
>
수정
</button>
</section>
</>
); );
} }

View File

@ -29,3 +29,37 @@
margin: 0; margin: 0;
color: var(--text-color); color: var(--text-color);
} }
.editButton {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border: 1px solid var(--primary-color);
background-color: var(--primary-color);
color: var(--on-primary);
stroke: var(--on-primary);
font-size: 16px;
line-height: 1.4;
font-weight: 700;
align-self: end;
border-radius: 8px;
cursor: pointer;
}
.deleteButton {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border: 1px solid var(--error-color);
background-color: var(--error-color);
color: var(--on-primary);
stroke: var(--on-primary);
font-size: 16px;
line-height: 1.4;
font-weight: 700;
align-self: end;
border-radius: 8px;
cursor: pointer;
}

View File

@ -0,0 +1,38 @@
import styles from './ArticleDetailAnswerInput.module.css';
import { useAnswerWrite } from '../../../../hooks/api/useAnswerWrite';
import { useParams } from 'react-router-dom';
import { useState } from 'react';
export default function ArticleDetailAnswerInput({ onSubmit, initialAnswer }) {
const { answerWrite } = useAnswerWrite();
const { questionId } = useParams();
const [newAnswer, setNewAnswer] = useState(initialAnswer);
const handleSubmit = async (e) => {
e.preventDefault();
await answerWrite(questionId, newAnswer);
onSubmit(newAnswer);
};
return (
<form
onSubmit={handleSubmit}
className={styles.answer}
>
{/* TODO: 여기 css 부분은 내가 임의로 넣었음 */}
<input
type="text"
value={newAnswer}
onChange={(e) => setNewAnswer(e.target.value)}
placeholder="답변 작성"
className={styles.input}
/>
<button
type="submit"
className={styles.button}
>
작성
</button>
</form>
);
}

View File

@ -0,0 +1,30 @@
.answer {
border: 1px solid #ccc;
padding: 16px;
border-radius: 8px;
display: flex;
align-items: center;
gap: 8px;
}
.input {
flex: 1;
border: none;
padding: 8px;
font-size: 16px;
border-radius: 4px;
outline: none;
}
.button {
padding: 8px 16px;
font-size: 16px;
line-height: 1.4;
font-weight: 700;
background-color: var(--primary-color);
color: var(--on-primary);
stroke: var(--on-primary);
border: 1px solid var(--primary-color);
border-radius: 8px;
cursor: pointer;
}

View File

@ -0,0 +1,64 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import styles from './EditQna.module.css';
import EditIcon from '/src/assets/icons/edit.svg?react';
import BackIcon from '/src/assets/icons/back.svg?react';
export default function EditQna({ topic, title, prevContent, prevTitle, onSubmit }) {
const [articleTitle, setArticleTitle] = useState(prevTitle);
const [articleContent, setArticleContent] = useState(prevContent);
const handleInput = (e) => {
setArticleContent(e.target.value);
e.target.style.height = 'auto';
e.target.style.height = e.target.scrollHeight + 'px';
};
return (
<div className={styles.createArticle}>
<header className={styles.header}>
<Link
to={'..'}
className={styles.goBack}
>
<BackIcon />
<span>{title}</span>
</Link>
<div className={styles.title}>{topic}</div>
</header>
<form
className={styles.formWrapper}
onSubmit={(e) => onSubmit(e, articleTitle, articleContent)}
>
<div className={styles.fieldWrapper}>
<label className={styles.label}>제목</label>
<input
type="text"
className={styles.titleInput}
placeholder={'제목을 입력하세요'}
value={articleTitle}
onChange={(e) => setArticleTitle(e.target.value)}
/>
</div>
<div className={styles.fieldWrapper}>
<label className={styles.label}>내용</label>
<textarea
className={styles.contentInput}
placeholder="내용을 입력하세요"
value={articleContent}
onChange={handleInput}
></textarea>
</div>
<button
type="button"
className={styles.button}
onClick={(e) => onSubmit(e, articleTitle, articleContent)}
disabled={!articleTitle || !articleContent}
>
<EditIcon />
<div> 수정하기</div>
</button>
</form>
</div>
);
}

View File

@ -0,0 +1,110 @@
.createArticle {
width: 100%;
}
.header {
display: flex;
flex-direction: column;
align-items: start;
gap: 8px;
}
.goBack {
display: flex;
align-items: center;
gap: 4px;
font-size: 20px;
line-height: 1.2;
font-weight: 400;
color: var(--text-color-secondary);
stroke: var(--text-color-secondary);
}
.title {
font-size: 32px;
line-height: 1.2;
font-weight: 800;
}
.formWrapper {
display: flex;
flex-direction: column;
margin-top: 20px;
}
.fieldWrapper {
display: flex;
flex-direction: column;
flex-wrap: nowrap;
margin-bottom: 20px;
}
.label {
color: var(--text-color);
font-size: 16px;
line-height: 1.4;
font-weight: 400;
margin-bottom: 8px;
}
.titleInput {
padding: 20px;
background: var(--background);
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 20px;
line-height: 1.2;
font-weight: 500;
}
.titleInput::placeholder {
color: var(--text-color-tertiary);
}
.contentInput {
padding: 20px;
height: 80px;
box-sizing: border-box;
overflow: hidden;
background: var(--background-color);
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 16px;
line-height: 1.4;
font-weight: 400;
}
.contentInput::placeholder {
color: var(--text-color-tertiary);
}
.button {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
border: 1px solid var(--primary-color);
background-color: var(--primary-color);
color: var(--on-primary);
stroke: var(--on-primary);
font-size: 16px;
line-height: 1.4;
font-weight: 700;
align-self: end;
border-radius: 8px;
cursor: pointer;
transition:
background-color 0.25s,
border-color 0.25s,
stroke 0.25s,
color 0.25s;
}
.button:disabled,
.button[disabled] {
border-color: var(--border-color);
background-color: var(--background-tertiary);
color: var(--text-color-tertiary);
cursor: not-allowed;
stroke: var(--text-color-tertiary);
}

View File

@ -3,3 +3,5 @@ export { default as ArticleDetailAnswer } from './ArticleDetail/ArticleDetailAns
export { default as CreateArticle } from './CreateArticle/CreateArticle.jsx'; export { default as CreateArticle } from './CreateArticle/CreateArticle.jsx';
export { default as ArticlePreview } from './ArticlePreview/ArticlePreview.jsx'; export { default as ArticlePreview } from './ArticlePreview/ArticlePreview.jsx';
export { default as EditArticle } from './EditArticle/EditArticle.jsx'; export { default as EditArticle } from './EditArticle/EditArticle.jsx';
export { default as EditQna } from './EditQna/EditQna.jsx';
export { default as ArticleDetailAnswerInput } from './ArticleDetail/ArticleDetailAnswer/ArticleDetailAnswerInput.jsx';

View File

@ -0,0 +1,13 @@
import instance from '../../utils/axios/instance';
import { API_URL } from '../../constants';
export function useAnswerDelete() {
const answerDelete = (questionId) => {
const newAnswer = {
answer: null,
};
return instance.post(`${API_URL}/qna/answer/create/${questionId}`, newAnswer);
};
return { answerDelete };
}

View File

@ -0,0 +1,15 @@
import instance from '../../utils/axios/instance';
import { API_URL } from '../../constants';
export function useAnswerEdit() {
const answerEdit = (questionId, title, content, answer) => {
const newAnswer = {
title,
content,
answer,
};
return instance.put(`${API_URL}/qna/answer/update/${questionId}`, newAnswer);
};
return { answerEdit };
}

View File

@ -0,0 +1,13 @@
import instance from '../../utils/axios/instance';
import { API_URL } from '../../constants';
export function useAnswerWrite() {
const answerWrite = (questionId, answer) => {
const newAnswer = {
answer: answer,
};
return instance.post(`${API_URL}/qna/answer/create/${questionId}`, newAnswer);
};
return { answerWrite };
}

View File

@ -0,0 +1,10 @@
import instance from '../../utils/axios/instance';
import { API_URL } from '../../constants';
export function useNoticeDelete() {
const noticeDelete = (boardId) => {
return instance.delete(`${API_URL}/board/${boardId}`);
};
return { noticeDelete };
}

View File

@ -0,0 +1,10 @@
import instance from '../../utils/axios/instance';
import { API_URL } from '../../constants';
export function useQnaDelete() {
const qnaDelete = (questionId) => {
return instance.delete(`${API_URL}/qna/${questionId}`);
};
return { qnaDelete };
}

View File

@ -0,0 +1,15 @@
import instance from '../../utils/axios/instance';
import { API_URL } from '../../constants';
export function useQnaEdit() {
const qnaEdit = (questionId, title, content, answer) => {
const newQna = {
title,
content,
answer,
};
return instance.put(`${API_URL}/qna/${questionId}`, newQna);
};
return { qnaEdit };
}

View File

@ -0,0 +1,27 @@
import { useQnaEdit } from '../../hooks/api/useQnaEdit';
import { useParams, useNavigate, useLocation } from 'react-router-dom';
import { EditQna } from '../../components/Article';
export default function EditQuestionPage() {
const navigate = useNavigate();
const { questionId } = useParams();
const { qnaEdit } = useQnaEdit();
const location = useLocation();
const handleSubmit = async (e, title, content, answer) => {
e.preventDefault();
await qnaEdit(questionId, title, content, answer);
navigate('..');
};
return (
<EditQna
topic="질문하기"
title="Q&A"
prevTitle={location.state.title}
prevContent={location.state.content}
prevAnswer={location.state.answer}
onSubmit={handleSubmit}
/>
);
}

View File

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

View File

@ -1,19 +1,28 @@
import { ArticleDetail } from '../../components/Article'; import { ArticleDetail } from '../../components/Article';
import { useParams } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { useNoticeDetail } from '../../hooks/api/useNoticeDetail'; import { useNoticeDetail } from '../../hooks/api/useNoticeDetail';
import { useNoticeDelete } from '../../hooks/api/useNoticeDelete';
export default function NoticeDetailPage() { export default function NoticeDetailPage() {
const params = useParams(); const params = useParams();
const noticeId = params.noticeId; const noticeId = params.noticeId;
const { data } = useNoticeDetail(noticeId); const { data } = useNoticeDetail(noticeId);
const notice = data?.data; const notice = data?.data;
const navigate = useNavigate();
const { noticeDelete } = useNoticeDelete();
// TODO: ( ArticleDetail) // TODO: ( ArticleDetail)
const handleDelete = async () => {
await noticeDelete(noticeId);
navigate('..');
};
return ( return (
<ArticleDetail <ArticleDetail
topic="공지사항" topic="공지사항"
title={notice.title} title={notice.title}
content={notice.content} content={notice.content}
onDelete={handleDelete}
/> />
); );
} }

View File

@ -1,12 +1,20 @@
import { ArticleDetail } from '../../components/Article'; import { ArticleDetail } from '../../components/Article';
import { useParams } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { useQnaDetail } from '../../hooks/api/useQnaDetail'; import { useQnaDetail } from '../../hooks/api/useQnaDetail';
import { useQnaDelete } from '../../hooks/api/useQnaDelete';
export default function QuestionDetailPage() { export default function QuestionDetailPage() {
const params = useParams(); const params = useParams();
const qnaId = params.questionId; const qnaId = params.questionId;
const { data } = useQnaDetail(qnaId); const { data } = useQnaDetail(qnaId);
const qna = data?.data; const qna = data?.data;
const { qnaDelete } = useQnaDelete();
const navigate = useNavigate();
const handleDelete = async () => {
await qnaDelete(qnaId);
navigate('..');
};
return ( return (
<ArticleDetail <ArticleDetail
@ -15,6 +23,7 @@ export default function QuestionDetailPage() {
author={qna.username} author={qna.username}
content={qna.content} content={qna.content}
answer={qna?.answer} answer={qna?.answer}
onDelete={handleDelete}
/> />
); );
} }