diff --git a/package.json b/package.json index fcb2757eb..9f5f64322 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ }, "dependencies": { "react": "^19.1.0", - "react-dom": "^19.1.0" + "react-dom": "^19.1.0", + "zustand": "^5.0.3" }, "devDependencies": { "@eslint/js": "^9.25.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e1c5650db..3690727be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: react-dom: specifier: ^19.1.0 version: 19.1.0(react@19.1.0) + zustand: + specifier: ^5.0.3 + version: 5.0.3(@types/react@19.1.2)(react@19.1.0) devDependencies: '@eslint/js': specifier: ^9.25.1 @@ -2229,6 +2232,24 @@ packages: resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==} engines: {node: '>=18'} + zustand@5.0.3: + resolution: {integrity: sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@adobe/css-tools@4.4.0': {} @@ -3177,7 +3198,7 @@ snapshots: '@vitest/utils@2.1.3': dependencies: '@vitest/pretty-format': 2.1.3 - loupe: 3.1.2 + loupe: 3.1.3 tinyrainbow: 1.2.0 '@vitest/utils@3.1.2': @@ -4234,3 +4255,8 @@ snapshots: yocto-queue@0.1.0: {} yoctocolors-cjs@2.1.2: {} + + zustand@5.0.3(@types/react@19.1.2)(react@19.1.0): + optionalDependencies: + '@types/react': 19.1.2 + react: 19.1.0 diff --git a/src/entities/comment/api/addCommentApi.ts b/src/entities/comment/api/addCommentApi.ts new file mode 100644 index 000000000..de48e54dd --- /dev/null +++ b/src/entities/comment/api/addCommentApi.ts @@ -0,0 +1,8 @@ +export const addCommentApi = async (comment: { body: string; postId: number; userId: number }) => { + const res = await fetch(`/api/comments/add`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(comment), + }); + return res.json(); +}; diff --git a/src/entities/comment/api/deleteCommentApi.ts b/src/entities/comment/api/deleteCommentApi.ts new file mode 100644 index 000000000..3fbd41ab2 --- /dev/null +++ b/src/entities/comment/api/deleteCommentApi.ts @@ -0,0 +1,5 @@ +export const deleteCommentApi = async (commentId: number) => { + return fetch(`/api/comments/${commentId}`, { + method: "DELETE", + }); +}; diff --git a/src/entities/comment/api/fetchCommentsApi.ts b/src/entities/comment/api/fetchCommentsApi.ts new file mode 100644 index 000000000..9539efe2a --- /dev/null +++ b/src/entities/comment/api/fetchCommentsApi.ts @@ -0,0 +1,7 @@ +import { Comment } from "../model/types.ts" + +export const fetchCommentsApi = async (postId: number): Promise => { + const res = await fetch(`/api/comments/post/${postId}`) + const data = await res.json() + return data.comments +} diff --git a/src/entities/comment/api/likeCommentApi.ts b/src/entities/comment/api/likeCommentApi.ts new file mode 100644 index 000000000..c7d94ed82 --- /dev/null +++ b/src/entities/comment/api/likeCommentApi.ts @@ -0,0 +1,8 @@ +export const likeCommentApi = async (commentId: number, newLikes: number) => { + const res = await fetch(`/api/comments/${commentId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ likes: newLikes }), + }); + return res.json(); +}; diff --git a/src/entities/comment/api/updateCommentApi.ts b/src/entities/comment/api/updateCommentApi.ts new file mode 100644 index 000000000..83a0bdaec --- /dev/null +++ b/src/entities/comment/api/updateCommentApi.ts @@ -0,0 +1,8 @@ +export const updateCommentApi = async (commentId: number, body: string) => { + const res = await fetch(`/api/comments/${commentId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ body }), + }); + return res.json(); +}; diff --git a/src/entities/comment/model/comments-store.ts b/src/entities/comment/model/comments-store.ts new file mode 100644 index 000000000..ece059600 --- /dev/null +++ b/src/entities/comment/model/comments-store.ts @@ -0,0 +1,34 @@ +import { create } from "zustand/react" +import { Comment } from "./types.ts" + +interface NewComment { + body: string + postId: number | null + userId: number +} + +interface CommentsState { + comments: Record + selectedComment: Comment | null + newComment: NewComment + + setComments: (postId: number, comments: Comment[]) => void + setSelectedComment: (comment: Comment | null) => void + setNewComment: (comment: { body: string; postId: number | null; userId: number }) => void +} + +export const useCommentsStore = create((set) => ({ + comments: {}, + selectedComment: null, + newComment: { body: "", postId: 1, userId: 1 }, + + setComments: (postId, comments) => + set((state) => ({ + comments: { + ...state.comments, + [postId]: comments, + }, + })), + setSelectedComment: (selectedComment) => set({ selectedComment }), + setNewComment: (newComment) => set({ newComment }), +})) \ No newline at end of file diff --git a/src/entities/comment/model/types.ts b/src/entities/comment/model/types.ts new file mode 100644 index 000000000..f1180f5a7 --- /dev/null +++ b/src/entities/comment/model/types.ts @@ -0,0 +1,10 @@ +export interface Comment { + id: number; + body: string; + likes: number; + postId: number; + user:{ + id: number; + username: string; + }; +} \ No newline at end of file diff --git a/src/entities/post/api/addPostApi.ts b/src/entities/post/api/addPostApi.ts new file mode 100644 index 000000000..b85b24b60 --- /dev/null +++ b/src/entities/post/api/addPostApi.ts @@ -0,0 +1,8 @@ +export const addPostApi = async (post: { title: string; body: string; userId: number }) => { + const res = await fetch("/api/posts/add", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(post), + }); + return res.json(); +}; diff --git a/src/entities/post/api/deletePostApi.ts b/src/entities/post/api/deletePostApi.ts new file mode 100644 index 000000000..75bc90dc9 --- /dev/null +++ b/src/entities/post/api/deletePostApi.ts @@ -0,0 +1,5 @@ +export const deletePostApi = async (postId: number) => { + return fetch(`/api/posts/${postId}`, { + method: "DELETE", + }); +}; diff --git a/src/entities/post/api/fetchPostsApi.ts b/src/entities/post/api/fetchPostsApi.ts new file mode 100644 index 000000000..aa6692c40 --- /dev/null +++ b/src/entities/post/api/fetchPostsApi.ts @@ -0,0 +1,16 @@ +import { Post, User } from "../model/types.ts" + +export const fetchPostsApi = async (skip: number, limit: number) => { + const postsRes = await fetch(`/api/posts?limit=${limit}&skip=${skip}`); + const postsData = await postsRes.json(); + + const usersRes = await fetch(`/api/users?limit=0&select=username,image`); + const usersData = await usersRes.json(); + + const postsWithUsers = postsData.posts.map((post: Post) => ({ + ...post, + author: usersData.users.find((user: User) => user.id === post.userId), + })); + + return { posts: postsWithUsers, total: postsData.total }; +}; diff --git a/src/entities/post/api/fetchPostsByTagApi.ts b/src/entities/post/api/fetchPostsByTagApi.ts new file mode 100644 index 000000000..a193ecd92 --- /dev/null +++ b/src/entities/post/api/fetchPostsByTagApi.ts @@ -0,0 +1,16 @@ +import { Post, User } from "../model/types.ts" + +export const fetchPostsByTagApi = async (tag: string) => { + const postsRes = await fetch(`/api/posts/tag/${tag}`); + const postsData = await postsRes.json(); + + const usersRes = await fetch(`/api/users?limit=0&select=username,image`); + const usersData = await usersRes.json(); + + const postsWithUsers = postsData.posts.map((post: Post) => ({ + ...post, + author: usersData.users.find((user: User) => user.id === post.userId), + })); + + return { posts: postsWithUsers, total: postsData.total }; +}; diff --git a/src/entities/post/api/searchPostsApi.ts b/src/entities/post/api/searchPostsApi.ts new file mode 100644 index 000000000..4d54a85bb --- /dev/null +++ b/src/entities/post/api/searchPostsApi.ts @@ -0,0 +1,5 @@ +export const searchPostsApi = async (query: string) => { + const res = await fetch(`/api/posts/search?q=${query}`); + const data = await res.json(); + return { posts: data.posts, total: data.total }; +}; diff --git a/src/entities/post/api/updatePostApi.ts b/src/entities/post/api/updatePostApi.ts new file mode 100644 index 000000000..61119c5ca --- /dev/null +++ b/src/entities/post/api/updatePostApi.ts @@ -0,0 +1,8 @@ +export const updatePostApi = async (postId: number, post: { title: string; body: string }) => { + const res = await fetch(`/api/posts/${postId}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(post), + }); + return res.json(); +}; diff --git a/src/entities/post/model/posts-store.ts b/src/entities/post/model/posts-store.ts new file mode 100644 index 000000000..c49101223 --- /dev/null +++ b/src/entities/post/model/posts-store.ts @@ -0,0 +1,51 @@ +import { create } from "zustand/react" +import { Post } from "./types.ts" + +interface PostsState { + posts: Post[] + total: number + skip: number + limit: number + searchQuery: string + sortBy: string + sortOrder: "asc" | "desc" + selectedTag: string + selectedPost: Post | null + newPost: { title: string; body: string; userId: number } + + setPosts: (posts: Post[]) => void + setTotal: (total: number) => void + setSkip: (skip: number) => void + setLimit: (limit: number) => void + setSearchQuery: (query: string) => void + setSortBy: (sortBy: string) => void + setSortOrder: (sortOrder: "asc" | "desc") => void + setSelectedTag: (tag: string) => void + setSelectedPost: (post: Post | null) => void + setNewPost: (post: { title: string; body: string; userId: number }) => void + +} + +export const usePostsStore = create((set) => ({ + posts: [], + total: 0, + skip: 0, + limit: 10, + searchQuery: "", + sortBy: "", + sortOrder: "asc", + selectedTag: "", + selectedPost: null, + newPost: { title: "", body: "", userId: 1 }, + + setPosts: (posts) => set({ posts }), + setTotal: (total) => set({ total }), + setSkip: (skip) => set({ skip }), + setLimit: (limit) => set({ limit }), + setSearchQuery: (searchQuery) => set({ searchQuery }), + setSortBy: (sortBy) => set({ sortBy }), + setSortOrder: (sortOrder) => set({ sortOrder }), + setSelectedTag: (selectedTag) => set({ selectedTag }), + setSelectedPost: (selectedPost) => set({ selectedPost }), + setNewPost: (newPost) => set({ newPost }), +})) diff --git a/src/entities/post/model/searchPosts.ts b/src/entities/post/model/searchPosts.ts new file mode 100644 index 000000000..7da69ae99 --- /dev/null +++ b/src/entities/post/model/searchPosts.ts @@ -0,0 +1,20 @@ +import { usePostsStore } from "./posts-store.ts" + +export const useSearchPosts = () => { + const { setPosts, setTotal } = usePostsStore() + + const searchPosts = async (query: string) => { + if (!query.trim()) return + + try { + const res = await fetch(`/api/posts/search?q=${query}`) + const data = await res.json() + setPosts(data.posts) + setTotal(data.total) + } catch (err) { + console.error("검색 실패:", err) + } + } + + return { searchPosts } +} diff --git a/src/entities/post/model/types.ts b/src/entities/post/model/types.ts new file mode 100644 index 000000000..f631dc882 --- /dev/null +++ b/src/entities/post/model/types.ts @@ -0,0 +1,17 @@ + +export interface Post { + id: number + title: string + body: string + tags: string[] + userId: number + author?: { + id: number + username: string + image: string + } + reactions?: { + likes: number + dislikes: number + } +} diff --git a/src/features/posts-manager/model/addComment.ts b/src/features/posts-manager/model/addComment.ts new file mode 100644 index 000000000..e65d491a6 --- /dev/null +++ b/src/features/posts-manager/model/addComment.ts @@ -0,0 +1,28 @@ +import { useCommentsStore } from "../../../entities/comment/model/comments-store.ts" +import { useDialogStore } from "../../../shared/model/dialog-store.ts" +import { addCommentApi } from "../../../entities/comment/api/addCommentApi.ts" + +export const useAddComment = () => { + const { comments, setComments, newComment, setNewComment } = useCommentsStore() + const { closeDialog } = useDialogStore() + + const addComment = async () => { + try { + if (newComment.postId == null) { + console.error("postId가 없습니다. 댓글을 추가할 수 없습니다.") + return + } + + const added = await addCommentApi(newComment as { body: string; postId: number; userId: number }) + + setComments(added.postId, [...(comments[added.postId] || []), added]) + + setNewComment({ body: "", postId: null, userId: 1 }) + closeDialog("showAddCommentDialog") + } catch (err) { + console.error("댓글 추가 오류", err) + } + } + + return { addComment } +} diff --git a/src/features/posts-manager/model/addPost.ts b/src/features/posts-manager/model/addPost.ts new file mode 100644 index 000000000..85718c242 --- /dev/null +++ b/src/features/posts-manager/model/addPost.ts @@ -0,0 +1,23 @@ +import { usePostsStore } from "../../../entities/post/model/posts-store.ts" +import { useDialogStore } from "../../../shared/model/dialog-store.ts" +import { Post } from "../../../entities/post/model/types.ts" +import { addPostApi } from "../../../entities/post/api/addPostApi.ts" + +export const useAddPost = () => { + const { setPosts } = usePostsStore() + const { closeDialog } = useDialogStore() + + const addPost = async (newPost: Omit) => { + try { + const created = await addPostApi(newPost) + + const prevPosts = usePostsStore.getState().posts + setPosts([created, ...prevPosts]) + + closeDialog("showAddPostDialog") + } catch (err) { + console.error("게시물 추가 오류", err) + } + } + return { addPost } +} diff --git a/src/features/posts-manager/model/deleteComment.ts b/src/features/posts-manager/model/deleteComment.ts new file mode 100644 index 000000000..0f650eeaa --- /dev/null +++ b/src/features/posts-manager/model/deleteComment.ts @@ -0,0 +1,20 @@ +import { useCommentsStore } from "../../../entities/comment/model/comments-store.ts" +import { deleteCommentApi } from "../../../entities/comment/api/deleteCommentApi.ts" + +export const useDeleteComment = () => { + const { setComments } = useCommentsStore() + + const deleteComment = async (commentId: number, postId: number) => { + try { + await deleteCommentApi(commentId) + const current = useCommentsStore.getState().comments + const updatedComments = current[postId]?.filter((c) => c.id !== commentId) || [] + + setComments(postId, updatedComments) + } catch (err) { + console.error("댓글 삭제 실패:", err) + } + } + + return { deleteComment } +} diff --git a/src/features/posts-manager/model/deletePost.ts b/src/features/posts-manager/model/deletePost.ts new file mode 100644 index 000000000..986874ac9 --- /dev/null +++ b/src/features/posts-manager/model/deletePost.ts @@ -0,0 +1,19 @@ +import { usePostsStore } from "../../../entities/post/model/posts-store.ts" +import { deletePostApi } from "../../../entities/post/api/deletePostApi.ts" + +export const useDeletePost = () => { + const { setPosts } = usePostsStore() + + const deletePost = async (postId: number) => { + try { + await deletePostApi(postId) + + const currentPosts = usePostsStore.getState().posts + const updatedList = currentPosts.filter((post) => post.id !== postId) + setPosts(updatedList) + } catch (err) { + console.error("게시물 삭제 오류", err) + } + } + return { deletePost } +} diff --git a/src/features/posts-manager/model/fetchComments.ts b/src/features/posts-manager/model/fetchComments.ts new file mode 100644 index 000000000..0c8cf787c --- /dev/null +++ b/src/features/posts-manager/model/fetchComments.ts @@ -0,0 +1,17 @@ +import { fetchCommentsApi } from "../../../entities/comment/api/fetchCommentsApi.ts" +import { useCommentsStore } from "../../../entities/comment/model/comments-store.ts" + +export const useFetchComments = () => { + const { setComments } = useCommentsStore() + + const fetchComments = async (postId: number) => { + try { + const comments = await fetchCommentsApi(postId) + setComments(postId, comments) + } catch (error) { + console.error("댓글 불러오기 실패:", error) + } + } + + return { fetchComments } +} diff --git a/src/features/posts-manager/model/likeComment.ts b/src/features/posts-manager/model/likeComment.ts new file mode 100644 index 000000000..07afe7901 --- /dev/null +++ b/src/features/posts-manager/model/likeComment.ts @@ -0,0 +1,25 @@ +import { useCommentsStore } from "../../../entities/comment/model/comments-store.ts" +import { likeCommentApi } from "../../../entities/comment/api/likeCommentApi.ts" + +export const useLikeComment = () => { + const { comments, setComments } = useCommentsStore() + + const likeComment = async (commentId: number, postId: number) => { + try { + const target = comments[postId].find((c) => c.id === commentId) + + if (!target) return + + const updated = await likeCommentApi(commentId, target.likes + 1) + + const updatedList = comments[postId].map((c) => + c.id === updated.id ? { ...updated, likes: target.likes + 1 } : c, + ) + + setComments(postId, updatedList) + } catch (err) { + console.error("댓글 좋아요 오류:", err) + } + } + return { likeComment } +} diff --git a/src/features/posts-manager/model/openPostDetail.ts b/src/features/posts-manager/model/openPostDetail.ts new file mode 100644 index 000000000..be0931314 --- /dev/null +++ b/src/features/posts-manager/model/openPostDetail.ts @@ -0,0 +1,24 @@ +import { usePostsStore } from "../../../entities/post/model/posts-store.ts" +import { useCommentsStore } from "../../../entities/comment/model/comments-store.ts" +import { useDialogStore } from "../../../shared/model/dialog-store.ts" +import { Post } from "../../../entities/post/model/types.ts" +import { useFetchComments } from "./fetchComments.ts" + +export const useOpenPostDetail = () => { + const { setSelectedPost } = usePostsStore() + const { comments } = useCommentsStore() + const { openDialog } = useDialogStore() + const { fetchComments } = useFetchComments() + + const openPostDetail = async (post: Post) => { + setSelectedPost(post) + + if (!comments[post.id]) { + await fetchComments(post.id) + } + + openDialog("showPostDetailDialog") + } + + return { openPostDetail } +} diff --git a/src/features/posts-manager/model/updateComment.ts b/src/features/posts-manager/model/updateComment.ts new file mode 100644 index 000000000..1667b24ff --- /dev/null +++ b/src/features/posts-manager/model/updateComment.ts @@ -0,0 +1,17 @@ +import { useCommentsStore } from "../../../entities/comment/model/comments-store.ts" +import { updateCommentApi } from "../../../entities/comment/api/updateCommentApi.ts" + +export const useUpdateComment = () => { + const { comments, setComments } = useCommentsStore() + + const updateComment = async (commentId: number, postId: number, newBody: string) => { + try { + const updated = await updateCommentApi(commentId, newBody) + const updatedList = comments[postId].map((comment) => (comment.id === updated.id ? updated : comment)) + setComments(postId, updatedList) + } catch (err) { + console.error("댓글 업데이트 오류", err) + } + } + return { updateComment } +} diff --git a/src/features/posts-manager/model/updatePost.ts b/src/features/posts-manager/model/updatePost.ts new file mode 100644 index 000000000..3eca9d311 --- /dev/null +++ b/src/features/posts-manager/model/updatePost.ts @@ -0,0 +1,29 @@ +import { usePostsStore } from "../../../entities/post/model/posts-store.ts" +import { useDialogStore } from "../../../shared/model/dialog-store.ts" +import { Post } from "../../../entities/post/model/types.ts" +import { updatePostApi } from "../../../entities/post/api/updatePostApi.ts" + +export const useUpdatePost = () => { + const { setPosts, setSelectedPost } = usePostsStore() + const { closeDialog } = useDialogStore() + + const updatePost = async (postId: number, updatedData: Partial) => { + try { + const title = updatedData.title ?? "" + const body = updatedData.body ?? "" + + const updated = await updatePostApi(postId, { title, body }) + + const currentPosts = usePostsStore.getState().posts + + const updatedList = currentPosts.map((post) => (post.id === updated.id ? updated : post)) + + setPosts(updatedList) + setSelectedPost(null) + closeDialog("showEditPostDialog") + } catch (err) { + console.error("게시물 업데이트 오류", err) + } + } + return { updatePost } +} diff --git a/src/features/posts-manager/ui/AddCommentDialog.tsx b/src/features/posts-manager/ui/AddCommentDialog.tsx new file mode 100644 index 000000000..bd7a98f6e --- /dev/null +++ b/src/features/posts-manager/ui/AddCommentDialog.tsx @@ -0,0 +1,33 @@ +import { useCommentsStore } from "../../../entities/comment/model/comments-store" +import { useDialogStore } from "../../../shared/model/dialog-store" +import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, Textarea } from "../../../shared/ui" +import { useAddComment } from "../model/addComment.ts" + +const AddCommentDialog = () => { + const { showAddCommentDialog, openDialog, closeDialog } = useDialogStore() + const { newComment, setNewComment } = useCommentsStore() + const { addComment } = useAddComment() + + return ( + (open ? openDialog("showAddCommentDialog") : closeDialog("showAddCommentDialog"))} + > + + + 새 댓글 추가 + +
+