From f028c0962c8bb51411b5f355cd903c63a5e5d739 Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Mon, 18 Aug 2025 13:53:14 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20=EC=98=A4=EB=8A=98=EC=9D=98=20?= =?UTF-8?q?=ED=95=9C=EB=A7=88=EB=94=94=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EC=99=84=EC=A0=84=20=EC=97=B0=EB=8F=99=20=EB=B0=8F=20=EB=AC=B4?= =?UTF-8?q?=ED=95=9C=20=EC=8A=A4=ED=81=AC=EB=A1=A4=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - getDailyGreeting API 함수 생성 (src/api/rooms/getDailyGreeting.ts) - TodayWords 컴포넌트에 실제 API 데이터 연동 - 무한 스크롤 및 페이지네이션 구현 (nextCursor 기반) - 로딩 상태 및 에러 처리 추가 - 프로필 이미지 지원 기능 추가 - 타입 정의 개선 (Message 인터페이스에 profileImageUrl, isWriter 필드 추가) - 새 메시지 작성 후 자동 새로고침 기능 - 개발용 토글 버튼으로 실제/더미 데이터 전환 가능 --- src/api/rooms/getDailyGreeting.ts | 33 +++ .../MessageList/MessageList.styled.ts | 6 +- .../today-words/MessageList/MessageList.tsx | 2 +- src/pages/today-words/TodayWords.tsx | 230 +++++++++++++++--- src/types/today.ts | 20 ++ 5 files changed, 256 insertions(+), 35 deletions(-) create mode 100644 src/api/rooms/getDailyGreeting.ts diff --git a/src/api/rooms/getDailyGreeting.ts b/src/api/rooms/getDailyGreeting.ts new file mode 100644 index 00000000..8f236192 --- /dev/null +++ b/src/api/rooms/getDailyGreeting.ts @@ -0,0 +1,33 @@ +import { apiClient } from '../index'; +import { type TodayCommentItem } from '../../types/today'; + +// 오늘의 한마디 조회 응답 타입 +export interface DailyGreetingResponse { + isSuccess: boolean; + code: number; + message: string; + data: { + todayCommentList: TodayCommentItem[]; + nextCursor: string; + isLast: boolean; + }; +} + +// 오늘의 한마디 조회 요청 파라미터 타입 +export interface DailyGreetingParams { + roomId: number; + cursor?: string; +} + +export const getDailyGreeting = async ({ roomId, cursor }: DailyGreetingParams): Promise => { + try { + const params = cursor ? { cursor } : {}; + const response = await apiClient.get(`/rooms/${roomId}/daily-greeting`, { + params + }); + return response.data; + } catch (error) { + console.error('오늘의 한마디 조회 API 오류:', error); + throw error; + } +}; \ No newline at end of file diff --git a/src/components/today-words/MessageList/MessageList.styled.ts b/src/components/today-words/MessageList/MessageList.styled.ts index 256fdd34..7de63227 100644 --- a/src/components/today-words/MessageList/MessageList.styled.ts +++ b/src/components/today-words/MessageList/MessageList.styled.ts @@ -25,13 +25,17 @@ export const UserInfo = styled.div` gap: 4px; `; -export const UserAvatar = styled.div` +export const UserAvatar = styled.div<{ profileImageUrl?: string }>` width: 36px; height: 36px; border-radius: 50%; background-color: ${semanticColors.background.card}; border: 1px solid #3d3d3d; flex-shrink: 0; + background-image: ${props => props.profileImageUrl ? `url(${props.profileImageUrl})` : 'none'}; + background-size: cover; + background-position: center; + background-repeat: no-repeat; `; export const UserDetails = styled.div` diff --git a/src/components/today-words/MessageList/MessageList.tsx b/src/components/today-words/MessageList/MessageList.tsx index ff9f73b0..fd451580 100644 --- a/src/components/today-words/MessageList/MessageList.tsx +++ b/src/components/today-words/MessageList/MessageList.tsx @@ -138,7 +138,7 @@ const MessageList = forwardRef( {groupedMessages[date].map(message => ( - + {message.user} {message.timeAgo} diff --git a/src/pages/today-words/TodayWords.tsx b/src/pages/today-words/TodayWords.tsx index 632cfc8a..de4d00ab 100644 --- a/src/pages/today-words/TodayWords.tsx +++ b/src/pages/today-words/TodayWords.tsx @@ -1,15 +1,17 @@ -import { useState, useRef, useCallback } from 'react'; +import { useState, useRef, useCallback, useEffect } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import TitleHeader from '../../components/common/TitleHeader'; import EmptyState from '../../components/today-words/EmptyState'; import MessageList from '../../components/today-words/MessageList/MessageList'; import type { MessageListRef } from '../../components/today-words/MessageList/MessageList'; import MessageInput from '../../components/today-words/MessageInput'; +import LoadingSpinner from '../../components/common/LoadingSpinner'; import leftarrow from '../../assets/common/leftArrow.svg'; import { Container, ContentArea } from './TodayWords.styled'; -import type { Message } from '../../types/today'; +import type { Message, TodayCommentItem } from '../../types/today'; import { dummyMessages } from '../../constants/today-constants'; import { createDailyGreeting } from '../../api/rooms/createDailyGreeting'; +import { getDailyGreeting } from '../../api/rooms/getDailyGreeting'; import { usePopupActions } from '../../hooks/usePopupActions'; const TodayWords = () => { @@ -19,6 +21,11 @@ const TodayWords = () => { const [messages, setMessages] = useState([]); const [inputValue, setInputValue] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [nextCursor, setNextCursor] = useState(null); + const [isLast, setIsLast] = useState(false); + const [hasInitiallyLoaded, setHasInitiallyLoaded] = useState(false); const { openSnackbar } = usePopupActions(); // 개발용: 빈 상태와 글 있는 상태 토글 @@ -28,6 +35,136 @@ const TodayWords = () => { navigate(-1); }; + // API 데이터를 Message 타입으로 변환하는 함수 + const convertToMessage = (item: TodayCommentItem): Message => { + const createdAt = new Date(item.postDate); + const now = new Date(); + const diffInMinutes = Math.floor((now.getTime() - createdAt.getTime()) / (1000 * 60)); + + let timeAgo: string; + if (diffInMinutes < 1) { + timeAgo = '방금 전'; + } else if (diffInMinutes < 60) { + timeAgo = `${diffInMinutes}분 전`; + } else if (diffInMinutes < 1440) { + timeAgo = `${Math.floor(diffInMinutes / 60)}시간 전`; + } else { + timeAgo = `${Math.floor(diffInMinutes / 1440)}일 전`; + } + + return { + id: item.attendanceCheckId.toString(), + user: item.creatorNickname, + content: item.todayComment, + timestamp: item.date, + timeAgo, + createdAt, + profileImageUrl: item.creatorProfileImageUrl, + isWriter: item.isWriter, + }; + }; + + // 오늘의 한마디 목록 조회 + const loadMessages = useCallback(async (cursor?: string, isRefresh = false) => { + if (!roomId) return; + + try { + if (isRefresh) { + setIsLoading(true); + } else { + setIsLoadingMore(true); + } + + const response = await getDailyGreeting({ + roomId: parseInt(roomId), + cursor: cursor || undefined, + }); + + if (response.isSuccess) { + const newMessages = response.data.todayCommentList.map(convertToMessage); + + if (isRefresh) { + setMessages(newMessages); + } else { + setMessages(prev => [...prev, ...newMessages]); + } + + setNextCursor(response.data.nextCursor); + setIsLast(response.data.isLast); + setHasInitiallyLoaded(true); + } else { + openSnackbar({ + message: response.message || '오늘의 한마디 목록을 불러오는데 실패했습니다.', + variant: 'top', + onClose: () => {}, + }); + } + } catch (error) { + console.error('오늘의 한마디 목록 조회 오류:', error); + + let errorMessage = '오늘의 한마디 목록을 불러오는 중 오류가 발생했습니다.'; + + if (error && typeof error === 'object' && 'response' in error) { + const axiosError = error as { + response?: { + data?: { + message?: string; + code?: number; + }; + }; + }; + + if (axiosError.response?.data?.message) { + errorMessage = axiosError.response.data.message; + } else if (axiosError.response?.data?.code === 403) { + errorMessage = '방 접근 권한이 없습니다.'; + } else if (axiosError.response?.data?.code === 404) { + errorMessage = '존재하지 않는 방입니다.'; + } + } + + openSnackbar({ + message: errorMessage, + variant: 'top', + onClose: () => {}, + }); + } finally { + setIsLoading(false); + setIsLoadingMore(false); + } + }, [roomId, openSnackbar]); + + // 더 많은 메시지 로드 + const loadMoreMessages = useCallback(() => { + if (!isLoadingMore && !isLast && nextCursor) { + loadMessages(nextCursor); + } + }, [loadMessages, isLoadingMore, isLast, nextCursor]); + + // 컴포넌트 마운트 시 초기 데이터 로드 + useEffect(() => { + if (!showMessages) { + loadMessages(undefined, true); + } + }, [loadMessages, showMessages]); + + // 무한 스크롤 처리 + useEffect(() => { + const handleScroll = () => { + if (showMessages) return; + + const { scrollTop, scrollHeight, clientHeight } = document.documentElement; + + // 스크롤이 하단 근처에 도달했을 때 더 많은 데이터 로드 + if (scrollTop + clientHeight >= scrollHeight - 100 && !isLoadingMore && !isLast && hasInitiallyLoaded) { + loadMoreMessages(); + } + }; + + window.addEventListener('scroll', handleScroll); + return () => window.removeEventListener('scroll', handleScroll); + }, [loadMoreMessages, isLoadingMore, isLast, hasInitiallyLoaded, showMessages]); + const handleSendMessage = useCallback(async () => { if (inputValue.trim() === '' || isSubmitting) return; @@ -48,27 +185,6 @@ const TodayWords = () => { const response = await createDailyGreeting(parseInt(roomId), inputValue.trim()); if (response.isSuccess) { - // 성공 시 새 메시지 생성 - const now = new Date(); - const newMessage: Message = { - id: response.data.attendanceCheckId.toString(), - user: 'user.01', // TODO: 실제 사용자 정보로 변경 - content: inputValue.trim(), - timestamp: now - .toLocaleDateString('ko-KR', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - }) - .replace(/\. /g, '.') - .replace(/\.$/, ''), - timeAgo: '방금 전', - createdAt: now, - }; - - // 실제 messages 상태에 추가 - setMessages(prevMessages => [...prevMessages, newMessage]); - // 입력 필드 초기화 setInputValue(''); @@ -79,6 +195,9 @@ const TodayWords = () => { onClose: () => {}, }); + // 최신 목록 다시 불러오기 + await loadMessages(undefined, true); + // 자동으로 스크롤을 아래로 이동 setTimeout(() => { window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); @@ -155,6 +274,17 @@ const TodayWords = () => { // 실제 메시지가 있으면 실제 메시지를, 더미 모드면 더미 메시지를 표시 const displayMessages = showMessages ? dummyMessages : messages; + + // 새로 고침 함수 + const handleRefresh = useCallback(() => { + if (!showMessages) { + setMessages([]); + setNextCursor(null); + setIsLast(false); + setHasInitiallyLoaded(false); + loadMessages(undefined, true); + } + }, [loadMessages, showMessages]); return ( <> @@ -165,15 +295,26 @@ const TodayWords = () => { /> - {displayMessages.length === 0 ? ( + {isLoading && !hasInitiallyLoaded ? ( +
+ +
+ ) : displayMessages.length === 0 ? ( ) : ( - + <> + + {isLoadingMore && ( +
+ +
+ )} + )}
@@ -189,9 +330,9 @@ const TodayWords = () => { + + {/* 새로고침 버튼 (실제 모드에서만) */} + {!showMessages && ( + + )}
); diff --git a/src/types/today.ts b/src/types/today.ts index 758d0f51..ba9cc656 100644 --- a/src/types/today.ts +++ b/src/types/today.ts @@ -5,4 +5,24 @@ export interface Message { timestamp: string; timeAgo: string; createdAt: Date; + profileImageUrl?: string; + isWriter?: boolean; +} + +// 오늘의 한마디 관련 타입들 +export interface TodayCommentItem { + attendanceCheckId: number; + creatorId: number; + creatorNickname: string; + creatorProfileImageUrl: string; + todayComment: string; + postDate: string; + date: string; + isWriter: boolean; +} + +export interface DailyGreetingData { + todayCommentList: TodayCommentItem[]; + nextCursor: string; + isLast: boolean; } From 262d6eeb9ff277c8bd548e32d8704cf4b08a22ed Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Mon, 18 Aug 2025 14:17:13 +0900 Subject: [PATCH 02/10] =?UTF-8?q?fix:=20=EB=AC=B4=ED=95=9C=20API=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0=20?= =?UTF-8?q?=EB=B0=8F=20=EC=83=88=EB=A1=9C=EA=B3=A0=EC=B9=A8=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/today-words/TodayWords.tsx | 58 +++++++--------------------- 1 file changed, 15 insertions(+), 43 deletions(-) diff --git a/src/pages/today-words/TodayWords.tsx b/src/pages/today-words/TodayWords.tsx index de4d00ab..4397c5f3 100644 --- a/src/pages/today-words/TodayWords.tsx +++ b/src/pages/today-words/TodayWords.tsx @@ -132,21 +132,21 @@ const TodayWords = () => { setIsLoading(false); setIsLoadingMore(false); } - }, [roomId, openSnackbar]); + }, [roomId]); // 더 많은 메시지 로드 const loadMoreMessages = useCallback(() => { - if (!isLoadingMore && !isLast && nextCursor) { + if (!isLoadingMore && !isLast && nextCursor && roomId) { loadMessages(nextCursor); } - }, [loadMessages, isLoadingMore, isLast, nextCursor]); + }, [isLoadingMore, isLast, nextCursor, roomId]); // 컴포넌트 마운트 시 초기 데이터 로드 useEffect(() => { - if (!showMessages) { + if (!showMessages && roomId && !hasInitiallyLoaded) { loadMessages(undefined, true); } - }, [loadMessages, showMessages]); + }, [roomId, showMessages, hasInitiallyLoaded]); // 무한 스크롤 처리 useEffect(() => { @@ -195,8 +195,11 @@ const TodayWords = () => { onClose: () => {}, }); - // 최신 목록 다시 불러오기 - await loadMessages(undefined, true); + // 최신 목록 다시 불러오기 위해 상태 초기화 + setMessages([]); + setNextCursor(null); + setIsLast(false); + setHasInitiallyLoaded(false); // 자동으로 스크롤을 아래로 이동 setTimeout(() => { @@ -274,17 +277,6 @@ const TodayWords = () => { // 실제 메시지가 있으면 실제 메시지를, 더미 모드면 더미 메시지를 표시 const displayMessages = showMessages ? dummyMessages : messages; - - // 새로 고침 함수 - const handleRefresh = useCallback(() => { - if (!showMessages) { - setMessages([]); - setNextCursor(null); - setIsLast(false); - setHasInitiallyLoaded(false); - loadMessages(undefined, true); - } - }, [loadMessages, showMessages]); return ( <> @@ -330,9 +322,12 @@ const TodayWords = () => { - - {/* 새로고침 버튼 (실제 모드에서만) */} - {!showMessages && ( - - )} ); From c02c188e6f818b7af0ef6539f87493d3516fee6b Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Mon, 18 Aug 2025 14:44:34 +0900 Subject: [PATCH 03/10] =?UTF-8?q?fix:=20timeAgo=20=ED=91=9C=EC=8B=9C=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95=20-=20postDate=EB=A5=BC?= =?UTF-8?q?=20=EC=A7=81=EC=A0=91=20=EC=82=AC=EC=9A=A9=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/today-words/TodayWords.tsx | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/src/pages/today-words/TodayWords.tsx b/src/pages/today-words/TodayWords.tsx index 4397c5f3..bc9b65e8 100644 --- a/src/pages/today-words/TodayWords.tsx +++ b/src/pages/today-words/TodayWords.tsx @@ -37,20 +37,12 @@ const TodayWords = () => { // API 데이터를 Message 타입으로 변환하는 함수 const convertToMessage = (item: TodayCommentItem): Message => { - const createdAt = new Date(item.postDate); - const now = new Date(); - const diffInMinutes = Math.floor((now.getTime() - createdAt.getTime()) / (1000 * 60)); + // 네트워크 응답에서 postDate가 "1일 전" 형태의 문자열로 오는 것으로 보임 + // 따라서 postDate를 그대로 timeAgo로 사용 + const timeAgo = item.postDate || '방금 전'; - let timeAgo: string; - if (diffInMinutes < 1) { - timeAgo = '방금 전'; - } else if (diffInMinutes < 60) { - timeAgo = `${diffInMinutes}분 전`; - } else if (diffInMinutes < 1440) { - timeAgo = `${Math.floor(diffInMinutes / 60)}시간 전`; - } else { - timeAgo = `${Math.floor(diffInMinutes / 1440)}일 전`; - } + // createdAt은 현재 시간으로 설정 (정확한 시간이 필요하다면 다른 API 필드 사용) + const createdAt = new Date(); return { id: item.attendanceCheckId.toString(), @@ -81,6 +73,8 @@ const TodayWords = () => { }); if (response.isSuccess) { + // 디버깅을 위한 로그 + console.log('API 응답 데이터:', response.data.todayCommentList); const newMessages = response.data.todayCommentList.map(convertToMessage); if (isRefresh) { From 8e7bbddd764a29eaabe7f2c62827cf231f103ac2 Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Mon, 18 Aug 2025 14:57:28 +0900 Subject: [PATCH 04/10] =?UTF-8?q?refactor:=20=EB=8D=94=EB=AF=B8=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EB=A1=9C=EC=A7=81=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B0=9C=EB=B0=9C=EC=9A=A9=20=ED=86=A0=EA=B8=80=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC=20=EC=99=84=EC=A0=84=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/today-words/TodayWords.tsx | 72 ++++------------------------ 1 file changed, 8 insertions(+), 64 deletions(-) diff --git a/src/pages/today-words/TodayWords.tsx b/src/pages/today-words/TodayWords.tsx index bc9b65e8..f22fc4e5 100644 --- a/src/pages/today-words/TodayWords.tsx +++ b/src/pages/today-words/TodayWords.tsx @@ -9,7 +9,6 @@ import LoadingSpinner from '../../components/common/LoadingSpinner'; import leftarrow from '../../assets/common/leftArrow.svg'; import { Container, ContentArea } from './TodayWords.styled'; import type { Message, TodayCommentItem } from '../../types/today'; -import { dummyMessages } from '../../constants/today-constants'; import { createDailyGreeting } from '../../api/rooms/createDailyGreeting'; import { getDailyGreeting } from '../../api/rooms/getDailyGreeting'; import { usePopupActions } from '../../hooks/usePopupActions'; @@ -28,8 +27,6 @@ const TodayWords = () => { const [hasInitiallyLoaded, setHasInitiallyLoaded] = useState(false); const { openSnackbar } = usePopupActions(); - // 개발용: 빈 상태와 글 있는 상태 토글 - const [showMessages, setShowMessages] = useState(false); const handleBackClick = () => { navigate(-1); @@ -73,8 +70,6 @@ const TodayWords = () => { }); if (response.isSuccess) { - // 디버깅을 위한 로그 - console.log('API 응답 데이터:', response.data.todayCommentList); const newMessages = response.data.todayCommentList.map(convertToMessage); if (isRefresh) { @@ -137,16 +132,14 @@ const TodayWords = () => { // 컴포넌트 마운트 시 초기 데이터 로드 useEffect(() => { - if (!showMessages && roomId && !hasInitiallyLoaded) { + if (roomId && !hasInitiallyLoaded) { loadMessages(undefined, true); } - }, [roomId, showMessages, hasInitiallyLoaded]); + }, [roomId, hasInitiallyLoaded]); // 무한 스크롤 처리 useEffect(() => { const handleScroll = () => { - if (showMessages) return; - const { scrollTop, scrollHeight, clientHeight } = document.documentElement; // 스크롤이 하단 근처에 도달했을 때 더 많은 데이터 로드 @@ -157,7 +150,7 @@ const TodayWords = () => { window.addEventListener('scroll', handleScroll); return () => window.removeEventListener('scroll', handleScroll); - }, [loadMoreMessages, isLoadingMore, isLast, hasInitiallyLoaded, showMessages]); + }, [loadMoreMessages, isLoadingMore, isLast, hasInitiallyLoaded]); const handleSendMessage = useCallback(async () => { if (inputValue.trim() === '' || isSubmitting) return; @@ -244,33 +237,12 @@ const TodayWords = () => { } }, [inputValue, roomId, isSubmitting, openSnackbar]); - // 더미 모드에서 메시지 전송 처리 (개발용) - const handleDummySendMessage = useCallback(() => { - if (inputValue.trim() === '') return; - - if (messageListRef.current) { - messageListRef.current.addMessage(inputValue.trim()); - } - setInputValue(''); - - // 자동으로 스크롤을 아래로 이동 - setTimeout(() => { - window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); - }, 100); - }, [inputValue]); - - // 최종 메시지 전송 핸들러 - const finalHandleSendMessage = showMessages ? handleDummySendMessage : handleSendMessage; // MessageList에서 메시지가 삭제되었을 때 호출될 콜백 const handleMessageDelete = (messageId: string) => { - if (!showMessages) { - setMessages(prevMessages => prevMessages.filter(message => message.id !== messageId)); - } + setMessages(prevMessages => prevMessages.filter(message => message.id !== messageId)); }; - // 실제 메시지가 있으면 실제 메시지를, 더미 모드면 더미 메시지를 표시 - const displayMessages = showMessages ? dummyMessages : messages; return ( <> @@ -285,15 +257,15 @@ const TodayWords = () => {
- ) : displayMessages.length === 0 ? ( + ) : messages.length === 0 ? ( ) : ( <> {isLoadingMore && (
@@ -307,38 +279,10 @@ const TodayWords = () => { - - {/* 개발용 토글 버튼 */} - ); From 66cf74b8daa4e90af00e531c47a4e2f7e3b5eada Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Mon, 18 Aug 2025 15:03:40 +0900 Subject: [PATCH 05/10] =?UTF-8?q?fix:=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC=20=EC=88=9C=EC=84=9C=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?-=20=EC=95=84=EB=9E=98=EB=A1=9C=20=EC=98=AC=EC=88=98=EB=A1=9D?= =?UTF-8?q?=20=EC=B5=9C=EC=8B=A0=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/today-words/MessageList/MessageList.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/components/today-words/MessageList/MessageList.tsx b/src/components/today-words/MessageList/MessageList.tsx index fd451580..d0e04b61 100644 --- a/src/components/today-words/MessageList/MessageList.tsx +++ b/src/components/today-words/MessageList/MessageList.tsx @@ -72,10 +72,9 @@ const MessageList = forwardRef( addMessage, })); - // 먼저 모든 메시지를 시간순으로 정렬 - const sortedMessages = messages.sort( - (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), - ); + // 먼저 모든 메시지를 시간순으로 정렬 (아래로 올수록 최신) + // ID를 기준으로 정렬 (ID가 클수록 최신) + const sortedMessages = messages.sort((a, b) => parseInt(a.id) - parseInt(b.id)); // 날짜별로 메시지 그룹화 const groupedMessages = sortedMessages.reduce( @@ -90,7 +89,7 @@ const MessageList = forwardRef( {} as Record, ); - // 날짜를 최신순으로 정렬 + // 날짜를 오래된 순으로 정렬 (아래로 올수록 최신) const sortedDates = Object.keys(groupedMessages).sort((a, b) => a.localeCompare(b)); const handleMoreClick = (messageId: string) => { From 971172b94d623ed96f7e69274f6471f8baaff2f0 Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Mon, 18 Aug 2025 15:07:58 +0900 Subject: [PATCH 06/10] =?UTF-8?q?feat:=20=EB=82=B4=20=EB=A9=94=EC=8B=9C?= =?UTF-8?q?=EC=A7=80=EC=97=90=20=EC=82=AD=EC=A0=9C=ED=95=98=EA=B8=B0=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20=ED=91=9C=EC=8B=9C=20=EB=B0=8F=20isWriter?= =?UTF-8?q?=20=ED=95=84=EB=93=9C=20=ED=99=9C=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../today-words/MessageList/MessageList.tsx | 17 ++++------------- src/pages/today-words/TodayWords.tsx | 6 ------ 2 files changed, 4 insertions(+), 19 deletions(-) diff --git a/src/components/today-words/MessageList/MessageList.tsx b/src/components/today-words/MessageList/MessageList.tsx index d0e04b61..10a8b703 100644 --- a/src/components/today-words/MessageList/MessageList.tsx +++ b/src/components/today-words/MessageList/MessageList.tsx @@ -34,8 +34,6 @@ const MessageList = forwardRef( { messages: initialMessages, currentUserId = 'user.01', - onMessageDelete, - isRealTimeMode = false, }, ref, ) => { @@ -102,16 +100,9 @@ const MessageList = forwardRef( const handleDelete = () => { if (selectedMessageId) { - if (isRealTimeMode && onMessageDelete) { - // 실시간 모드일 때는 부모 컴포넌트의 상태를 업데이트 - onMessageDelete(selectedMessageId); - } else { - // 더미 모드일 때는 내부 상태만 업데이트 - setMessages(prevMessages => - prevMessages.filter(message => message.id !== selectedMessageId), - ); - } - console.log(`메시지 ID ${selectedMessageId} 삭제됨`); + // TODO: 실제 삭제 API 연동 필요 + console.log(`메시지 ID ${selectedMessageId} 삭제 요청 (API 개발 대기중)`); + alert('삭제 기능은 현재 개발 중입니다.'); } setSelectedMessageId(null); }; @@ -122,7 +113,7 @@ const MessageList = forwardRef( }; const selectedMessage = messages.find(msg => msg.id === selectedMessageId); - const isMyMessage = selectedMessage?.user === currentUserId; + const isMyMessage = selectedMessage?.isWriter === true; return ( <> diff --git a/src/pages/today-words/TodayWords.tsx b/src/pages/today-words/TodayWords.tsx index f22fc4e5..97a7b428 100644 --- a/src/pages/today-words/TodayWords.tsx +++ b/src/pages/today-words/TodayWords.tsx @@ -238,10 +238,6 @@ const TodayWords = () => { }, [inputValue, roomId, isSubmitting, openSnackbar]); - // MessageList에서 메시지가 삭제되었을 때 호출될 콜백 - const handleMessageDelete = (messageId: string) => { - setMessages(prevMessages => prevMessages.filter(message => message.id !== messageId)); - }; return ( @@ -264,8 +260,6 @@ const TodayWords = () => { {isLoadingMore && (
From d587c0e714b3d1032ff16906f12534c018dd883b Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Mon, 18 Aug 2025 15:14:31 +0900 Subject: [PATCH 07/10] =?UTF-8?q?feat:=20=ED=95=98=EB=A3=A8=205=EA=B0=9C?= =?UTF-8?q?=20=EC=9E=91=EC=84=B1=20=EC=A0=9C=ED=95=9C=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84=20-=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=8C=9D=EC=97=85=20=EB=B0=8F=20=EC=B0=A8=EB=8B=A8=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/today-words/TodayWords.tsx | 53 +++++++++++++++++++++++----- 1 file changed, 45 insertions(+), 8 deletions(-) diff --git a/src/pages/today-words/TodayWords.tsx b/src/pages/today-words/TodayWords.tsx index 97a7b428..f21beb2a 100644 --- a/src/pages/today-words/TodayWords.tsx +++ b/src/pages/today-words/TodayWords.tsx @@ -27,6 +27,24 @@ const TodayWords = () => { const [hasInitiallyLoaded, setHasInitiallyLoaded] = useState(false); const { openSnackbar } = usePopupActions(); + // 하루 5개 제한 관련 + const DAILY_LIMIT = 5; + + // 오늘 작성한 내 메시지 개수 계산 + const getTodayMyMessageCount = useCallback(() => { + const today = new Date().toLocaleDateString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).replace(/\. /g, '.').replace(/\.$/, ''); + + return messages.filter(message => + message.isWriter === true && message.timestamp === today + ).length; + }, [messages]); + + const todayMyMessageCount = getTodayMyMessageCount(); + const handleBackClick = () => { navigate(-1); @@ -165,6 +183,17 @@ const TodayWords = () => { return; } + // 6개 작성 시도 시 빨간색 토스트로 차단 + if (todayMyMessageCount >= DAILY_LIMIT) { + openSnackbar({ + message: '오늘의 한마디는 하루에 다섯번까지 작성할 수 있어요', + variant: 'top', + isError: true, + onClose: () => {}, + }); + return; + } + setIsSubmitting(true); try { @@ -175,19 +204,27 @@ const TodayWords = () => { // 입력 필드 초기화 setInputValue(''); - // 성공 메시지 표시 - openSnackbar({ - message: '오늘의 한마디가 작성되었습니다.', - variant: 'top', - onClose: () => {}, - }); - // 최신 목록 다시 불러오기 위해 상태 초기화 setMessages([]); setNextCursor(null); setIsLast(false); setHasInitiallyLoaded(false); + // 5개 도달 시 흰색 토스트, 아니면 일반 성공 메시지 + if (todayMyMessageCount + 1 >= DAILY_LIMIT) { + openSnackbar({ + message: '오늘의 한마디는 하루에 다섯번까지 작성할 수 있어요', + variant: 'top', + onClose: () => {}, + }); + } else { + openSnackbar({ + message: '오늘의 한마디가 작성되었습니다.', + variant: 'top', + onClose: () => {}, + }); + } + // 자동으로 스크롤을 아래로 이동 setTimeout(() => { window.scrollTo({ top: document.body.scrollHeight, behavior: 'smooth' }); @@ -235,7 +272,7 @@ const TodayWords = () => { } finally { setIsSubmitting(false); } - }, [inputValue, roomId, isSubmitting, openSnackbar]); + }, [inputValue, roomId, isSubmitting, openSnackbar, todayMyMessageCount, DAILY_LIMIT]); From 8df11e14a395c93ca8e1d13919c5517e49c94d56 Mon Sep 17 00:00:00 2001 From: fr0gydev Date: Mon, 18 Aug 2025 15:20:08 +0900 Subject: [PATCH 08/10] =?UTF-8?q?fix:=20400=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=9A=B0=EC=84=A0=EC=88=9C?= =?UTF-8?q?=EC=9C=84=20=EC=A1=B0=EC=A0=95=20-=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=9A=B0?= =?UTF-8?q?=EC=84=A0=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/today-words/TodayWords.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/pages/today-words/TodayWords.tsx b/src/pages/today-words/TodayWords.tsx index f21beb2a..f80932d7 100644 --- a/src/pages/today-words/TodayWords.tsx +++ b/src/pages/today-words/TodayWords.tsx @@ -183,12 +183,11 @@ const TodayWords = () => { return; } - // 6개 작성 시도 시 빨간색 토스트로 차단 + // 6개 작성 시도 시 토스트로 차단 if (todayMyMessageCount >= DAILY_LIMIT) { openSnackbar({ message: '오늘의 한마디는 하루에 다섯번까지 작성할 수 있어요', variant: 'top', - isError: true, onClose: () => {}, }); return; @@ -253,14 +252,14 @@ const TodayWords = () => { }; }; - if (axiosError.response?.data?.message) { - errorMessage = axiosError.response.data.message; - } else if (axiosError.response?.data?.code === 400) { - errorMessage = '오늘의 한마디 작성 가능 횟수를 초과했습니다.'; + if (axiosError.response?.data?.code === 400) { + errorMessage = '오늘의 한마디는 하루에 다섯번까지 작성할 수 있어요'; } else if (axiosError.response?.data?.code === 403) { errorMessage = '방 접근 권한이 없습니다.'; } else if (axiosError.response?.data?.code === 404) { errorMessage = '존재하지 않는 방입니다.'; + } else if (axiosError.response?.data?.message) { + errorMessage = axiosError.response.data.message; } } From 0aae0294a089a04adcad73ce0f8a1d05895bcc8d Mon Sep 17 00:00:00 2001 From: heeyongKim <166043860+heeeeyong@users.noreply.github.com> Date: Mon, 18 Aug 2025 16:45:34 +0900 Subject: [PATCH 09/10] =?UTF-8?q?fix:=20=ED=94=BC=EB=93=9C=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=EC=97=90=20=EC=B1=85=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=B6=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/feeds/getFeedDetail.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/api/feeds/getFeedDetail.ts b/src/api/feeds/getFeedDetail.ts index 75e5d338..ecd107f4 100644 --- a/src/api/feeds/getFeedDetail.ts +++ b/src/api/feeds/getFeedDetail.ts @@ -10,6 +10,7 @@ export interface FeedDetailData { aliasColor: string; postDate: string; isbn: string; + bookImageUrl: string; bookTitle: string; bookAuthor: string; contentBody: string; From 43aecfac448f73fc9a6d9cc7f62a51cf6d9394c2 Mon Sep 17 00:00:00 2001 From: heeyongKim <166043860+heeeeyong@users.noreply.github.com> Date: Mon, 18 Aug 2025 17:31:56 +0900 Subject: [PATCH 10/10] =?UTF-8?q?fix:=20isWriter=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=A5=B8=20=EB=8D=94=EB=B3=B4=EA=B8=B0=20=EB=AA=A8=EB=8B=AC=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/feeds/getFeedDetail.ts | 1 + src/components/common/Modal/MoreMenu.tsx | 101 +++++++++++++++++++---- src/components/common/Post/Reply.tsx | 36 +++++--- src/components/common/Post/SubReply.tsx | 38 ++++++--- src/pages/feed/FeedDetailPage.tsx | 100 +++++++++++++--------- src/stores/usePopupStore.ts | 2 + 6 files changed, 197 insertions(+), 81 deletions(-) diff --git a/src/api/feeds/getFeedDetail.ts b/src/api/feeds/getFeedDetail.ts index ecd107f4..f634e453 100644 --- a/src/api/feeds/getFeedDetail.ts +++ b/src/api/feeds/getFeedDetail.ts @@ -21,6 +21,7 @@ export interface FeedDetailData { isLiked: boolean; isPublic: boolean; tagList: string[]; + isWriter: boolean; } // API 응답 타입 diff --git a/src/components/common/Modal/MoreMenu.tsx b/src/components/common/Modal/MoreMenu.tsx index 84165ffa..26fd6c72 100644 --- a/src/components/common/Modal/MoreMenu.tsx +++ b/src/components/common/Modal/MoreMenu.tsx @@ -2,17 +2,49 @@ import styled from '@emotion/styled'; import { colors, typography } from '@/styles/global/global'; import type { MoreMenuProps } from '@/stores/usePopupStore'; -const MoreMenu = ({ onEdit, onDelete, onClose }: MoreMenuProps) => { +const MoreMenu = ({ onEdit, onDelete, onClose, onReport, isWriter, type }: MoreMenuProps) => { return ( - e.stopPropagation()}> - - - + {type === 'post' ? ( + // post 타입: 기존 로직 유지 + <> + {isWriter ? ( + <> + e.stopPropagation()}> + + + + + ) : ( + e.stopPropagation()}> + + + )} + + ) : ( + // reply 타입: isWriter에 따라 삭제하기 또는 신고하기만 표시 + <> + {isWriter ? ( + e.stopPropagation()}> + + + ) : ( + e.stopPropagation()}> + + + )} + + )} ); }; @@ -52,25 +84,58 @@ const Container = styled.div` z-index: 1201; `; -const Button = styled.div<{ variant: 'edit' | 'delete' }>` +const ReportContainer = styled.div` + position: fixed; + left: 0; + right: 0; + bottom: 0; + + display: flex; + flex-direction: column; + min-width: 320px; + max-width: 767px; + width: 100%; + height: 90px; + padding: 20px; + border-radius: 12px 12px 0px 0px; + background-color: ${colors.darkgrey.main}; + z-index: 1201; +`; + +const Button = styled.div<{ variant: 'edit' | 'delete' | 'report' }>` display: flex; height: 50px; align-items: center; - color: ${({ variant }) => (variant === 'edit' ? colors.white : colors.red)}; + color: ${({ variant }) => { + if (variant === 'edit') return colors.white; + if (variant === 'delete') return colors.red; + if (variant === 'report') return colors.red; + return colors.white; + }}; font-size: ${typography.fontSize.base}; font-weight: ${typography.fontWeight.semibold}; line-height: 24px; border-bottom: 1px solid ${colors.grey[400]}; cursor: pointer; - &:first-of-type { - padding: 8px 12px 16px 12px; - } + ${({ variant }) => { + if (variant === 'report') { + return ` + padding: 0; + border-bottom: none; + `; + } + return ` + &:first-of-type { + padding: 8px 12px 16px 12px; + } - &:last-of-type { - padding: 16px 12px 8px 12px; - border-bottom: none; - } + &:last-of-type { + padding: 16px 12px 8px 12px; + border-bottom: none; + } + `; + }} `; export default MoreMenu; diff --git a/src/components/common/Post/Reply.tsx b/src/components/common/Post/Reply.tsx index 59f2b791..784285a3 100644 --- a/src/components/common/Post/Reply.tsx +++ b/src/components/common/Post/Reply.tsx @@ -35,7 +35,7 @@ const Reply = ({ const containerRef = useRef(null); const { startReply } = useReplyActions(); - const { openMoreMenu, closePopup, openConfirm, openSnackbar } = usePopupActions(); + const { openMoreMenu, closePopup, openSnackbar } = usePopupActions(); const handleLike = async () => { try { @@ -93,16 +93,30 @@ const Reply = ({ }; const handleMoreClick = () => { - openMoreMenu({ - onDelete: () => { - openConfirm({ - title: '이 댓글을 삭제하시겠어요?', - disc: '삭제 후에는 되돌릴 수 없어요', - onConfirm: handleDelete, - }); - }, - onClose: closePopup, - }); + if (isWriter) { + // 작성자인 경우: 삭제하기만 표시 + openMoreMenu({ + onDelete: handleDelete, + type: 'reply', + isWriter: true, + onClose: closePopup, + }); + } else { + // 작성자가 아닌 경우: 신고하기만 표시 + openMoreMenu({ + onReport: () => { + closePopup(); + openSnackbar({ + message: '신고가 접수되었어요.', + variant: 'top', + onClose: closePopup, + }); + }, + type: 'reply', + isWriter: false, + onClose: closePopup, + }); + } }; // 삭제된 댓글인 경우 처리 diff --git a/src/components/common/Post/SubReply.tsx b/src/components/common/Post/SubReply.tsx index d83edffa..c3c5913f 100644 --- a/src/components/common/Post/SubReply.tsx +++ b/src/components/common/Post/SubReply.tsx @@ -37,7 +37,7 @@ const SubReply = ({ const containerRef = useRef(null); const { startReply } = useReplyActions(); - const { openMoreMenu, closePopup, openConfirm, openSnackbar } = usePopupActions(); + const { openMoreMenu, closePopup, openSnackbar } = usePopupActions(); const handleReplyClick = () => { startReply(creatorNickname, commentId); @@ -56,6 +56,7 @@ const SubReply = ({ } }; + // 이전 더보기 모달 // const handleMoreClick = () => { // if (containerRef.current) { // const rect = containerRef.current.getBoundingClientRect(); @@ -110,17 +111,30 @@ const SubReply = ({ }; const handleMoreClick = () => { - openMoreMenu({ - onDelete: () => { - openConfirm({ - title: '이 댓글을 삭제하시겠어요?', - disc: '삭제 후에는 되돌릴 수 없어요', - onConfirm: handleDelete, - onClose: closePopup, - }); - }, - onClose: closePopup, - }); + if (isWriter) { + // 작성자인 경우: 삭제하기만 표시 + openMoreMenu({ + onDelete: handleDelete, + type: 'reply', + isWriter: true, + onClose: closePopup, + }); + } else { + // 작성자가 아닌 경우: 신고하기만 표시 + openMoreMenu({ + onReport: () => { + closePopup(); + openSnackbar({ + message: '신고가 접수되었어요.', + variant: 'top', + onClose: closePopup, + }); + }, + type: 'reply', + isWriter: false, + onClose: closePopup, + }); + } }; // 삭제된 댓글인 경우 처리 diff --git a/src/pages/feed/FeedDetailPage.tsx b/src/pages/feed/FeedDetailPage.tsx index 048346ba..8e24eb51 100644 --- a/src/pages/feed/FeedDetailPage.tsx +++ b/src/pages/feed/FeedDetailPage.tsx @@ -86,51 +86,71 @@ const FeedDetailPage = () => { }; const handleMoreClick = () => { - openMoreMenu({ - onEdit: () => { - closePopup(); - navigate(`/post/update/${feedId}`); - }, - onClose: () => { - closePopup(); - }, - onDelete: () => { - openConfirm({ - title: '이 피드를 삭제하시겠어요?', - disc: '삭제 후에는 되돌릴 수 없어요', - onClose: closePopup, - onConfirm: async () => { - try { - if (!feedId) return; - const resp = await deleteFeedPost(Number(feedId)); - if (resp.isSuccess) { - closePopup(); + if (feedData?.isWriter) { + // 작성자인 경우: 수정하기, 삭제하기 메뉴 + openMoreMenu({ + onEdit: () => { + closePopup(); + // navigate(`/post/update/${feedId}`); + }, + onClose: () => { + closePopup(); + }, + onDelete: () => { + openConfirm({ + title: '이 피드를 삭제하시겠어요?', + disc: '삭제 후에는 되돌릴 수 없어요', + onClose: closePopup, + onConfirm: async () => { + try { + if (!feedId) return; + const resp = await deleteFeedPost(Number(feedId)); + if (resp.isSuccess) { + closePopup(); + openSnackbar({ + message: '피드 삭제를 완료했어요.', + variant: 'top', + onClose: closePopup, + }); + navigate('/feed', { state: { initialTab: '내 피드' } }); + } else { + openSnackbar({ + message: '피드 삭제를 실패했어요.', + variant: 'top', + onClose: closePopup, + }); + } + } catch (e) { + console.error('피드 삭제 실패:', e); openSnackbar({ - message: '피드 삭제를 완료했어요.', - variant: 'top', - onClose: closePopup, - }); - // 즉시 /feed로 리다이렉트 - navigate('/feed', { state: { initialTab: '내 피드' } }); - } else { - openSnackbar({ - message: '피드 삭제를 실패했어요.', + message: '피드 삭제 중 오류가 발생했어요.', variant: 'top', onClose: closePopup, }); } - } catch (e) { - console.error('피드 삭제 실패:', e); - openSnackbar({ - message: '피드 삭제 중 오류가 발생했어요.', - variant: 'top', - onClose: closePopup, - }); - } - }, - }); - }, - }); + }, + }); + }, + isWriter: true, + type: 'post', + }); + } else { + openMoreMenu({ + onClose: () => { + closePopup(); + }, + onReport: () => { + closePopup(); + openSnackbar({ + message: '신고가 접수되었어요.', + variant: 'top', + onClose: closePopup, + }); + }, + isWriter: false, + type: 'post', + }); + } }; const handleBackClick = () => { diff --git a/src/stores/usePopupStore.ts b/src/stores/usePopupStore.ts index 8416e825..9d203af1 100644 --- a/src/stores/usePopupStore.ts +++ b/src/stores/usePopupStore.ts @@ -16,6 +16,8 @@ export interface MoreMenuProps { onDelete?: () => void; onClose?: () => void; onReport?: () => void; + isWriter?: boolean; + type?: 'post' | 'reply'; } export interface SnackbarProps {