From 2c01cb867510c07e3bd8f89cba1f40b1ae1884dc Mon Sep 17 00:00:00 2001 From: kdhye1119 Date: Thu, 28 May 2026 00:38:19 +0900 Subject: [PATCH] =?UTF-8?q?[feat]=20=EC=A7=88=EB=AC=B8=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20api=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.js | 16 +- frontend/src/pages/login/LoginPage.js | 4 +- frontend/src/pages/qna/QnADetailPage.js | 594 ++++++++++++------ .../src/pages/qna/QnADetailPage.module.css | 88 ++- frontend/src/pages/qna/QnADetailePage.js | 0 .../src/pages/qna/QnADetailePage.module.css | 0 frontend/src/pages/qna/QnAListPage.js | 541 ++++++++-------- frontend/src/pages/qna/QnAMainPage.js | 92 ++- 8 files changed, 795 insertions(+), 540 deletions(-) delete mode 100644 frontend/src/pages/qna/QnADetailePage.js delete mode 100644 frontend/src/pages/qna/QnADetailePage.module.css diff --git a/frontend/src/App.js b/frontend/src/App.js index 0e5c892..bc1358b 100644 --- a/frontend/src/App.js +++ b/frontend/src/App.js @@ -26,19 +26,19 @@ function App() { {/* 라이트 헤더 페이지 */} }> } /> - } /> - } /> + } /> + } /> } /> {/* 다크 헤더 페이지 */} }> - }/> - }/> - }/> - }/> - }/> - }/> + } /> + } /> + } /> + } /> + } /> + } /> diff --git a/frontend/src/pages/login/LoginPage.js b/frontend/src/pages/login/LoginPage.js index c025156..91a2eeb 100644 --- a/frontend/src/pages/login/LoginPage.js +++ b/frontend/src/pages/login/LoginPage.js @@ -24,17 +24,15 @@ function LoginPage() { if (response.ok) { const data = await response.json(); - console.log('로그인 응답:', data); localStorage.setItem('token', data.token); localStorage.setItem('role', data.role); + localStorage.setItem('name', data.name); navigate('/sessions'); // 로그인 성공 시 이동할 페이지 } else { const errData = await response.json(); - console.log('로그인 실패 응답:', errData); alert('이름 또는 비밀번호가 올바르지 않습니다.'); } } catch (error) { - console.log('catch 에러:', error); alert('서버 오류가 발생했습니다.'); } }; diff --git a/frontend/src/pages/qna/QnADetailPage.js b/frontend/src/pages/qna/QnADetailPage.js index f617a2b..a947bd9 100644 --- a/frontend/src/pages/qna/QnADetailPage.js +++ b/frontend/src/pages/qna/QnADetailPage.js @@ -1,245 +1,449 @@ -import '../../assets/styles/global.css'; -import { useState } from 'react'; -import styles from './QnADetailPage.module.css'; -import { FiChevronLeft, FiMoreVertical, FiCornerDownRight } from 'react-icons/fi'; +import { useState, useEffect, useCallback } from 'react'; +import { useParams, useNavigate, useLocation } from 'react-router-dom'; +import styles from './QnAListPage.module.css'; +import { FiChevronLeft, FiChevronRight } from 'react-icons/fi'; +import { authFetch } from '../../utils/Api'; import { - CommentImoji, - MeCuriousToo, - StaffCheck, - SumitBtn, + CommentImoji, MeCuriousToo, SortBtn, + OBtn, XBtn, CommentCommentArraw, SumitBtn, } from '../../components/qna_svg'; -import profileImg from '../../assets/images/profile.png'; - - - -// ── 목업 데이터 ────────────────────────────────────────── -const MOCK_QUESTION = { - id: 2, - author: '익명', - isStaff: false, - avatarUrl: null, - date: '2025/04/25 13:20', - text: '오류 났어요', - image: 'https://dora-guide.com/wp-content/uploads/2019/11/Visual-studio-code-%EC%84%A4%EC%B9%98-%EB%B0%8F-%EC%82%AC%EC%9A%A9%EB%B2%95.png', - likes: 7, - iLiked: false, - isSolved: true, - comments: [ - { - id: 1, - author: '운영진1', - isStaff: true, - avatarUrl: null, - date: '2025/04/25 13:28', - content: '사진 참고하세요', - image: 'https://dora-guide.com/wp-content/uploads/2019/11/Visual-studio-code-%EC%84%A4%EC%B9%98-%EB%B0%8F-%EC%82%AC%EC%9A%A9%EB%B2%95.png', - }, - { - id: 2, - author: '작성자', - isStaff: false, - avatarUrl: null, - date: '2025/04/25 13:28', - content: '감사합니다', - image: null, - }, - { - id: 3, - author: '익명1', - isStaff: false, - avatarUrl: null, - date: '2025/04/25 13:28', - content: '감사합니다', - image: null, - }, - ], + +const MAX_VISIBLE_COMMENTS = 3; + +const DAY_PART_KO = { AM: '오전', PM: '오후' }; +const DAY_OF_WEEK_KO = { + MONDAY: '월', TUESDAY: '화', WEDNESDAY: '수', + THURSDAY: '목', FRIDAY: '금', SATURDAY: '토', SUNDAY: '일', }; +function QnAListPage() { + const { sessionId } = useParams(); + const navigate = useNavigate(); + const location = useLocation(); + const isPast = location.state?.status === 'AFTER_SESSION'; + const isStaff = localStorage.getItem('role') === 'ADMIN'; + + const [sessionTitle, setSessionTitle] = useState(''); + const [understanding, setUnderstanding] = useState(null); + const [understandingIndex, setUnderstandingIndex] = useState(0); + const [myChoices, setMyChoices] = useState({}); -// ── 메인 컴포넌트 ──────────────────────────────────────── -function QnADetailPage({ - question: initialQuestion = MOCK_QUESTION, - isStaff = false, - onBack, -}) { - const [question, setQuestion] = useState(initialQuestion); - const [commentText, setCommentText] = useState(''); + const [popularQuestions, setPopularQuestions] = useState([]); + const [unresolvedQuestions, setUnresolvedQuestions] = useState([]); + const [resolvedQuestions, setResolvedQuestions] = useState([]); + + const [filterCurious, setFilterCurious] = useState(false); + const [filterUnsolved, setFilterUnsolved] = useState(false); + const [sortOrder, setSortOrder] = useState('정렬'); + const [showSortMenu, setShowSortMenu] = useState(false); + + const [commentOpenId, setCommentOpenId] = useState(null); + const [commentInputs, setCommentInputs] = useState({}); + const [newQuestion, setNewQuestion] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + + const fetchQuestions = useCallback(async (index) => { + try { + const res = await authFetch(`/api/sessions/${sessionId}/questions?understandingIndex=${index}`); + if (!res.ok) throw new Error(`서버 오류: ${res.status}`); + const json = await res.json(); + if (!json.isSuccess) throw new Error(json.message); + + const { session, understanding, questions } = json.result; + + setSessionTitle(`${session.week}주차 ${DAY_OF_WEEK_KO[session.dayOfWeek]}요일 ${DAY_PART_KO[session.dayPart]} (${session.title})`); + setUnderstanding(understanding); + + const allQ = [ + ...(questions.popularQuestions ?? []), + ...(questions.unresolvedQuestions ?? []), + ...(questions.resolvedQuestions ?? []), + ]; + + const withLiked = await Promise.all( + allQ.map(async (q) => { + try { + const r = await authFetch(`/api/questions/${q.questionId}`); + const j = await r.json(); + return { ...q, iLiked: j.result?.isLiked ?? false }; + } catch { + return { ...q, iLiked: false }; + } + }) + ); + + const idSet = (list) => new Set(list.map(q => q.questionId)); + const popularIds = idSet(questions.popularQuestions ?? []); + const unresolvedIds = idSet(questions.unresolvedQuestions ?? []); + const resolvedIds = idSet(questions.resolvedQuestions ?? []); - // 좋아요 토글 - const toggleLike = () => { - setQuestion(prev => ({ - ...prev, - iLiked: !prev.iLiked, - likes: prev.iLiked ? prev.likes - 1 : prev.likes + 1, - })); + setPopularQuestions(withLiked.filter(q => popularIds.has(q.questionId))); + setUnresolvedQuestions(withLiked.filter(q => unresolvedIds.has(q.questionId))); + setResolvedQuestions(withLiked.filter(q => resolvedIds.has(q.questionId))); + + } catch (err) { + console.error('질문 불러오기 실패:', err); + } + }, [sessionId]); + + useEffect(() => { + if (sessionId) fetchQuestions(understandingIndex); + }, [sessionId, understandingIndex, fetchQuestions]); + + const goPrevUnderstand = () => { + if (understanding?.hasOlder) setUnderstandingIndex(prev => prev + 1); + }; + const goNextUnderstand = () => { + if (understanding?.hasNewer) setUnderstandingIndex(prev => prev - 1); + }; + + const handleUnderstandChoice = async (choice) => { + if (!understanding?.current?.checkId) return; + const checkId = understanding.current.checkId; + const newChoice = myChoices[checkId] === choice ? null : choice; + setMyChoices(prev => ({ ...prev, [checkId]: newChoice })); + if (!newChoice) return; + try { + const res = await authFetch( + `/api/sessions/${sessionId}/understanding-checks/${checkId}/responses`, + { method: 'POST', body: JSON.stringify({ choice: newChoice }) } + ); + if (!res.ok) throw new Error(); + const json = await res.json(); + if (json.isSuccess) { + setUnderstanding(prev => ({ + ...prev, + current: { + ...prev.current, + understoodCount: json.result.understoodCount, + notUnderstoodCount: json.result.notUnderstoodCount, + } + })); + } + } catch (err) { + console.error('이해도 응답 실패:', err); + } }; - // 댓글 제출 - const handleCommentSubmit = async () => { - const text = commentText.trim(); + const toggleLike = async (e, questionId) => { + e.stopPropagation(); + try { + const res = await authFetch(`/api/questions/${questionId}/like`, { method: 'POST' }); + if (!res.ok) throw new Error(); + const json = await res.json(); + if (json.isSuccess) { + const update = (list) => list.map(q => + q.questionId === questionId + ? { ...q, likeCount: json.result.likeCount, iLiked: json.result.isLiked } + : q + ); + setPopularQuestions(update); + setUnresolvedQuestions(update); + setResolvedQuestions(update); + } + } catch (err) { + console.error('좋아요 실패:', err); + } + }; + + const toggleCommentInput = (e, questionId) => { + e.stopPropagation(); + if (isPast) return; + setCommentOpenId(prev => prev === questionId ? null : questionId); + }; + + const handleCommentChange = (questionId, value) => { + setCommentInputs(prev => ({ ...prev, [questionId]: value })); + }; + + const handleCommentSubmit = async (e, questionId) => { + e.stopPropagation(); + const text = (commentInputs[questionId] || '').trim(); if (!text) return; + try { + const res = await authFetch(`/api/questions/${questionId}/comments`, { + method: 'POST', + body: JSON.stringify({ content: text, parentCommentId: null }), + }); + if (!res.ok) throw new Error(); + const json = await res.json(); + if (json.isSuccess) { + if (isStaff) { + await authFetch(`/api/questions/${questionId}/status`, { method: 'PATCH' }); + } + const newComment = { + commentId: json.result.commentId, + displayName: json.result.displayName, + content: json.result.content, + }; + const update = (list) => list.map(q => + q.questionId === questionId + ? { + ...q, + isResolved: isStaff ? true : q.isResolved, + previewComments: [...(q.previewComments ?? []), newComment], + commentCount: (q.commentCount ?? 0) + 1 + } + : q + ); + setPopularQuestions(update); + setUnresolvedQuestions(update); + setResolvedQuestions(update); + setCommentInputs(prev => ({ ...prev, [questionId]: '' })); + setCommentOpenId(null); + } + } catch (err) { + console.error('댓글 등록 실패:', err); + } + }; + const handleNewQuestion = async () => { + const text = newQuestion.trim(); + if (!text) return; setIsSubmitting(true); + setSubmitError(null); try { - const newComment = { - id: Date.now(), - author: isStaff ? '운영진' : '나', - isStaff, - avatarUrl: null, - date: new Date().toLocaleDateString('ko-KR', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - }).replace(/\. /g, '/').replace('.', ''), - content: text, - image: null, - }; - setQuestion(prev => ({ - ...prev, - comments: [...prev.comments, newComment], - })); - setCommentText(''); + const res = await authFetch(`/api/sessions/${sessionId}/questions`, { + method: 'POST', + body: JSON.stringify({ content: text }), + }); + if (!res.ok) throw new Error(); + const json = await res.json(); + if (json.isSuccess) { + setNewQuestion(''); + fetchQuestions(understandingIndex); + } } catch (err) { - console.error('댓글 등록 실패:', err); + console.error('질문 등록 실패:', err); + setSubmitError('질문 등록에 실패했어요.'); + } finally { + setIsSubmitting(false); + } + }; + + const handleNewUnderstandCheck = async () => { + const text = newQuestion.trim(); + if (!text) return; + setIsSubmitting(true); + setSubmitError(null); + try { + const res = await authFetch(`/api/sessions/${sessionId}/understanding-checks`, { + method: 'POST', + body: JSON.stringify({ content: text }), + }); + if (!res.ok) throw new Error(); + const json = await res.json(); + if (json.isSuccess) { + setNewQuestion(''); + setUnderstandingIndex(0); + fetchQuestions(0); + } + } catch (err) { + console.error('이해도 등록 실패:', err); + setSubmitError('이해도 등록에 실패했어요.'); } finally { setIsSubmitting(false); } }; + const allQuestions = [ + ...popularQuestions, + ...unresolvedQuestions.filter(q => !popularQuestions.some(p => p.questionId === q.questionId)), + ...resolvedQuestions.filter(q => !popularQuestions.some(p => p.questionId === q.questionId)), + ]; + + const displayedQuestions = (() => { + let list = allQuestions; + if (isStaff && filterUnsolved) list = unresolvedQuestions; + if (!isStaff && filterCurious) list = allQuestions.filter(q => q.iLiked); + + if (sortOrder === '최신순') { + list = [...list].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + } else if (sortOrder === '저도궁금해요순') { + list = [...list].sort((a, b) => b.likeCount - a.likeCount); + } + + return list; + })(); + + const currentChoice = myChoices[understanding?.current?.checkId]; + return (
+

{sessionTitle}

- {/* ── 상단 바:해결 여부 ── */} -
- {question.isSolved ? ( - 해결 질문 +
+ {isStaff ? ( + ) : ( - 미해결 질문 + )} -
- - {/* ── 작성자 행 ── */} -
-
- {question.author} +
+ + {showSortMenu && ( +
    + {['기본', '최신순', '저도궁금해요순'].map(option => ( +
  • { setSortOrder(option); setShowSortMenu(false); }}> + {option} +
  • + ))} +
+ )}
-
- - {question.author} - {question.isStaff && ( - - )} - - {question.date} -
-
+
- {/* ── 질문 제목 ── */} -
- Q. - {question.text} -
- - {/* ── 첨부 이미지 ── */} - {question.image && ( - 첨부 이미지 - )} - - {/* ── 액션 버튼 (저도 궁금해요 / 댓글달기) ── */} -
+ {/* 이해도 */} +
+ + + {understanding?.current?.content ?? '이해도 없음'} + + ({understanding?.current?.understoodCount ?? 0}/ + {(understanding?.current?.understoodCount ?? 0) + (understanding?.current?.notUnderstoodCount ?? 0)}) + + +
-
- - {/* ── 댓글 목록 ── */} -
- {question.comments.map(comment => ( -
- - {/* 댓글 작성자 */} -
-
- {comment.author} -
- - {comment.author} - {comment.isStaff && ( - + {/* 질문 목록 */} +
+ {displayedQuestions.map(question => ( +
navigate(`/sessions/${sessionId}/questions/${question.questionId}`)}> +
+ Q. + {question.content} +
+ + {!isPast && ( + )} - +
- {/* 댓글 말풍선 */} -
-
- - {comment.content} + {question.imageUrl && ( + 첨부 이미지 e.stopPropagation()} /> + )} + + {question.previewComments?.length > 0 && ( +
+ {question.previewComments.slice(0, MAX_VISIBLE_COMMENTS).map(comment => ( +
+ {comment.displayName} +
+
+ {comment.content} +
+
+
+ ))} + {question.commentCount > MAX_VISIBLE_COMMENTS && ( + + 외 {question.commentCount - MAX_VISIBLE_COMMENTS}개 댓글 + + )}
- {comment.image && ( - 댓글 첨부 이미지 - )} -
+ )} - {/* 타임스탬프 */} -

{comment.date}

+ {commentOpenId === question.questionId && ( +
e.stopPropagation()}> + handleCommentChange(question.questionId, e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleCommentSubmit(e, question.questionId); }} + autoFocus + /> + +
+ )}
))}
- {/* ── 하단 그라디언트 커버 ── */}
- {/* ── 댓글 입력 바 (하단 고정) ── */} -
- setCommentText(e.target.value)} - onKeyDown={e => { - if (e.key === 'Enter') handleCommentSubmit(); - }} - disabled={isSubmitting} - /> - -
- + {!isPast && ( +
+ {submitError &&

{submitError}

} +
+ + setNewQuestion(e.target.value)} + onKeyDown={e => { + if (e.key === 'Enter') isStaff ? handleNewUnderstandCheck() : handleNewQuestion(); + }} + disabled={isSubmitting} + /> + +
+
+ )}
); } -export default QnADetailPage; \ No newline at end of file +export default QnAListPage; \ No newline at end of file diff --git a/frontend/src/pages/qna/QnADetailPage.module.css b/frontend/src/pages/qna/QnADetailPage.module.css index 1f24ed2..9df34c4 100644 --- a/frontend/src/pages/qna/QnADetailPage.module.css +++ b/frontend/src/pages/qna/QnADetailPage.module.css @@ -44,9 +44,10 @@ /* ── 작성자 행 ── */ .authorRow { display: flex; - align-items: center; + align-items: flex-start; + gap: 10px; - margin-bottom: 16px; + margin-bottom: 5px; } .avatar { @@ -75,8 +76,8 @@ .authorName { font-family: var(--font-main); - font-size: 0.95rem; - font-weight: 600; + font-size: 16px; + font-weight: 500; color: var(--black); display: flex; align-items: center; @@ -97,6 +98,75 @@ padding: 4px; display: flex; align-items: center; + +} + +.dropdownMenu { + position: absolute; + right: 0; + top: 100%; + background: var(--white); + border-radius: 10px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15); + z-index: 100; + min-width: 100px; + overflow: hidden; +} + +.dropdownItem { + display: block; + width: 100%; + padding: 10px 16px; + border: none; + background: none; + font-family: var(--font-main); + font-size: 0.9rem; + color: var(--black); + cursor: pointer; + text-align: left; +} + +.dropdownItem:hover { + background: var(--gray20); +} + +.editInput { + flex: 1; + font-family: var(--font-main); + font-size: 24px; + font-weight: 600; + color: var(--black); + border: 1px solid var(--gray200); + border-radius: 8px; + padding: 4px 8px; + resize: none; + outline: none; + line-height: 1.3; + width: 100%; +} + +.editConfirmBtn { + background: var(--main); + color: var(--white); + border: none; + border-radius: 6px; + padding: 4px 10px; + font-family: var(--font-main); + font-size: 0.85rem; + cursor: pointer; + white-space: nowrap; +} + +.editCancelBtn { + background: var(--gray20); + color: var(--black); + border: none; + border-radius: 6px; + padding: 4px 10px; + font-family: var(--font-main); + font-size: 0.85rem; + cursor: pointer; + white-space: nowrap; } /* ── 질문 본문 ── */ @@ -104,12 +174,12 @@ display: flex; align-items: flex-start; gap: 6px; - margin-bottom: 18px; + padding-bottom: 5px; } .qIcon { font-family: var(--font-main); - font-size: 2rem; + font-size: 36px; font-weight: 900; color: var(--main); line-height: 1.1; @@ -188,6 +258,7 @@ height: 1px; border: none; margin: 4px 0 0; + margin-bottom: 20px; } /* ── 댓글 목록 ── */ @@ -195,19 +266,14 @@ display: flex; flex-direction: column; gap: 0; - padding-top: 4px; } -.commentBlock { - padding: 16px 0; -} /* 댓글 작성자 행 */ .commentAuthorRow { display: flex; align-items: center; gap: 8px; - margin-bottom: 10px; } .commentAvatar { diff --git a/frontend/src/pages/qna/QnADetailePage.js b/frontend/src/pages/qna/QnADetailePage.js deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/src/pages/qna/QnADetailePage.module.css b/frontend/src/pages/qna/QnADetailePage.module.css deleted file mode 100644 index e69de29..0000000 diff --git a/frontend/src/pages/qna/QnAListPage.js b/frontend/src/pages/qna/QnAListPage.js index 83e66ca..6666a3f 100644 --- a/frontend/src/pages/qna/QnAListPage.js +++ b/frontend/src/pages/qna/QnAListPage.js @@ -1,151 +1,148 @@ -import { useState } from 'react'; +import { useState, useEffect, useCallback } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; import styles from './QnAListPage.module.css'; import { FiChevronLeft, FiChevronRight } from 'react-icons/fi'; +import { authFetch } from '../../utils/Api'; import { - CommentImoji, - MeCuriousToo, - StaffCheck, - SortBtn, - OBtn, - XBtn, - CommentCommentArraw, - SumitBtn, + CommentImoji, MeCuriousToo, SortBtn, + OBtn, XBtn, CommentCommentArraw, SumitBtn, } from '../../components/qna_svg'; -const UNDERSTAND = ['이해했다', '성공했다']; - -// 댓글 최대 표시 개수 (카드에서 항상 노출) const MAX_VISIBLE_COMMENTS = 3; -// 질문 목록 데이터 -const MOCK_QUESTIONS = [ - { - id: 1, - text: '벤브 어떻게 활성화 시켜요?', - likes: 7, - iLiked: false, // 내가 좋아요 눌렀는지 - isSolved: false, // 운영진이 해결 표시했는지 - image: null, // 첨부 이미지 없음 - comments: [], // 댓글 없음 - }, - { - id: 2, - text: '오류났어요', - likes: 7, - iLiked: false, - isSolved: false, - image: 'https://dora-guide.com/wp-content/uploads/2019/11/Visual-studio-code-%EC%84%A4%EC%B9%98-%EB%B0%8F-%EC%82%AC%EC%9A%A9%EB%B2%95.png', - comments: [ - { id: 1, author: '운영진1', isStaff: true, content: '사진 참고하세요' }, - { id: 2, author: '작성자', isStaff: false, content: '감사합니다' }, - { id: 3, author: '익명1', isStaff: false, content: '감사합니다' }, - ], - }, - { - id: 3, - text: '벤브 어떻게 활성화 시켜요?', - likes: 7, - iLiked: false, - isSolved: false, - image: null, - comments: [], - }, - { - id: 4, - text: '벤브 어떻게 활성화 시켜요?', - likes: 7, - iLiked: false, - isSolved: true, - image: null, - comments: [], - }, - { - id: 5, - text: '벤브 어떻게 활성화 시켜요?', - likes: 7, - iLiked: false, - isSolved: true, - image: null, - comments: [], - }, -]; - - - - - -function QnAListPage({ - sessionTitle = '1주차 화요일 오전 세션(HTML/CSS)', - sessionId = 1, - isStaff = true, - onBack, - onCardClick, // 카드 클릭 시 상세 페이지 이동 (questionId를 인자로 받아) -}) { - - - - // 현재 보고 있는 이해도 체크 인덱스 (0 = '이해했다') - const [understandIndex, setUnderstandIndex] = useState(0); - - // 저도 궁금해요 필터 켜져 있는지(부원) - const [filterCurious, setFilterCurious] = useState(false); - - // 미해결 질문 필터 켜져 있는지(운영진) - const [filterUnsolved, setFilterUnsolved] = useState(false); +const DAY_PART_KO = { AM: '오전', PM: '오후' }; +const DAY_OF_WEEK_KO = { + MONDAY: '월', TUESDAY: '화', WEDNESDAY: '수', + THURSDAY: '목', FRIDAY: '금', SATURDAY: '토', SUNDAY: '일', +}; - // 정렬 방식 - const [sortOrder, setSortOrder] = useState('정렬'); +function QnAListPage() { + const { sessionId } = useParams(); + const navigate = useNavigate(); + const isStaff = localStorage.getItem('role') === 'ADMIN'; - // 정렬 드롭다운 열려 있는지 - const [showSortMenu, setShowSortMenu] = useState(false); + const [sessionTitle, setSessionTitle] = useState(''); + const [understanding, setUnderstanding] = useState(null); + const [understandingIndex, setUnderstandingIndex] = useState(0); + const [myChoices, setMyChoices] = useState({}); - // 질문 목록 (좋아요 토글 등 변경사항 반영하려고 state로 관리) - const [questions, setQuestions] = useState(MOCK_QUESTIONS); + const [popularQuestions, setPopularQuestions] = useState([]); + const [unresolvedQuestions, setUnresolvedQuestions] = useState([]); + const [resolvedQuestions, setResolvedQuestions] = useState([]); - // 내가 이해했는지 여부: true = O 누름, false = X 누름, null = 아직 안 누름 - const [myUnderstand, setMyUnderstand] = useState(null); + const [filterCurious, setFilterCurious] = useState(false); + const [filterUnsolved, setFilterUnsolved] = useState(false); + const [sortOrder, setSortOrder] = useState('정렬'); + const [showSortMenu, setShowSortMenu] = useState(false); - // 댓글 입력창이 열려 있는 질문 id - // null이면 아무 질문도 댓글 입력창이 안 열려 있는 상태 - // '댓글달기' 버튼을 누른 질문의 id가 들어옴 const [commentOpenId, setCommentOpenId] = useState(null); - - // 각 질문별 댓글 입력창 텍스트 (객체로 관리: { 질문id: 입력된텍스트 }) const [commentInputs, setCommentInputs] = useState({}); - - // 새 질문 입력창 텍스트x const [newQuestion, setNewQuestion] = useState(''); - - // API 요청 중인지 여부 (true면 버튼 비활성화 → 중복 제출 방지) const [isSubmitting, setIsSubmitting] = useState(false); - - // 에러 메시지 (null이면 에러 없음) const [submitError, setSubmitError] = useState(null); + const fetchQuestions = useCallback(async (index) => { + try { + const res = await authFetch(`/api/sessions/${sessionId}/questions?understandingIndex=${index}`); + if (!res.ok) throw new Error(`서버 오류: ${res.status}`); + const json = await res.json(); + if (!json.isSuccess) throw new Error(json.message); + + const { session, understanding, questions } = json.result; + + setSessionTitle(`${session.week}주차 ${DAY_OF_WEEK_KO[session.dayOfWeek]}요일 ${DAY_PART_KO[session.dayPart]} (${session.title})`); + setUnderstanding(understanding); + + const allQ = [ + ...(questions.popularQuestions ?? []), + ...(questions.unresolvedQuestions ?? []), + ...(questions.resolvedQuestions ?? []), + ]; + + const withLiked = await Promise.all( + allQ.map(async (q) => { + try { + const r = await authFetch(`/api/questions/${q.questionId}`); + const j = await r.json(); + return { ...q, iLiked: j.result?.isLiked ?? false }; + } catch { + return { ...q, iLiked: false }; + } + }) + ); + + const idSet = (list) => new Set(list.map(q => q.questionId)); + const popularIds = idSet(questions.popularQuestions ?? []); + const unresolvedIds = idSet(questions.unresolvedQuestions ?? []); + const resolvedIds = idSet(questions.resolvedQuestions ?? []); + + setPopularQuestions(withLiked.filter(q => popularIds.has(q.questionId))); + setUnresolvedQuestions(withLiked.filter(q => unresolvedIds.has(q.questionId))); + setResolvedQuestions(withLiked.filter(q => resolvedIds.has(q.questionId))); + + } catch (err) { + console.error('질문 불러오기 실패:', err); + } + }, [sessionId]); + + useEffect(() => { + if (sessionId) fetchQuestions(understandingIndex); + }, [sessionId, understandingIndex, fetchQuestions]); - // 이해도 const goPrevUnderstand = () => { - if (understandIndex > 0) setUnderstandIndex(prev => prev - 1); + if (understanding?.hasOlder) setUnderstandingIndex(prev => prev + 1); }; const goNextUnderstand = () => { - if (understandIndex < UNDERSTAND.length - 1) setUnderstandIndex(prev => prev + 1); + if (understanding?.hasNewer) setUnderstandingIndex(prev => prev - 1); }; - // 질문 - const toggleLike = (e, id) => { - e.stopPropagation(); - - setQuestions(prev => - prev.map(q => - q.id === id - ? { - ...q, - iLiked: !q.iLiked, - likes: q.iLiked ? q.likes - 1 : q.likes + 1, + const handleUnderstandChoice = async (choice) => { + if (!understanding?.current?.checkId) return; + const checkId = understanding.current.checkId; + const newChoice = myChoices[checkId] === choice ? null : choice; + setMyChoices(prev => ({ ...prev, [checkId]: newChoice })); + if (!newChoice) return; + try { + const res = await authFetch( + `/api/sessions/${sessionId}/understanding-checks/${checkId}/responses`, + { method: 'POST', body: JSON.stringify({ choice: newChoice }) } + ); + if (!res.ok) throw new Error(); + const json = await res.json(); + if (json.isSuccess) { + setUnderstanding(prev => ({ + ...prev, + current: { + ...prev.current, + understoodCount: json.result.understoodCount, + notUnderstoodCount: json.result.notUnderstoodCount, } - : q - ) - ); + })); + } + } catch (err) { + console.error('이해도 응답 실패:', err); + } + }; + + const toggleLike = async (e, questionId) => { + e.stopPropagation(); + try { + const res = await authFetch(`/api/questions/${questionId}/like`, { method: 'POST' }); + if (!res.ok) throw new Error(); + const json = await res.json(); + if (json.isSuccess) { + const update = (list) => list.map(q => + q.questionId === questionId + ? { ...q, likeCount: json.result.likeCount, iLiked: json.result.isLiked } + : q + ); + setPopularQuestions(update); + setUnresolvedQuestions(update); + setResolvedQuestions(update); + } + } catch (err) { + console.error('좋아요 실패:', err); + } }; const toggleCommentInput = (e, questionId) => { @@ -154,124 +151,150 @@ function QnAListPage({ }; const handleCommentChange = (questionId, value) => { - setCommentInputs(prev => ({ - ...prev, - [questionId]: value, - })); + setCommentInputs(prev => ({ ...prev, [questionId]: value })); }; - const handleCommentSubmit = (e, questionId) => { + const handleCommentSubmit = async (e, questionId) => { e.stopPropagation(); - const text = (commentInputs[questionId] || '').trim(); if (!text) return; - - setQuestions(prev => - prev.map(q => - q.id === questionId - ? { - ...q, - comments: [ - ...q.comments, - { id: Date.now(), author: '나', isStaff: false, content: text }, - ], - } - : q - ) - ); - setCommentInputs(prev => ({ ...prev, [questionId]: '' })); - setCommentOpenId(null); + try { + const res = await authFetch(`/api/questions/${questionId}/comments`, { + method: 'POST', + body: JSON.stringify({ content: text, parentCommentId: null }), + }); + if (!res.ok) throw new Error(); + const json = await res.json(); + if (json.isSuccess) { + if (isStaff) { + await authFetch(`/api/questions/${questionId}/status`, { method: 'PATCH' }); + } + const newComment = { + commentId: json.result.commentId, + displayName: json.result.displayName, + content: json.result.content, + }; + const update = (list) => list.map(q => + q.questionId === questionId + ? { + ...q, + isResolved: isStaff ? true : q.isResolved, + previewComments: [...(q.previewComments ?? []), newComment], + commentCount: (q.commentCount ?? 0) + 1 + } + : q + ); + setPopularQuestions(update); + setUnresolvedQuestions(update); + setResolvedQuestions(update); + setCommentInputs(prev => ({ ...prev, [questionId]: '' })); + setCommentOpenId(null); + } + } catch (err) { + console.error('댓글 등록 실패:', err); + } }; - - const handleNewQuestion = async () => { const text = newQuestion.trim(); if (!text) return; - setIsSubmitting(true); setSubmitError(null); - try { - setQuestions(prev => [ - { id: Date.now(), text, likes: 0, iLiked: false, image: null, comments: [] }, - ...prev, - ]); - setNewQuestion(''); - - } catch (error) { - console.error('질문 등록 실패:', error); + const res = await authFetch(`/api/sessions/${sessionId}/questions`, { + method: 'POST', + body: JSON.stringify({ content: text }), + }); + if (!res.ok) throw new Error(); + const json = await res.json(); + if (json.isSuccess) { + setNewQuestion(''); + fetchQuestions(understandingIndex); + } + } catch (err) { + console.error('질문 등록 실패:', err); setSubmitError('질문 등록에 실패했어요.'); } finally { setIsSubmitting(false); } }; + const handleNewUnderstandCheck = async () => { + const text = newQuestion.trim(); + if (!text) return; + setIsSubmitting(true); + setSubmitError(null); + try { + const res = await authFetch(`/api/sessions/${sessionId}/understanding-checks`, { + method: 'POST', + body: JSON.stringify({ content: text }), + }); + if (!res.ok) throw new Error(); + const json = await res.json(); + if (json.isSuccess) { + setNewQuestion(''); + setUnderstandingIndex(0); + fetchQuestions(0); + } + } catch (err) { + console.error('이해도 등록 실패:', err); + setSubmitError('이해도 등록에 실패했어요.'); + } finally { + setIsSubmitting(false); + } + }; + const allQuestions = [ + ...popularQuestions, + ...unresolvedQuestions.filter(q => !popularQuestions.some(p => p.questionId === q.questionId)), + ...resolvedQuestions.filter(q => !popularQuestions.some(p => p.questionId === q.questionId)), + ]; - - const currentUnderstand = UNDERSTAND[understandIndex]; - - // 저도 궁금해요 필터가 켜져 있으면 필터링 const displayedQuestions = (() => { - if (isStaff && filterUnsolved) return questions.filter(q => q.comments.length === 0 && !q.isSolved); - if (!isStaff && filterCurious) return questions.filter(q => q.iLiked); - return questions; - })(); - - - + let list = allQuestions; + if (isStaff && filterUnsolved) list = unresolvedQuestions; + if (!isStaff && filterCurious) list = allQuestions.filter(q => q.iLiked); + + if (sortOrder === '최신순') { + list = [...list].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt)); + } else if (sortOrder === '저도궁금해요순') { + list = [...list].sort((a, b) => b.likeCount - a.likeCount); + } + return list; + })(); + const currentChoice = myChoices[understanding?.current?.checkId]; return ( - // 상단
-

{sessionTitle}

{isStaff ? ( ) : ( )} -
- - {showSortMenu && (
    {['기본', '최신순', '저도궁금해요순'].map(option => ( -
  • { - setSortOrder(option); - setShowSortMenu(false); - }} - > +
  • { setSortOrder(option); setShowSortMenu(false); }}> {option}
  • ))} @@ -281,92 +304,77 @@ function QnAListPage({

- {/* 이해도 */}
- - - {currentUnderstand} - (13/29) - + {understanding?.current?.content ?? '이해도 없음'} + + ({understanding?.current?.understoodCount ?? 0}/ + {(understanding?.current?.understoodCount ?? 0) + (understanding?.current?.notUnderstoodCount ?? 0)}) + -
- - {/* ── 질문 목록 ── */} + {/* 질문 목록 */}
{displayedQuestions.map(question => ( -
onCardClick?.(question.id)} - > - +
navigate(`/sessions/${sessionId}/questions/${question.questionId}`)}>
- Q. - {question.text} - + Q. + {question.content}
-
- {question.image && ( - 첨부 이미지 e.stopPropagation()} - /> + onClick={e => e.stopPropagation()} /> )} - - {question.comments.length > 0 && ( + {question.previewComments?.length > 0 && (
- {question.comments.slice(0, MAX_VISIBLE_COMMENTS).map(comment => ( -
- - {comment.author} - {comment.isStaff && ( - - )} - - {/* 댓글 내용 */} + {question.previewComments.slice(0, MAX_VISIBLE_COMMENTS).map(comment => ( +
+ {comment.displayName}
{comment.content} @@ -374,35 +382,26 @@ function QnAListPage({
))} - - - {question.comments.length > MAX_VISIBLE_COMMENTS && ( + {question.commentCount > MAX_VISIBLE_COMMENTS && ( - 외 {question.comments.length - MAX_VISIBLE_COMMENTS}개 댓글 + 외 {question.commentCount - MAX_VISIBLE_COMMENTS}개 댓글 )}
)} - {commentOpenId === question.id && ( -
e.stopPropagation()} - > + + {commentOpenId === question.questionId && ( +
e.stopPropagation()}> handleCommentChange(question.id, e.target.value)} - // 엔터 누르면 제출 - onKeyDown={e => { - if (e.key === 'Enter') handleCommentSubmit(e, question.id); - }} + value={commentInputs[question.questionId] || ''} + onChange={e => handleCommentChange(question.questionId, e.target.value)} + onKeyDown={e => { if (e.key === 'Enter') handleCommentSubmit(e, question.questionId); }} autoFocus /> -
@@ -411,36 +410,30 @@ function QnAListPage({ ))}
-
- - {submitError && ( -

{submitError}

- )} - + {submitError &&

{submitError}

}
setNewQuestion(e.target.value)} onKeyDown={e => { - if (e.key === 'Enter') handleNewQuestion(); + if (e.key === 'Enter') isStaff ? handleNewUnderstandCheck() : handleNewQuestion(); }} disabled={isSubmitting} />
-
); } diff --git a/frontend/src/pages/qna/QnAMainPage.js b/frontend/src/pages/qna/QnAMainPage.js index 4e611ea..c750fb9 100644 --- a/frontend/src/pages/qna/QnAMainPage.js +++ b/frontend/src/pages/qna/QnAMainPage.js @@ -1,27 +1,22 @@ import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; import styles from './QnAMainPage.module.css'; import { FiLogIn } from 'react-icons/fi'; +import { authFetch } from '../../utils/Api'; -// ───────────────────────────────────────────── -// 📌 목업 데이터 -// ───────────────────────────────────────────── -// const MOCK_DATA = { -// activeSessions: [ -// { sessionId: 1, week: 1, dayOfWeek: '화요일', dayPart: '오전', sessionDate: '2026.05.23', title: 'HTML/CSS' } -// ], -// pastSessions: [ -// { sessionId: 1, week: 1, dayOfWeek: '화요일', dayPart: '오전', sessionDate: '2026.05.23', title: 'HTML/CSS' }, -// { sessionId: 2, week: 1, dayOfWeek: '화요일', dayPart: '오후', sessionDate: '2026.05.23', title: 'Git 기초' }, -// ] -// }; -// ───────────────────────────────────────────── +const DAY_PART_KO = { AM: '오전', PM: '오후' }; -const BASE_URL = ''; +const DAY_OF_WEEK_KO = { + MONDAY: '월요일', TUESDAY: '화요일', WEDNESDAY: '수요일', + THURSDAY: '목요일', FRIDAY: '금요일', SATURDAY: '토요일', SUNDAY: '일요일', +}; -const getIcon = (dayPart) => dayPart === '오전' ? '☀' : '☾'; -const getTime = (dayPart) => dayPart === '오전' ? '10:00 ~ 13:00' : '14:00 ~ 17:00'; +const formatDate = (dateStr) => dateStr?.replace(/-/g, '.') ?? ''; +const getIcon = (dayPart) => dayPart === 'AM' ? '☀' : '☾'; +const getTime = (dayPart) => dayPart === 'AM' ? '10:00 ~ 13:00' : '14:00 ~ 17:00'; function QNAMainPage() { + const navigate = useNavigate(); const [activeSessions, setActiveSessions] = useState([]); const [pastSessions, setPastSessions] = useState([]); const [loading, setLoading] = useState(true); @@ -31,31 +26,16 @@ function QNAMainPage() { const fetchSessions = async () => { try { setLoading(true); - - const token = localStorage.getItem('token'); - - const res = await fetch(`${BASE_URL}/api/sessions`, { - method: 'GET', - headers: { - 'Authorization': `Bearer ${token}`, - 'Content-Type': 'application/json', - } - }); - + const res = await authFetch('/api/sessions'); if (!res.ok) throw new Error(`서버 오류: ${res.status}`); - const json = await res.json(); - if (!json.isSuccess) throw new Error(json.message); - // DB에 데이터 없으면 목업으로 대체 - // setActiveSessions(json.result.activeSessions.length > 0 ? json.result.activeSessions : MOCK_DATA.activeSessions); - // setPastSessions(json.result.pastSessions.length > 0 ? json.result.pastSessions : MOCK_DATA.pastSessions); - + setActiveSessions(json.result.activeSessions ?? []); + setPastSessions(json.result.pastSessions ?? []); } catch (err) { console.error('세션 불러오기 실패:', err); - // setActiveSessions(MOCK_DATA.activeSessions); - // setPastSessions(MOCK_DATA.pastSessions); + setError(err.message); } finally { setLoading(false); } @@ -70,19 +50,26 @@ function QNAMainPage() { return (
- {/* 진행 중인 세션 있을 때만 표시 */} + {/* 진행 중인 세션 */} {activeSessions.length > 0 && ( <>

Q&A

{activeSessions.map(session => ( -
+
navigate(`/sessions/${session.sessionId}/questions`, { state: { status: 'IN_SESSION' } })} + style={{ cursor: 'pointer' }} + >

{getIcon(session.dayPart)} {session.title}

-

{session.week}주차 {session.dayOfWeek} {session.dayPart}

-

{session.sessionDate}

+

+ {session.week}주차 {DAY_OF_WEEK_KO[session.dayOfWeek]} {DAY_PART_KO[session.dayPart]} +

+

{formatDate(session.sessionDate)}

{getTime(session.dayPart)}

))} @@ -92,7 +79,7 @@ function QNAMainPage() { )} {/* 지난 세션 */} - {pastSessions.length > 0 ? ( + {pastSessions.length > 0 && (

지난 세션

@@ -101,20 +88,27 @@ function QNAMainPage() { {getIcon(session.dayPart)} {session.title} - •{session.week}주차 {session.dayOfWeek} {session.dayPart} + +  • {session.week}주차 {DAY_OF_WEEK_KO[session.dayOfWeek]} {DAY_PART_KO[session.dayPart]} + - +
))}
- ) : ( - /* 진행 중인 세션도 없고 지난 세션도 없을 때 */ - activeSessions.length === 0 && ( -
-

아직 생성된 Q&A가 없어요

-
- ) + )} + + {/* 둘 다 없을 때 */} + {activeSessions.length === 0 && pastSessions.length === 0 && ( +
+

아직 생성된 Q&A가 없어요

+
)}