Skip to content
This repository was archived by the owner on Jan 2, 2026. It is now read-only.

Commit 851f9b0

Browse files
authored
Feature/#51 뉴스 하이라이팅 기능 개발
* ✨ Feature: 뉴스 상세페이지 하이라이팅기능 개발 * ✨ Feature: 인용구 메시지 렌더링 추가 및 링크 클릭 시 이동 기능 구현 * ✨ Feature: 이전 채팅 불러오기 추가
1 parent 46e3ca5 commit 851f9b0

7 files changed

Lines changed: 532 additions & 81 deletions

File tree

src/app/api/chat/chatLoadingApi.js

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import instance from '@/lib/axios';
2+
3+
/**
4+
* 채팅방의 이전 메시지들을 불러오는 함수
5+
*
6+
* @param {string} articleId - 채팅방 ID
7+
* @returns {Promise<Object>} 정렬된 채팅 메시지 데이터
8+
* - 성공 시: { message: "요청 성공", data: { items: [...], hasNext: boolean } }
9+
* - 실패 시: { message: "채팅 메시지 로딩 실패", data: { items: [], hasNext: false } }
10+
*/
11+
export const fetchChatMessages = async (articleId) => {
12+
try {
13+
const response = await instance.get(`/api/chat/${articleId}/messages`);
14+
// timestamp 기준으로 과거 채팅이 위로오게 정렬
15+
const sortedData = {
16+
...response.data,
17+
data: {
18+
...response.data.data,
19+
items: response.data.data.items.sort((a, b) =>
20+
new Date(a.timestamp) - new Date(b.timestamp)
21+
)
22+
}
23+
};
24+
return sortedData;
25+
} catch (error) {
26+
console.warn('채팅 메시지 로딩 실패:', error);
27+
return {
28+
message: '채팅 메시지 로딩 실패',
29+
data: {
30+
items: [],
31+
hasNext: false
32+
}
33+
};
34+
}
35+
}
36+
37+
export const fetchOlderChatMessages = async (articleId, beforeTimestamp) => {
38+
try {
39+
const response = await instance.get(`/api/chat/${articleId}/messages/older?before=${beforeTimestamp}`);
40+
// timestamp 기준으로 과거 채팅이 위로오게 정렬
41+
const sortedData = {
42+
...response.data,
43+
data: {
44+
...response.data.data,
45+
items: response.data.data.items.sort((a, b) =>
46+
new Date(a.timestamp) - new Date(b.timestamp)
47+
)
48+
}
49+
};
50+
return sortedData;
51+
} catch (error) {
52+
console.warn('채팅 메시지 로딩 실패:', error);
53+
return {
54+
message: '채팅 메시지 로딩 실패',
55+
data: {
56+
items: [],
57+
hasNext: false
58+
}
59+
};
60+
}
61+
}
62+

src/app/api/chat/quote.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import instance from '@/lib/axios';
2+
3+
export const scrapQuote = async (articleId, quote) => {
4+
try {
5+
const response = await instance.post(`/api/scrap/${articleId}`,
6+
quote
7+
);
8+
return response.data;
9+
} catch (error) {
10+
throw error.response?.data.message || { message: '회원가입 중 오류가 발생했습니다.' };
11+
}
12+
};

src/app/news/detail/[id]/page.js

Lines changed: 129 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import SummaryIcon from '@/components/icons/SummaryIcon';
1313
import { Stomp } from '@stomp/stompjs';
1414
import SockJS from 'sockjs-client';
1515
import { SOCKET_CONFIG, SOCKET_CONNECTION_TYPE } from '@/constants/socketConstants';
16+
import SelectableText from '@/components/SelectableText';
17+
import HighlightedText from '@/components/HighlightedText';
1618

1719

