diff --git a/package.json b/package.json index e014c5272..fdfec1604 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "coverage": "vitest run --coverage" }, "dependencies": { + "jotai": "^2.13.1", "react": "^19.1.1", "react-dom": "^19.1.1" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b2a40d18..bf6ef3396 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + jotai: + specifier: ^2.13.1 + version: 2.13.1(@babel/core@7.28.0)(@babel/template@7.27.2)(@types/react@19.1.9)(react@19.1.1) react: specifier: ^19.1.1 version: 19.1.1 @@ -103,10 +106,6 @@ packages: '@asamuzakjp/css-color@2.8.3': resolution: {integrity: sha512-GIc76d9UI1hCvOATjZPyHFmE5qhRccp3/zGfMPapK3jBi+yocEzp6BBB0UnfRYP9NP4FANqUZYb0hnfs3TM3hw==} - '@babel/code-frame@7.26.2': - resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} - engines: {node: '>=6.9.0'} - '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -1634,6 +1633,24 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jotai@2.13.1: + resolution: {integrity: sha512-cRsw6kFeGC9Z/D3egVKrTXRweycZ4z/k7i2MrfCzPYsL9SIWcPXTyqv258/+Ay8VUEcihNiE/coBLE6Kic6b8A==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@babel/core': '>=7.0.0' + '@babel/template': '>=7.0.0' + '@types/react': '>=17.0.0' + react: '>=17.0.0' + peerDependenciesMeta: + '@babel/core': + optional: true + '@babel/template': + optional: true + '@types/react': + optional: true + react: + optional: true + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2362,12 +2379,6 @@ snapshots: '@csstools/css-tokenizer': 3.0.3 lru-cache: 10.4.3 - '@babel/code-frame@7.26.2': - dependencies: - '@babel/helper-validator-identifier': 7.25.9 - js-tokens: 4.0.0 - picocolors: 1.1.1 - '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.27.1 @@ -3083,7 +3094,7 @@ snapshots: '@testing-library/dom@10.4.0': dependencies: - '@babel/code-frame': 7.26.2 + '@babel/code-frame': 7.27.1 '@babel/runtime': 7.26.0 '@types/aria-query': 5.0.4 aria-query: 5.3.0 @@ -3347,7 +3358,7 @@ snapshots: '@vitest/utils@2.1.3': dependencies: '@vitest/pretty-format': 2.1.3 - loupe: 3.1.3 + loupe: 3.2.0 tinyrainbow: 1.2.0 '@vitest/utils@3.2.4': @@ -3835,6 +3846,13 @@ snapshots: isexe@2.0.0: {} + jotai@2.13.1(@babel/core@7.28.0)(@babel/template@7.27.2)(@types/react@19.1.9)(react@19.1.1): + optionalDependencies: + '@babel/core': 7.28.0 + '@babel/template': 7.27.2 + '@types/react': 19.1.9 + react: 19.1.1 + js-tokens@4.0.0: {} js-tokens@9.0.1: {} diff --git a/src/App.tsx b/src/App.tsx index 0c0032aab..82d35d55b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import { BrowserRouter as Router } from "react-router-dom" -import Header from "./components/Header.tsx" -import Footer from "./components/Footer.tsx" +import Header from "./widgets/ui/Header.tsx" +import Footer from "./widgets/ui/Footer.tsx" import PostsManagerPage from "./pages/PostsManagerPage.tsx" const App = () => { diff --git a/src/entities/post/api/post-api.ts b/src/entities/post/api/post-api.ts new file mode 100644 index 000000000..0a3c393b7 --- /dev/null +++ b/src/entities/post/api/post-api.ts @@ -0,0 +1,64 @@ +import { Post } from "../model/types" + +// 게시물 목록을 가져오는 API 함수 +export const getPostApi = async (limit: number, skip: number) => { + const response = await fetch(`/api/posts?limit=${limit}&skip=${skip}`) + if (!response.ok) { + throw new Error("게시물 목록을 가져오는데 실패했습니다.") + } + return response +} + +// 게시물 추가 API 함수 +export const addPostApi = async (newPost: Post) => { + const response = await fetch("/api/posts/add", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(newPost), + }) + if (!response.ok) { + throw new Error("게시물 추가에 실패했습니다.") + } + return response +} +// 게시물 수정 API 함수 +export const updatePostApi = async (post: Post) => { + const response = await fetch(`/api/posts/${post.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(post), + }) + if (!response.ok) { + throw new Error("게시물 수정에 실패했습니다.") + } + return response +} + +// 게시물 삭제 API 함수 +export const deletePostApi = async (id: string) => { + const response = await fetch(`/api/posts/${id}`, { + method: "DELETE", + }) + if (!response.ok) { + throw new Error("게시물 삭제에 실패했습니다.") + } + return response +} + +// 게시물 검색 API 함수 +export const searchPostApi = async (searchQuery: string) => { + const response = await fetch(`/api/posts/search?q=${searchQuery}`) + if (!response.ok) { + throw new Error("게시물 검색에 실패했습니다.") + } + return response +} + +// 모든 유저의 username과 image만 가져오는 API 함수 +export const getAllUsersApi = async () => { + const response = await fetch("/api/users?limit=0&select=username,image") + if (!response.ok) { + throw new Error("유저 목록을 가져오는데 실패했습니다.") + } + return response +} diff --git a/src/entities/post/hooks/usePost.ts b/src/entities/post/hooks/usePost.ts new file mode 100644 index 000000000..fe54634f1 --- /dev/null +++ b/src/entities/post/hooks/usePost.ts @@ -0,0 +1,172 @@ +import { useState, useEffect } from "react" +import { + getPostApi, + addPostApi, + updatePostApi, + deletePostApi, + getTagApi, + searchPostApi, + getPostsByTagApi, +} from "../api/post-api" +import { getUserApi } from "../../user/api/user-api" +import { loadingAtom } from "../../../shared/model/store" +import { + postsAtom, + totalPostsAtom, + tagsAtom, + selectedTagAtom, + searchQueryAtom, + skipAtom, + limitAtom, + selectedPostAtom, + showEditDialogAtom, + showAddDialogAtom, + newPostAtom, +} from "../model/store" +import { useAtom } from "jotai" + +export const usePost = () => { + const [loading, setLoading] = useAtom(loadingAtom) + const [posts, setPosts] = useAtom(postsAtom) + const [total, setTotal] = useAtom(totalPostsAtom) + const [tags, setTags] = useAtom(tagsAtom) + const [selectedTag, setSelectedTag] = useAtom(selectedTagAtom) + const [searchQuery, setSearchQuery] = useAtom(searchQueryAtom) + const [skip, setSkip] = useAtom(skipAtom) + const [limit, setLimit] = useAtom(limitAtom) + const [selectedPost, setSelectedPost] = useAtom(selectedPostAtom) + const [showEditDialog, setShowEditDialog] = useAtom(showEditDialogAtom) + const [showAddDialog, setShowAddDialog] = useAtom(showAddDialogAtom) + const [newPost, setNewPost] = useAtom(newPostAtom) + + // 게시물 목록 가져오기 + const fetchPosts = async () => { + setLoading(true) + try { + const [postsResponse, usersResponse] = await Promise.all([getPostApi(limit, skip), getUserApi()]) + const postsData = await postsResponse.json() + const usersData = await usersResponse.json() + const postsWithUsers = postsData.posts.map((post) => ({ + ...post, + author: usersData.users.find((user) => user.id === post.userId), + })) + setPosts(postsWithUsers) + setTotal(postsData.total) + } catch (error) { + console.error("게시물 가져오기 오류:", error) + } + setLoading(false) + } + + // 태그 목록 가져오기 + const fetchTags = async () => { + try { + const response = await getTagApi() + const data = await response.json() + setTags(data) + } catch (error) { + console.error("태그 가져오기 오류:", error) + } + } + + // 태그별 게시물 가져오기 + const fetchPostsByTag = async (tag) => { + setLoading(true) + try { + const [postsResponse, usersResponse] = await Promise.all([getPostsByTagApi(tag, limit, skip), getUserApi()]) + const postsData = await postsResponse.json() + const usersData = await usersResponse.json() + const postsWithUsers = postsData.posts.map((post) => ({ + ...post, + author: usersData.users.find((user) => user.id === post.userId), + })) + setPosts(postsWithUsers) + setTotal(postsData.total) + } catch (error) { + console.error("태그별 게시물 가져오기 오류:", error) + } + setLoading(false) + } + + // 게시물 검색 + const searchPosts = async (query) => { + setLoading(true) + try { + const [postsResponse, usersResponse] = await Promise.all([searchPostApi(query, limit, skip), getUserApi()]) + const postsData = await postsResponse.json() + const usersData = await usersResponse.json() + const postsWithUsers = postsData.posts.map((post) => ({ + ...post, + author: usersData.users.find((user) => user.id === post.userId), + })) + setPosts(postsWithUsers) + setTotal(postsData.total) + } catch (error) { + console.error("게시물 검색 오류:", error) + } + setLoading(false) + } + + // 게시물 추가 + const addPost = async () => { + try { + const data = await addPostApi(newPost) + setPosts([data, ...posts]) + setShowAddDialog(false) + setNewPost({ title: "", body: "", userId: 1 }) + } catch (error) { + console.error("게시물 추가 오류:", error) + } + } + + // 게시물 수정 + const updatePost = async () => { + try { + const response = await updatePostApi(selectedPost) + const data = await response.json() + setPosts(posts.map((post) => (post.id === data.id ? data : post))) + setShowEditDialog(false) + } catch (error) { + console.error("게시물 업데이트 오류:", error) + } + } + + // 게시물 삭제 + const deletePost = async (id) => { + try { + await deletePostApi(id) + setPosts(posts.filter((post) => post.id !== id)) + } catch (error) { + console.error("게시물 삭제 오류:", error) + } + } + + return { + posts, + total, + tags, + selectedTag, + setSelectedTag, + searchQuery, + setSearchQuery, + skip, + setSkip, + limit, + setLimit, + selectedPost, + setSelectedPost, + showEditDialog, + setShowEditDialog, + showAddDialog, + setShowAddDialog, + newPost, + setNewPost, + fetchPosts, + fetchPostsByTag, + searchPosts, + addPost, + updatePost, + deletePost, + fetchTags, + } +} diff --git a/src/entities/post/model/store.ts b/src/entities/post/model/store.ts new file mode 100644 index 000000000..6010c6a12 --- /dev/null +++ b/src/entities/post/model/store.ts @@ -0,0 +1,29 @@ +import { atom } from "jotai" +import { PostTag } from "./types" + +// 게시물 목록 atom +export const postsAtom = atom([]) + +// 게시물 총 개수 atom +export const totalPostsAtom = atom(0) + +// 검색어 atom +export const searchQueryAtom = atom("") + +// 페이지네이션 skip atom +export const skipAtom = atom(0) + +// 페이지네이션 limit atom +export const limitAtom = atom(10) + +// 선택된 게시물 atom +export const selectedPostAtom = atom(null) + +// 게시물 수정 다이얼로그 표시 여부 atom +export const showEditDialogAtom = atom(false) + +// 게시물 추가 다이얼로그 표시 여부 atom +export const showAddDialogAtom = atom(false) + +// 새 게시물 atom +export const newPostAtom = atom({ title: "", body: "", userId: 1 }) diff --git a/src/entities/post/model/types.ts b/src/entities/post/model/types.ts new file mode 100644 index 000000000..b4e6e21e4 --- /dev/null +++ b/src/entities/post/model/types.ts @@ -0,0 +1,18 @@ +// 게시물(Post) 타입 정의 + +export interface Post { + id: number + title: string + body: string + userId: number + tags?: string[] + reactions?: number + // author 필드는 API에서 join해서 넘길 때만 사용 (옵션) + author?: { + id: number + username: string + image?: string + } + createdAt?: string + updatedAt?: string +} diff --git a/src/entities/post/ui/PostAddDialog.tsx b/src/entities/post/ui/PostAddDialog.tsx new file mode 100644 index 000000000..d7ade5c61 --- /dev/null +++ b/src/entities/post/ui/PostAddDialog.tsx @@ -0,0 +1,37 @@ +import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, Input, Textarea } from "../../../widgets/ui" +import { usePost } from "../hooks/usePost" + +// 게시물 테이블 렌더링 +export const PostAddDialog = () => { + const { newPost, setNewPost, showAddDialog, setShowAddDialog, addPost } = usePost() + + return ( + + + + 새 게시물 추가 + +
+ setNewPost({ ...newPost, title: e.target.value })} + /> +