diff --git a/src/api/roomPosts/postRoomPostLike.ts b/src/api/roomPosts/postRoomPostLike.ts new file mode 100644 index 00000000..8cce7cfa --- /dev/null +++ b/src/api/roomPosts/postRoomPostLike.ts @@ -0,0 +1,64 @@ +import { apiClient } from '../index'; +import type { RoomPostLikeRequest, RoomPostLikeResponse } from '@/types/roomPostLike'; + +// 방 게시물(기록,투표) 좋아요 상태변경 API 함수 +export const postRoomPostLike = async ( + postId: number, + requestData: RoomPostLikeRequest, +): Promise => { + try { + const response = await apiClient.post( + `/room-posts/${postId}/likes`, + requestData, + ); + return response.data; + } catch (error) { + console.error('방 게시물 좋아요 API 오류:', error); + throw error; + } +}; + +/* +사용 예시: + +// 기록 게시물 좋아요 +const likeRecordPost = async (postId: number, isLiked: boolean) => { + try { + const result = await postRoomPostLike(postId, { + type: !isLiked, // 현재 상태 반대로 전송 + roomPostType: 'RECORD' + }); + + if (result.isSuccess) { + console.log('좋아요 상태 변경 성공:', result.data.isLiked); + console.log('게시물 ID:', result.data.postId); + // UI 업데이트 로직 + } else { + console.error('좋아요 상태 변경 실패:', result.message); + // 에러 처리 로직 + } + } catch (error) { + console.error('API 호출 오류:', error); + // 네트워크 에러 처리 로직 + } +}; + +// 투표 게시물 좋아요 +const likeVotePost = async (postId: number, isLiked: boolean) => { + try { + const result = await postRoomPostLike(postId, { + type: !isLiked, // 현재 상태 반대로 전송 + roomPostType: 'VOTE' + }); + + if (result.isSuccess) { + console.log('좋아요 상태 변경 성공:', result.data.isLiked); + // UI 업데이트 로직 + } else { + console.error('좋아요 상태 변경 실패:', result.message); + } + } catch (error) { + console.error('API 호출 오류:', error); + } +}; +*/ diff --git a/src/api/rooms/getBookPage.ts b/src/api/rooms/getBookPage.ts new file mode 100644 index 00000000..12367720 --- /dev/null +++ b/src/api/rooms/getBookPage.ts @@ -0,0 +1,48 @@ +import { apiClient } from '../index'; + +// 책 페이지 정보 응답 데이터 타입 +export interface BookPageData { + totalBookPage: number; // 책 전체 페이지 수 + recentBookPage: number; // 최근 기록한 페이지 번호 + isOverviewPossible: boolean; // 총평 작성 가능 여부 + roomId: number; // 방 ID +} + +// API 응답 타입 +export interface BookPageResponse { + isSuccess: boolean; + code: number; + message: string; + data: BookPageData; +} + +// 책 전체 페이지수 및 총평 작성 가능 여부 조회 API 함수 +export const getBookPage = async (roomId: number): Promise => { + try { + const response = await apiClient.get(`/rooms/${roomId}/book-page`); + return response.data; + } catch (error) { + console.error('책 페이지 정보 조회 API 오류:', error); + throw error; + } +}; + +/* +사용 예시: +try { + const result = await getBookPage(1); + if (result.isSuccess) { + console.log("책 전체 페이지 수:", result.data.totalBookPage); + console.log("최근 기록한 페이지:", result.data.recentBookPage); + console.log("총평 작성 가능:", result.data.isOverviewPossible); + console.log("방 ID:", result.data.roomId); + // 성공 처리 로직 + } else { + console.error("책 페이지 정보 조회 실패:", result.message); + // 실패 처리 로직 + } +} catch (error) { + console.error("API 호출 오류:", error); + // 에러 처리 로직 (400: 파라미터 잘못, 403: 접근 권한 없음, 404: 방 없음) +} +*/ diff --git a/src/api/rooms/postPassword.ts b/src/api/rooms/postPassword.ts new file mode 100644 index 00000000..ef5cbbe1 --- /dev/null +++ b/src/api/rooms/postPassword.ts @@ -0,0 +1,23 @@ +import { apiClient } from '../index'; + +export interface PostPasswordRequest { + password: string; +} + +export interface PostPasswordResponse { + isSuccess: boolean; + code: number; + message: string; + data: { + matched: boolean; + roomId: number; + }; +} + +export const postPassword = async ( + roomId: number, + data: PostPasswordRequest, +): Promise => { + const response = await apiClient.post(`/rooms/${roomId}/password`, data); + return response.data; +}; diff --git a/src/api/users/getRecentFollowing.ts b/src/api/users/getRecentFollowing.ts index db0773bd..469c1698 100644 --- a/src/api/users/getRecentFollowing.ts +++ b/src/api/users/getRecentFollowing.ts @@ -13,7 +13,7 @@ export interface GetRecentFollowingResponse { code: number; message: string; data: { - recentWriters: RecentWriterData[]; + myFollowingUsers: RecentWriterData[]; }; } diff --git a/src/components/feed/FollowList.tsx b/src/components/feed/FollowList.tsx index d259d57e..abe00877 100644 --- a/src/components/feed/FollowList.tsx +++ b/src/components/feed/FollowList.tsx @@ -9,7 +9,7 @@ import { getRecentFollowing, type RecentWriterData } from '@/api/users/getRecent const FollowList = () => { const navigate = useNavigate(); - const [recentWriters, setRecentWriters] = useState([]); + const [myFollowings, setMyFollowings] = useState([]); const [loading, setLoading] = useState(false); // API에서 최근 글 작성한 팔로우 리스트 조회 @@ -19,14 +19,14 @@ const FollowList = () => { const response = await getRecentFollowing(); if (response.isSuccess) { - setRecentWriters(response.data.recentWriters); + setMyFollowings(response.data.myFollowingUsers); } else { console.error('최근 팔로우 작성자 조회 실패:', response.message); - setRecentWriters([]); + setMyFollowings([]); } } catch (error) { console.error('최근 팔로우 작성자 조회 중 오류:', error); - setRecentWriters([]); + setMyFollowings([]); } finally { setLoading(false); } @@ -37,8 +37,8 @@ const FollowList = () => { fetchRecentFollowing(); }, []); - const hasFollowers = recentWriters.length > 0; - const visible = hasFollowers ? recentWriters.slice(0, 10) : []; + const hasFollowers = myFollowings.length > 0; + const visible = hasFollowers ? myFollowings.slice(0, 10) : []; const handleFindClick = () => { navigate('/feed/search'); diff --git a/src/components/feed/TabBar.tsx b/src/components/feed/TabBar.tsx index 4e53d2b3..d761b782 100644 --- a/src/components/feed/TabBar.tsx +++ b/src/components/feed/TabBar.tsx @@ -26,21 +26,31 @@ const TabButton = styled.div` padding: 8px 4px; font-size: var(--font-size-lg); cursor: pointer; + position: relative; &.active { color: var(--color-white); - border-bottom: 2px solid var(--color-white); font-weight: var(--font-weight-semibold); line-height: 24px; } &.inactive { color: var(--color-grey-300); - border-bottom: 2px solid transparent; font-weight: var(--font-weight-medium); line-height: 24px; } `; +const ActiveIndicator = styled.div<{ activeIndex: number }>` + position: absolute; + bottom: 0; + left: 20px; + width: 60px; + height: 2px; + background-color: var(--color-white); + transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1); + transform: translateX(${props => props.activeIndex * 80}px); +`; + interface TabProps { tabs: string[]; activeTab: string; @@ -48,6 +58,9 @@ interface TabProps { } const TabBar = ({ tabs, activeTab, onTabClick }: TabProps) => { + // 현재 활성 탭의 인덱스 계산 + const activeIndex = tabs.findIndex(tab => tab === activeTab); + return ( {tabs.map(tab => ( @@ -59,6 +72,8 @@ const TabBar = ({ tabs, activeTab, onTabClick }: TabProps) => { {tab} ))} + {/* 슬라이드 애니메이션 밑줄 */} + ); }; diff --git a/src/components/group/PasswordModal.tsx b/src/components/group/PasswordModal.tsx new file mode 100644 index 00000000..09291765 --- /dev/null +++ b/src/components/group/PasswordModal.tsx @@ -0,0 +1,226 @@ +import React, { useState, useRef, useEffect } from 'react'; +import styled from '@emotion/styled'; +import { colors, typography } from '@/styles/global/global'; +import leftArrow from '@/assets/common/leftArrow.svg'; +import TitleHeader from '@/components/common/TitleHeader'; +import { useNavigate } from 'react-router-dom'; +import { postPassword } from '@/api/rooms/postPassword'; +import { postJoinRoom } from '@/api/rooms/postJoinRoom'; +import { usePopupActions } from '@/hooks/usePopupActions'; + +interface PasswordModalProps { + roomId: number; +} + +const PasswordModal = ({ roomId }: PasswordModalProps) => { + const [password, setPassword] = useState(['', '', '', '']); + const [activeIndex, setActiveIndex] = useState(0); + const [errorMessage, setErrorMessage] = useState(''); + const inputRefs = useRef<(HTMLInputElement | null)[]>([]); + const navigate = useNavigate(); + const { openSnackbar } = usePopupActions(); + + // 컴포넌트 마운트 시 상태 초기화 + useEffect(() => { + setPassword(['', '', '', '']); + setActiveIndex(0); + setErrorMessage(''); + }, []); + + // 입력 필드 포커스 관리 + useEffect(() => { + if (inputRefs.current[activeIndex]) { + inputRefs.current[activeIndex]?.focus(); + } + }, [activeIndex]); + + useEffect(() => { + if (errorMessage) { + const timer = setTimeout(() => { + setErrorMessage(''); + }, 1500); + + return () => clearTimeout(timer); + } + }, [errorMessage]); + + const handleClickBack = () => { + navigate(-1); + }; + + // 비밀번호 확인 API 호출 + const handlePasswordConfirm = async (fullPassword: string) => { + setErrorMessage(''); + + try { + // 1. 비밀번호 확인 API 호출 + const passwordResponse = await postPassword(roomId, { password: fullPassword }); + + if (passwordResponse.isSuccess && passwordResponse.data.matched) { + // 2. 비밀번호가 맞으면 방 참가 API 호출 + const joinResponse = await postJoinRoom(roomId, 'join'); + + if (joinResponse.isSuccess) { + // 성공 후 처리 (방 상세 페이지로 이동) + navigate(`/group/detail/${roomId}`); + // 성공 snackbar 표시 + openSnackbar({ + message: '모임방 참여가 완료되었어요! 모집 마감 후 활동이 시작돼요.', + variant: 'top', + onClose: () => {}, + }); + } else { + // 실패 후 처리 (방 상세 페이지로 이동) + navigate(`/group/detail/${roomId}`); + // 실패 snackbar 표시 + openSnackbar({ + message: '모임방 참여에 실패했어요. 다시 시도해 주세요.', + variant: 'top', + onClose: () => {}, + }); + } + } else { + setErrorMessage('비밀번호가 일치하지 않습니다.'); + // 비밀번호 입력 필드 초기화 + setPassword(['', '', '', '']); + setActiveIndex(0); + } + } catch { + setErrorMessage('오류가 발생했습니다. 다시 시도해주세요.'); + // 비밀번호 입력 필드 초기화 + setPassword(['', '', '', '']); + setActiveIndex(0); + } + }; + + const handleInputChange = (index: number, value: string) => { + // 숫자만 입력 허용 + if (!/^\d*$/.test(value)) return; + + const newPassword = [...password]; + newPassword[index] = value; + setPassword(newPassword); + + // 다음 입력 필드로 이동 + if (value && index < 3) { + setActiveIndex(index + 1); + } + + // 4자리 모두 입력 완료 시 자동으로 비밀번호 확인 + if (newPassword.every(digit => digit !== '') && index === 3) { + const fullPassword = newPassword.join(''); + handlePasswordConfirm(fullPassword); + } + }; + + const handleKeyDown = (index: number, e: React.KeyboardEvent) => { + if (e.key === 'Backspace') { + if (password[index] === '') { + // 현재 필드가 비어있으면 이전 필드로 이동 + if (index > 0) { + setActiveIndex(index - 1); + const newPassword = [...password]; + newPassword[index - 1] = ''; + setPassword(newPassword); + } + } else { + // 현재 필드 내용 삭제 + const newPassword = [...password]; + newPassword[index] = ''; + setPassword(newPassword); + } + } + }; + + const handleInputClick = (index: number) => { + setActiveIndex(index); + }; + + return ( + + } + onLeftClick={handleClickBack} + /> + 비밀번호를 입력해주세요. + + {password.map((digit, index) => ( + { + inputRefs.current[index] = el; + }} + type="text" + value={digit} + onChange={e => handleInputChange(index, e.target.value)} + onKeyDown={e => handleKeyDown(index, e)} + onClick={() => handleInputClick(index)} + maxLength={1} + inputMode="numeric" + hasError={!!errorMessage} + /> + ))} + + {errorMessage && {errorMessage}} + + ); +}; + +export default PasswordModal; + +const Overlay = styled.div` + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + min-width: 320px; + max-width: 767px; + min-height: 100vh; + background-color: ${colors.black.main}; + margin: 0 auto; + display: flex; + flex-direction: column; + align-items: center; + z-index: 2000; +`; + +const Title = styled.div` + color: ${colors.white}; + font-size: ${typography.fontSize.lg}; + font-weight: ${typography.fontWeight.semibold}; + line-height: 24px; + letter-spacing: 0.018px; + text-align: center; + margin-top: 217px; +`; + +const PasswordInputContainer = styled.div` + display: flex; + gap: 12px; + margin-top: 32px; +`; + +const PasswordInput = styled.input<{ hasError: boolean }>` + width: 44px; + height: 44px; + border-radius: 12px; + background-color: ${colors.darkgrey.main}; + border: ${props => (props.hasError ? `1px solid ${colors.red}` : 'none')}; + outline: none; + + text-align: center; + color: ${colors.white}; + caret-color: ${colors.neongreen}; + font-family: ${typography.fontFamily.secondary}; + font-size: ${typography.fontSize.lg}; + font-weight: ${typography.fontWeight.semibold}; + line-height: normal; +`; + +const ErrorMessage = styled.div` + color: ${colors.red}; + font-size: ${typography.fontSize.sm}; + text-align: center; + margin-top: 12px; +`; diff --git a/src/components/memory/MemoryContent/MemoryContent.tsx b/src/components/memory/MemoryContent/MemoryContent.tsx index 6113adef..ce487c8b 100644 --- a/src/components/memory/MemoryContent/MemoryContent.tsx +++ b/src/components/memory/MemoryContent/MemoryContent.tsx @@ -1,4 +1,5 @@ -import type { RecordType, FilterType, Record } from '../../../pages/memory/Memory'; +import type { RecordType, FilterType } from '../../../pages/memory/Memory'; +import type { Record } from '../../../types/memory'; import type { SortType } from '../SortDropdown'; import RecordTabs from '../RecordTabs'; import RecordFilters from '../RecordFilters/RecordFilters'; @@ -17,6 +18,7 @@ interface MemoryContentProps { selectedPageRange: { start: number; end: number } | null; hasRecords: boolean; showUploadProgress: boolean; + currentUserPage: number; onTabChange: (tab: RecordType) => void; onFilterChange: (filter: FilterType) => void; onSortChange: (sort: SortType) => void; diff --git a/src/components/memory/MemoryContent/RecordList.tsx b/src/components/memory/MemoryContent/RecordList.tsx index 5ea45287..4f1beb1b 100644 --- a/src/components/memory/MemoryContent/RecordList.tsx +++ b/src/components/memory/MemoryContent/RecordList.tsx @@ -1,4 +1,4 @@ -import type { Record } from '../../../pages/memory/Memory'; +import type { Record } from '../../../types/memory'; import RecordItem from '../RecordItem/RecordItem'; import { RecordListContainer } from './RecordList.styled'; diff --git a/src/components/memory/RecordFilters/FilterButtons.tsx b/src/components/memory/RecordFilters/FilterButtons.tsx index e10ed99b..d5fc4982 100644 --- a/src/components/memory/RecordFilters/FilterButtons.tsx +++ b/src/components/memory/RecordFilters/FilterButtons.tsx @@ -40,9 +40,9 @@ const FilterButtons = ({ switch (sort) { case 'latest': return '최신순'; - case 'popular': + case 'like': return '인기순'; - case 'comments': + case 'comment': return '댓글 많은순'; default: return '최신순'; diff --git a/src/components/memory/RecordItem/PollRecord.tsx b/src/components/memory/RecordItem/PollRecord.tsx index 7ab919a2..7b497cb9 100644 --- a/src/components/memory/RecordItem/PollRecord.tsx +++ b/src/components/memory/RecordItem/PollRecord.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useRef } from 'react'; -import type { PollOption } from '../../../pages/memory/Memory'; +import type { PollOption } from '../../../types/memory'; import { PollSection, PollQuestion, diff --git a/src/components/memory/RecordItem/RecordItem.tsx b/src/components/memory/RecordItem/RecordItem.tsx index a715d897..8dbe4dfd 100644 --- a/src/components/memory/RecordItem/RecordItem.tsx +++ b/src/components/memory/RecordItem/RecordItem.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useCallback } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; -import type { Record } from '../../../pages/memory/Memory'; +import type { Record } from '../../../types/memory'; import TextRecord from './TextRecord'; import PollRecord from './PollRecord'; import heartIcon from '../../../assets/memory/heart.svg'; @@ -21,6 +21,7 @@ import { import { usePopupActions } from '@/hooks/usePopupActions'; import { deleteRecord } from '@/api/record/deleteRecord'; import { deleteVote } from '@/api/record/deleteVote'; +import { postRoomPostLike } from '@/api/roomPosts/postRoomPostLike'; interface RecordItemProps { record: Record; @@ -30,9 +31,10 @@ interface RecordItemProps { const RecordItem = ({ record, shouldBlur = false }: RecordItemProps) => { const navigate = useNavigate(); const { roomId } = useParams<{ roomId: string }>(); - const { openMoreMenu, openConfirm, openSnackbar, closePopup } = usePopupActions(); + const { openMoreMenu, openConfirm, openSnackbar } = usePopupActions(); const { + id, user, content, likeCount, @@ -45,8 +47,8 @@ const RecordItem = ({ record, shouldBlur = false }: RecordItemProps) => { isWriter, } = record; - // 좋아요 상태 관리 - const [isLiked, setIsLiked] = useState(false); + // 좋아요 상태 관리 - record 객체에서 isLiked 속성 가져오기 + const [isLiked, setIsLiked] = useState(record.isLiked || false); const [currentLikeCount, setCurrentLikeCount] = useState(likeCount); // 길게 누르기 상태 관리 @@ -58,15 +60,55 @@ const RecordItem = ({ record, shouldBlur = false }: RecordItemProps) => { // API에서 받은 isWriter 속성으로 내 기록인지 판단 const isMyRecord = isWriter ?? false; - const handleLikeClick = () => { - if (isLiked) { - // 좋아요 취소 - setIsLiked(false); - setCurrentLikeCount(prev => prev - 1); - } else { - // 좋아요 추가 - setIsLiked(true); - setCurrentLikeCount(prev => prev + 1); + // 좋아요 클릭 핸들러 - API 연동 + const handleLikeClick = async () => { + try { + const postId = parseInt(id); + const roomPostType = type === 'poll' ? 'VOTE' : 'RECORD'; + + const response = await postRoomPostLike(postId, { + type: !isLiked, // 현재 상태 반대로 전송 + roomPostType, + }); + + if (response.isSuccess) { + // 서버 응답으로 상태 업데이트 + setIsLiked(response.data.isLiked); + setCurrentLikeCount((prev: number) => (response.data.isLiked ? prev + 1 : prev - 1)); + console.log('좋아요 상태 변경 성공:', response.data.isLiked); + } else { + console.error('좋아요 상태 변경 실패:', response.message); + + // 에러 메시지에 따른 사용자 알림 + let errorMessage = '좋아요 처리 중 오류가 발생했습니다.'; + + if (response.code === 140011) { + errorMessage = '방 접근 권한이 없습니다.'; + } else if (response.code === 185001) { + errorMessage = '이미 좋아요한 게시물입니다.'; + } else if (response.code === 185002) { + errorMessage = '좋아요하지 않은 게시물은 취소할 수 없습니다.'; + } else if (response.code === 100009) { + errorMessage = '잘못된 게시물 타입입니다.'; + } else if (response.code === 110009) { + errorMessage = '존재하지 않는 게시물입니다.'; + } else if (response.code === 40500) { + errorMessage = '허용되지 않는 HTTP 메소드입니다.'; + } + + openSnackbar({ + message: errorMessage, + variant: 'top', + onClose: () => {}, + }); + } + } catch (error) { + console.error('좋아요 API 호출 실패:', error); + openSnackbar({ + message: '네트워크 오류가 발생했습니다. 다시 시도해주세요.', + variant: 'top', + onClose: () => {}, + }); } }; @@ -149,44 +191,46 @@ const RecordItem = ({ record, shouldBlur = false }: RecordItemProps) => { }); }, [openSnackbar]); - const handleLongPress = useCallback(() => { - if (isMyRecord) { - // 내 기록: 수정하기, 삭제하기 - openMoreMenu({ - onEdit: handleEdit, - onDelete: handleDeleteConfirm, - onClose: closePopup, - }); - } else { - // 다른 사람 기록: 신고하기 - handleReport(); - } - }, [isMyRecord, openMoreMenu, handleEdit, handleDeleteConfirm, handleReport, closePopup]); - - const handleLongPressStart = useCallback( - (e: React.MouseEvent | React.TouchEvent) => { - if ('touches' in e) { - e.preventDefault(); - } - - const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX; - const clientY = 'touches' in e ? e.touches[0].clientY : e.clientY; + // 길게 누르기 이벤트 핸들러 + const handleTouchStart = useCallback( + (e: React.TouchEvent) => { + if (isMyRecord) return; - pressStartPos.current = { x: clientX, y: clientY }; - hasTriggeredLongPress.current = false; setIsPressed(true); + hasTriggeredLongPress.current = false; + pressStartPos.current = { + x: e.touches[0].clientX, + y: e.touches[0].clientY, + }; longPressTimer.current = setTimeout(() => { - if (!hasTriggeredLongPress.current) { - hasTriggeredLongPress.current = true; - handleLongPress(); - } - }, 500); + hasTriggeredLongPress.current = true; + setIsPressed(false); + + openMoreMenu({ + onReport: handleReport, + }); + }, 800); }, - [handleLongPress], + [isMyRecord, openMoreMenu, handleReport], ); - const handleLongPressEnd = useCallback(() => { + const handleTouchMove = useCallback((e: React.TouchEvent) => { + if (!longPressTimer.current) return; + + const currentX = e.touches[0].clientX; + const currentY = e.touches[0].clientY; + const deltaX = Math.abs(currentX - pressStartPos.current.x); + const deltaY = Math.abs(currentY - pressStartPos.current.y); + + if (deltaX > 10 || deltaY > 10) { + clearTimeout(longPressTimer.current); + longPressTimer.current = null; + setIsPressed(false); + } + }, []); + + const handleTouchEnd = useCallback(() => { if (longPressTimer.current) { clearTimeout(longPressTimer.current); longPressTimer.current = null; @@ -194,33 +238,28 @@ const RecordItem = ({ record, shouldBlur = false }: RecordItemProps) => { setIsPressed(false); }, []); - const handleTouchMove = useCallback( - (e: React.TouchEvent) => { - if (!longPressTimer.current) return; - - const clientX = e.touches[0].clientX; - const clientY = e.touches[0].clientY; - - const deltaX = Math.abs(clientX - pressStartPos.current.x); - const deltaY = Math.abs(clientY - pressStartPos.current.y); + const handleClick = useCallback(() => { + if (hasTriggeredLongPress.current) { + hasTriggeredLongPress.current = false; + return; + } - if (deltaX > 10 || deltaY > 10) { - handleLongPressEnd(); - } - }, - [handleLongPressEnd], - ); + if (isMyRecord) { + openMoreMenu({ + onEdit: handleEdit, + onDelete: handleDeleteConfirm, + }); + } + }, [isMyRecord, openMoreMenu, handleEdit, handleDeleteConfirm]); return ( void; readingProgress: number; + isOverviewPossible: boolean; } const PageRangeSection = ({ diff --git a/src/components/search/GroupSearchResult.tsx b/src/components/search/GroupSearchResult.tsx index b624a7eb..5dfea5fb 100644 --- a/src/components/search/GroupSearchResult.tsx +++ b/src/components/search/GroupSearchResult.tsx @@ -8,11 +8,16 @@ import type { SearchRoomItem } from '@/api/rooms/getSearchRooms'; const FILTER = ['마감임박순', '인기순']; const CATEGORIES = ['문학', '과학·IT', '사회과학', '인문학', '예술'] as const; +type ResultType = 'searching' | 'searched'; + interface Props { + type: ResultType; rooms: SearchRoomItem[]; isLoading: boolean; - isLast: boolean; - onLoadMore: () => void; + isLoadingMore?: boolean; + hasMore?: boolean; + lastRoomElementCallback?: (node: HTMLDivElement | null) => void; + error: string | null; selectedFilter: string; setSelectedFilter: (v: string) => void; @@ -33,10 +38,12 @@ const mapToGroupCardModel = (r: SearchRoomItem) => ({ }); const GroupSearchResult = ({ + type, rooms, isLoading, - isLast, - onLoadMore, + isLoadingMore = false, + hasMore = false, + lastRoomElementCallback, error, selectedFilter, setSelectedFilter, @@ -63,33 +70,41 @@ const GroupSearchResult = ({ ); })} + - 전체 {mapped.length} + {type === 'searched' && 전체 {mapped.length}} + {error && {error}} + {isEmpty ? ( 해당하는 모임방이 없어요 검색어를 바꿔보거나 직접 모임방을 만들어보세요. ) : ( - mapped.map(group => ( - + mapped.map((group, idx) => ( +
+ +
)) )} - - {isLoading && 불러오는 중...} - {!isLoading && !isLast && mapped.length > 0 && ( - 더 보기 - )} - + {isLoadingMore && mapped.length > 0 && 불러오는 중...} + {!hasMore && mapped.length > 0 && 더 이상 결과가 없어요}
); @@ -121,9 +136,9 @@ const Tab = styled.button<{ selected?: boolean }>` const Content = styled.div` display: flex; flex-direction: column; - gap: 20px; + gap: 12px; overflow-y: auto; - padding: 0 20px; + padding: 0 20px 24px; `; const GroupCardHeader = styled.div` @@ -136,7 +151,7 @@ const GroupCardHeader = styled.div` const GroupNum = styled.span` display: flex; align-items: center; - color: ${colors.grey[100]}; + color: ${colors.white}; font-size: ${typography.fontSize.sm}; font-weight: ${typography.fontWeight.medium}; `; @@ -164,25 +179,16 @@ const EmptySubText = styled.p` text-align: center; `; -const LoadMoreArea = styled.div` - display: flex; - justify-content: center; - padding: 12px 0 24px; -`; - -const LoadMoreButton = styled.button` - padding: 10px 16px; - border: none; - border-radius: 8px; - background: var(--color-darkgrey-main); - color: #fff; - font-size: ${typography.fontSize.sm}; - cursor: pointer; -`; - const LoadingText = styled.p` color: ${colors.grey[100]}; font-size: ${typography.fontSize.sm}; + text-align: center; +`; + +const EndText = styled.p` + color: ${colors.grey[200]}; + font-size: ${typography.fontSize.xs}; + text-align: center; `; const ErrorText = styled.p` diff --git a/src/hooks/useLogout.ts b/src/hooks/useLogout.ts new file mode 100644 index 00000000..548cf4b6 --- /dev/null +++ b/src/hooks/useLogout.ts @@ -0,0 +1,15 @@ +import { useNavigate } from 'react-router-dom'; + +export const useLogout = () => { + const navigate = useNavigate(); + + const handleLogout = () => { + // 1. localStorage에서 토큰 제거 + localStorage.removeItem('authToken'); + + // 2. 로그인 페이지로 리다이렉트 + navigate('/', { replace: true }); + }; + + return { handleLogout }; +}; diff --git a/src/pages/groupDetail/ParticipatedGroupDetail.tsx b/src/pages/groupDetail/ParticipatedGroupDetail.tsx index c13588b1..cc597407 100644 --- a/src/pages/groupDetail/ParticipatedGroupDetail.tsx +++ b/src/pages/groupDetail/ParticipatedGroupDetail.tsx @@ -124,15 +124,19 @@ const ParticipatedGroupDetail = () => { }; const handleRecordSectionClick = () => { - navigate(`/memory/${roomId}`); + navigate(`/rooms/${roomId}/memory`); }; - const handleCommentSectionClick = () => { - navigate(`/today-words/${roomId}`); + const handleHotTopicSectionClick = () => { + navigate(`/rooms/${roomId}/memory`); }; - const handleHotTopicSectionClick = () => { - navigate(`/memory/${roomId}`); + const handlePollClick = (pageNumber: number) => { + navigate(`/rooms/${roomId}/memory?page=${pageNumber}&filter=poll`); + }; + + const handleCommentSectionClick = () => { + navigate(`/today-words/${roomId}`); }; const handleBookSectionClick = () => { @@ -141,10 +145,6 @@ const ParticipatedGroupDetail = () => { } }; - const handlePollClick = (pageNumber: number) => { - navigate(`/memory/${roomId}?page=${pageNumber}&filter=poll`); - }; - const handleMembersClick = () => { navigate(`/group/${roomId}/members`); }; diff --git a/src/pages/groupSearch/GroupSearch.tsx b/src/pages/groupSearch/GroupSearch.tsx index f332cee8..ffa26eb8 100644 --- a/src/pages/groupSearch/GroupSearch.tsx +++ b/src/pages/groupSearch/GroupSearch.tsx @@ -1,122 +1,232 @@ import TitleHeader from '@/components/common/TitleHeader'; import { Modal, Overlay } from '@/components/group/Modal.styles'; import leftArrow from '../../assets/common/leftArrow.svg'; -import { useNavigate } from 'react-router-dom'; import SearchBar from '@/components/search/SearchBar'; -import { useState, useEffect, useCallback } from 'react'; +import { useState, useEffect, useCallback, useRef } from 'react'; import RecentSearchTabs from '@/components/search/RecentSearchTabs'; import GroupSearchResult from '@/components/search/GroupSearchResult'; import { getRecentSearch, type RecentSearchData } from '@/api/recentsearch/getRecentSearch'; import { deleteRecentSearch } from '@/api/recentsearch/deleteRecentSearch'; - import { getSearchRooms, type SearchRoomItem } from '@/api/rooms/getSearchRooms'; type SortKey = 'deadline' | 'memberCount'; const GroupSearch = () => { - const navigate = useNavigate(); - const [searchTerm, setSearchTerm] = useState(''); const [isSearching, setIsSearching] = useState(false); - - const [recentSearches, setRecentSearches] = useState([]); - const [isLoadingRecent, setIsLoadingRecent] = useState(false); + const [isFinalized, setIsFinalized] = useState(false); const [rooms, setRooms] = useState([]); const [nextCursor, setNextCursor] = useState(null); const [isLast, setIsLast] = useState(true); - const [isLoadingList, setIsLoadingList] = useState(false); + + // 로딩 + const [isLoading, setIsLoading] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); const [error, setError] = useState(null); + // 정렬/카테고리 const [selectedFilter, setSelectedFilter] = useState('마감임박순'); const toSortKey = useCallback( (f: string): SortKey => (f === '인기순' ? 'memberCount' : 'deadline'), [], ); - const [category, setCategory] = useState(''); - const [isFinalized] = useState(false); - useEffect(() => { - (async () => { - try { - setIsLoadingRecent(true); - const response = await getRecentSearch('ROOM'); - setRecentSearches(response.isSuccess ? response.data.recentSearchList : []); - } finally { - setIsLoadingRecent(false); - } - })(); - }, []); + const [recentSearches, setRecentSearches] = useState([]); + const [searchTimeoutId, setSearchTimeoutId] = useState(null); - const runSearch = useCallback( - async (keyword: string, sortKey: SortKey, cursor?: string, append = false) => { - if (!keyword.trim()) return; - try { - setIsLoadingList(true); - setError(null); + const observerRef = useRef(null); - const res = await getSearchRooms(keyword.trim(), sortKey, cursor, isFinalized, category); + const fetchRecentSearches = async () => { + try { + const response = await getRecentSearch('ROOM'); + setRecentSearches(response.isSuccess ? response.data.recentSearchList : []); + } catch { + setRecentSearches([]); + } + }; + useEffect(() => { + fetchRecentSearches(); + }, []); - if (!res.isSuccess) { - if (!append) { - setRooms([]); - setNextCursor(null); - setIsLast(true); - } - setError(res.message || '검색 실패'); - return; - } + const searchFirstPage = useCallback( + async (term: string, sortKey: SortKey, manual: boolean) => { + if (!term.trim()) return; + setIsSearching(true); + if (manual) setIsFinalized(false); - const { roomList, nextCursor: nc, isLast: last } = res.data; - setRooms(prev => (append ? [...prev, ...roomList] : roomList)); - setNextCursor(nc); - setIsLast(last); - } catch { - if (!append) { + setIsLoading(true); + setError(null); + try { + const res = await getSearchRooms(term.trim(), sortKey, undefined, isFinalized, category); + if (res.isSuccess) { + const { roomList, nextCursor: nc, isLast: last } = res.data; + setRooms(roomList); + setNextCursor(nc); + setIsLast(last); + } else { setRooms([]); setNextCursor(null); setIsLast(true); + setError(res.message || '검색 실패'); } + } catch { + setRooms([]); + setNextCursor(null); + setIsLast(true); setError('네트워크 오류가 발생했습니다.'); } finally { - setIsLoadingList(false); + setIsLoading(false); + if (manual) setIsFinalized(true); } }, [category, isFinalized], ); + const loadMore = useCallback(async () => { + if (!searchTerm.trim() || !nextCursor || isLast || isLoadingMore) return; + + try { + setIsLoadingMore(true); + const res = await getSearchRooms( + searchTerm.trim(), + toSortKey(selectedFilter), + nextCursor, + isFinalized, + category, + ); + if (res.isSuccess) { + const { roomList, nextCursor: nc, isLast: last } = res.data; + setRooms(prev => [...prev, ...roomList]); + setNextCursor(nc); + setIsLast(last); + } else { + setIsLast(true); + } + } catch { + setIsLast(true); + } finally { + setIsLoadingMore(false); + } + }, [ + searchTerm, + nextCursor, + isLast, + isLoadingMore, + selectedFilter, + toSortKey, + isFinalized, + category, + ]); + + const lastRoomElementCallback = useCallback( + (node: HTMLDivElement | null) => { + if (isLoadingMore || isLast) return; + if (observerRef.current) observerRef.current.disconnect(); + + observerRef.current = new IntersectionObserver(entries => { + if (entries[0].isIntersecting && !isLoadingMore && !isLast) { + loadMore(); + } + }); + if (node) observerRef.current.observe(node); + }, + [isLoadingMore, isLast, loadMore], + ); + + const handleChange = (value: string) => { + setSearchTerm(value); + setIsFinalized(false); + const trimmed = value.trim(); + setIsSearching(trimmed !== ''); + setNextCursor(null); + setIsLast(true); + setRooms([]); + + if (searchTimeoutId) { + clearTimeout(searchTimeoutId); + } + + if (trimmed) { + const id = setTimeout(() => { + searchFirstPage(trimmed, toSortKey(selectedFilter), false); + }, 300); + setSearchTimeoutId(id); + } else { + setError(null); + } + }; + const handleSearch = () => { - if (!searchTerm.trim()) return; - setIsSearching(true); - runSearch(searchTerm, toSortKey(selectedFilter), undefined, false); + const term = searchTerm.trim(); + if (!term) return; + if (searchTimeoutId) { + clearTimeout(searchTimeoutId); + setSearchTimeoutId(null); + } + searchFirstPage(term, toSortKey(selectedFilter), true); }; - const handleRecentSearchClick = (recentSearch: string) => { - setSearchTerm(recentSearch); + const handleRecentSearchClick = (recent: string) => { + setSearchTerm(recent); setIsSearching(true); - runSearch(recentSearch, toSortKey(selectedFilter), undefined, false); + setIsFinalized(false); + if (searchTimeoutId) { + clearTimeout(searchTimeoutId); + setSearchTimeoutId(null); + } + searchFirstPage(recent, toSortKey(selectedFilter), true); }; useEffect(() => { if (isSearching && searchTerm.trim()) { - runSearch(searchTerm, toSortKey(selectedFilter), undefined, false); + if (searchTimeoutId) { + clearTimeout(searchTimeoutId); + setSearchTimeoutId(null); + } + searchFirstPage(searchTerm.trim(), toSortKey(selectedFilter), false); } - }, [selectedFilter, isSearching, searchTerm, runSearch, toSortKey]); + }, [selectedFilter, isSearching, searchTerm, searchFirstPage, toSortKey, searchTimeoutId]); useEffect(() => { if (isSearching && searchTerm.trim()) { - runSearch(searchTerm, toSortKey(selectedFilter), undefined, false); + if (searchTimeoutId) { + clearTimeout(searchTimeoutId); + setSearchTimeoutId(null); + } + searchFirstPage(searchTerm.trim(), toSortKey(selectedFilter), false); } - }, [category, isSearching, searchTerm, runSearch, toSortKey, selectedFilter]); + }, [ + category, + isSearching, + searchTerm, + searchFirstPage, + toSortKey, + selectedFilter, + searchTimeoutId, + ]); - const handleLoadMore = () => { - if (!isLast && nextCursor && searchTerm.trim()) { - runSearch(searchTerm, toSortKey(selectedFilter), nextCursor, true); + const handleBackButton = () => { + if (searchTimeoutId) { + clearTimeout(searchTimeoutId); + setSearchTimeoutId(null); } + setSearchTerm(''); + setIsSearching(false); + setIsFinalized(false); + setRooms([]); + setNextCursor(null); + setIsLast(true); + setError(null); }; - const handleBackButton = () => navigate('/group'); + useEffect(() => { + return () => { + if (searchTimeoutId) clearTimeout(searchTimeoutId); + if (observerRef.current) observerRef.current.disconnect(); + }; + }, [searchTimeoutId]); return ( @@ -130,16 +240,19 @@ const GroupSearch = () => { {isSearching ? ( { /> ) : ( i.searchTerm)} + recentSearches={recentSearches.map(i => i.searchTerm)} handleDelete={(term: string) => { const x = recentSearches.find(i => i.searchTerm === term); - if (x) - deleteRecentSearch(x.recentSearchId, 1).then(res => { - if (res.isSuccess) { - setRecentSearches(prev => - prev.filter(it => it.recentSearchId !== x.recentSearchId), - ); - } - }); + if (!x) return; + const userId = 1; + deleteRecentSearch(x.recentSearchId, userId).then(res => { + if (res.isSuccess) { + setRecentSearches(prev => + prev.filter(it => it.recentSearchId !== x.recentSearchId), + ); + } + }); }} handleRecentSearchClick={handleRecentSearchClick} /> diff --git a/src/pages/memory/Memory.tsx b/src/pages/memory/Memory.tsx index aeb6de6a..777095c3 100644 --- a/src/pages/memory/Memory.tsx +++ b/src/pages/memory/Memory.tsx @@ -7,34 +7,11 @@ import MemoryAddButton from '../../components/memory/MemoryAddButton/MemoryAddBu import Snackbar from '../../components/common/Modal/Snackbar'; import { Container, FixedHeader, ScrollableContent, FloatingElements } from './Memory.styled'; import { getMemoryPosts } from '../../api/memory/getMemoryPosts'; -import type { Post } from '../../types/memory'; +import type { GetMemoryPostsParams, Post, Record } from '../../types/memory'; export type RecordType = 'group' | 'my'; export type FilterType = 'page' | 'overall'; -export interface Record { - id: string; - user: string; - userPoints: number; - content: string; - likeCount: number; - commentCount: number; - timeAgo: string; - createdAt: Date; - type: 'text' | 'poll'; - recordType?: 'page' | 'overall'; - pollOptions?: PollOption[]; - pageRange?: string; - isWriter?: boolean; -} - -export interface PollOption { - id: string; - text: string; - percentage: number; - isHighest?: boolean; -} - // API 포스트를 기존 Record 타입으로 변환하는 함수 const convertPostToRecord = (post: Post): Record => { return { @@ -50,6 +27,7 @@ const convertPostToRecord = (post: Post): Record => { recordType: post.isOverview ? 'overall' : 'page', pageRange: post.isOverview ? undefined : post.page.toString(), isWriter: post.isWriter, + isLiked: post.isLiked, pollOptions: post.voteItems.map((item, index) => ({ id: item.voteItemId.toString(), text: item.itemName, @@ -59,30 +37,23 @@ const convertPostToRecord = (post: Post): Record => { }; }; -const addRecordIfNotExists = (prevRecords: Record[], newRecord: Record) => { - const exists = prevRecords.some(record => record.id === newRecord.id); - if (exists) { - return prevRecords; - } - return [newRecord, ...prevRecords]; -}; - const Memory = () => { const navigate = useNavigate(); const location = useLocation(); const { roomId } = useParams<{ roomId: string }>(); + // 상태 관리 const [activeTab, setActiveTab] = useState('group'); const [activeFilter, setActiveFilter] = useState(null); const [selectedSort, setSelectedSort] = useState('latest'); const [showSnackbar, setShowSnackbar] = useState(false); - const [readingProgress] = useState(70); const [selectedPageRange, setSelectedPageRange] = useState<{ start: number; end: number } | null>( null, ); // API 관련 상태 const [error, setError] = useState(null); + const [isOverviewEnabled, setIsOverviewEnabled] = useState(false); // 업로드 프로그레스 상태 const [showUploadProgress, setShowUploadProgress] = useState(false); @@ -90,118 +61,103 @@ const Memory = () => { // 개발용 상태 - 기록 유무 전환 const [hasRecords, setHasRecords] = useState(true); - // 내 기록들을 별도로 관리 + // 기록 데이터 const [myRecords, setMyRecords] = useState([]); - - // 그룹 기록들을 별도로 관리 const [groupRecords, setGroupRecords] = useState([]); - // API 데이터 로드 + // API 데이터 로드 함수 const loadMemoryPosts = useCallback(async () => { - // roomId가 없으면 기본값 1 사용 또는 API 호출 스킵 - const currentRoomId = roomId || '1'; - + if (!roomId) { + console.log('❌ roomId가 없습니다:', roomId); + return; + } setError(null); try { - // 정렬 타입 변환 - let sortType: 'latest' | 'like' | 'comment' | undefined = undefined; + // API 파라미터 구성 + const params: GetMemoryPostsParams = { + roomId: parseInt(roomId), + type: activeTab === 'group' ? 'group' : 'mine', + }; + + // group 탭인 경우에만 sort 파라미터 추가 if (activeTab === 'group') { - if (selectedSort === 'latest') sortType = 'latest'; - else if (selectedSort === 'popular') sortType = 'like'; - else if (selectedSort === 'comments') sortType = 'comment'; + params.sort = selectedSort; } - // API 타입에 맞는 파라미터 구성 - const requestParams: { - roomId: number; - type: 'group' | 'mine'; - sort?: 'latest' | 'like' | 'comment'; - pageStart?: number | null; - pageEnd?: number | null; - isOverview?: boolean; - isPageFilter?: boolean; - cursor?: string | null; - } = { - roomId: parseInt(currentRoomId), - type: activeTab === 'my' ? 'mine' : 'group', - pageStart: selectedPageRange ? selectedPageRange.start : null, - pageEnd: selectedPageRange ? selectedPageRange.end : null, - isOverview: activeFilter === 'overall' ? true : false, - isPageFilter: activeFilter === 'page' ? true : false, - cursor: null, - }; - - // sort는 group 타입일 때만 추가 - if (activeTab === 'group' && sortType) { - requestParams.sort = sortType; + // 필터 적용 + if (activeFilter === 'overall') { + params.isOverview = true; + console.log('🎯 총평 필터 적용 - 독서 진행률 80% 이상 필요'); + } else if (selectedPageRange) { + params.pageStart = selectedPageRange.start; + params.pageEnd = selectedPageRange.end; + params.isPageFilter = true; + console.log('📖 페이지 필터 적용:', selectedPageRange); } - console.log('API 호출 파라미터:', requestParams); + console.log('📤 API 요청 파라미터:', params); - const response = await getMemoryPosts(requestParams); + const response = await getMemoryPosts(params); + console.log('📨 API 응답 성공:', response); if (response.isSuccess) { const convertedRecords = response.data.postList.map(convertPostToRecord); - if (activeTab === 'my') { - setMyRecords(convertedRecords); - } else { + if (activeTab === 'group') { setGroupRecords(convertedRecords); + } else { + setMyRecords(convertedRecords); } - setHasRecords(convertedRecords.length > 0); - - console.log('API 응답 성공:', response.data); + setIsOverviewEnabled(response.data.isOverviewEnabled); } else { setError(response.message); - console.error('API 응답 실패:', response.message); } } catch (error) { - const errorMessage = - error instanceof Error ? error.message : '기록을 불러오는 중 오류가 발생했습니다.'; - setError(errorMessage); - console.error('API 호출 오류:', error); + // Axios 에러인 경우 상세 정보 출력 + if (error && typeof error === 'object' && 'response' in error) { + const axiosError = error as { response?: { data?: { code?: number } } }; + if (axiosError.response?.data?.code === 40002) { + setError('독서 진행률이 80% 이상이어야 총평을 볼 수 있습니다.'); + setActiveFilter(null); // 총평 필터를 자동으로 해제 + return; // 다른 에러 메시지 설정하지 않음 + } + } + + setError('기록을 불러오는 중 오류가 발생했습니다.'); } - }, [roomId, activeTab, selectedSort, selectedPageRange, activeFilter]); + }, [roomId, activeTab, selectedSort, activeFilter, selectedPageRange]); - // 컴포넌트 마운트 시 및 필터/탭 변경 시 데이터 로드 + // 컴포넌트 마운트 시 데이터 로드 useEffect(() => { loadMemoryPosts(); }, [loadMemoryPosts]); - // location.state에서 새로 추가된 기록 확인 + // 새로운 기록이 추가되었을 때 처리 (작성 완료 후 돌아왔을 때) useEffect(() => { if (location.state?.newRecord) { - const { isUploading, ...recordData } = location.state.newRecord as Record & { - isUploading?: boolean; - }; + const newRecord = location.state.newRecord as Record; + setShowUploadProgress(true); - if (isUploading) { - setShowUploadProgress(true); - const finalRecord: Record = recordData; - setMyRecords(prev => addRecordIfNotExists(prev, finalRecord)); - setGroupRecords(prev => addRecordIfNotExists(prev, finalRecord)); + if (activeTab === 'group') { + setGroupRecords(prev => [newRecord, ...prev]); + } else { + setMyRecords(prev => [newRecord, ...prev]); } - setActiveTab('my'); - navigate(location.pathname, { replace: true, state: null }); + // 상태 정리 + navigate(location.pathname, { replace: true }); } - }, [location.state?.newRecord, location.pathname, navigate]); + }, [location.state, activeTab, navigate, location.pathname]); - // 업로드 완료 처리 - const handleUploadComplete = useCallback(() => { - setShowUploadProgress(false); - }, []); - - // 현재 표시할 기록들 + // 현재 탭에 따른 기록 목록 결정 const currentRecords = useMemo(() => { - if (activeTab === 'my') { - return myRecords; - } else { - return hasRecords ? groupRecords : []; + if (!hasRecords) { + return []; } - }, [activeTab, myRecords, hasRecords, groupRecords]); + return activeTab === 'group' ? groupRecords : myRecords; + }, [activeTab, hasRecords, groupRecords, myRecords]); // 정렬된 기록 목록 const sortedRecords = useMemo(() => { @@ -210,9 +166,24 @@ const Memory = () => { // 필터링된 기록 목록 const filteredRecords = useMemo(() => { - return sortedRecords; - }, [sortedRecords]); + const filtered = sortedRecords; + + if (activeFilter === 'overall') { + const overallRecords = filtered.filter(record => record.recordType === 'overall'); + return overallRecords; + } else if (activeFilter === 'page' && selectedPageRange) { + const pageRecords = filtered.filter(record => { + if (record.recordType === 'overall') return false; + const page = parseInt(record.pageRange || '0'); + return page >= selectedPageRange.start && page <= selectedPageRange.end; + }); + return pageRecords; + } + + return filtered; + }, [sortedRecords, activeFilter, selectedPageRange]); + // 이벤트 핸들러들 const handleBackClick = useCallback(() => { if (roomId) { navigate(`/rooms/${roomId}`); @@ -246,6 +217,7 @@ const Memory = () => { const handlePageRangeClear = useCallback(() => { setSelectedPageRange(null); + setActiveFilter(null); }, []); const handlePageRangeSet = useCallback((range: { start: number; end: number }) => { @@ -257,6 +229,15 @@ const Memory = () => { setHasRecords(!hasRecords); }, [hasRecords]); + const handleUploadComplete = useCallback(() => { + setShowUploadProgress(false); + }, []); + + // 독서 진행률 계산 + const readingProgress = isOverviewEnabled ? 85 : 70; + const currentUserPage = 350; // 임시로 350으로 설정 (나중에 API에서 가져올 것) + + // 에러 상태 렌더링 if (error) { return ( @@ -273,6 +254,7 @@ const Memory = () => { ); } + // 메인 렌더링 return ( @@ -289,6 +271,7 @@ const Memory = () => { selectedPageRange={selectedPageRange} hasRecords={hasRecords} showUploadProgress={showUploadProgress} + currentUserPage={currentUserPage} onTabChange={handleTabChange} onFilterChange={handleFilterChange} onSortChange={handleSortChange} diff --git a/src/pages/mypage/Mypage.tsx b/src/pages/mypage/Mypage.tsx index 46df6451..2a08d959 100644 --- a/src/pages/mypage/Mypage.tsx +++ b/src/pages/mypage/Mypage.tsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { colors, typography } from '@/styles/global/global'; import MenuButton from '@/components/Mypage/MenuButton'; import { usePopupActions } from '@/hooks/usePopupActions'; +import { useLogout } from '@/hooks/useLogout'; import alert from '../../assets/mypage/alert.svg'; import guide from '../../assets/mypage/guide.svg'; import save from '../../assets/mypage/save.svg'; @@ -18,6 +19,7 @@ const Mypage = () => { const [profile, setProfile] = useState(null); const { openConfirm, closePopup } = usePopupActions(); const navigate = useNavigate(); + const { handleLogout: logout } = useLogout(); useEffect(() => { const fetchProfile = async () => { @@ -40,10 +42,9 @@ const Mypage = () => { title: '로그아웃', disc: '또 THIP 해주실거죠?', onConfirm: () => { - //실제 로그아웃 로직 구현 console.log('로그아웃 실행'); closePopup(); - //토큰 삭제, 메인 페이지로 이동 + logout(); // 실제 로그아웃 로직 실행 }, onClose: () => { console.log('로그아웃 취소'); @@ -53,7 +54,7 @@ const Mypage = () => { }; const handleNotice = () => { - window.open('https://www.naver.com', '_blank'); + window.open('https://slashpage.com/thip/7vgjr4m1nynpy2dwpy86', '_blank'); }; const handleSave = () => { @@ -68,6 +69,14 @@ const Mypage = () => { navigate('/mypage/withdraw'); }; + const handleGuide = () => { + window.open('https://slashpage.com/thip/ywk9j72989p6rmgpqvnd', '_blank'); + }; + + const handleService = () => { + window.open('https://slashpage.com/thip/xjqy1g2vw7vejm6vd54z', '_blank'); + }; + return (
마이페이지
@@ -96,10 +105,15 @@ const Mypage = () => { 메뉴 - + - - + + diff --git a/src/pages/pollwrite/PollWrite.tsx b/src/pages/pollwrite/PollWrite.tsx index 44b67348..197676c1 100644 --- a/src/pages/pollwrite/PollWrite.tsx +++ b/src/pages/pollwrite/PollWrite.tsx @@ -1,17 +1,19 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import TitleHeader from '../../components/common/TitleHeader'; import PageRangeSection from '../../components/recordwrite/PageRangeSection'; import PollCreationSection from '../../components/pollwrite/PollCreationSection'; import leftArrow from '../../assets/common/leftArrow.svg'; import { Container } from './PollWrite.styled'; -import type { Record } from '../memory/Memory'; import { createVote } from '../../api/record/createVote'; import type { CreateVoteRequest } from '../../types/record'; +import { getBookPage } from '../../api/rooms/getBookPage'; +import { usePopupActions } from '../../hooks/usePopupActions'; const PollWrite = () => { const navigate = useNavigate(); const { roomId } = useParams<{ roomId: string }>(); + const { openSnackbar } = usePopupActions(); const [pageRange, setPageRange] = useState(''); const [pollContent, setPollContent] = useState(''); @@ -19,35 +21,136 @@ const PollWrite = () => { const [isOverallEnabled, setIsOverallEnabled] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); - // TODO: 실제로는 백엔드에서 받아온 책 정보에서 전체 페이지 수를 가져와야 함 - const totalPages = 600; // 임시 값 - // TODO: 실제로는 백엔드에서 받아온 가장 마지막 기록 페이지를 가져와야 함 - const lastRecordedPage = 456; // 임시 값 (기록이 없으면 0) - // TODO: 실제로는 백엔드에서 받아온 읽기 진행도를 가져와야 함 - const readingProgress = 70; // 임시 값 (80% 미만이므로 총평 비활성화) + // API에서 받아올 데이터 + const [totalPages, setTotalPages] = useState(0); + const [lastRecordedPage, setLastRecordedPage] = useState(0); + const [isOverviewPossible, setIsOverviewPossible] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + // 컴포넌트 마운트 시 책 페이지 정보 조회 + useEffect(() => { + const fetchBookPageInfo = async () => { + if (!roomId) { + openSnackbar({ + message: '방 정보를 찾을 수 없습니다.', + variant: 'top', + onClose: () => {}, + }); + navigate(-1); + return; + } + + try { + setIsLoading(true); + const response = await getBookPage(parseInt(roomId)); + + if (response.isSuccess) { + setTotalPages(response.data.totalBookPage); + setLastRecordedPage(response.data.recentBookPage); + setIsOverviewPossible(response.data.isOverviewPossible); + } 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 === 400) { + errorMessage = '파라미터 값 중 유효하지 않은 값이 있습니다.'; + } 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); + } + }; + + fetchBookPageInfo(); + }, [roomId]); + + // 총평 모드가 변경될 때 isOverviewPossible 체크 + useEffect(() => { + if (isOverallEnabled && !isOverviewPossible) { + setIsOverallEnabled(false); + openSnackbar({ + message: '총평 작성 조건을 만족하지 않습니다.', + variant: 'top', + onClose: () => {}, + }); + } + }, [isOverallEnabled, isOverviewPossible]); const handleBackClick = () => { navigate(-1); }; const handleCompleteClick = async () => { - if (isSubmitting || !roomId) return; // 중복 실행 방지 및 roomId 체크 + if (isSubmitting || !roomId) return; setIsSubmitting(true); try { - // 페이지 범위 결정: 총평이 아닌 경우 페이지 번호 필요 - const finalPage = isOverallEnabled - ? 0 // 총평인 경우 페이지는 0 - : pageRange.trim() !== '' - ? parseInt(pageRange.trim()) - : lastRecordedPage; // 입력값이 없으면 마지막 기록 페이지 사용 - // 투표 옵션 필터링 (빈 옵션 제거) const validOptions = pollOptions.filter(option => option.trim() !== ''); if (validOptions.length < 2) { - alert('투표 옵션은 최소 2개 이상이어야 합니다.'); + openSnackbar({ + message: '투표 옵션은 최소 2개 이상이어야 합니다.', + variant: 'top', + onClose: () => {}, + }); + setIsSubmitting(false); + return; + } + + // 페이지 범위 결정 + let finalPage: number; + + if (isOverallEnabled) { + // 총평인 경우: 책의 마지막 페이지 또는 전체 페이지 수 사용 + finalPage = totalPages; + } else { + // 일반 투표인 경우 + if (pageRange.trim() !== '') { + finalPage = parseInt(pageRange.trim()); + } else { + finalPage = lastRecordedPage; + } + } + + // 페이지 유효성 검사 + if (finalPage <= 0 || finalPage > totalPages) { + openSnackbar({ + message: `유효하지 않은 페이지입니다. (1-${totalPages} 사이의 값을 입력해주세요)`, + variant: 'top', + onClose: () => {}, + }); setIsSubmitting(false); return; } @@ -69,40 +172,18 @@ const PollWrite = () => { if (response.isSuccess) { console.log('투표 생성 성공:', response.data); - // 투표 옵션 생성 (Memory 페이지 표시용) - const pollOptionsData = validOptions.map((option, index) => ({ - id: `${index + 1}.`, - text: option.trim(), - percentage: index === 0 ? 90 : 10, // 첫 번째 옵션을 90%로 설정 (데모용) - isHighest: index === 0, // 첫 번째 옵션이 최고값 - })); - - // 임시로 Memory 페이지용 투표 객체 생성 (기존 인터페이스 호환성을 위해) - const newPollRecord: Record & { isUploading?: boolean } = { - id: response.data.voteId.toString(), - user: 'user.01', // TODO: 실제 사용자 정보로 변경 - userPoints: 132, // TODO: 실제 사용자 포인트로 변경 - content: pollContent, - likeCount: 0, - commentCount: 0, - timeAgo: '방금 전', - createdAt: new Date(), - type: 'poll', - recordType: isOverallEnabled ? 'overall' : 'page', - pageRange: isOverallEnabled ? undefined : finalPage.toString(), - pollOptions: pollOptionsData, - isUploading: false, // API 호출이 완료되었으므로 false - }; - // 성공 시 기록장으로 이동 navigate(`/rooms/${roomId}/memory`, { - state: { newRecord: newPollRecord }, replace: true, }); } else { // API 에러 응답 처리 console.error('투표 생성 실패:', response.message); - alert(`투표 생성에 실패했습니다: ${response.message}`); + openSnackbar({ + message: response.message || '투표 생성에 실패했습니다.', + variant: 'top', + onClose: () => {}, + }); setIsSubmitting(false); } } catch (error) { @@ -114,36 +195,68 @@ const PollWrite = () => { if (error && typeof error === 'object' && 'response' in error) { const axiosError = error as { response?: { - status: number; - data?: { message?: string }; + data?: { + message?: string; + code?: number; + }; }; }; if (axiosError.response?.data?.message) { errorMessage = axiosError.response.data.message; - } else if (axiosError.response?.status) { - errorMessage = `서버 오류 (${axiosError.response.status})`; + } else if (axiosError.response?.data?.code === 400) { + errorMessage = '입력값을 확인해 주세요.'; + } else if (axiosError.response?.data?.code === 403) { + errorMessage = '방 접근 권한이 없습니다.'; + } else if (axiosError.response?.data?.code === 404) { + errorMessage = '존재하지 않는 방입니다.'; } } - alert(errorMessage); + openSnackbar({ + message: errorMessage, + variant: 'top', + onClose: () => {}, + }); setIsSubmitting(false); } }; - // 투표 내용과 최소 2개의 옵션이 필요 - const isFormValid = - pollContent.trim() !== '' && pollOptions.filter(option => option.trim() !== '').length >= 2; + // 로딩 중일 때 표시 + if (isLoading) { + return ( + <> + } + title="투표 작성" + onLeftClick={handleBackClick} + /> + +
+ 로딩 중... +
+
+ + ); + } return ( <> } - title="투표 생성" - rightButton="완료" + title="투표 작성" + rightButton={
완료
} onLeftClick={handleBackClick} onRightClick={handleCompleteClick} - isNextActive={isFormValid && !isSubmitting} + isNextActive={pollContent.trim().length > 0 && !isSubmitting} /> { totalPages={totalPages} lastRecordedPage={lastRecordedPage} isOverallEnabled={isOverallEnabled} - onOverallToggle={() => setIsOverallEnabled(!isOverallEnabled)} - readingProgress={readingProgress} + onOverallToggle={() => setIsOverallEnabled(prev => !prev)} + readingProgress={isOverviewPossible ? 80 : 70} // 총평 가능하면 80% 이상으로 표시 + isOverviewPossible={isOverviewPossible} /> - { const navigate = useNavigate(); const { roomId } = useParams<{ roomId: string }>(); + const { openSnackbar } = usePopupActions(); const [pageRange, setPageRange] = useState(''); const [content, setContent] = useState(''); const [isOverallEnabled, setIsOverallEnabled] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); - // TODO: 실제로는 백엔드에서 받아온 책 정보에서 전체 페이지 수를 가져와야 함 - const totalPages = 600; // 임시 값 - // TODO: 실제로는 백엔드에서 받아온 가장 마지막 기록 페이지를 가져와야 함 - const lastRecordedPage = 456; // 임시 값 (기록이 없으면 0) - // TODO: 실제로는 백엔드에서 받아온 읽기 진행도를 가져와야 함 - const readingProgress = 70; // 임시 값 (80% 미만이므로 총평 비활성화) + // API에서 받아올 데이터 + const [totalPages, setTotalPages] = useState(0); + const [lastRecordedPage, setLastRecordedPage] = useState(0); + const [isOverviewPossible, setIsOverviewPossible] = useState(false); + const [isLoading, setIsLoading] = useState(true); + + // 컴포넌트 마운트 시 책 페이지 정보 조회 + useEffect(() => { + const fetchBookPageInfo = async () => { + if (!roomId) { + openSnackbar({ + message: '방 정보를 찾을 수 없습니다.', + variant: 'top', + onClose: () => {}, + }); + navigate(-1); + return; + } + + try { + setIsLoading(true); + const response = await getBookPage(parseInt(roomId)); + + if (response.isSuccess) { + setTotalPages(response.data.totalBookPage); + setLastRecordedPage(response.data.recentBookPage); + setIsOverviewPossible(response.data.isOverviewPossible); + } 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 === 400) { + errorMessage = '파라미터 값 중 유효하지 않은 값이 있습니다.'; + } 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); + } + }; + + fetchBookPageInfo(); + }, [roomId]); + + // 총평 모드가 변경될 때 isOverviewPossible 체크 + useEffect(() => { + if (isOverallEnabled && !isOverviewPossible) { + setIsOverallEnabled(false); + openSnackbar({ + message: '총평 작성 조건을 만족하지 않습니다.', + variant: 'top', + onClose: () => {}, + }); + } + }, [isOverallEnabled, isOverviewPossible]); const handleBackClick = () => { navigate(-1); }; const handleCompleteClick = async () => { - if (isSubmitting || !roomId) return; // 중복 실행 방지 및 roomId 체크 + if (isSubmitting || !roomId) return; setIsSubmitting(true); try { - // 페이지 범위 결정: 총평이 아닌 경우 페이지 번호 필요 - const finalPage = isOverallEnabled - ? 0 // 총평인 경우 페이지는 0 - : pageRange.trim() !== '' - ? parseInt(pageRange.trim()) - : lastRecordedPage; // 입력값이 없으면 마지막 기록 페이지 사용 + // 페이지 범위 결정 + let finalPage: number; + + if (isOverallEnabled) { + // 총평인 경우: 책의 마지막 페이지 또는 전체 페이지 수 사용 + finalPage = totalPages; + } else { + // 일반 기록인 경우 + if (pageRange.trim() !== '') { + finalPage = parseInt(pageRange.trim()); + } else { + finalPage = lastRecordedPage; + } + } + + // 페이지 유효성 검사 + if (finalPage <= 0 || finalPage > totalPages) { + openSnackbar({ + message: `유효하지 않은 페이지입니다. (1-${totalPages} 사이의 값을 입력해주세요)`, + variant: 'top', + onClose: () => {}, + }); + setIsSubmitting(false); + return; + } // API 요청 데이터 생성 const recordData: CreateRecordRequest = { @@ -58,31 +157,18 @@ const RecordWrite = () => { if (response.isSuccess) { console.log('기록 생성 성공:', response.data); - // 임시로 Memory 페이지용 기록 객체 생성 (기존 인터페이스 호환성을 위해) - const newRecord: Record & { isUploading?: boolean } = { - id: response.data.recordId.toString(), - user: 'user.01', // TODO: 실제 사용자 정보로 변경 - userPoints: 132, // TODO: 실제 사용자 포인트로 변경 - content: content, - likeCount: 0, - commentCount: 0, - timeAgo: '방금 전', - createdAt: new Date(), - type: 'text', - recordType: isOverallEnabled ? 'overall' : 'page', - pageRange: isOverallEnabled ? undefined : finalPage.toString(), - isUploading: false, // API 호출이 완료되었으므로 false - }; - // 성공 시 기록장으로 이동 navigate(`/rooms/${roomId}/memory`, { - state: { newRecord }, replace: true, }); } else { // API 에러 응답 처리 console.error('기록 생성 실패:', response.message); - alert(`기록 생성에 실패했습니다: ${response.message}`); + openSnackbar({ + message: response.message || '기록 작성에 실패했습니다.', + variant: 'top', + onClose: () => {}, + }); setIsSubmitting(false); } } catch (error) { @@ -94,36 +180,68 @@ const RecordWrite = () => { if (error && typeof error === 'object' && 'response' in error) { const axiosError = error as { response?: { - status: number; - data?: { message?: string }; + data?: { + message?: string; + code?: number; + }; }; }; if (axiosError.response?.data?.message) { errorMessage = axiosError.response.data.message; - } else if (axiosError.response?.status) { - errorMessage = `서버 오류 (${axiosError.response.status})`; + } else if (axiosError.response?.data?.code === 400) { + errorMessage = '입력값을 확인해 주세요.'; + } else if (axiosError.response?.data?.code === 403) { + errorMessage = '방 접근 권한이 없습니다.'; + } else if (axiosError.response?.data?.code === 404) { + errorMessage = '존재하지 않는 방입니다.'; } } - alert(errorMessage); + openSnackbar({ + message: errorMessage, + variant: 'top', + onClose: () => {}, + }); setIsSubmitting(false); } }; - // 폼 유효성 검사: 내용은 필수, 총평이 아닌 경우 페이지 번호도 확인 - const isFormValid = - content.trim() !== '' && (isOverallEnabled || pageRange.trim() !== '' || lastRecordedPage > 0); + // 로딩 중일 때 표시 + if (isLoading) { + return ( + <> + } + title="기록 작성" + onLeftClick={handleBackClick} + /> + +
+ 로딩 중... +
+
+ + ); + } return ( <> } title="기록 작성" - rightButton="완료" + rightButton={
완료
} onLeftClick={handleBackClick} onRightClick={handleCompleteClick} - isNextActive={isFormValid && !isSubmitting} + isNextActive={content.trim().length > 0 && !isSubmitting} /> { totalPages={totalPages} lastRecordedPage={lastRecordedPage} isOverallEnabled={isOverallEnabled} - onOverallToggle={() => setIsOverallEnabled(!isOverallEnabled)} - readingProgress={readingProgress} + onOverallToggle={() => setIsOverallEnabled(prev => !prev)} + readingProgress={isOverviewPossible ? 80 : 70} // 총평 가능하면 80% 이상으로 표시 + isOverviewPossible={isOverviewPossible} /> - diff --git a/src/stores/usePopupStore.ts b/src/stores/usePopupStore.ts index ffaacb98..8416e825 100644 --- a/src/stores/usePopupStore.ts +++ b/src/stores/usePopupStore.ts @@ -15,6 +15,7 @@ export interface MoreMenuProps { onEdit?: () => void; onDelete?: () => void; onClose?: () => void; + onReport?: () => void; } export interface SnackbarProps { diff --git a/src/types/memory.ts b/src/types/memory.ts index ac5ebbf9..2e37dd5e 100644 --- a/src/types/memory.ts +++ b/src/types/memory.ts @@ -54,3 +54,29 @@ export interface GetMemoryPostsResponse { message: string; data: MemoryPostsData; } + +// Memory 페이지에서 사용하는 Record 타입 (좋아요 상태 포함) +export interface Record { + id: string; + user: string; + userPoints: number; + content: string; + likeCount: number; + commentCount: number; + timeAgo: string; + createdAt: Date; + type: 'text' | 'poll'; + recordType: 'page' | 'overall'; + pageRange?: string; + isWriter: boolean; + isLiked: boolean; // 좋아요 상태 추가 + pollOptions?: PollOption[]; +} + +// 투표 옵션 타입 +export interface PollOption { + id: string; + text: string; + percentage: number; + isHighest: boolean; +} diff --git a/src/types/roomPostLike.ts b/src/types/roomPostLike.ts new file mode 100644 index 00000000..6d01b1f6 --- /dev/null +++ b/src/types/roomPostLike.ts @@ -0,0 +1,19 @@ +// 방 게시물 좋아요 요청 데이터 타입 +export interface RoomPostLikeRequest { + type: boolean; // true: 좋아요, false: 좋아요 취소 + roomPostType: 'RECORD' | 'VOTE'; // 방 게시물 타입 +} + +// 방 게시물 좋아요 응답 데이터 타입 +export interface RoomPostLikeData { + postId: number; // 게시물 ID + isLiked: boolean; // 좋아요 상태 +} + +// API 응답 타입 +export interface RoomPostLikeResponse { + isSuccess: boolean; + code: number; + message: string; + data: RoomPostLikeData; +}