diff --git a/src/app/providers/Providers.tsx b/src/app/providers/Providers.tsx index b207a1b..f7a568d 100644 --- a/src/app/providers/Providers.tsx +++ b/src/app/providers/Providers.tsx @@ -1,10 +1,9 @@ import type { PropsWithChildren } from 'react' - import { ThemeProvider } from 'next-themes' - import { SidebarProvider } from '@/shared/ui/sidebar' import { Toaster } from '@/shared/ui/sonner' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { AuthProvider } from '@/shared/auth/AuthProvider' const queryClient = new QueryClient() @@ -12,7 +11,9 @@ export function Providers({ children }: PropsWithChildren) { return ( - {children} + + {children} + diff --git a/src/components/post/CommentItem.tsx b/src/components/post/CommentItem.tsx index 13ee842..9714b39 100644 --- a/src/components/post/CommentItem.tsx +++ b/src/components/post/CommentItem.tsx @@ -10,6 +10,8 @@ interface Comment { content: string profileImageUrl: string createdAt: string + likeCount: number + liked: boolean } interface CommentItemProps { @@ -44,39 +46,33 @@ export default function CommentItem({ setIsMenuOpen(false) } + const displayLikeCount = comment.likeCount || 0 + return ( <>
onDoubleClick(comment.id)} > -
- {comment.profileImageUrl ? ( - - ) : ( -
- {comment.nickname.trim().slice(0, 1).toUpperCase() || '?'} -
- )} -
+
+ +
{comment.nickname} {comment.content} -
- {timeDisplay} - + +
+ {timeDisplay} + {displayLikeCount > 0 && ( + + 좋아요 {displayLikeCount}개 + + )}
+
e.stopPropagation()} > -
- +
- {images.map((img, idx) => ( -
- -
- ))} - - - - {showHeart && ( - + {images.map((img) => ( +
+ +
+ ))} +
+ + + {showHeart && ( + setIsAnimating(false)} + className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center" + > + + + )} + + + {images.length > 1 && currentIndex > 0 && ( + + )} + {images.length > 1 && currentIndex < images.length - 1 && ( + )} -
- - {images.length > 1 && currentIndex > 0 && ( - - )} - {images.length > 1 && currentIndex < images.length - 1 && ( - - )} + + {images.length > 1 && ( +
+ {images.map((_, i) => ( +
+ ))} +
+ )} +
diff --git a/src/components/post/PostInfoSection.tsx b/src/components/post/PostInfoSection.tsx index 049110b..ccb6e95 100644 --- a/src/components/post/PostInfoSection.tsx +++ b/src/components/post/PostInfoSection.tsx @@ -5,6 +5,7 @@ import PostMenuModal from './PostMenuModal' import CommentItem from './CommentItem' import PostActionSection from './PostActionSection' import { formatRelativeTime } from '../../utils/date.ts' +import { instance } from '../../shared/api/ky' interface Comment { id: number @@ -16,6 +17,9 @@ interface Comment { createdAt: string updatedAt: string parentId: number | null + likeCount: number + liked: boolean + likedUserIds: number[] } export default function PostInfoSection({ data }: { data: PostData | null }) { @@ -37,19 +41,30 @@ export default function PostInfoSection({ data }: { data: PostData | null }) { isFetching.current = true try { - const response = await fetch( - `/api/v1/posts/${postId}/comments?page=${pageNum}` - ) - const result = await response.json() + const response = await instance + .get(`api/v1/posts/${postId}/comments`, { + searchParams: { page: pageNum }, + }) + .json<{ data: Comment[]; success: boolean }>() + + if (response.success && response.data.length > 0) { + const newComments = response.data + + setLikedComments((prev) => { + const nextLiked = { ...prev } + newComments.forEach((c: Comment) => { + nextLiked[c.id] = c.liked + }) + return nextLiked + }) - if (result.isSuccess && result.data.length > 0) { setComments((prev) => { - if (pageNum === 1) return result.data + if (pageNum === 1) return newComments const existingIds = new Set(prev.map((c) => c.id)) - const newComments = result.data.filter( + const filtered = newComments.filter( (c: Comment) => !existingIds.has(c.id) ) - return [...prev, ...newComments] + return [...prev, ...filtered] }) pageRef.current = pageNum setHasMore(true) @@ -62,7 +77,7 @@ export default function PostInfoSection({ data }: { data: PostData | null }) { isFetching.current = false } }, - [data] + [data?.id] ) useEffect(() => { @@ -92,34 +107,85 @@ export default function PostInfoSection({ data }: { data: PostData | null }) { } }, [hasMore, fetchComments]) - const handleDoubleClick = (id: number) => { - if (!likedComments[id]) { - setLikedComments((p) => ({ ...p, [id]: true })) + const handleCommentSubmit = async (content: string) => { + const postId = data?.id + if (!postId) return + + try { + const response = await instance + .post(`api/v1/posts/${postId}/comments`, { + json: { content }, + }) + .json<{ data: Comment; success: boolean }>() + + if (response.success) { + setComments((prev) => [response.data, ...prev]) + setLikedComments((prev) => ({ + ...prev, + [response.data.id]: response.data.liked, + })) + } + } catch (error) { + console.error(error) } } - const handleHeartClick = (id: number, e: React.MouseEvent) => { - e.stopPropagation() - setLikedComments((p) => ({ ...p, [id]: !p[id] })) + const handleHeartClick = async (commentId: number, e?: React.MouseEvent) => { + e?.stopPropagation() + const postId = data?.id + if (!postId) return + + const isCurrentlyLiked = !!likedComments[commentId] + + try { + if (isCurrentlyLiked) { + await instance.delete( + `api/v1/posts/${postId}/comments/${commentId}/like` + ) + } else { + await instance.post(`api/v1/posts/${postId}/comments/${commentId}/like`) + } + + setLikedComments((p) => ({ ...p, [commentId]: !isCurrentlyLiked })) + setComments((prev) => + prev.map((c) => + c.id === commentId + ? { + ...c, + likeCount: Math.max( + 0, + c.likeCount + (isCurrentlyLiked ? -1 : 1) + ), + } + : c + ) + ) + } catch (error) { + console.error(error) + } + } + + const handleDoubleClick = (id: number) => { + if (!likedComments[id]) { + handleHeartClick(id) + } } return (
- {data?.userImage ? ( + {data?.profileImageUrl ? ( ) : ( -
- {data?.username?.trim().slice(0, 1).toUpperCase() || '?'} -
+
)}
- {data?.username || ''} + {data?.nickname || ''}
- {showReplies[comment.id] && - comments - .filter((r) => r.parentId === comment.id) - .map((r) => ( - - ))} -
- )} -
- ))} -
+ .map((comment) => { + const replyCount = comments.filter( + (r) => r.parentId === comment.id + ).length + return ( +
+ + + {replyCount > 0 && ( +
+ + {showReplies[comment.id] && + comments + .filter((r) => r.parentId === comment.id) + .map((r) => ( + + ))} +
+ )} +
+ ) + })} + {hasMore &&
}
{}} onBookmarkClick={() => {}} - onCommentSubmit={() => {}} + onCommentSubmit={handleCommentSubmit} /> {isModalOpen && setIsModalOpen(false)} />} diff --git a/src/features/auth/verification/main/model.ts b/src/features/auth/verification/main/model.ts index 759943d..c9d152c 100644 --- a/src/features/auth/verification/main/model.ts +++ b/src/features/auth/verification/main/model.ts @@ -2,12 +2,14 @@ import { useState } from 'react' import { useNavigate, useSearch } from '@tanstack/react-router' import { instance } from '@/shared/api/ky' import { HTTPError } from 'ky' +import { useAuthStore } from '@/shared/auth/authStore' export function useVerification() { const navigate = useNavigate() const search = useSearch({ from: '/accounts/emailsignup/verification' }) const [code, setCode] = useState('') const [isLoading, setIsLoading] = useState(false) + const { setAuthenticated } = useAuthStore() const handleCodeChange = (value: string) => { setCode(value.replace(/[^0-9]/g, '')) @@ -29,10 +31,16 @@ export function useVerification() { code: code, }, }) - .json<{ data: { accessToken: string }; isSuccess: boolean }>() + .json<{ + data: { + accessToken: string + user: { id: number; nickname: string; profileImageUrl: string } + } + success: boolean + }>() - if (res.isSuccess) { - localStorage.setItem('accessToken', res.data.accessToken) + if (res.success) { + setAuthenticated(true, res.data.user) navigate({ to: '/', replace: true, diff --git a/src/mocks/browser.ts b/src/mocks/browser.ts index 4dd03f0..d13f7ec 100644 --- a/src/mocks/browser.ts +++ b/src/mocks/browser.ts @@ -2,3 +2,25 @@ import { setupWorker } from 'msw/browser' import { handlers } from './handlers' export const worker = setupWorker(...handlers) + +worker.start({ + onUnhandledRequest(req, print) { + const url = new URL(req.url) + + if ( + url.hostname.includes('picsum.photos') || + url.hostname.includes('pravatar.cc') + ) { + return + } + + if ( + !url.pathname.startsWith('/api') && + url.origin === window.location.origin + ) { + return + } + + print.warning() + }, +}) diff --git a/src/mocks/db/comment.db.ts b/src/mocks/db/comment.db.ts index 664c517..a114659 100644 --- a/src/mocks/db/comment.db.ts +++ b/src/mocks/db/comment.db.ts @@ -1,145 +1,282 @@ export const comments = [ { id: 1, - postId: 100, + postId: 1, userId: 10, - nickname: 'jameshull1013', + nickname: 'ocean_breeze', profileImageUrl: 'https://i.pravatar.cc/150?u=10', - content: '우와 사진 너무 예뻐요! 어디서 찍으신 건가요?', - createdAt: '2025-12-01T10:00:00.000', - updatedAt: '2025-12-01T10:00:00.000', + content: '와... 바다 색깔 실화인가요? 보정 없이 이 정도면 대박이네요.', + createdAt: '2025-07-15T14:30:00.000Z', + updatedAt: '2025-07-15T14:30:00.000Z', parentId: null, + likeCount: 156, + liked: true, + likedUserIds: [1, 2, 3, 4, 10, 22, 35, 40], }, { id: 2, - postId: 100, + postId: 1, userId: 11, - nickname: 'snu_official', + nickname: 'surf_master', profileImageUrl: 'https://i.pravatar.cc/150?u=11', - content: '정보 감사합니다! 나중에 꼭 가봐야겠네요.', - createdAt: '2025-12-01T11:00:00.000', - updatedAt: '2025-12-01T11:00:00.000', + content: '여기 서핑하기 딱 좋아 보이는데 위치 좀 알 수 있을까요?', + createdAt: '2025-07-15T15:00:00.000Z', + updatedAt: '2025-07-15T15:00:00.000Z', parentId: 1, + likeCount: 12, + liked: false, + likedUserIds: [10, 15, 20], }, { id: 3, - postId: 100, - userId: 12, - nickname: 'traveler_kim', - profileImageUrl: 'https://i.pravatar.cc/150?u=12', - content: '분위기 대박... 완전 제 스타일이에요!', - createdAt: '2026-01-02T09:30:00.000', - updatedAt: '2026-01-02T09:30:00.000', - parentId: null, + postId: 1, + userId: 10, + nickname: 'ocean_breeze', + profileImageUrl: 'https://i.pravatar.cc/150?u=10', + content: '@surf_master 양양 서피비치예요! 평일에 가면 사람도 없고 좋아요.', + createdAt: '2025-07-15T15:20:00.000Z', + updatedAt: '2025-07-15T15:20:00.000Z', + parentId: 2, + likeCount: 5, + liked: false, + likedUserIds: [11], }, { id: 4, - postId: 100, - userId: 10, - nickname: 'jameshull1013', - profileImageUrl: 'https://i.pravatar.cc/150?u=10', - content: '진짜 예쁘죠? 직접 보면 더 좋아요 ㅎㅎ', - createdAt: '2026-01-02T10:15:00.000', - updatedAt: '2026-01-02T10:15:00.000', - parentId: 3, + postId: 2, + userId: 20, + nickname: 'mountain_man', + profileImageUrl: 'https://i.pravatar.cc/150?u=20', + content: '운해 장난 아니네요. 몇 시쯤 올라가야 이런 풍경 보나요?', + createdAt: '2025-10-10T05:45:00.000Z', + updatedAt: '2025-10-10T05:45:00.000Z', + parentId: null, + likeCount: 89, + liked: false, + likedUserIds: [1, 5, 8, 12], }, { id: 5, - postId: 100, - userId: 13, - nickname: 'camera_lover', - profileImageUrl: 'https://i.pravatar.cc/150?u=13', - content: '색감이 정말 따뜻하고 좋네요.', - createdAt: '2026-01-02T11:00:00.000', - updatedAt: '2026-01-02T11:00:00.000', - parentId: 3, + postId: 2, + userId: 21, + nickname: 'hiking_girl', + profileImageUrl: 'https://i.pravatar.cc/150?u=21', + content: '와 저도 어제 갔다 왔는데 저는 곰탕이었거든요 ㅠㅠ 부럽습니다!', + createdAt: '2025-10-10T09:12:00.000Z', + updatedAt: '2025-10-10T09:12:00.000Z', + parentId: 4, + likeCount: 3, + liked: true, + likedUserIds: [1, 20], }, { id: 6, - postId: 100, - userId: 14, - nickname: 'foodie_nana', - profileImageUrl: 'https://i.pravatar.cc/150?u=14', - content: '벌써 주말이 다 끝났네요 ㅠㅠ 다음 주도 화이팅!', - createdAt: '2026-01-11T15:20:00.000', - updatedAt: '2026-01-11T15:20:00.000', + postId: 3, + userId: 30, + nickname: 'city_light', + profileImageUrl: 'https://i.pravatar.cc/150?u=30', + content: '장노출 장인이시네요. 빛 갈라짐이 너무 예술이에요.', + createdAt: '2025-12-24T22:00:00.000Z', + updatedAt: '2025-12-24T22:00:00.000Z', parentId: null, + likeCount: 245, + liked: true, + likedUserIds: [1, 2, 3, 30, 45, 60, 70], }, { id: 7, - postId: 100, - userId: 15, - nickname: 'coding_king', - profileImageUrl: 'https://i.pravatar.cc/150?u=15', - content: '오 오늘 날씨 정말 좋았는데 사진 잘 나오셨네요!', - createdAt: '2026-01-14T20:00:00.000', - updatedAt: '2026-01-14T20:00:00.000', - parentId: null, + postId: 3, + userId: 31, + nickname: 'night_view_seeker', + profileImageUrl: 'https://i.pravatar.cc/150?u=31', + content: '혹시 조리개값 몇으로 두고 찍으셨나요?', + createdAt: '2025-12-24T23:15:00.000Z', + updatedAt: '2025-12-24T23:15:00.000Z', + parentId: 6, + likeCount: 1, + liked: false, + likedUserIds: [], }, { id: 8, - postId: 100, - userId: 16, - nickname: 'react_master', - profileImageUrl: 'https://i.pravatar.cc/150?u=16', - content: '항상 좋은 게시물 잘 보고 있습니다~', - createdAt: '2026-01-15T14:00:00.000', - updatedAt: '2026-01-15T14:00:00.000', + postId: 4, + userId: 40, + nickname: 'cafe_tourist', + profileImageUrl: 'https://i.pravatar.cc/150?u=40', + content: '창가로 들어오는 햇살이 너무 평화로워 보여요. 힐링되네요.', + createdAt: '2026-01-05T11:20:00.000Z', + updatedAt: '2026-01-05T11:20:00.000Z', parentId: null, + likeCount: 42, + liked: false, + likedUserIds: [1, 10, 15], }, { id: 9, - postId: 100, - userId: 17, - nickname: 'designer_lee', - profileImageUrl: 'https://i.pravatar.cc/150?u=17', - content: '구도가 너무 안정적이고 좋아요. 굿굿!', - createdAt: '2026-01-16T10:30:00.000', - updatedAt: '2026-01-16T10:30:00.000', - parentId: null, + postId: 4, + userId: 41, + nickname: 'dessert_lover', + profileImageUrl: 'https://i.pravatar.cc/150?u=41', + content: '여기 커피도 맛있나요? 분위기는 일단 합격!', + createdAt: '2026-01-05T13:40:00.000Z', + updatedAt: '2026-01-05T13:40:00.000Z', + parentId: 8, + likeCount: 2, + liked: true, + likedUserIds: [1], }, { id: 10, - postId: 100, - userId: 10, - nickname: 'jameshull1013', - profileImageUrl: 'https://i.pravatar.cc/150?u=10', - content: '디자이너님께 칭찬받으니 기분 좋네요 감사합니다!', - createdAt: '2026-01-16T11:45:00.000', - updatedAt: '2026-01-16T11:45:00.000', - parentId: 9, + postId: 5, + userId: 50, + nickname: 'forest_walker', + profileImageUrl: 'https://i.pravatar.cc/150?u=50', + content: '초록초록한 느낌이 가득해서 눈이 시원해지는 기분이에요.', + createdAt: '2026-01-15T10:05:00.000Z', + updatedAt: '2026-01-15T10:05:00.000Z', + parentId: null, + likeCount: 67, + liked: true, + likedUserIds: [1, 5, 10, 20, 50], }, { id: 11, - postId: 100, - userId: 18, - nickname: 'frontend_dev', - profileImageUrl: 'https://i.pravatar.cc/150?u=18', - content: '와... 저도 여행 가고 싶어지는 사진이네요.', - createdAt: '2026-01-16T16:00:00.000', - updatedAt: '2026-01-16T16:00:00.000', - parentId: null, + postId: 5, + userId: 51, + nickname: 'green_vibe', + profileImageUrl: 'https://i.pravatar.cc/150?u=51', + content: '비 온 뒤인가요? 숲 냄새가 사진 밖까지 나는 것 같아요.', + createdAt: '2026-01-15T12:30:00.000Z', + updatedAt: '2026-01-15T12:30:00.000Z', + parentId: 10, + likeCount: 15, + liked: false, + likedUserIds: [50], }, { id: 12, - postId: 100, - userId: 19, - nickname: 'sunny_day', - profileImageUrl: 'https://i.pravatar.cc/150?u=19', - content: '힐링하고 갑니다~ 오늘도 좋은 하루 되세요!', - createdAt: '2026-01-16T17:20:00.000', - updatedAt: '2026-01-16T17:20:00.000', + postId: 6, + userId: 60, + nickname: 'snow_angel', + profileImageUrl: 'https://i.pravatar.cc/150?u=60', + content: '와... 온 세상이 하얗네요. 겨울 왕국 그 자체입니다.', + createdAt: '2026-01-28T08:50:00.000Z', + updatedAt: '2026-01-28T08:50:00.000Z', parentId: null, + likeCount: 312, + liked: true, + likedUserIds: [1, 2, 3, 60, 100, 120, 150], }, { id: 13, - postId: 100, - userId: 20, - nickname: 'pixel_art', - profileImageUrl: 'https://i.pravatar.cc/150?u=20', - content: '인생샷 건지셨네요! 부러워요 ㅎㅎ', - createdAt: '2026-01-16T18:10:00.000', - updatedAt: '2026-01-16T18:10:00.000', + postId: 6, + userId: 61, + nickname: 'cold_guy', + profileImageUrl: 'https://i.pravatar.cc/150?u=61', + content: '사진 찍을 때 손 엄청 시리셨을 것 같은데 열정이 대단하십니다!', + createdAt: '2026-01-28T10:15:00.000Z', + updatedAt: '2026-01-28T10:15:00.000Z', + parentId: 12, + likeCount: 24, + liked: false, + likedUserIds: [60], + }, + { + id: 14, + postId: 7, + userId: 70, + nickname: 'flower_garden', + profileImageUrl: 'https://i.pravatar.cc/150?u=70', + content: '꽃망울 터지는 순간을 정말 잘 포착하셨네요. 봄이 온 것 같아요.', + createdAt: '2026-01-30T09:00:00.000Z', + updatedAt: '2026-01-30T09:00:00.000Z', + parentId: null, + likeCount: 18, + liked: false, + likedUserIds: [1, 2, 5], + }, + { + id: 15, + postId: 7, + userId: 71, + nickname: 'macro_king', + profileImageUrl: 'https://i.pravatar.cc/150?u=71', + content: '마크로 렌즈 쓰신 건가요? 디테일이 살아있네요.', + createdAt: '2026-01-30T14:45:00.000Z', + updatedAt: '2026-01-30T14:45:00.000Z', + parentId: 14, + likeCount: 0, + liked: false, + likedUserIds: [], + }, + { + id: 16, + postId: 1, + userId: 12, + nickname: 'travel_holic', + profileImageUrl: 'https://i.pravatar.cc/150?u=12', + content: '여름 휴가 여기로 가야겠어요. 숙소 정보도 부탁드려도 될까요?', + createdAt: '2025-07-16T09:20:00.000Z', + updatedAt: '2025-07-16T09:20:00.000Z', + parentId: null, + likeCount: 34, + liked: false, + likedUserIds: [1, 10], + }, + { + id: 17, + postId: 2, + userId: 15, + nickname: 'peak_chaser', + profileImageUrl: 'https://i.pravatar.cc/150?u=15', + content: '정상에서 마시는 컵라면이 생각나는 사진이네요 ㅋㅋㅋ', + createdAt: '2025-10-11T12:00:00.000Z', + updatedAt: '2025-10-11T12:00:00.000Z', + parentId: null, + likeCount: 56, + liked: true, + likedUserIds: [1, 15, 20], + }, + { + id: 18, + postId: 2, + userId: 1, + nickname: 'me', + profileImageUrl: 'https://i.pravatar.cc/150?u=1', + content: '@peak_chaser 크으 역시 배우신 분! 다음 산행은 컵라면 필참입니다.', + createdAt: '2025-10-11T13:30:00.000Z', + updatedAt: '2025-10-11T13:30:00.000Z', + parentId: 17, + likeCount: 10, + liked: false, + likedUserIds: [15], + }, + { + id: 19, + postId: 3, + userId: 35, + nickname: 'iso_pro', + profileImageUrl: 'https://i.pravatar.cc/150?u=35', + content: '노이즈 하나도 없고 깔끔하네요. 삼각대 좋은 거 쓰시나 봐요.', + createdAt: '2025-12-25T10:20:00.000Z', + updatedAt: '2025-12-25T10:20:00.000Z', + parentId: null, + likeCount: 22, + liked: false, + likedUserIds: [30], + }, + { + id: 20, + postId: 6, + userId: 65, + nickname: 'winter_lover', + profileImageUrl: 'https://i.pravatar.cc/150?u=65', + content: '강아지랑 같이 가셨나요? 발자국이 너무 귀여워요!', + createdAt: '2026-01-29T15:40:00.000Z', + updatedAt: '2026-01-29T15:40:00.000Z', parentId: null, + likeCount: 95, + liked: true, + likedUserIds: [1, 60, 65], }, ] diff --git a/src/mocks/handlers/auth.ts b/src/mocks/handlers/auth.ts index ba34891..69dad00 100644 --- a/src/mocks/handlers/auth.ts +++ b/src/mocks/handlers/auth.ts @@ -5,6 +5,8 @@ interface RegisterRequest { email: string password: string nickname: string + name?: string + birthday?: string } interface LoginRequest { @@ -20,7 +22,7 @@ export const authHandlers = [ if (isDuplicate) { return HttpResponse.json({ - isSuccess: true, + success: true, code: 'AUTH_409', message: '이미 존재하는 닉네임입니다.', data: { isAvailable: false }, @@ -28,7 +30,7 @@ export const authHandlers = [ } return HttpResponse.json({ - isSuccess: true, + success: true, code: 'COMMON_200', message: '사용 가능한 닉네임입니다.', data: { isAvailable: true }, @@ -44,7 +46,7 @@ export const authHandlers = [ if (!user) { return HttpResponse.json( { - isSuccess: false, + success: false, message: '계정을 찾을 수 없습니다.', }, { status: 404 } @@ -55,7 +57,7 @@ export const authHandlers = [ const maskedEmail = `${name.slice(0, 2)}****@${domain}` return HttpResponse.json({ - isSuccess: true, + success: true, data: { sentEmail: maskedEmail }, }) }), @@ -71,31 +73,33 @@ export const authHandlers = [ { code: 'AUTH_400', message: '이미 존재하는 이메일/닉네임입니다.', - data: { - accessToken: 'string', - refreshToken: 'string', - }, - isSuccess: false, + success: false, }, { status: 400 } ) } - authDb.push({ - userId: authDb.length + 1, + const userId = authDb.length + 1 + const userObj = { + userId, email: newUser.email, password: newUser.password, nickname: newUser.nickname, - }) + } + authDb.push(userObj) return HttpResponse.json({ code: 'COMMON_200', message: '회원가입 및 로그인 성공', data: { - accessToken: 'mock-access-token-123', - refreshToken: 'mock-refresh-token-456', + accessToken: `mock-access-token-${userId}`, + user: { + id: userId, + nickname: userObj.nickname, + profileImageUrl: 'https://i.pravatar.cc/150?u=1', + }, }, - isSuccess: true, + success: true, }) }), @@ -112,7 +116,7 @@ export const authHandlers = [ code: 'AUTH_404', message: '사용자를 찾을 수 없습니다.', data: null, - isSuccess: false, + success: false, }, { status: 404 } ) @@ -124,7 +128,7 @@ export const authHandlers = [ code: 'AUTH_401', message: '비밀번호가 틀렸습니다.', data: null, - isSuccess: false, + success: false, }, { status: 401 } ) @@ -135,9 +139,29 @@ export const authHandlers = [ message: '로그인 성공', data: { accessToken: `mock-access-token-${userExists.userId}`, - refreshToken: `mock-refresh-token-${userExists.userId}`, + user: { + id: userExists.userId, + nickname: userExists.nickname, + profileImageUrl: 'https://i.pravatar.cc/150?u=1', + }, + }, + success: true, + }) + }), + + http.post('*/api/v1/auth/refresh', () => { + return HttpResponse.json({ + success: true, + code: 'COMMON_200', + message: '토큰 재발급 성공', + data: { + accessToken: 'mock-refreshed-access-token', + user: { + id: 1, + nickname: 'me', + profileImageUrl: 'https://i.pravatar.cc/150?u=1', + }, }, - isSuccess: true, }) }), ] diff --git a/src/mocks/handlers/comment.ts b/src/mocks/handlers/comment.ts index dc30736..e25ba89 100644 --- a/src/mocks/handlers/comment.ts +++ b/src/mocks/handlers/comment.ts @@ -2,12 +2,89 @@ import { http, HttpResponse } from 'msw' import { comments } from '../db/comment.db' export const commentHandlers = [ - http.get('/api/v1/posts/:postId/comments', () => { + http.get('*/api/v1/posts/:postId/comments', ({ params }) => { + const { postId } = params + + const postComments = comments.filter( + (comment) => String(comment.postId) === String(postId) + ) + return HttpResponse.json({ code: 'COMMON_200', message: '댓글 목록 조회 성공', - data: comments, - isSuccess: true, + data: postComments, + success: true, + }) + }), + + http.post('*/api/v1/posts/:postId/comments', async ({ request, params }) => { + const { postId } = params + const { content, parentId } = (await request.json()) as { + content: string + parentId?: number | null + } + + const newComment = { + id: Math.floor(Math.random() * 1000000), + postId: Number(postId), + userId: 1, + nickname: 'me', + profileImageUrl: 'https://i.pravatar.cc/150?u=1', + content: content, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + parentId: parentId || null, + likeCount: 0, + liked: false, + likedUserIds: [], + } + + comments.push(newComment) + + return HttpResponse.json({ + code: 'COMMON_200', + message: '댓글 등록 성공', + data: newComment, + success: true, }) }), + + http.post('*/api/v1/posts/:postId/comments/:commentId/like', ({ params }) => { + const { commentId } = params + const comment = comments.find((c) => String(c.id) === String(commentId)) + + if (comment && !comment.liked) { + comment.liked = true + comment.likeCount += 1 + comment.likedUserIds.push(1) + } + + return HttpResponse.json({ + code: 'COMMON_200', + message: `댓글 ${commentId} 좋아요 성공`, + data: null, + success: true, + }) + }), + + http.delete( + '*/api/v1/posts/:postId/comments/:commentId/like', + ({ params }) => { + const { commentId } = params + const comment = comments.find((c) => String(c.id) === String(commentId)) + + if (comment && comment.liked) { + comment.liked = false + comment.likeCount = Math.max(0, comment.likeCount - 1) + comment.likedUserIds = comment.likedUserIds.filter((id) => id !== 1) + } + + return HttpResponse.json({ + code: 'COMMON_200', + message: `댓글 ${commentId} 좋아요 취소 성공`, + data: null, + success: true, + }) + } + ), ] diff --git a/src/mocks/handlers/post.ts b/src/mocks/handlers/post.ts index b9d3450..e3b69fe 100644 --- a/src/mocks/handlers/post.ts +++ b/src/mocks/handlers/post.ts @@ -9,8 +9,6 @@ import { users } from '../db/user.db' export const postHandlers = [ http.post('*/api/v1/posts', async ({ request }) => { - await new Promise((resolve) => setTimeout(resolve, 5000)) - const body = (await request.json()) as { content: string albumId?: number | null @@ -24,8 +22,8 @@ export const postHandlers = [ id: String(postId), images: body.imageUrls ?? [], caption: body.content, - username: 'mock_user', - userImage: 'https://picsum.photos/id/100/50/50', + username: 'me', + userImage: 'https://i.pravatar.cc/150?u=1', createdAt: new Date().toISOString(), likeCount: 0, commentCount: 0, @@ -62,227 +60,127 @@ export const postHandlers = [ code: '201', message: '게시글을 생성했습니다.', data: responsePost, - isSuccess: true, + success: true, }, { status: 201 } ) }), - http.get('*/api/v1/posts/bookmarks', () => { - const bookmarkedPosts = posts - .filter((p) => bookmarkedPostIds.has(Number(p.id))) - .map((p) => ({ - id: Number(p.id), - userId: 1, - nickname: p.username, - profileImageUrl: p.userImage, - content: p.caption, - albumId: postAlbumMap[Number(p.id)] ?? null, - images: p.images.map((url, imgIndex) => ({ - id: Number(p.id) * 100 + imgIndex, - url, - orderIndex: imgIndex, - })), - likeCount: p.likeCount, - commentCount: p.commentCount, - createdAt: p.createdAt, - updatedAt: p.createdAt, - liked: likedPostIds.has(Number(p.id)), - bookmarked: true, - })) - return HttpResponse.json({ - code: '200', - message: '요청에 성공하였습니다.', - data: bookmarkedPosts, - isSuccess: true, - }) - }), - http.get('/api/v1/posts/:postId', ({ params }) => { + http.get('*/api/v1/posts/:postId', ({ params }) => { const { postId } = params - const post = posts.find((p) => p.id === postId) + const post = posts.find((p) => String(p.id) === String(postId)) if (!post) { - return new HttpResponse(null, { status: 404 }) + return HttpResponse.json( + { + code: '404', + message: '게시글을 찾을 수 없습니다.', + success: false, + }, + { status: 404 } + ) + } + + const idNum = Number(post.id) + + const responseData = { + id: idNum, + userId: 1, + nickname: post.username, + profileImageUrl: post.userImage, + content: post.caption, + albumId: postAlbumMap[idNum] ?? null, + images: post.images.map((url, index) => ({ + id: idNum * 100 + index, + url: url, + orderIndex: index, + })), + likeCount: post.likeCount, + commentCount: post.commentCount, + createdAt: post.createdAt, + updatedAt: post.createdAt, + liked: likedPostIds.has(idNum), + bookmarked: bookmarkedPostIds.has(idNum), } return HttpResponse.json({ - isSuccess: true, - data: post, + code: 'COMMON_200', + message: '게시글 상세 조회 성공', + data: responseData, + success: true, }) }), + http.post('*/api/v1/posts/:postId/like', ({ params }) => { const postId = Number(params.postId) - - if (!Number.isInteger(postId)) { - return HttpResponse.json( - { - code: '400', - message: '유효하지 않은 경로 파라미터입니다.', - isSuccess: false, - }, - { status: 400 } - ) - } - const post = posts.find((p) => Number(p.id) === postId) - if (!post) { - return HttpResponse.json( - { - code: '404', - message: '게시글을 찾을 수 없습니다.', - isSuccess: false, - }, - { status: 404 } - ) - } + if (!post) return new HttpResponse(null, { status: 404 }) if (!likedPostIds.has(postId)) { likedPostIds.add(postId) post.likeCount += 1 } - return HttpResponse.json( - { - code: '200', - message: '게시글에 좋아요를 남겼습니다.', - isSuccess: true, - }, - { status: 200 } - ) + return HttpResponse.json({ + code: '200', + message: '좋아요 성공', + success: true, + }) }), + http.delete('*/api/v1/posts/:postId/like', ({ params }) => { const postId = Number(params.postId) - - if (!Number.isInteger(postId)) { - return HttpResponse.json( - { - code: '400', - message: '유효하지 않은 경로 파라미터입니다.', - isSuccess: false, - }, - { status: 400 } - ) - } - const post = posts.find((p) => Number(p.id) === postId) - if (!post) { - return HttpResponse.json( - { - code: '404', - message: '게시글을 찾을 수 없습니다.', - isSuccess: false, - }, - { status: 404 } - ) - } + if (!post) return new HttpResponse(null, { status: 404 }) if (likedPostIds.has(postId)) { likedPostIds.delete(postId) post.likeCount = Math.max(0, post.likeCount - 1) } - return HttpResponse.json( - { - code: '200', - message: '게시글 좋아요를 취소했습니다.', - isSuccess: true, - }, - { status: 200 } - ) + return HttpResponse.json({ + code: '200', + message: '좋아요 취소 성공', + success: true, + }) }), + http.post('*/api/v1/posts/:postId/bookmark', ({ params }) => { const postId = Number(params.postId) - - if (!Number.isInteger(postId)) { - return HttpResponse.json( - { - code: '400', - message: '유효하지 않은 경로 파라미터입니다.', - isSuccess: false, - }, - { status: 400 } - ) - } - - const post = posts.find((p) => Number(p.id) === postId) - - if (!post) { - return HttpResponse.json( - { - code: '404', - message: '게시글을 찾을 수 없습니다.', - isSuccess: false, - }, - { status: 404 } - ) - } - bookmarkedPostIds.add(postId) - - return HttpResponse.json( - { - code: '200', - message: '게시글을 북마크했습니다.', - isSuccess: true, - }, - { status: 200 } - ) + return HttpResponse.json({ + code: '200', + message: '북마크 성공', + success: true, + }) }), + http.delete('*/api/v1/posts/:postId/bookmark', ({ params }) => { const postId = Number(params.postId) - - if (!Number.isInteger(postId)) { - return HttpResponse.json( - { - code: '400', - message: '유효하지 않은 경로 파라미터입니다.', - isSuccess: false, - }, - { status: 400 } - ) - } - - const post = posts.find((p) => Number(p.id) === postId) - - if (!post) { - return HttpResponse.json( - { - code: '404', - message: '게시글을 찾을 수 없습니다.', - isSuccess: false, - }, - { status: 404 } - ) - } - bookmarkedPostIds.delete(postId) - - return HttpResponse.json( - { - code: '200', - message: '게시글 북마크를 취소했습니다.', - isSuccess: true, - }, - { status: 200 } - ) + return HttpResponse.json({ + code: '200', + message: '북마크 취소 성공', + success: true, + }) }), + http.get('*/api/v1/posts/search', () => { const searchResults = posts.map((p) => { const postId = Number(p.id) - const userId = 1 return { id: postId, - userId, + userId: 1, nickname: p.username, profileImageUrl: p.userImage, content: p.caption, - albumId: (postAlbumMap[postId] ?? null) as number | null, - images: (p.images ?? []).map((url, imgIndex) => ({ - id: postId * 100 + imgIndex, + albumId: postAlbumMap[postId] ?? null, + images: p.images.map((url, index) => ({ + id: postId * 100 + index, url, - orderIndex: imgIndex, + orderIndex: index, })), likeCount: p.likeCount, commentCount: p.commentCount, @@ -295,11 +193,12 @@ export const postHandlers = [ return HttpResponse.json({ code: '200', - message: '요청에 성공하였습니다.', + message: '성공', data: searchResults, - isSuccess: true, + success: true, }) }), + http.get('*/api/v1/users/:userId/posts', ({ params }) => { const userId = Number(params.userId) @@ -308,7 +207,7 @@ export const postHandlers = [ { code: '400', message: '유효하지 않은 경로 파라미터입니다.', - isSuccess: false, + success: false, }, { status: 400 } ) @@ -359,7 +258,7 @@ export const postHandlers = [ code: '200', message: '요청에 성공하였습니다.', data: grouped, - isSuccess: true, + success: true, }) }), ] diff --git a/src/shared/api/authApi.ts b/src/shared/api/authApi.ts new file mode 100644 index 0000000..f8d3b9a --- /dev/null +++ b/src/shared/api/authApi.ts @@ -0,0 +1,28 @@ +import ky from 'ky' + +const API_URL = import.meta.env.VITE_API_URL + +type RefreshResponse = { + code: string + message: string + data: { + accessToken: string + user: { + id: number + nickname: string + profileImageUrl: string + } + } + success: boolean +} + +export const authInstance = ky.create({ + prefixUrl: API_URL, + credentials: 'include', +}) + +export async function refreshAccessToken() { + const response = await authInstance.post('api/v1/auth/refresh') + const result = await response.json() + return result.data +} diff --git a/src/shared/api/ky.ts b/src/shared/api/ky.ts index c57bf8c..45599a0 100644 --- a/src/shared/api/ky.ts +++ b/src/shared/api/ky.ts @@ -1,12 +1,18 @@ import ky, { type NormalizedOptions } from 'ky' +import { refreshAccessToken } from './authApi' +import { useAuthStore } from '../auth/authStore' if (!import.meta.env.VITE_API_URL) { throw new Error('VITE_API_URL is not set') } const DEV = import.meta.env.DEV - const requestStartTimes = new WeakMap() +let isRefreshing = false +let refreshPromise: Promise<{ + accessToken: string + user: { id: number; nickname: string; profileImageUrl: string } +}> | null = null function maskHeaders(headersInit: HeadersInit | undefined) { const headers = headersInit ? new Headers(headersInit) : undefined @@ -21,13 +27,13 @@ function maskHeaders(headersInit: HeadersInit | undefined) { } export class AuthError extends Error { - constructor(status: 401, response: Response) { + constructor(status: 401 | 403, response: Response) { super(`Auth error: ${status}`) this.name = 'AuthError' this.status = status this.response = response } - public status: 401 + public status: 401 | 403 public response: Response } @@ -47,7 +53,7 @@ export const instance = ky.create({ retry: { limit: 2, methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'], - statusCodes: [408, 429, 500, 502, 503, 504], + statusCodes: [401, 408, 429, 500, 502, 503, 504], }, hooks: { beforeRequest: [ @@ -96,14 +102,44 @@ export const instance = ky.create({ }, ], beforeError: [ - (error) => { + async (error) => { if (error.options) { requestStartTimes.delete(error.options) } - const response = error.response + + const { response } = error + if (response?.status === 401) { + if (isRefreshing && refreshPromise) { + try { + await refreshPromise + return error + } catch { + useAuthStore.getState().setSessionExpired() + return error + } + } + + isRefreshing = true + refreshPromise = refreshAccessToken() + + try { + const data = await refreshPromise + useAuthStore.getState().setAuthenticated(true, data.user) + isRefreshing = false + refreshPromise = null + return error + } catch { + isRefreshing = false + refreshPromise = null + useAuthStore.getState().setSessionExpired() + return error + } + } + if (response && (response.status === 401 || response.status === 403)) { throw new AuthError(response.status as 401, response) } + if (DEV) { console.groupCollapsed('[API][ERROR]') console.error(error) diff --git a/src/shared/auth/AuthProvider.tsx b/src/shared/auth/AuthProvider.tsx new file mode 100644 index 0000000..c8107d5 --- /dev/null +++ b/src/shared/auth/AuthProvider.tsx @@ -0,0 +1,45 @@ +import { useEffect, type ReactNode, useRef } from 'react' +import { useNavigate, useLocation } from '@tanstack/react-router' +import { toast } from 'sonner' +import { useAuthStore } from './authStore' +import { refreshAccessToken } from '../api/authApi' + +export function AuthProvider({ children }: { children: ReactNode }) { + const navigate = useNavigate() + const location = useLocation() + const isInitialMount = useRef(true) + const { isAuthenticated, isSessionExpired, setAuthenticated, reset } = + useAuthStore() + + useEffect(() => { + const initAuth = async () => { + if (location.pathname === '/login' || isAuthenticated) return + + try { + const data = await refreshAccessToken() + setAuthenticated(true, data.user) + } catch { + reset() + // 로그인이 필요한 페이지에서만 튕기도록 설정 (필요 시 조건 추가) + if (location.pathname !== '/') { + navigate({ to: '/login' }) + } + } + } + + if (isInitialMount.current) { + isInitialMount.current = false + initAuth() + } + }, [isAuthenticated, location.pathname, navigate, reset, setAuthenticated]) + + useEffect(() => { + if (isSessionExpired) { + toast.error('세션이 만료되었습니다. 다시 로그인해주세요.') + reset() + navigate({ to: '/login' }) + } + }, [isSessionExpired, reset, navigate]) + + return <>{children} +} diff --git a/src/shared/auth/authStore.ts b/src/shared/auth/authStore.ts new file mode 100644 index 0000000..9b5ee79 --- /dev/null +++ b/src/shared/auth/authStore.ts @@ -0,0 +1,39 @@ +import { create } from 'zustand' + +interface User { + id: number + nickname: string + profileImageUrl: string +} + +type AuthState = { + isAuthenticated: boolean + isSessionExpired: boolean + user: User | null + setAuthenticated: (value: boolean, user?: User) => void + setSessionExpired: () => void + reset: () => void +} + +export const useAuthStore = create((set) => ({ + isAuthenticated: false, + isSessionExpired: false, + user: null, + setAuthenticated: (value, user) => + set({ + isAuthenticated: value, + user: user || null, + }), + setSessionExpired: () => + set({ + isAuthenticated: false, + user: null, + isSessionExpired: true, + }), + reset: () => + set({ + isAuthenticated: false, + user: null, + isSessionExpired: false, + }), +})) diff --git a/src/shared/auth/useAuth.ts b/src/shared/auth/useAuth.ts new file mode 100644 index 0000000..4ca6f89 --- /dev/null +++ b/src/shared/auth/useAuth.ts @@ -0,0 +1,50 @@ +import { useNavigate } from '@tanstack/react-router' +import { useAuthStore } from './authStore' +import { authInstance } from '../api/authApi' + +interface LoginCredentials { + email?: string + password?: string + [key: string]: unknown +} + +interface LoginResponse { + success: boolean + data: { + user: { + id: number + nickname: string + profileImageUrl: string + } + } +} + +export function useAuth() { + const navigate = useNavigate() + const { setAuthenticated, reset } = useAuthStore() + + const login = async (credentials: LoginCredentials) => { + const response = await authInstance + .post('api/v1/auth/login', { + json: credentials, + }) + .json() + + if (response.success) { + setAuthenticated(true, response.data.user) + navigate({ to: '/' }) + } + return response + } + + const logout = async () => { + try { + await authInstance.post('api/v1/auth/logout') + } finally { + reset() + navigate({ to: '/login' }) + } + } + + return { login, logout } +}