From 92b515e51b630c13c234c656c1dec59d3812de9d Mon Sep 17 00:00:00 2001 From: issuejong Date: Sun, 7 Jun 2026 11:54:21 +0900 Subject: [PATCH 1/8] =?UTF-8?q?[Feat]=20=ED=94=84=EB=A1=A0=ED=8A=B8=20SSE?= =?UTF-8?q?=20=EA=B5=AC=EB=8F=85=20=EC=9C=A0=ED=8B=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/utils/sse.js | 190 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 frontend/src/utils/sse.js diff --git a/frontend/src/utils/sse.js b/frontend/src/utils/sse.js new file mode 100644 index 0000000..d994219 --- /dev/null +++ b/frontend/src/utils/sse.js @@ -0,0 +1,190 @@ +const DEFAULT_RETRY_DELAY = 3000; + +function buildSseHeaders(headers = {}) { + const token = localStorage.getItem('token'); + const authHeader = token ? { Authorization: `Bearer ${token}` } : {}; + + return { + Accept: 'text/event-stream', + 'Cache-Control': 'no-cache', + ...authHeader, + ...headers, + }; +} + +function parseSseData(rawData) { + if (!rawData) { + return null; + } + + try { + return JSON.parse(rawData); + } catch { + return rawData; + } +} + +export function parseSseMessage(rawMessage) { + const lines = rawMessage.split('\n'); + let event = 'message'; + let id = null; + let retry = null; + const dataLines = []; + + lines.forEach((line) => { + if (!line || line.startsWith(':')) { + return; + } + + const separatorIndex = line.indexOf(':'); + const field = separatorIndex === -1 ? line : line.slice(0, separatorIndex); + let value = separatorIndex === -1 ? '' : line.slice(separatorIndex + 1); + + if (value.startsWith(' ')) { + value = value.slice(1); + } + + if (field === 'event') { + event = value; + } else if (field === 'data') { + dataLines.push(value); + } else if (field === 'id') { + id = value; + } else if (field === 'retry') { + const retryValue = Number(value); + retry = Number.isNaN(retryValue) ? null : retryValue; + } + }); + + const rawData = dataLines.join('\n'); + + return { + event, + data: parseSseData(rawData), + rawData, + id, + retry, + }; +} + +async function readSseStream(stream, onEvent, signal) { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (!signal.aborted) { + const { done, value } = await reader.read(); + + if (done) { + break; + } + + buffer += decoder.decode(value, { stream: true }).replace(/\r\n/g, '\n'); + + let messageEndIndex = buffer.indexOf('\n\n'); + while (messageEndIndex !== -1) { + const rawMessage = buffer.slice(0, messageEndIndex); + buffer = buffer.slice(messageEndIndex + 2); + + if (rawMessage.trim()) { + onEvent(parseSseMessage(rawMessage)); + } + + messageEndIndex = buffer.indexOf('\n\n'); + } + } + + buffer += decoder.decode(); + + if (buffer.trim()) { + onEvent(parseSseMessage(buffer)); + } +} + +export function subscribeToSse( + url, + { + headers, + retry = true, + retryDelay = DEFAULT_RETRY_DELAY, + onOpen, + onEvent, + onError, + } = {} +) { + let closed = false; + let retryTimer = null; + let controller = null; + + const clearRetryTimer = () => { + if (retryTimer) { + clearTimeout(retryTimer); + retryTimer = null; + } + }; + + const scheduleReconnect = () => { + if (closed || !retry) { + return; + } + + clearRetryTimer(); + retryTimer = setTimeout(connect, retryDelay); + }; + + const connect = async () => { + controller = new AbortController(); + + try { + const response = await fetch(url, { + method: 'GET', + headers: buildSseHeaders(headers), + signal: controller.signal, + }); + + if (!response.ok) { + const error = new Error(`SSE connection failed: ${response.status}`); + error.status = response.status; + error.retryable = response.status === 429 || response.status >= 500; + throw error; + } + if (!response.body) { + throw new Error('SSE response body is empty.'); + } + + onOpen?.(response); + await readSseStream(response.body, (message) => { + if (!closed) { + onEvent?.(message); + } + }, controller.signal); + + scheduleReconnect(); + } catch (error) { + if (closed || error.name === 'AbortError') { + return; + } + + onError?.(error); + if (error.retryable !== false) { + scheduleReconnect(); + } + } + }; + + connect(); + + return () => { + closed = true; + clearRetryTimer(); + controller?.abort(); + }; +} + +export function subscribeQuestionEvents(sessionId, handlers = {}) { + if (!sessionId) { + return () => {}; + } + + return subscribeToSse(`/api/sessions/${sessionId}/questions/events`, handlers); +} From 52ac7488bf5ea24b93c47768f48a99efc85f9870 Mon Sep 17 00:00:00 2001 From: issuejong Date: Sun, 7 Jun 2026 11:59:31 +0900 Subject: [PATCH 2/8] =?UTF-8?q?[Feat]=20=EC=A7=88=EB=AC=B8=20=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20SSE=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/qna/QnAListPage.js | 39 +++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/frontend/src/pages/qna/QnAListPage.js b/frontend/src/pages/qna/QnAListPage.js index 4858121..85a00e3 100644 --- a/frontend/src/pages/qna/QnAListPage.js +++ b/frontend/src/pages/qna/QnAListPage.js @@ -3,6 +3,7 @@ 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 { subscribeQuestionEvents } from '../../utils/sse'; import { CommentImoji, MeCuriousToo, SortBtn, OBtn, XBtn, CommentCommentArraw, SumitBtn, StaffCheck, ImgPreview, @@ -111,6 +112,44 @@ function QnAListPage() { if (sessionId) fetchQuestions(understandingIndex); }, [sessionId, understandingIndex, fetchQuestions]); + const handleQuestionEvent = useCallback((message) => { + const { event, data } = message; + + switch (event) { + case 'connected': + console.debug('질문방 SSE 연결 완료'); + break; + case 'comment-created': + console.debug('댓글 생성 이벤트 수신:', data); + break; + case 'question-created': + console.debug('질문 생성 이벤트 수신:', data); + break; + case 'understanding-check-created': + console.debug('이해도 체크 생성 이벤트 수신:', data); + break; + case 'understanding-response-updated': + console.debug('이해도 응답 갱신 이벤트 수신:', data); + break; + default: + console.debug('알 수 없는 질문방 SSE 이벤트 수신:', message); + break; + } + }, []); + + useEffect(() => { + if (!sessionId) { + return undefined; + } + + return subscribeQuestionEvents(sessionId, { + onEvent: handleQuestionEvent, + onError: (error) => { + console.error('질문방 SSE 연결 실패:', error); + }, + }); + }, [sessionId, handleQuestionEvent]); + // ── 이해도 네비게이션 ──────────────────────────── const goPrevUnderstand = () => { if (understanding?.hasOlder) setUnderstandingIndex(prev => prev + 1); From a04f9ebd5091a1010e0ad89f1e5060e81927efcb Mon Sep 17 00:00:00 2001 From: issuejong Date: Sun, 7 Jun 2026 12:06:03 +0900 Subject: [PATCH 3/8] =?UTF-8?q?[Feat]=20=EC=A7=88=EB=AC=B8=20=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=8C=93=EA=B8=80=20=EC=8B=A4=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/qna/QnAListPage.js | 89 +++++++++++++++++++++++++-- 1 file changed, 83 insertions(+), 6 deletions(-) diff --git a/frontend/src/pages/qna/QnAListPage.js b/frontend/src/pages/qna/QnAListPage.js index 85a00e3..f83bc54 100644 --- a/frontend/src/pages/qna/QnAListPage.js +++ b/frontend/src/pages/qna/QnAListPage.js @@ -11,6 +11,56 @@ import { } from '../../utils/qnaUtils'; const MAX_VISIBLE_COMMENTS = 3; +const POPULAR_LIKE_THRESHOLD = 5; + +const getQuestionGroupKey = (question) => { + if (question.isResolved) return 'resolvedQuestions'; + if ((question.likeCount ?? 0) >= POPULAR_LIKE_THRESHOLD) return 'popularQuestions'; + return 'unresolvedQuestions'; +}; + +const regroupQuestions = (questions) => { + const groups = { + popularQuestions: [], + unresolvedQuestions: [], + resolvedQuestions: [], + }; + const seenQuestionIds = new Set(); + + questions.forEach(question => { + if (seenQuestionIds.has(question.questionId)) return; + seenQuestionIds.add(question.questionId); + groups[getQuestionGroupKey(question)].push(question); + }); + + return groups; +}; + +const updateQuestionGroupsByCommentEvent = (groups, eventData) => { + if (!eventData?.questionId) return groups; + + let hasUpdatedQuestion = false; + const questions = [ + ...groups.popularQuestions, + ...groups.unresolvedQuestions, + ...groups.resolvedQuestions, + ].map(question => { + if (question.questionId !== eventData.questionId) return question; + + hasUpdatedQuestion = true; + const isResolved = eventData.isResolved ?? question.isResolved; + + return { + ...question, + isResolved, + isPopular: !isResolved && (question.likeCount ?? 0) >= POPULAR_LIKE_THRESHOLD, + commentCount: eventData.commentCount ?? question.commentCount, + previewComments: eventData.previewComments ?? question.previewComments, + }; + }); + + return hasUpdatedQuestion ? regroupQuestions(questions) : groups; +}; function QnAListPage() { const { sessionId } = useParams(); @@ -29,6 +79,11 @@ function QnAListPage() { const [popularQuestions, setPopularQuestions] = useState([]); const [unresolvedQuestions, setUnresolvedQuestions] = useState([]); const [resolvedQuestions, setResolvedQuestions] = useState([]); + const questionGroupsRef = useRef({ + popularQuestions: [], + unresolvedQuestions: [], + resolvedQuestions: [], + }); // ── 필터 / 정렬 상태 ───────────────────────────── const [filterCurious, setFilterCurious] = useState(false); @@ -51,6 +106,21 @@ function QnAListPage() { const [imagePreview, setImagePreview] = useState(null); const fileInputRef = useRef(null); + const applyQuestionGroups = useCallback((groups) => { + questionGroupsRef.current = groups; + setPopularQuestions(groups.popularQuestions); + setUnresolvedQuestions(groups.unresolvedQuestions); + setResolvedQuestions(groups.resolvedQuestions); + }, []); + + useEffect(() => { + questionGroupsRef.current = { + popularQuestions, + unresolvedQuestions, + resolvedQuestions, + }; + }, [popularQuestions, unresolvedQuestions, resolvedQuestions]); + // ── 질문 목록 불러오기 ─────────────────────────── const fetchQuestions = useCallback(async (index) => { try { @@ -99,19 +169,26 @@ function QnAListPage() { const unresolvedIds = idSet(questions.unresolvedQuestions ?? []); const resolvedIds = idSet(questions.resolvedQuestions ?? []); - setPopularQuestions(withBlob.filter(q => popularIds.has(q.questionId))); - setUnresolvedQuestions(withBlob.filter(q => unresolvedIds.has(q.questionId))); - setResolvedQuestions(withBlob.filter(q => resolvedIds.has(q.questionId))); + applyQuestionGroups({ + popularQuestions: withBlob.filter(q => popularIds.has(q.questionId)), + unresolvedQuestions: withBlob.filter(q => unresolvedIds.has(q.questionId)), + resolvedQuestions: withBlob.filter(q => resolvedIds.has(q.questionId)), + }); } catch (err) { console.error('질문 불러오기 실패:', err); } - }, [sessionId]); + }, [sessionId, applyQuestionGroups]); useEffect(() => { if (sessionId) fetchQuestions(understandingIndex); }, [sessionId, understandingIndex, fetchQuestions]); + const handleCommentCreatedEvent = useCallback((eventData) => { + const nextGroups = updateQuestionGroupsByCommentEvent(questionGroupsRef.current, eventData); + applyQuestionGroups(nextGroups); + }, [applyQuestionGroups]); + const handleQuestionEvent = useCallback((message) => { const { event, data } = message; @@ -120,7 +197,7 @@ function QnAListPage() { console.debug('질문방 SSE 연결 완료'); break; case 'comment-created': - console.debug('댓글 생성 이벤트 수신:', data); + handleCommentCreatedEvent(data); break; case 'question-created': console.debug('질문 생성 이벤트 수신:', data); @@ -135,7 +212,7 @@ function QnAListPage() { console.debug('알 수 없는 질문방 SSE 이벤트 수신:', message); break; } - }, []); + }, [handleCommentCreatedEvent]); useEffect(() => { if (!sessionId) { From bfd079404889b5bdfcc51aadc16f5e004e1f9fda Mon Sep 17 00:00:00 2001 From: issuejong Date: Sun, 7 Jun 2026 12:13:29 +0900 Subject: [PATCH 4/8] =?UTF-8?q?[Feat]=20=EC=A7=88=EB=AC=B8=20=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EC=9D=B4=ED=95=B4=EB=8F=84=20=EC=8B=A4=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/qna/QnAListPage.js | 146 +++++++++++++++++++++++++- 1 file changed, 142 insertions(+), 4 deletions(-) diff --git a/frontend/src/pages/qna/QnAListPage.js b/frontend/src/pages/qna/QnAListPage.js index f83bc54..585e4df 100644 --- a/frontend/src/pages/qna/QnAListPage.js +++ b/frontend/src/pages/qna/QnAListPage.js @@ -62,6 +62,48 @@ const updateQuestionGroupsByCommentEvent = (groups, eventData) => { return hasUpdatedQuestion ? regroupQuestions(questions) : groups; }; +const addQuestionToGroups = (groups, question) => { + if (!question?.questionId) return groups; + + const existingQuestions = [ + ...groups.popularQuestions, + ...groups.unresolvedQuestions, + ...groups.resolvedQuestions, + ]; + const alreadyExists = existingQuestions.some(item => item.questionId === question.questionId); + + if (alreadyExists) return groups; + return regroupQuestions([question, ...existingQuestions]); +}; + +const buildUnderstandingCheckFromEvent = (eventData) => ({ + checkId: eventData.checkId, + content: eventData.content, + respondedCount: eventData.respondedCount ?? 0, + attendanceCount: eventData.attendanceCount ?? 0, + understoodCount: eventData.understoodCount ?? 0, + notUnderstoodCount: eventData.notUnderstoodCount ?? 0, + selectedChoice: null, + createdAt: eventData.createdAt, +}); + +const updateCurrentUnderstandingCounts = (understanding, eventData) => { + if (!understanding?.current || understanding.current.checkId !== eventData?.checkId) { + return understanding; + } + + return { + ...understanding, + current: { + ...understanding.current, + respondedCount: eventData.respondedCount ?? understanding.current.respondedCount, + attendanceCount: eventData.attendanceCount ?? understanding.current.attendanceCount, + understoodCount: eventData.understoodCount ?? understanding.current.understoodCount, + notUnderstoodCount: eventData.notUnderstoodCount ?? understanding.current.notUnderstoodCount, + }, + }; +}; + function QnAListPage() { const { sessionId } = useParams(); const navigate = useNavigate(); @@ -74,6 +116,7 @@ function QnAListPage() { const [understanding, setUnderstanding] = useState(null); const [understandingIndex, setUnderstandingIndex] = useState(0); const [myChoices, setMyChoices] = useState({}); + const understandingRef = useRef(null); // ── 질문 목록 상태 ─────────────────────────────── const [popularQuestions, setPopularQuestions] = useState([]); @@ -113,6 +156,10 @@ function QnAListPage() { setResolvedQuestions(groups.resolvedQuestions); }, []); + useEffect(() => { + understandingRef.current = understanding; + }, [understanding]); + useEffect(() => { questionGroupsRef.current = { popularQuestions, @@ -189,6 +236,92 @@ function QnAListPage() { applyQuestionGroups(nextGroups); }, [applyQuestionGroups]); + const buildQuestionFromCreatedEvent = useCallback(async (eventData) => { + if (!eventData?.questionId) return null; + + let blobImageUrl = null; + if (eventData.imageUrl) { + try { + const imgRes = await authFetch(eventData.imageUrl); + const blob = await imgRes.blob(); + blobImageUrl = URL.createObjectURL(blob); + } catch { + blobImageUrl = null; + } + } + + return { + questionId: eventData.questionId, + content: eventData.content, + imageUrl: blobImageUrl, + isResolved: false, + isPopular: false, + isLiked: false, + isMine: false, + iLiked: false, + likeCount: eventData.likeCount ?? 0, + commentCount: eventData.commentCount ?? 0, + previewComments: [], + createdAt: eventData.createdAt, + }; + }, []); + + const handleQuestionCreatedEvent = useCallback(async (eventData) => { + const createdQuestion = await buildQuestionFromCreatedEvent(eventData); + if (!createdQuestion) return; + + const nextGroups = addQuestionToGroups(questionGroupsRef.current, createdQuestion); + applyQuestionGroups(nextGroups); + }, [applyQuestionGroups, buildQuestionFromCreatedEvent]); + + const handleUnderstandingCheckCreatedEvent = useCallback((eventData) => { + if (!eventData?.checkId) return; + if (understandingRef.current?.current?.checkId === eventData.checkId) return; + + if (understandingIndex === 0) { + setUnderstanding(prev => { + if (prev?.current?.checkId === eventData.checkId) return prev; + + const previousTotalCount = prev?.totalCount ?? 0; + const nextUnderstanding = { + current: buildUnderstandingCheckFromEvent(eventData), + currentIndex: 0, + totalCount: previousTotalCount + 1, + hasOlder: previousTotalCount > 0, + hasNewer: false, + }; + understandingRef.current = nextUnderstanding; + return nextUnderstanding; + }); + return; + } + + setUnderstanding(prev => { + if (prev?.current?.checkId === eventData.checkId) return prev; + + const nextTotalCount = (prev?.totalCount ?? understandingIndex + 1) + 1; + const nextCurrentIndex = (prev?.currentIndex ?? understandingIndex) + 1; + const nextUnderstanding = { + ...(prev ?? {}), + currentIndex: nextCurrentIndex, + totalCount: nextTotalCount, + hasOlder: nextCurrentIndex < nextTotalCount - 1, + hasNewer: true, + }; + understandingRef.current = nextUnderstanding; + return nextUnderstanding; + }); + setUnderstandingIndex(prev => prev + 1); + }, [understandingIndex]); + + const handleUnderstandingResponseUpdatedEvent = useCallback((eventData) => { + setUnderstanding(prev => { + const nextUnderstanding = updateCurrentUnderstandingCounts(prev, eventData); + understandingRef.current = nextUnderstanding; + return nextUnderstanding; + }); + }, []); + const handleQuestionEvent = useCallback((message) => { const { event, data } = message; @@ -200,19 +333,24 @@ function QnAListPage() { handleCommentCreatedEvent(data); break; case 'question-created': - console.debug('질문 생성 이벤트 수신:', data); + void handleQuestionCreatedEvent(data); break; case 'understanding-check-created': - console.debug('이해도 체크 생성 이벤트 수신:', data); + handleUnderstandingCheckCreatedEvent(data); break; case 'understanding-response-updated': - console.debug('이해도 응답 갱신 이벤트 수신:', data); + handleUnderstandingResponseUpdatedEvent(data); break; default: console.debug('알 수 없는 질문방 SSE 이벤트 수신:', message); break; } - }, [handleCommentCreatedEvent]); + }, [ + handleCommentCreatedEvent, + handleQuestionCreatedEvent, + handleUnderstandingCheckCreatedEvent, + handleUnderstandingResponseUpdatedEvent, + ]); useEffect(() => { if (!sessionId) { From 9faf6bfad067c30ff5736328bbc0fed17cb01f39 Mon Sep 17 00:00:00 2001 From: issuejong Date: Sun, 7 Jun 2026 12:19:07 +0900 Subject: [PATCH 5/8] =?UTF-8?q?[Feat]=20=EC=A7=88=EB=AC=B8=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EC=8B=A4=EC=8B=9C=EA=B0=84=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/qna/QnADetailPage.js | 287 +++++++++++------- .../src/pages/qna/QnADetailPage.module.css | 41 ++- 2 files changed, 214 insertions(+), 114 deletions(-) diff --git a/frontend/src/pages/qna/QnADetailPage.js b/frontend/src/pages/qna/QnADetailPage.js index b58cea4..1c7c7b9 100644 --- a/frontend/src/pages/qna/QnADetailPage.js +++ b/frontend/src/pages/qna/QnADetailPage.js @@ -1,5 +1,5 @@ import '../../assets/styles/global.css'; -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import styles from './QnADetailPage.module.css'; import { FiMoreVertical, FiCornerDownRight } from 'react-icons/fi'; @@ -12,6 +12,7 @@ import { } from '../../utils/qnaUtils'; import profileImg from '../../assets/images/profile.png'; import { authFetch } from '../../utils/Api'; +import { subscribeQuestionEvents } from '../../utils/sse'; // 시간만 표시하는 포맷 함수 (HH:MM) const formatTime = (dateStr) => { @@ -24,8 +25,46 @@ const formatTime = (dateStr) => { }); }; +const createBlobImageUrl = async (imageUrl) => { + if (!imageUrl) return null; + + try { + const imgRes = await authFetch(imageUrl); + const blob = await imgRes.blob(); + return URL.createObjectURL(blob); + } catch { + return null; + } +}; + +const attachCommentBlobImages = async (comments = []) => Promise.all( + comments.map(async (comment) => ({ + ...comment, + imageUrl: await createBlobImageUrl(comment.imageUrl), + replies: await attachCommentBlobImages(comment.replies ?? []), + })) +); + +const removeCommentFromTree = (comments = [], commentId) => comments + .filter(comment => comment.commentId !== commentId) + .map(comment => ({ + ...comment, + replies: removeCommentFromTree(comment.replies ?? [], commentId), + })); + +const updateCommentInTree = (comments = [], commentId, updater) => comments.map(comment => { + if (comment.commentId === commentId) { + return updater(comment); + } + + return { + ...comment, + replies: updateCommentInTree(comment.replies ?? [], commentId, updater), + }; +}); + function QnADetailPage() { - const { questionId } = useParams(); + const { sessionId, questionId } = useParams(); const navigate = useNavigate(); const isStaff = localStorage.getItem('role') === 'ADMIN'; @@ -50,58 +89,69 @@ function QnADetailPage() { const [editingCommentId, setEditingCommentId] = useState(null); const [editCommentText, setEditCommentText] = useState(''); - // ── 질문 불러오기 ──────────────────────────────── - useEffect(() => { - document.title = "Q&A | PIROIN"; - - const fetchQuestion = async () => { - try { + const fetchQuestion = useCallback(async ({ showLoading = false } = {}) => { + try { + if (showLoading) { 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; - - // 질문 이미지 blob 변환 - if (result.imageUrl) { - try { - const imgRes = await authFetch(result.imageUrl); - const blob = await imgRes.blob(); - result.imageUrl = URL.createObjectURL(blob); - } catch { - result.imageUrl = null; - } - } + } - // 댓글 이미지 blob 변환 - 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 { + 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; + + // 질문 이미지 blob 변환 + result.imageUrl = await createBlobImageUrl(result.imageUrl); + + // 댓글과 대댓글 이미지 blob 변환 + if (result.comments) { + result.comments = await attachCommentBlobImages(result.comments); + } + setQuestion(result); + } catch (err) { + console.error('질문 불러오기 실패:', err); + } finally { + if (showLoading) { setLoading(false); } - }; - if (questionId) fetchQuestion(); + } }, [questionId]); + // ── 질문 불러오기 ──────────────────────────────── + useEffect(() => { + document.title = "Q&A | PIROIN"; + + if (questionId) { + void fetchQuestion({ showLoading: true }); + } + }, [questionId, fetchQuestion]); + + const handleQuestionEvent = useCallback((message) => { + if (message.event !== 'comment-created') { + return; + } + if (String(message.data?.questionId) !== String(questionId)) { + return; + } + + void fetchQuestion(); + }, [fetchQuestion, questionId]); + + useEffect(() => { + if (!sessionId || !questionId) { + return undefined; + } + + return subscribeQuestionEvents(sessionId, { + onEvent: handleQuestionEvent, + onError: (error) => { + console.error('질문 상세 SSE 연결 실패:', error); + }, + }); + }, [sessionId, questionId, handleQuestionEvent]); + // ── 메뉴 외부 클릭 시 닫기 ────────────────────── useEffect(() => { const handleClickOutside = () => { @@ -255,7 +305,7 @@ function QnADetailPage() { if (!res.ok) throw new Error(); setQuestion(prev => ({ ...prev, - comments: prev.comments.filter(c => c.commentId !== commentId), + comments: removeCommentFromTree(prev.comments ?? [], commentId), })); } catch (err) { console.error('댓글 삭제 실패:', err); @@ -283,8 +333,10 @@ function QnADetailPage() { if (json.isSuccess) { setQuestion(prev => ({ ...prev, - comments: prev.comments.map(c => - c.commentId === commentId ? { ...c, content: text } : c + comments: updateCommentInTree( + prev.comments ?? [], + commentId, + comment => ({ ...comment, content: text }) ), })); setEditingCommentId(null); @@ -298,6 +350,75 @@ function QnADetailPage() { if (!question) return
질문을 찾을 수 없어요
; const isMyQuestion = question.isMine; + const renderCommentBlock = (comment, isReply = false) => ( +
+ {/* 댓글 작성자 행 */} +
+
+ {comment.displayName} +
+ + {comment.displayName} + {comment.displayName?.startsWith('운영진') && ( + + )} + + {/* 본인 댓글만 수정/삭제 메뉴 표시 */} + {comment.isMine && ( +
+ + {commentMenuId === comment.commentId && ( +
+ + +
+ )} +
+ )} +
+ + {/* 댓글 말풍선 */} +
+ {editingCommentId === comment.commentId ? ( +
+