diff --git a/.gitignore b/.gitignore index a547bf36..880e7b52 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ node_modules dist dist-ssr *.local +.env.local # Editor directories and files .vscode/* diff --git a/src/api/feeds/getMyFeed.ts b/src/api/feeds/getMyFeed.ts new file mode 100644 index 00000000..f0ed5259 --- /dev/null +++ b/src/api/feeds/getMyFeed.ts @@ -0,0 +1,52 @@ +import { apiClient } from '../index'; +import type { PostData } from '@/types/post'; + +// API 응답 데이터 타입 +export interface MyFeedData { + feedList: PostData[]; + nextCursor: string; + isLast: boolean; +} + +// API 응답 타입 +export interface MyFeedResponse { + success: boolean; + code: number; + message: string; + data: MyFeedData; +} + +// 요청 파라미터 타입 +export interface GetMyFeedParams { + cursor?: string; // 첫 페이지는 null 또는 없음, 다음 페이지부터는 nextCursor 값 사용 +} + +// 내 피드 조회 API 함수 +export const getMyFeeds = async (params?: GetMyFeedParams): Promise => { + const queryParams = new URLSearchParams(); + + // cursor가 있을 때만 쿼리 파라미터에 추가 + if (params?.cursor) { + queryParams.append('cursor', params.cursor); + } + + const url = `/feeds/mine${queryParams.toString() ? `?${queryParams.toString()}` : ''}`; + + const response = await apiClient.get(url); + return response.data; +}; + +/* +// 첫 페이지 조회 +const firstPage = await getMyFeeds(); + +// 다음 페이지 조회 (nextCursor 사용) +const nextPage = await getMyFeeds({ + cursor: firstPage.data.nextCursor +}); + +// 마지막 페이지인지 확인 +if (firstPage.data.isLast) { + console.log('더 이상 불러올 데이터가 없습니다.'); +} +*/ diff --git a/src/api/feeds/getTotalFeed.ts b/src/api/feeds/getTotalFeed.ts new file mode 100644 index 00000000..81e44649 --- /dev/null +++ b/src/api/feeds/getTotalFeed.ts @@ -0,0 +1,53 @@ +import { apiClient } from '../index'; +import type { PostData } from '@/types/post'; + +// API 응답 데이터 타입 +export interface TotalFeedData { + feedList: PostData[]; + nextCursor: string; + isLast: boolean; +} + +// API 응답 타입 +export interface TotalFeedResponse { + success: boolean; + code: number; + message: string; + data: TotalFeedData; +} + +// 요청 파라미터 타입 +export interface GetTotalFeedParams { + cursor?: string; // 첫 페이지는 null 또는 없음, 다음 페이지부터는 nextCursor 값 사용 +} + +export const getTotalFeeds = async (params?: GetTotalFeedParams): Promise => { + const queryParams = new URLSearchParams(); + + // cursor가 있을 때만 쿼리 파라미터에 추가 + if (params?.cursor) { + queryParams.append('cursor', params.cursor); + } + + const url = `/feeds${queryParams.toString() ? `?${queryParams.toString()}` : ''}`; + + const response = await apiClient.get(url); + return response.data; +}; + +/* +사용 방법: + +// 첫 페이지 조회 +const firstPage = await getTotalFeeds(); + +// 다음 페이지 조회 (nextCursor 사용) +const nextPage = await getTotalFeeds({ + cursor: firstPage.data.nextCursor +}); + +// 마지막 페이지인지 확인 +if (firstPage.data.isLast) { + console.log('더 이상 불러올 데이터가 없습니다.'); +} +*/ diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 00000000..a2bc9589 --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,87 @@ +import axios, { type AxiosResponse, type AxiosError } from 'axios'; + +// 하드코딩된 액세스 토큰 +const ACCESS_TOKEN = + 'Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjEsImlhdCI6MTc1NDIwMTY4OCwiZXhwIjoxNzU2NzkzNjg4fQ.oOyJ7JI_t2-Xq1-gfAv4ZaYNrbyplvqdxhCk76-Txe4'; + +// 토큰 관리 유틸리티 +export const TokenManager = { + setAccessToken: (token: string) => localStorage.setItem('accessToken', token), + getAccessToken: (): string | null => localStorage.getItem('accessToken'), + // setRefreshToken: (token: string) => localStorage.setItem('refreshToken', token), + // getRefreshToken: (): string | null => localStorage.getItem('refreshToken'), + clearTokens: () => { + localStorage.removeItem('accessToken'); + // localStorage.removeItem('refreshToken'); + }, + hasValidToken: (): boolean => !!localStorage.getItem('accessToken'), +}; + +// API 기본 설정 +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; + +// 환경변수 확인용 +console.log('API_BASE_URL:', API_BASE_URL); + +// axios 인스턴스 생성 +export const apiClient = axios.create({ + baseURL: API_BASE_URL, + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + }, +}); + +// 요청 인터셉터 +apiClient.interceptors.request.use( + config => { + // 로컬스토리지에서 토큰 먼저 확인 + const token = TokenManager.getAccessToken(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } else { + // 토큰이 없으면 하드코딩된 토큰 사용 (개발용) + config.headers.Authorization = ACCESS_TOKEN; + } + return config; + }, + error => Promise.reject(error), +); + +// 응답 인터셉터 - 토큰 만료 처리 및 에러 처리 +apiClient.interceptors.response.use( + (response: AxiosResponse) => response, + (error: AxiosError) => { + const { status } = error.response || {}; + + // 에러 로깅 + console.error('API Error:', status, error.message); + + // 토큰 만료 또는 인증 실패 시 로그인 페이지로 리다이렉트 + if (status === 401) { + // alert('토큰이 만료되었거나 유효하지 않습니다. 로그인 페이지로 이동합니다.'); + + // 현재 페이지가 로그인 페이지가 아닌 경우에만 리다이렉트 + if (window.location.pathname !== '/') { + // alert('로그인이 필요합니다. 로그인 페이지로 이동합니다.'); + window.location.href = '/'; + } + } + + // 권한 없음 (403) 에러 처리 + if (status === 403) { + console.warn('접근 권한이 없습니다.'); + alert('접근 권한이 없습니다.'); + } + + // 서버 에러 (500번대) 처리 + if (status && status >= 500) { + console.error('서버 오류가 발생했습니다.'); + alert('서버에 문제가 발생했습니다. 잠시 후 다시 시도해주세요.'); + } + + return Promise.reject(error); + }, +); + +export default apiClient; diff --git a/src/components/common/Post/PostBody.tsx b/src/components/common/Post/PostBody.tsx index 6131d6b2..0e48816f 100644 --- a/src/components/common/Post/PostBody.tsx +++ b/src/components/common/Post/PostBody.tsx @@ -48,12 +48,12 @@ const PostBody = ({ bookTitle, isbn, bookAuthor, - postContent, + contentBody, feedId, - images = [], + contentsUrl = [], }: PostBodyProps) => { const navigate = useNavigate(); - const hasImage = images.length > 0; + const hasImage = contentsUrl.length > 0; const handlePostClick = (feedId: number) => { // if (!isClickable) return; @@ -65,10 +65,10 @@ const PostBody = ({ handlePostClick(feedId)}> -
{postContent}
+
{contentBody}
{hasImage && (
- {images.map((src, i) => ( + {contentsUrl.map((src: string, i: number) => ( ))}
diff --git a/src/components/common/Post/PostFooter.tsx b/src/components/common/Post/PostFooter.tsx index 9dff82c9..03b794bb 100644 --- a/src/components/common/Post/PostFooter.tsx +++ b/src/components/common/Post/PostFooter.tsx @@ -43,27 +43,31 @@ const Container = styled.div<{ isDetail: boolean }>` `; interface PostFooterProps { - initialLikeCount: number; + likeCount: number; commentCount: number; feedId: number; isMyFeed: boolean; + isSaved?: boolean; + isLiked?: boolean; isPublic?: boolean; isDetail?: boolean; } const PostFooter = ({ - initialLikeCount, + likeCount: initialLikeCount, commentCount, feedId, isMyFeed, - isPublic, + isSaved = false, + isLiked = false, + isPublic = true, isDetail = false, }: PostFooterProps) => { const navigate = useNavigate(); - const [liked, setLiked] = useState(false); + const [liked, setLiked] = useState(isLiked); const [likeCount, setLikeCount] = useState(initialLikeCount); - const [saved, setSaved] = useState(false); + const [saved, setSaved] = useState(isSaved); const handleLike = () => { setLiked(!liked); diff --git a/src/components/common/Post/PostHeader.tsx b/src/components/common/Post/PostHeader.tsx index 4145c9f9..bf854e72 100644 --- a/src/components/common/Post/PostHeader.tsx +++ b/src/components/common/Post/PostHeader.tsx @@ -1,41 +1,44 @@ import { useNavigate } from 'react-router-dom'; import styled from '@emotion/styled'; interface PostHeaderProps { - profileImgUrl: string; - userName: string; - userTitle: string; - titleColor: string; - createdAt: string; - userId: number; + creatorProfileImageUrl?: string; + creatorNickname?: string; + alias?: string; + titleColor?: string; + postDate: string; + creatorId?: number; type?: 'post' | 'reply'; } const PostHeader = ({ - profileImgUrl, - userName, - userTitle, - titleColor, - createdAt, - userId, + creatorProfileImageUrl, + creatorNickname, + alias, + titleColor = '#FFFFFF', // 기본값 설정 + postDate, + creatorId, type = 'post', }: PostHeaderProps) => { const navigate = useNavigate(); const handleClick = () => { - navigate(`/otherfeed/${userId}`); + if (creatorId) { + navigate(`/otherfeed/${creatorId}`); + } }; + return (
- 칭호 이미지 + 칭호 이미지
-
{userName}
+
{creatorNickname}
- {userTitle} + {alias}
-
{createdAt}
+
{postDate}
); }; diff --git a/src/components/common/Post/Reply.tsx b/src/components/common/Post/Reply.tsx index 88e590e0..54dae1ec 100644 --- a/src/components/common/Post/Reply.tsx +++ b/src/components/common/Post/Reply.tsx @@ -36,12 +36,12 @@ const Reply = ({ return ( diff --git a/src/components/common/Post/SubReply.tsx b/src/components/common/Post/SubReply.tsx index 93098386..f95c3fd2 100644 --- a/src/components/common/Post/SubReply.tsx +++ b/src/components/common/Post/SubReply.tsx @@ -41,12 +41,12 @@ const SubReply = ({ diff --git a/src/components/feed/BookInfoCard.tsx b/src/components/feed/BookInfoCard.tsx index 8c850c92..59e10fcc 100644 --- a/src/components/feed/BookInfoCard.tsx +++ b/src/components/feed/BookInfoCard.tsx @@ -49,7 +49,7 @@ const BookContainer = styled.div` interface BookInfoCardProps { bookTitle: string; bookAuthor: string; - isbn: number; + isbn: string; } const BookInfoCard = ({ bookTitle, bookAuthor, isbn }: BookInfoCardProps) => { diff --git a/src/components/feed/FeedDetailPostBody.tsx b/src/components/feed/FeedDetailPostBody.tsx index faff1c93..3755556e 100644 --- a/src/components/feed/FeedDetailPostBody.tsx +++ b/src/components/feed/FeedDetailPostBody.tsx @@ -72,18 +72,22 @@ const TagContainer = styled.div` } `; +interface FeedDetailPostBodyProps extends PostBodyProps { + tags?: string[]; // API에 없지만 컴포넌트에서 사용 +} + const FeedDetailPostBody = ({ bookTitle, isbn, bookAuthor, - postContent, - images = [], + contentBody, + contentsUrl = [], tags = [], -}: PostBodyProps) => { +}: FeedDetailPostBodyProps) => { const [isImageViewerOpen, setIsImageViewerOpen] = useState(false); const [selectedImageIndex, setSelectedImageIndex] = useState(0); - const hasImage = images.length > 0; + const hasImage = contentsUrl.length > 0; const hasTag = tags.length > 0; const handleImageClick = (index: number) => { @@ -99,10 +103,10 @@ const FeedDetailPostBody = ({ -
{postContent}
+
{contentBody}
{hasImage && (
- {images.map((src, i) => ( + {contentsUrl.map((src: string, i: number) => ( {`이미지 handleImageClick(i)} /> ))}
@@ -122,7 +126,7 @@ const FeedDetailPostBody = ({
{isImageViewerOpen && ( { const navigate = useNavigate(); const [activeTab, setActiveTab] = useState(tabs[0]); + // 전체 피드 상태 + const [totalFeedPosts, setTotalFeedPosts] = useState([]); + const [totalLoading, setTotalLoading] = useState(false); + const [totalNextCursor, setTotalNextCursor] = useState(''); + const [totalIsLast, setTotalIsLast] = useState(false); + + // 내 피드 상태 + const [myFeedPosts, setMyFeedPosts] = useState([]); + const [myLoading, setMyLoading] = useState(false); + const [myNextCursor, setMyNextCursor] = useState(''); + const [myIsLast, setMyIsLast] = useState(false); + const handleSearchButton = () => { navigate('/feed/search'); }; + // 전체 피드 로드 함수 + const loadTotalFeeds = async (cursor?: string) => { + try { + setTotalLoading(true); + const response = await getTotalFeeds(cursor ? { cursor } : undefined); + + if (cursor) { + // 다음 페이지 데이터 추가 + setTotalFeedPosts(prev => [...prev, ...response.data.feedList]); + } else { + // 첫 페이지 데이터 설정 + setTotalFeedPosts(response.data.feedList); + } + + setTotalNextCursor(response.data.nextCursor); + setTotalIsLast(response.data.isLast); + } catch (error) { + console.error('전체 피드 로드 실패:', error); + // 에러 시 mockPosts 사용 (fallback) + setTotalFeedPosts(mockPosts); + } finally { + setTotalLoading(false); + } + }; + + // 내 피드 로드 함수 + const loadMyFeeds = async (cursor?: string) => { + try { + setMyLoading(true); + const response = await getMyFeeds(cursor ? { cursor } : undefined); + + if (cursor) { + // 다음 페이지 데이터 추가 + setMyFeedPosts(prev => [...prev, ...response.data.feedList]); + } else { + // 첫 페이지 데이터 설정 + setMyFeedPosts(response.data.feedList); + } + + setMyNextCursor(response.data.nextCursor); + setMyIsLast(response.data.isLast); + } catch (error) { + console.error('내 피드 로드 실패:', error); + // 에러 시 mockPosts 사용 (fallback) + setMyFeedPosts(mockPosts); + } finally { + setMyLoading(false); + } + }; + + // 다음 페이지 로드 (무한 스크롤용) + const loadMoreFeeds = useCallback(() => { + if (activeTab === '피드') { + if (!totalIsLast && !totalLoading && totalNextCursor) { + loadTotalFeeds(totalNextCursor); + } + } else { + if (!myIsLast && !myLoading && myNextCursor) { + loadMyFeeds(myNextCursor); + } + } + }, [activeTab, totalIsLast, totalLoading, totalNextCursor, myIsLast, myLoading, myNextCursor]); + + // 무한스크롤 구현 + useEffect(() => { + const handleScroll = () => { + const isLoading = activeTab === '피드' ? totalLoading : myLoading; + const isLastPage = activeTab === '피드' ? totalIsLast : myIsLast; + + // 로딩 중이거나 마지막 페이지면 return + if (isLoading || isLastPage) return; + + // 스크롤이 하단 근처에 도달했는지 확인 (하단에서 200px 이전) + const scrollTop = window.pageYOffset || document.documentElement.scrollTop; + const windowHeight = window.innerHeight; + const documentHeight = document.documentElement.scrollHeight; + + if (scrollTop + windowHeight >= documentHeight - 200) { + loadMoreFeeds(); + } + }; + + // 스크롤 이벤트 리스너 추가 + window.addEventListener('scroll', handleScroll); + + // 컴포넌트 언마운트 시 이벤트 리스너 제거 + return () => { + window.removeEventListener('scroll', handleScroll); + }; + }, [activeTab, totalLoading, myLoading, totalIsLast, myIsLast, loadMoreFeeds]); + useEffect(() => { window.scrollTo(0, 0); }, [activeTab]); + useEffect(() => { + // 탭별로 API 호출 + if (activeTab === '피드') { + loadTotalFeeds(); + } else if (activeTab === '내 피드') { + loadMyFeeds(); + } + }, [activeTab]); + return ( {activeTab === '피드' ? ( - + <> + + ) : ( - + <> + + )} diff --git a/src/pages/mypage/SavePage.tsx b/src/pages/mypage/SavePage.tsx index ef80ff3c..f7ead048 100644 --- a/src/pages/mypage/SavePage.tsx +++ b/src/pages/mypage/SavePage.tsx @@ -19,7 +19,7 @@ const SavePage = () => { const navigate = useNavigate(); const [activeTab, setActiveTab] = useState(tabs[0]); - const [savedBooks, setSavedBooks] = useState<{ [isbn: number]: boolean }>({}); + const [savedBooks, setSavedBooks] = useState<{ [isbn: string]: boolean }>({}); const handleBack = () => { navigate('/mypage'); @@ -29,7 +29,7 @@ const SavePage = () => { window.scrollTo(0, 0); }, [activeTab]); - const handleSaveToggle = (isbn: number) => { + const handleSaveToggle = (isbn: string) => { setSavedBooks(prev => ({ ...prev, [isbn]: !prev[isbn], diff --git a/src/types/book.ts b/src/types/book.ts index 99031060..0974cc82 100644 --- a/src/types/book.ts +++ b/src/types/book.ts @@ -1,5 +1,5 @@ export interface Book { - isbn: number; + isbn: string; title: string; author: string; coverUrl: string; diff --git a/src/types/post.ts b/src/types/post.ts index 6226e700..178ce0c5 100644 --- a/src/types/post.ts +++ b/src/types/post.ts @@ -1,19 +1,19 @@ export interface PostData { - profileImgUrl: string; - userName: string; - userId: number; - userTitle: string; - titleColor: string; - createdAt: string; + feedId: number; + creatorId?: number; + creatorNickname?: string; + creatorProfileImageUrl?: string; + alias?: string; + postDate: string; + isbn: string; bookTitle: string; - isbn: number; bookAuthor: string; - postContent: string; - feedId: number; - initialLikeCount: number; + contentBody: string; + contentsUrl: string[]; + likeCount: number; commentCount: number; - images?: string[]; - tags?: string[]; + isSaved?: boolean; + isLiked?: boolean; isPublic?: boolean; } @@ -30,7 +30,7 @@ export interface FeedPostProps extends PostData { export type PostBodyProps = Pick< PostData, - 'bookTitle' | 'bookAuthor' | 'postContent' | 'feedId' | 'images' | 'tags' | 'isbn' + 'bookTitle' | 'bookAuthor' | 'contentBody' | 'feedId' | 'contentsUrl' | 'isbn' >; // 대댓글(SubReply)