1820
// 이미지 URL에서 사이즈 정보 제거하는 함수
@@ -31,11 +33,27 @@ const NewsDetailPage = () => {
3133
const [isSummaryOpen, setIsSummaryOpen] = useState(false);
3234
const [userCount, setUserCount] = useState(0);
3335
const [isChatOpen, setIsChatOpen] = useState(false);
36+
const [isChatLoading, setIsChatLoading] = useState(false);
37+
const [selectedQuote, setSelectedQuote] = useState(null);
3438
const chatRoomRef = useRef(null);
3539
const [showErrorToast, setShowErrorToast] = useState(false);
3640
const [errorMessage, setErrorMessage] = useState('');
3741
const socketRef = useRef(null);
38-
42+
const [highlightSegments, setHighlightSegments] = useState([]);
43+
44+
// 인용구 클릭 시 해당 문단으로 스크롤 이동하는 함수
45+
const handleQuoteScroll = (paragraphIndex) => {
46+
const newsContent = document.getElementById('news-content');
47+
if (newsContent) {
48+
const paragraphs = newsContent.getElementsByTagName('p');
49+
if (paragraphs[paragraphIndex]) {
50+
paragraphs[paragraphIndex].scrollIntoView({
51+
behavior: 'smooth',
52+
block: 'center'
53+
});
54+
}
55+
}
56+
};
3957

4058
const scrollToChatRoom = () => {
4159
chatRoomRef.current?.scrollIntoView({ behavior: 'smooth' });
@@ -69,6 +87,7 @@ const NewsDetailPage = () => {
6987

7088
setNews(newsData);
7189
setSelectedCategory(getCategoryId(data.category));
90+
setHighlightSegments(data.highlightSegments || []);
7291
} catch (error) {
7392
console.error('뉴스를 가져오는데 실패했습니다:', error);
7493
setError(error.message);
@@ -110,42 +129,76 @@ const NewsDetailPage = () => {
110129

111130
// 채팅 에러 핸들러 추가
112131
const handleChatError = (error) => {
113-
// if (error.code === 'AUTH_REQUIRED') {
114-
// setErrorMessage('로그인이 필요한 서비스입니다.');
115-
// setShowErrorToast(true);
116-
// setIsChatOpen(false);
117-
118-
// // 3초 후 토스트 메시지 숨기기
119-
// setTimeout(() => {
120-
// setShowErrorToast(false);
121-
// }, 3000);
122-
// }
123132
setErrorMessage('채팅 서비스 연결에 실패했습니다.');
124-
setShowErrorToast(true);
125-
setIsChatOpen(false);
126-
127-
// 3초 후 토스트 메시지 숨기기
128-
setTimeout(() => {
129-
setShowErrorToast(false);
130-
}, 3000);
133+
setShowErrorToast(true);
134+
setIsChatOpen(false);
135+
setIsChatLoading(false);
136+
137+
// 3초 후 토스트 메시지 숨기기
138+
setTimeout(() => {
139+
setShowErrorToast(false);
140+
}, 3000);
131141
};
132142

143+
// 문단별 하이라이트 분리
144+
const getHighlightsForParagraph = (idx) =>
145+
highlightSegments.filter(seg => seg.paragraphIndex === idx);
146+
147+
// // 스크랩 후 하이라이트 정보 갱신
148+
// const handleSendScrap = async ({ snippetText, startOffset, endOffset, paragraphIndex }) => {
149+
// await fetch(`api/public/news/${params.id}/scrap`, {
150+
// method: 'POST',
151+
// headers: { 'Content-Type': 'application/json' },
152+
// body: JSON.stringify({ snippetText, startOffset, endOffset, paragraphIndex })
153+
// });
154+
// // 스크랩 저장 후, 다시 하이라이트 정보 갱신
155+
// const res2 = await getNewsDetail(params.id);
156+
// setHighlightSegments(res2.data.highlightSegments || []);
157+
// };
158+
133159
// content를 문단별로 나누는 함수
134160
const parseContent = (content) => {
161+
// 일반 텍스트 렌더링 함수
162+
const renderText = (text, key = 0) => (
163+
<p key={key} className="mb-4 leading-relaxed text-lg">
164+
<HighlightedText
165+
text={text}
166+
highlights={getHighlightsForParagraph(key)}
167+
/>
168+
</p>
169+
);
170+
171+
// SelectableText로 감싸서 렌더링하는 함수
172+
const renderSelectableText = (text, index = 0) => (
173+
<p key={index} className="mb-4 leading-relaxed text-lg">
174+
<SelectableText
175+
text={text}
176+
paragraphIndex={index}
177+
onSend={(selectionInfo) => {
178+
console.log('Selected text info:', selectionInfo);
179+
setSelectedQuote(selectionInfo);
180+
}}
181+
>
182+
<HighlightedText
183+
text={text}
184+
highlights={getHighlightsForParagraph(index)}
185+
/>
186+
</SelectableText>
187+
</p>
188+
);
189+
135190
try {
136191
// 문자열을 파싱하여 배열로 변환
137192
const paragraphs = JSON.parse(content);
138193

139-
// 각 문단을 p 태그로 감싸서 반환
140-
return paragraphs.map((paragraph, index) => (
141-
<p key={index} className="mb-4 leading-relaxed text-lg">
142-
{parse(paragraph)}
143-
</p>
144-
));
194+
// 각 문단을 렌더링
195+
return paragraphs.map((paragraph, index) =>
196+
isChatOpen ? renderSelectableText(paragraph, index) : renderText(paragraph, index)
197+
);
145198
} catch (error) {
146199
console.error('Content 파싱 중 오류 발생:', error);
147-
// 파싱 실패 시 원본 content를 그대로 반환
148-
return <p className="mb-4 leading-relaxed text-lg">{parse(content)}</p>;
200+
// 문자열 파싱 실패 시 원본 content를 그대로 반환
201+
return isChatOpen ? renderSelectableText(content) : renderText(content);
149202
}
150203
};
151204

@@ -279,7 +332,7 @@ const NewsDetailPage = () => {
279332

280333
{/* 본문 */}
281334
<div className="space-y-6">
282-
<div className="prose prose-lg max-w-none">
335+
<div id="news-content" className="prose prose-lg max-w-none">
283336
{parseContent(news.content)}
284337
</div>
285338
</div>
@@ -333,7 +386,18 @@ const NewsDetailPage = () => {
333386
</div>
334387
</div>
335388
<div className="flex-1 overflow-y-auto">
336-
<ChatRoom articleId={params.id} onError={handleChatError} isPcVersion={true} />
389+
<ChatRoom
390+
articleId={params.id}
391+
onError={handleChatError}
392+
isPcVersion={true}
393+
isChatOpen={isChatOpen}
394+
setIsChatOpen={setIsChatOpen}
395+
selectedQuote={selectedQuote}
396+
setSelectedQuote={setSelectedQuote}
397+
onQuoteClick={handleQuoteScroll}
398+
isChatLoading={isChatLoading}
399+
setIsChatLoading={setIsChatLoading}
400+
/>
337401
</div>
338402
</div>
339403

@@ -363,8 +427,7 @@ const NewsDetailPage = () => {
363427
</div>
364428

365429
{/* 뉴스 컨테이너 - 카드 스타일 */}
366-
<div className="bg-white rounded-[2rem] shadow-inner flex flex-col h-[calc(100vh-10rem)] overflow-hidden">
367-
{/* 위와 동일한 내용 반복 */}
430+
<div className="bg-white rounded-[2rem] shadow-inner">
368431
<div className="p-6 sm:p-4">
369432
{/* 카테고리 */}
370433
<div className="flex items-center gap-4 mb-4">
@@ -413,8 +476,8 @@ const NewsDetailPage = () => {
413476
</div>
414477
</div>
415478

416-
{/* 뉴스 본문 영역 - 스크롤 가능 */}
417-
<div className="flex-1 overflow-y-auto px-6 sm:px-4">
479+
{/* 뉴스 본문 영역 */}
480+
<div className="px-6 sm:px-4">
418481
{/* 요약 섹션 */}
419482
<div className={`mb-6 transition-all duration-300 ease-in-out ${isSummaryOpen ? 'opacity-100 max-h-[500px]' : 'opacity-0 max-h-0 overflow-hidden'}`}>
420483
<div className="pt-2">
@@ -438,7 +501,7 @@ const NewsDetailPage = () => {
438501

439502
{/* 본문 */}
440503
<div className="space-y-6">
441-
<div className="prose prose-lg max-w-none">
504+
<div id="news-content" className="prose prose-lg max-w-none">
442505
{parseContent(news.content)}
443506
</div>
444507
</div>
@@ -462,23 +525,32 @@ const NewsDetailPage = () => {
462525
</div>
463526

464527
{/* 채팅방 영역 */}
465-
<div className="bg-white shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.1)]" ref={chatRoomRef}>
528+
<div className="fixed bottom-0 left-0 right-0 bg-white shadow-[0_-4px_6px_-1px_rgba(0,0,0,0.1)] z-50" ref={chatRoomRef}>
466529
<button
467530
onClick={() => setIsChatOpen(!isChatOpen)}
468-
className="w-full py-3 px-4 flex items-center justify-between hover:bg-gray-50 transition-colors"
531+
disabled={isChatLoading}
532+
className={`w-full py-3 px-4 flex items-center justify-between transition-colors ${
533+
isChatLoading ? 'opacity-50 cursor-not-allowed' : 'hover:bg-gray-50'
534+
}`}
469535
>
470536
<span className="text-sm text-gray-500">현재 {userCount}명이 참여 중</span>
471537
<div className="flex items-center gap-2 text-[#0E74F9]">
472-
<span className="font-medium">채팅방 {isChatOpen ? '닫기' : '열기'}</span>
473-
<svg
474-
xmlns="http://www.w3.org/2000/svg"
475-
className={`h-5 w-5 transform transition-transform ${isChatOpen ? 'rotate-180' : ''}`}
476-
fill="none"
477-
viewBox="0 0 24 24"
478-
stroke="currentColor"
479-
>
480-
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
481-
</svg>
538+
{isChatLoading ? (
539+
<span className="font-medium">연결 중...</span>
540+
) : (
541+
<>
542+
<span className="font-medium">채팅방 {isChatOpen ? '닫기' : '열기'}</span>
543+
<svg
544+
xmlns="http://www.w3.org/2000/svg"
545+
className={`h-5 w-5 transform transition-transform ${isChatOpen ? '' : 'rotate-180'}`}
546+
fill="none"
547+
viewBox="0 0 24 24"
548+
stroke="currentColor"
549+
>
550+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
551+
</svg>
552+
</>
553+
)}
482554
</div>
483555
</button>
484556
<div
@@ -487,7 +559,18 @@ const NewsDetailPage = () => {
487559
} bg-white`}
488560
>
489561
<div className="h-full pb-safe">
490-
<ChatRoom articleId={params.id} onError={handleChatError} isPcVersion={false} />
562+
<ChatRoom
563+
articleId={params.id}
564+
onError={handleChatError}
565+
isPcVersion={false}
566+
isChatOpen={isChatOpen}
567+
setIsChatOpen={setIsChatOpen}
568+
selectedQuote={selectedQuote}
569+
setSelectedQuote={setSelectedQuote}
570+
onQuoteClick={handleQuoteScroll}
571+
isChatLoading={isChatLoading}
572+
setIsChatLoading={setIsChatLoading}
573+
/>
491574
</div>
492575
</div>
493576
</div>

0 commit comments

Comments
 (0)