- {submitError &&
{submitError}
}
-
-
-
setNewQuestion(e.target.value)}
- onKeyDown={e => {
- if (e.key === 'Enter') isStaff ? handleNewUnderstandCheck() : handleNewQuestion();
- }}
- disabled={isSubmitting}
- />
+ {/* 댓글 입력 바 */}
+
+ {imagePreview && (
+
+
+ className={styles.imageRemoveBtn}
+ onClick={() => { setSelectedImage(null); setImagePreview(null); }}
+ >✕
+ )}
+
+
+
+ setCommentText(e.target.value)}
+ onKeyDown={e => { if (e.key === 'Enter') handleCommentSubmit(); }}
+ onPaste={handlePaste}
+ disabled={isSubmitting}
+ />
+
- )}
+
);
}
-export default QnAListPage;
\ No newline at end of file
+export default QnADetailPage;
\ 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 9df34c4..161d608 100644
--- a/frontend/src/pages/qna/QnADetailPage.module.css
+++ b/frontend/src/pages/qna/QnADetailPage.module.css
@@ -341,6 +341,7 @@
display: block;
}
+
/* 댓글 타임스탬프 */
.commentDate {
margin-left: 42px;
@@ -368,18 +369,76 @@
left: 50%;
transform: translateX(-50%);
width: min(740px, 100vw - 32px);
- height: 56px;
border-radius: 30px;
background: var(--white);
box-shadow: 1px 2px 10px 0 rgba(0, 0, 0, 0.25);
display: flex;
- align-items: center;
- padding: 0 12px;
+ flex-direction: column;
+ padding: 8px 12px;
box-sizing: border-box;
z-index: 100;
+ gap: 4px;
+}
+
+.commentBlock {
+ padding: 8px 0;
+}
+
+.commentInputRow {
+ display: flex;
+ align-items: center;
+ width: 100%;
gap: 8px;
}
+.commentPlusBtn {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ background: var(--gray20);
+ border: none;
+ cursor: pointer;
+ font-size: 1.2rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ color: var(--gray600);
+}
+
+.imagePreviewWrapper {
+ position: relative;
+ display: inline-block;
+ margin: 4px 0 0 12px;
+ overflow: visible;
+ align-self: flex-start;
+}
+
+.imagePreview {
+ width: 80px;
+ height: 80px;
+ object-fit: cover;
+ border-radius: 8px;
+}
+
+.imageRemoveBtn {
+ position: absolute;
+ top: -6px;
+ right: -6px;
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ background: var(--gray600);
+ color: var(--white);
+ border: none;
+ cursor: pointer;
+ font-size: 0.7rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 101;
+}
+
.commentInput {
flex: 1;
border: none;
diff --git a/frontend/src/pages/qna/QnAListPage.js b/frontend/src/pages/qna/QnAListPage.js
index 6666a3f..4fa8b21 100644
--- a/frontend/src/pages/qna/QnAListPage.js
+++ b/frontend/src/pages/qna/QnAListPage.js
@@ -1,11 +1,11 @@
-import { useState, useEffect, useCallback } from 'react';
-import { useParams, useNavigate } from 'react-router-dom';
+import { useState, useEffect, useCallback, useRef } 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, SortBtn,
- OBtn, XBtn, CommentCommentArraw, SumitBtn,
+ OBtn, XBtn, CommentCommentArraw, SumitBtn, StaffCheck, ImgPreview,
} from '../../components/qna_svg';
const MAX_VISIBLE_COMMENTS = 3;
@@ -19,6 +19,8 @@ const DAY_OF_WEEK_KO = {
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('');
@@ -40,6 +42,14 @@ function QnAListPage() {
const [newQuestion, setNewQuestion] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState(null);
+ const [selectedImage, setSelectedImage] = useState(null);
+ const [imagePreview, setImagePreview] = useState(null);
+ const [commentImages, setCommentImages] = useState({});
+ const [commentImagePreviews, setCommentImagePreviews] = useState({});
+ const commentFileRefs = useRef({});
+
+ const fileInputRef = useRef(null);
+
const fetchQuestions = useCallback(async (index) => {
try {
@@ -59,15 +69,20 @@ function QnAListPage() {
...(questions.resolvedQuestions ?? []),
];
- const withLiked = await Promise.all(
+ // 이미지 blob URL 변환만 처리 (개별 API 호출 제거)
+ const withBlob = 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 };
+ let blobImageUrl = null;
+ if (q.imageUrl) {
+ try {
+ const imgRes = await authFetch(q.imageUrl);
+ const blob = await imgRes.blob();
+ blobImageUrl = URL.createObjectURL(blob);
+ } catch {
+ blobImageUrl = null;
+ }
}
+ return { ...q, iLiked: q.isLiked, imageUrl: blobImageUrl };
})
);
@@ -76,9 +91,9 @@ function QnAListPage() {
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)));
+ setPopularQuestions(withBlob.filter(q => popularIds.has(q.questionId)));
+ setUnresolvedQuestions(withBlob.filter(q => unresolvedIds.has(q.questionId)));
+ setResolvedQuestions(withBlob.filter(q => resolvedIds.has(q.questionId)));
} catch (err) {
console.error('질문 불러오기 실패:', err);
@@ -116,6 +131,7 @@ function QnAListPage() {
...prev.current,
understoodCount: json.result.understoodCount,
notUnderstoodCount: json.result.notUnderstoodCount,
+ attendanceCount: json.result.attendanceCount,
}
}));
}
@@ -126,6 +142,7 @@ function QnAListPage() {
const toggleLike = async (e, questionId) => {
e.stopPropagation();
+ if (isPast) return;
try {
const res = await authFetch(`/api/questions/${questionId}/like`, { method: 'POST' });
if (!res.ok) throw new Error();
@@ -147,6 +164,7 @@ function QnAListPage() {
const toggleCommentInput = (e, questionId) => {
e.stopPropagation();
+ if (isPast) return;
setCommentOpenId(prev => prev === questionId ? null : questionId);
};
@@ -159,9 +177,13 @@ function QnAListPage() {
const text = (commentInputs[questionId] || '').trim();
if (!text) return;
try {
+ let imageUrl = null;
+ if (commentImages[questionId]) {
+ imageUrl = await uploadImage(commentImages[questionId]);
+ }
const res = await authFetch(`/api/questions/${questionId}/comments`, {
method: 'POST',
- body: JSON.stringify({ content: text, parentCommentId: null }),
+ body: JSON.stringify({ content: text, parentCommentId: null, imageUrl }),
});
if (!res.ok) throw new Error();
const json = await res.json();
@@ -188,6 +210,8 @@ function QnAListPage() {
setUnresolvedQuestions(update);
setResolvedQuestions(update);
setCommentInputs(prev => ({ ...prev, [questionId]: '' }));
+ setCommentImages(prev => ({ ...prev, [questionId]: null }));
+ setCommentImagePreviews(prev => ({ ...prev, [questionId]: null }));
setCommentOpenId(null);
}
} catch (err) {
@@ -195,20 +219,66 @@ function QnAListPage() {
}
};
+ 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 handleCommentImageSelect = (e, questionId) => {
+ const file = e.target.files[0];
+ if (!file) return;
+ setCommentImages(prev => ({ ...prev, [questionId]: file }));
+ setCommentImagePreviews(prev => ({ ...prev, [questionId]: URL.createObjectURL(file) }));
+ };
+
+ const handleCommentPaste = (e, questionId) => {
+ const items = e.clipboardData?.items;
+ if (!items) return;
+ for (const item of items) {
+ if (item.type.startsWith('image/')) {
+ const file = item.getAsFile();
+ if (file) {
+ setCommentImages(prev => ({ ...prev, [questionId]: file }));
+ setCommentImagePreviews(prev => ({ ...prev, [questionId]: URL.createObjectURL(file) }));
+ }
+ break;
+ }
+ }
+ };
const handleNewQuestion = async () => {
const text = newQuestion.trim();
if (!text) return;
setIsSubmitting(true);
setSubmitError(null);
try {
+ let imageUrl = null;
+ if (selectedImage) {
+ imageUrl = await uploadImage(selectedImage);
+ }
const res = await authFetch(`/api/sessions/${sessionId}/questions`, {
method: 'POST',
- body: JSON.stringify({ content: text }),
+ body: JSON.stringify({ content: text, imageUrl }),
});
if (!res.ok) throw new Error();
const json = await res.json();
if (json.isSuccess) {
setNewQuestion('');
+ setSelectedImage(null);
+ setImagePreview(null);
fetchQuestions(understandingIndex);
}
} catch (err) {
@@ -313,14 +383,14 @@ function QnAListPage() {
{understanding?.current?.content ?? '이해도 없음'}
- ({understanding?.current?.understoodCount ?? 0}/
- {(understanding?.current?.understoodCount ?? 0) + (understanding?.current?.notUnderstoodCount ?? 0)})
+ ({understanding?.current?.respondedCount ?? 0}/
+ {understanding?.current?.attendanceCount ?? 0})