diff --git a/frontend/src/components/qna_svg.js b/frontend/src/components/qna_svg.js index cc6fe51..b80a483 100644 --- a/frontend/src/components/qna_svg.js +++ b/frontend/src/components/qna_svg.js @@ -21,7 +21,7 @@ export const StaffCheck = () => ( - + ); export const SortBtn = () => ( @@ -53,4 +53,18 @@ export const CommentCommentArraw = () => ( +); + + +export const ImgPreview = () => ( + + + + + + + + + + ); \ No newline at end of file diff --git a/frontend/src/pages/qna/QnADetailPage.js b/frontend/src/pages/qna/QnADetailPage.js index a947bd9..c25d29f 100644 --- a/frontend/src/pages/qna/QnADetailPage.js +++ b/frontend/src/pages/qna/QnADetailPage.js @@ -1,449 +1,503 @@ -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 '../../assets/styles/global.css'; +import { useState, useEffect, useRef } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import styles from './QnADetailPage.module.css'; +import { FiMoreVertical, FiCornerDownRight } from 'react-icons/fi'; import { - CommentImoji, MeCuriousToo, SortBtn, - OBtn, XBtn, CommentCommentArraw, SumitBtn, + CommentImoji, + MeCuriousToo, + StaffCheck, + SumitBtn, } from '../../components/qna_svg'; +import profileImg from '../../assets/images/profile.png'; +import { authFetch } from '../../utils/Api'; -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(); +function QnADetailPage() { + const { questionId } = 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({}); - - 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 [question, setQuestion] = useState(null); + const [commentText, setCommentText] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); - const [submitError, setSubmitError] = useState(null); + const [loading, setLoading] = useState(true); + const [showMenu, setShowMenu] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [editText, setEditText] = useState(''); + const [selectedImage, setSelectedImage] = useState(null); + const [imagePreview, setImagePreview] = useState(null); + const fileInputRef = useRef(null); + + const [commentMenuId, setCommentMenuId] = useState(null); + const [editingCommentId, setEditingCommentId] = useState(null); + const [editCommentText, setEditCommentText] = useState(''); + + const formatDate = (dateStr) => { + if (!dateStr) return ''; + const d = new Date(dateStr); + return d.toLocaleTimeString('ko-KR', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + }); + }; - const fetchQuestions = useCallback(async (index) => { + const handleCommentDelete = async (commentId) => { + if (!window.confirm('댓글을 삭제할까요?')) return; 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))); - + const res = await authFetch(`/api/comments/${commentId}`, { method: 'DELETE' }); + if (!res.ok) throw new Error(); + setQuestion(prev => ({ + ...prev, + comments: prev.comments.filter(c => c.commentId !== commentId), + })); } catch (err) { - console.error('질문 불러오기 실패:', err); + console.error('댓글 삭제 실패:', err); } - }, [sessionId]); - - useEffect(() => { - if (sessionId) fetchQuestions(understandingIndex); - }, [sessionId, understandingIndex, fetchQuestions]); - - const goPrevUnderstand = () => { - if (understanding?.hasOlder) setUnderstandingIndex(prev => prev + 1); + setCommentMenuId(null); }; - const goNextUnderstand = () => { - if (understanding?.hasNewer) setUnderstandingIndex(prev => prev - 1); + + const handleCommentEditStart = (comment) => { + setEditingCommentId(comment.commentId); + setEditCommentText(comment.content); + setCommentMenuId(null); }; - 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; + const handleCommentEditSubmit = async (commentId) => { + const text = editCommentText.trim(); + if (!text) return; try { - const res = await authFetch( - `/api/sessions/${sessionId}/understanding-checks/${checkId}/responses`, - { method: 'POST', body: JSON.stringify({ choice: newChoice }) } - ); + const res = await authFetch(`/api/comments/${commentId}`, { + method: 'PATCH', + body: JSON.stringify({ content: text }), + }); if (!res.ok) throw new Error(); const json = await res.json(); if (json.isSuccess) { - setUnderstanding(prev => ({ + setQuestion(prev => ({ ...prev, - current: { - ...prev.current, - understoodCount: json.result.understoodCount, - notUnderstoodCount: json.result.notUnderstoodCount, - } + comments: prev.comments.map(c => + c.commentId === commentId ? { ...c, content: text } : c + ), })); + setEditingCommentId(null); } } catch (err) { - console.error('이해도 응답 실패:', err); + console.error('댓글 수정 실패:', err); } }; - const toggleLike = async (e, questionId) => { - e.stopPropagation(); + + useEffect(() => { + const fetchQuestion = async () => { + try { + setLoading(true); + const res = await authFetch(`/api/questions/${questionId}`); + if (!res.ok) throw new Error(`서버 오류: ${res.status}`); + const json = await res.json(); + if (!json.isSuccess) throw new Error(json.message); + + const result = json.result; + + if (result.imageUrl) { + try { + const imgRes = await authFetch(result.imageUrl); + const blob = await imgRes.blob(); + result.imageUrl = URL.createObjectURL(blob); + } catch { + result.imageUrl = null; + } + } + + if (result.comments) { + result.comments = await Promise.all( + result.comments.map(async (comment) => { + if (comment.imageUrl) { + try { + const imgRes = await authFetch(comment.imageUrl); + const blob = await imgRes.blob(); + return { ...comment, imageUrl: URL.createObjectURL(blob) }; + } catch { + return { ...comment, imageUrl: null }; + } + } + return comment; + }) + ); + } + setQuestion(result); + } catch (err) { + console.error('질문 불러오기 실패:', err); + } finally { + setLoading(false); + } + }; + if (questionId) fetchQuestion(); + }, [questionId]); + + useEffect(() => { + const handleClickOutside = () => { + setShowMenu(false); + setCommentMenuId(null); // ← 추가 + }; + if (showMenu || commentMenuId) document.addEventListener('click', handleClickOutside); + return () => document.removeEventListener('click', handleClickOutside); + }, [showMenu, commentMenuId]); + + const toggleLike = async () => { 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); + setQuestion(prev => ({ + ...prev, + likeCount: json.result.likeCount, + isLiked: json.result.isLiked, + })); } } catch (err) { console.error('좋아요 실패:', err); } }; - const toggleCommentInput = (e, questionId) => { - e.stopPropagation(); - if (isPast) return; - setCommentOpenId(prev => prev === questionId ? null : questionId); + const handleEditStart = () => { + setEditText(question.content); + setIsEditing(true); + setShowMenu(false); }; - const handleCommentChange = (questionId, value) => { - setCommentInputs(prev => ({ ...prev, [questionId]: value })); - }; - - const handleCommentSubmit = async (e, questionId) => { - e.stopPropagation(); - const text = (commentInputs[questionId] || '').trim(); + const handleEditSubmit = async () => { + const text = editText.trim(); if (!text) return; try { - const res = await authFetch(`/api/questions/${questionId}/comments`, { - method: 'POST', - body: JSON.stringify({ content: text, parentCommentId: null }), + const res = await authFetch(`/api/questions/${questionId}/modify`, { + method: 'PATCH', + body: JSON.stringify({ content: text }), }); 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); + setQuestion(prev => ({ ...prev, content: text })); + setIsEditing(false); } } catch (err) { - console.error('댓글 등록 실패:', err); + console.error('수정 실패:', err); } }; - const handleNewQuestion = async () => { - const text = newQuestion.trim(); - if (!text) return; - setIsSubmitting(true); - setSubmitError(null); + const handleDelete = async () => { + if (!window.confirm('질문을 삭제할까요?')) return; try { - const res = await authFetch(`/api/sessions/${sessionId}/questions`, { - method: 'POST', - body: JSON.stringify({ content: text }), - }); + const res = await authFetch(`/api/questions/${questionId}`, { method: 'DELETE' }); if (!res.ok) throw new Error(); - const json = await res.json(); - if (json.isSuccess) { - setNewQuestion(''); - fetchQuestions(understandingIndex); - } + navigate(-1); } catch (err) { - console.error('질문 등록 실패:', err); - setSubmitError('질문 등록에 실패했어요.'); - } finally { - setIsSubmitting(false); + console.error('삭제 실패:', err); + } + setShowMenu(false); + }; + + const handleResolve = async () => { + try { + const res = await authFetch(`/api/questions/${questionId}/status`, { method: 'PATCH' }); + if (!res.ok) throw new Error(); + setQuestion(prev => ({ ...prev, isResolved: true })); + } catch (err) { + console.error('해결됨 처리 실패:', err); + } + setShowMenu(false); + }; + + const handleImageSelect = (e) => { + const file = e.target.files[0]; + if (!file) return; + setSelectedImage(file); + setImagePreview(URL.createObjectURL(file)); + }; + + const uploadImage = async (file) => { + const formData = new FormData(); + formData.append('file', file); + const token = localStorage.getItem('token'); + const res = await fetch('/api/images', { + method: 'POST', + headers: { 'Authorization': `Bearer ${token}` }, + body: formData, + }); + const json = await res.json(); + return json.imageUrl; + }; + + const handlePaste = (e) => { + const items = e.clipboardData?.items; + if (!items) return; + for (const item of items) { + if (item.type.startsWith('image/')) { + const file = item.getAsFile(); + if (file) { + setSelectedImage(file); + setImagePreview(URL.createObjectURL(file)); + } + break; + } } }; - const handleNewUnderstandCheck = async () => { - const text = newQuestion.trim(); + const handleCommentSubmit = async () => { + const text = commentText.trim(); if (!text) return; setIsSubmitting(true); - setSubmitError(null); try { - const res = await authFetch(`/api/sessions/${sessionId}/understanding-checks`, { + let imageUrl = null; + if (selectedImage) { + imageUrl = await uploadImage(selectedImage); + } + const res = await authFetch(`/api/questions/${questionId}/comments`, { method: 'POST', - body: JSON.stringify({ content: text }), + body: JSON.stringify({ content: text, parentCommentId: null, imageUrl }), }); if (!res.ok) throw new Error(); const json = await res.json(); if (json.isSuccess) { - setNewQuestion(''); - setUnderstandingIndex(0); - fetchQuestions(0); + if (isStaff) { + await authFetch(`/api/questions/${questionId}/status`, { method: 'PATCH' }); + setQuestion(prev => ({ ...prev, isResolved: true })); + } else if (question.isResolved) { + await authFetch(`/api/questions/${questionId}/status`, { method: 'PATCH' }); + setQuestion(prev => ({ ...prev, isResolved: false })); + } + + const newComment = { + commentId: json.result.commentId, + displayName: json.result.displayName, + content: json.result.content, + createdAt: json.result.createdAt, + imageUrl: imagePreview, + }; + setQuestion(prev => ({ + ...prev, + comments: [...(prev.comments ?? []), newComment], + })); + setCommentText(''); + setSelectedImage(null); + setImagePreview(null); } } catch (err) { - console.error('이해도 등록 실패:', err); - setSubmitError('이해도 등록에 실패했어요.'); + console.error('댓글 등록 실패:', err); } 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; - })(); + if (loading) return
불러오는 중...
; + if (!question) return
질문을 찾을 수 없어요
; - const currentChoice = myChoices[understanding?.current?.checkId]; + const isMyQuestion = question.isMine; return (
-

{sessionTitle}

- -
- {isStaff ? ( - + + {/* 작성자 행 */} +
+
+ {question.displayName} +
+
+ 익명 + {formatDate(question.createdAt)} +
+ {(isMyQuestion || isStaff) && ( +
+ + {showMenu && ( +
+ {isMyQuestion && ( + <> + + + + )} + {isStaff && !isMyQuestion && ( + <> + + {!question.isResolved && ( + + )} + + )} +
+ )} +
+ )} +
+ + {/* 상단 바: 해결 여부 */} +
+ {question.isResolved ? ( + 해결 질문 ) : ( - + 미해결 질문 )} -
- - {showSortMenu && ( -
    - {['기본', '최신순', '저도궁금해요순'].map(option => ( -
  • { setSortOrder(option); setShowSortMenu(false); }}> - {option} -
  • - ))} -
- )} -
-
- {/* 이해도 */} -
- - - {understanding?.current?.content ?? '이해도 없음'} - - ({understanding?.current?.understoodCount ?? 0}/ - {(understanding?.current?.understoodCount ?? 0) + (understanding?.current?.notUnderstoodCount ?? 0)}) - - + {/* 질문 내용 */} +
+ Q. + {isEditing ? ( +
+