From f2c421ff509288e406255cff15e95e6cc3d1cfcc Mon Sep 17 00:00:00 2001 From: c0912jy Date: Sat, 31 Jan 2026 00:09:41 +0900 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=8C=93=EA=B8=80=20ap?= =?UTF-8?q?i=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/post/CommentItem.tsx | 37 ++-- src/components/post/PostDetail.tsx | 198 +++++++++++--------- src/components/post/PostInfoSection.tsx | 149 +++++++++------ src/mocks/db/comment.db.ts | 13 ++ src/mocks/handlers/comment.ts | 25 ++- src/mocks/handlers/post.ts | 231 +++++++----------------- 6 files changed, 333 insertions(+), 320 deletions(-) diff --git a/src/components/post/CommentItem.tsx b/src/components/post/CommentItem.tsx index 57aa669..9c05582 100644 --- a/src/components/post/CommentItem.tsx +++ b/src/components/post/CommentItem.tsx @@ -10,6 +10,7 @@ interface Comment { content: string profileImageUrl: string createdAt: string + likeCount?: number } interface CommentItemProps { @@ -44,31 +45,33 @@ export default function CommentItem({ setIsMenuOpen(false) } + const displayLikeCount = comment.likeCount || 0 + return ( <>
onDoubleClick(comment.id)} > -
+
-
+
{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 338c375..2322d82 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,7 @@ interface Comment { createdAt: string updatedAt: string parentId: number | null + likeCount?: number } export default function PostInfoSection({ data }: { data: PostData | null }) { @@ -62,7 +64,7 @@ export default function PostInfoSection({ data }: { data: PostData | null }) { isFetching.current = false } }, - [data] + [data?.id] ) useEffect(() => { @@ -92,15 +94,45 @@ export default function PostInfoSection({ data }: { data: PostData | null }) { } }, [hasMore, fetchComments]) - const handleDoubleClick = (id: number) => { - if (!likedComments[id]) { - setLikedComments((p) => ({ ...p, [id]: true })) + 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 || 0) + (isCurrentlyLiked ? -1 : 1) + ), + } + : c + ) + ) + } catch (error) { + console.error(error) } } - const handleHeartClick = (id: number, e: React.MouseEvent) => { - e.stopPropagation() - setLikedComments((p) => ({ ...p, [id]: !p[id] })) + const handleDoubleClick = (id: number) => { + if (!likedComments[id]) { + handleHeartClick(id) + } } return ( @@ -108,12 +140,12 @@ export default function PostInfoSection({ data }: { data: PostData | null }) {
- {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) => ( + + ))} +
+ )} +
+ ) + })}
@@ -193,8 +232,8 @@ export default function PostInfoSection({ data }: { data: PostData | null }) { {}} onBookmarkClick={() => {}} onCommentSubmit={() => {}} diff --git a/src/mocks/db/comment.db.ts b/src/mocks/db/comment.db.ts index 664c517..ce33550 100644 --- a/src/mocks/db/comment.db.ts +++ b/src/mocks/db/comment.db.ts @@ -9,6 +9,7 @@ export const comments = [ createdAt: '2025-12-01T10:00:00.000', updatedAt: '2025-12-01T10:00:00.000', parentId: null, + likeCount: 5, }, { id: 2, @@ -20,6 +21,7 @@ export const comments = [ createdAt: '2025-12-01T11:00:00.000', updatedAt: '2025-12-01T11:00:00.000', parentId: 1, + likeCount: 0, }, { id: 3, @@ -31,6 +33,7 @@ export const comments = [ createdAt: '2026-01-02T09:30:00.000', updatedAt: '2026-01-02T09:30:00.000', parentId: null, + likeCount: 12, }, { id: 4, @@ -42,6 +45,7 @@ export const comments = [ createdAt: '2026-01-02T10:15:00.000', updatedAt: '2026-01-02T10:15:00.000', parentId: 3, + likeCount: 2, }, { id: 5, @@ -53,6 +57,7 @@ export const comments = [ createdAt: '2026-01-02T11:00:00.000', updatedAt: '2026-01-02T11:00:00.000', parentId: 3, + likeCount: 0, }, { id: 6, @@ -64,6 +69,7 @@ export const comments = [ createdAt: '2026-01-11T15:20:00.000', updatedAt: '2026-01-11T15:20:00.000', parentId: null, + likeCount: 3, }, { id: 7, @@ -75,6 +81,7 @@ export const comments = [ createdAt: '2026-01-14T20:00:00.000', updatedAt: '2026-01-14T20:00:00.000', parentId: null, + likeCount: 1, }, { id: 8, @@ -86,6 +93,7 @@ export const comments = [ createdAt: '2026-01-15T14:00:00.000', updatedAt: '2026-01-15T14:00:00.000', parentId: null, + likeCount: 0, }, { id: 9, @@ -97,6 +105,7 @@ export const comments = [ createdAt: '2026-01-16T10:30:00.000', updatedAt: '2026-01-16T10:30:00.000', parentId: null, + likeCount: 8, }, { id: 10, @@ -108,6 +117,7 @@ export const comments = [ createdAt: '2026-01-16T11:45:00.000', updatedAt: '2026-01-16T11:45:00.000', parentId: 9, + likeCount: 0, }, { id: 11, @@ -119,6 +129,7 @@ export const comments = [ createdAt: '2026-01-16T16:00:00.000', updatedAt: '2026-01-16T16:00:00.000', parentId: null, + likeCount: 4, }, { id: 12, @@ -130,6 +141,7 @@ export const comments = [ createdAt: '2026-01-16T17:20:00.000', updatedAt: '2026-01-16T17:20:00.000', parentId: null, + likeCount: 0, }, { id: 13, @@ -141,5 +153,6 @@ export const comments = [ createdAt: '2026-01-16T18:10:00.000', updatedAt: '2026-01-16T18:10:00.000', parentId: null, + likeCount: 2, }, ] diff --git a/src/mocks/handlers/comment.ts b/src/mocks/handlers/comment.ts index 7f4455c..5a29000 100644 --- a/src/mocks/handlers/comment.ts +++ b/src/mocks/handlers/comment.ts @@ -2,7 +2,7 @@ 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', () => { return HttpResponse.json({ code: 'COMMON_200', message: '댓글 목록 조회 성공', @@ -10,4 +10,27 @@ export const commentHandlers = [ success: true, }) }), + + http.post('*/api/v1/posts/:postId/comments/:commentId/like', ({ params }) => { + const { commentId } = params + 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 + 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 e7f8a1d..eae14cf 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 @@ -67,222 +65,122 @@ export const postHandlers = [ { status: 201 } ) }), - 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) 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({ + code: 'COMMON_200', + message: '게시글 상세 조회 성공', + data: responseData, success: true, - data: post, }) }), + http.post('*/api/v1/posts/:postId/like', ({ params }) => { const postId = Number(params.postId) - - if (!Number.isInteger(postId)) { - return HttpResponse.json( - { - code: '400', - message: '유효하지 않은 경로 파라미터입니다.', - success: false, - }, - { status: 400 } - ) - } - const post = posts.find((p) => Number(p.id) === postId) - if (!post) { - return HttpResponse.json( - { - code: '404', - message: '게시글을 찾을 수 없습니다.', - success: 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: '게시글에 좋아요를 남겼습니다.', - success: 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: '유효하지 않은 경로 파라미터입니다.', - success: false, - }, - { status: 400 } - ) - } - const post = posts.find((p) => Number(p.id) === postId) - if (!post) { - return HttpResponse.json( - { - code: '404', - message: '게시글을 찾을 수 없습니다.', - success: 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: '게시글 좋아요를 취소했습니다.', - success: 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: '유효하지 않은 경로 파라미터입니다.', - success: false, - }, - { status: 400 } - ) - } - - const post = posts.find((p) => Number(p.id) === postId) - - if (!post) { - return HttpResponse.json( - { - code: '404', - message: '게시글을 찾을 수 없습니다.', - success: false, - }, - { status: 404 } - ) - } - bookmarkedPostIds.add(postId) - - return HttpResponse.json( - { - code: '200', - message: '게시글을 북마크했습니다.', - success: 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: '유효하지 않은 경로 파라미터입니다.', - success: false, - }, - { status: 400 } - ) - } - - const post = posts.find((p) => Number(p.id) === postId) - - if (!post) { - return HttpResponse.json( - { - code: '404', - message: '게시글을 찾을 수 없습니다.', - success: false, - }, - { status: 404 } - ) - } - bookmarkedPostIds.delete(postId) - - return HttpResponse.json( - { - code: '200', - message: '게시글 북마크를 취소했습니다.', - success: true, - }, - { status: 200 } - ) - }), - 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, + 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, success: true, }) }), + http.get('*/api/v1/users/:userId/posts', ({ params }) => { const userId = Number(params.userId) From b36ba7b4eaf27a5db6df3a3f5de9d68b921be3e9 Mon Sep 17 00:00:00 2001 From: c0912jy Date: Sat, 31 Jan 2026 00:44:13 +0900 Subject: [PATCH 2/4] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?api=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/post/CommentItem.tsx | 3 +- src/components/post/PostDetail.tsx | 24 ++++++++---- src/components/post/PostInfoSection.tsx | 49 +++++++++++++++++++++---- src/mocks/handlers/comment.ts | 26 +++++++++++++ 4 files changed, 87 insertions(+), 15 deletions(-) diff --git a/src/components/post/CommentItem.tsx b/src/components/post/CommentItem.tsx index 9c05582..9714b39 100644 --- a/src/components/post/CommentItem.tsx +++ b/src/components/post/CommentItem.tsx @@ -10,7 +10,8 @@ interface Comment { content: string profileImageUrl: string createdAt: string - likeCount?: number + likeCount: number + liked: boolean } interface CommentItemProps { diff --git a/src/components/post/PostDetail.tsx b/src/components/post/PostDetail.tsx index 5b98082..e35cbe8 100644 --- a/src/components/post/PostDetail.tsx +++ b/src/components/post/PostDetail.tsx @@ -5,24 +5,33 @@ import { motion, AnimatePresence } from 'framer-motion' import PostInfoSection from './PostInfoSection' import { instance } from '../../shared/api/ky' +export interface PostImage { + id: number + url: string + orderIndex: number +} + export interface PostData { id: number userId: number nickname: string profileImageUrl: string content: string - images: { - id: number - url: string - orderIndex: number - }[] + albumId: number + images: PostImage[] likeCount: number commentCount: number createdAt: string + updatedAt: string liked: boolean bookmarked: boolean } +interface SearchParams { + returnToPath?: string + returnToSearch?: Record +} + export default function PostDetail() { const { post_id: postId } = useParams({ from: '/_app/p/$post_id' }) const location = useLocation() @@ -51,8 +60,9 @@ export default function PostDetail() { const images = postData?.images || [] const handleClose = () => { - const returnToPath = location.search.returnToPath - const returnToSearch = location.search.returnToSearch + const search = location.search as SearchParams + const returnToPath = search.returnToPath + const returnToSearch = search.returnToSearch if (returnToPath) { navigate({ diff --git a/src/components/post/PostInfoSection.tsx b/src/components/post/PostInfoSection.tsx index 2322d82..7b4a6be 100644 --- a/src/components/post/PostInfoSection.tsx +++ b/src/components/post/PostInfoSection.tsx @@ -17,7 +17,9 @@ interface Comment { createdAt: string updatedAt: string parentId: number | null - likeCount?: number + likeCount: number + liked: boolean + likedUserIds: number[] } export default function PostInfoSection({ data }: { data: PostData | null }) { @@ -45,13 +47,23 @@ export default function PostInfoSection({ data }: { data: PostData | null }) { const result = await response.json() if (result.success && result.data.length > 0) { + const newComments = result.data + + setLikedComments((prev) => { + const nextLiked = { ...prev } + newComments.forEach((c: Comment) => { + nextLiked[c.id] = c.liked + }) + return nextLiked + }) + 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) @@ -94,6 +106,29 @@ export default function PostInfoSection({ data }: { data: PostData | null }) { } }, [hasMore, fetchComments]) + 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 = async (commentId: number, e?: React.MouseEvent) => { e?.stopPropagation() const postId = data?.id @@ -118,7 +153,7 @@ export default function PostInfoSection({ data }: { data: PostData | null }) { ...c, likeCount: Math.max( 0, - (c.likeCount || 0) + (isCurrentlyLiked ? -1 : 1) + c.likeCount + (isCurrentlyLiked ? -1 : 1) ), } : c @@ -225,7 +260,7 @@ export default function PostInfoSection({ data }: { data: PostData | null }) {
) })} -
+ {hasMore &&
}
@@ -236,7 +271,7 @@ export default function PostInfoSection({ data }: { data: PostData | null }) { isBookmarked={data?.bookmarked || false} onLikeClick={() => {}} onBookmarkClick={() => {}} - onCommentSubmit={() => {}} + onCommentSubmit={handleCommentSubmit} /> {isModalOpen && setIsModalOpen(false)} />} diff --git a/src/mocks/handlers/comment.ts b/src/mocks/handlers/comment.ts index 5a29000..5ba7948 100644 --- a/src/mocks/handlers/comment.ts +++ b/src/mocks/handlers/comment.ts @@ -11,6 +11,32 @@ export const commentHandlers = [ }) }), + http.post('*/api/v1/posts/:postId/comments', async ({ request }) => { + const { content } = (await request.json()) as { content: string } + + const newComment = { + id: Math.floor(Math.random() * 1000000), + postId: 1, + userId: 999, + nickname: '사용자', + profileImageUrl: 'https://via.placeholder.com/150', + content: content, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + parentId: null, + likeCount: 0, + liked: false, + likedUserIds: [], + } + + return HttpResponse.json({ + code: 'COMMON_200', + message: '댓글 등록 성공', + data: newComment, + success: true, + }) + }), + http.post('*/api/v1/posts/:postId/comments/:commentId/like', ({ params }) => { const { commentId } = params return HttpResponse.json({ From 5ad84e273348b083ea67d6ff0dc7c4ae48f82dd8 Mon Sep 17 00:00:00 2001 From: c0912jy Date: Sat, 31 Jan 2026 01:07:53 +0900 Subject: [PATCH 3/4] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=ED=86=A0=ED=81=B0=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/providers/Providers.tsx | 7 +-- src/features/auth/verification/main/model.ts | 12 ++++- src/shared/api/authApi.ts | 28 +++++++++++ src/shared/api/ky.ts | 48 ++++++++++++++++--- src/shared/auth/AuthProvider.tsx | 40 ++++++++++++++++ src/shared/auth/authStore.ts | 39 +++++++++++++++ src/shared/auth/useAuth.ts | 50 ++++++++++++++++++++ 7 files changed, 213 insertions(+), 11 deletions(-) create mode 100644 src/shared/api/authApi.ts create mode 100644 src/shared/auth/AuthProvider.tsx create mode 100644 src/shared/auth/authStore.ts create mode 100644 src/shared/auth/useAuth.ts 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/features/auth/verification/main/model.ts b/src/features/auth/verification/main/model.ts index 075424a..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 }; success: boolean }>() + .json<{ + data: { + accessToken: string + user: { id: number; nickname: string; profileImageUrl: string } + } + success: boolean + }>() if (res.success) { - localStorage.setItem('accessToken', res.data.accessToken) + setAuthenticated(true, res.data.user) navigate({ to: '/', replace: 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..86db179 --- /dev/null +++ b/src/shared/auth/AuthProvider.tsx @@ -0,0 +1,40 @@ +import { useEffect, type ReactNode } 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 { isAuthenticated, isSessionExpired, setAuthenticated, reset } = + useAuthStore() + + useEffect(() => { + const initAuth = async () => { + if (location.pathname === '/login') return + + try { + const data = await refreshAccessToken() + setAuthenticated(true, data.user) + } catch { + reset() + navigate({ to: '/login' }) + } + } + + if (!isAuthenticated) { + 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 } +} From 142a6b6eacabc4c70fdc9f6221226608bb6e3e3a Mon Sep 17 00:00:00 2001 From: c0912jy Date: Sat, 31 Jan 2026 08:13:02 +0900 Subject: [PATCH 4/4] =?UTF-8?q?=E2=9C=A8=20feat:=20=EB=8C=93=EA=B8=80=20ap?= =?UTF-8?q?i=20=EC=97=B0=EA=B2=B0=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=ED=86=A0=ED=81=B0=20=EC=9E=91?= =?UTF-8?q?=EC=97=85,=20=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20=EC=8B=9C?= =?UTF-8?q?=20=20=EC=9E=90=EB=8F=99=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/post/PostInfoSection.tsx | 41 +-- src/mocks/browser.ts | 22 ++ src/mocks/db/comment.db.ts | 336 ++++++++++++++++-------- src/mocks/handlers/auth.ts | 44 +++- src/mocks/handlers/comment.ts | 46 +++- src/mocks/handlers/post.ts | 6 +- src/shared/auth/AuthProvider.tsx | 13 +- 7 files changed, 360 insertions(+), 148 deletions(-) diff --git a/src/components/post/PostInfoSection.tsx b/src/components/post/PostInfoSection.tsx index 7b4a6be..ccb6e95 100644 --- a/src/components/post/PostInfoSection.tsx +++ b/src/components/post/PostInfoSection.tsx @@ -41,13 +41,14 @@ 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 (result.success && result.data.length > 0) { - const newComments = result.data + if (response.success && response.data.length > 0) { + const newComments = response.data setLikedComments((prev) => { const nextLiked = { ...prev } @@ -174,11 +175,15 @@ export default function PostInfoSection({ data }: { data: PostData | null }) {
- + {data?.profileImageUrl ? ( + + ) : ( +
+ )}
{data?.nickname || ''}
@@ -196,11 +201,15 @@ export default function PostInfoSection({ data }: { data: PostData | null }) { />
- + {data?.profileImageUrl ? ( + + ) : ( +
+ )}
{data?.nickname} {data?.content} 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 ce33550..a114659 100644 --- a/src/mocks/db/comment.db.ts +++ b/src/mocks/db/comment.db.ts @@ -1,158 +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: 5, + 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: 0, + 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, - likeCount: 12, + 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, - likeCount: 2, + 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, - likeCount: 0, + 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: 3, + 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: 0, + 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, - likeCount: 8, + 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, - likeCount: 0, + 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, - likeCount: 4, + 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: 0, + 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: 2, + 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 afc720e..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 { @@ -71,29 +73,31 @@ export const authHandlers = [ { code: 'AUTH_400', message: '이미 존재하는 이메일/닉네임입니다.', - data: { - accessToken: 'string', - refreshToken: 'string', - }, 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', + }, }, success: true, }) @@ -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', + }, + }, + }) + }), ] diff --git a/src/mocks/handlers/comment.ts b/src/mocks/handlers/comment.ts index 5ba7948..e25ba89 100644 --- a/src/mocks/handlers/comment.ts +++ b/src/mocks/handlers/comment.ts @@ -2,33 +2,45 @@ 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, + data: postComments, success: true, }) }), - http.post('*/api/v1/posts/:postId/comments', async ({ request }) => { - const { content } = (await request.json()) as { content: string } + 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: 1, - userId: 999, - nickname: '사용자', - profileImageUrl: 'https://via.placeholder.com/150', + 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: null, + parentId: parentId || null, likeCount: 0, liked: false, likedUserIds: [], } + comments.push(newComment) + return HttpResponse.json({ code: 'COMMON_200', message: '댓글 등록 성공', @@ -39,6 +51,14 @@ export const commentHandlers = [ 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} 좋아요 성공`, @@ -51,6 +71,14 @@ export const commentHandlers = [ '*/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} 좋아요 취소 성공`, diff --git a/src/mocks/handlers/post.ts b/src/mocks/handlers/post.ts index eae14cf..e3b69fe 100644 --- a/src/mocks/handlers/post.ts +++ b/src/mocks/handlers/post.ts @@ -22,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, @@ -68,7 +68,7 @@ export const postHandlers = [ 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 HttpResponse.json( diff --git a/src/shared/auth/AuthProvider.tsx b/src/shared/auth/AuthProvider.tsx index 86db179..c8107d5 100644 --- a/src/shared/auth/AuthProvider.tsx +++ b/src/shared/auth/AuthProvider.tsx @@ -1,4 +1,4 @@ -import { useEffect, type ReactNode } from 'react' +import { useEffect, type ReactNode, useRef } from 'react' import { useNavigate, useLocation } from '@tanstack/react-router' import { toast } from 'sonner' import { useAuthStore } from './authStore' @@ -7,23 +7,28 @@ 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') return + if (location.pathname === '/login' || isAuthenticated) return try { const data = await refreshAccessToken() setAuthenticated(true, data.user) } catch { reset() - navigate({ to: '/login' }) + // 로그인이 필요한 페이지에서만 튕기도록 설정 (필요 시 조건 추가) + if (location.pathname !== '/') { + navigate({ to: '/login' }) + } } } - if (!isAuthenticated) { + if (isInitialMount.current) { + isInitialMount.current = false initAuth() } }, [isAuthenticated, location.pathname, navigate, reset, setAuthenticated])