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 (
+
+ )
+}
diff --git a/src/entities/post/ui/PostEditDialog.tsx b/src/entities/post/ui/PostEditDialog.tsx
new file mode 100644
index 000000000..ba000a42d
--- /dev/null
+++ b/src/entities/post/ui/PostEditDialog.tsx
@@ -0,0 +1,31 @@
+import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, Input, Textarea } from "../../../widgets/ui"
+import { usePost } from "../hooks/usePost"
+
+// 게시물 테이블 렌더링
+export const PostEditDialog = () => {
+ const { showEditDialog, setShowEditDialog, selectedPost, setSelectedPost, updatePost } = usePost()
+
+ return (
+
+ )
+}
diff --git a/src/entities/post/ui/PostTable.tsx b/src/entities/post/ui/PostTable.tsx
new file mode 100644
index 000000000..6831da73f
--- /dev/null
+++ b/src/entities/post/ui/PostTable.tsx
@@ -0,0 +1,89 @@
+import { highlightText } from "../../../shared/lib/highlight-text"
+import { Button, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../../widgets/ui"
+import { Edit2, MessageSquare, ThumbsDown, ThumbsUp, Trash2 } from "lucide-react"
+import { usePost } from "../hooks/usePost"
+
+// 게시물 테이블 렌더링
+export const PostTable = (props) => {
+ const { posts, selectedTag, setSelectedTag, searchQuery, setSelectedPost, setShowEditDialog, deletePost } = usePost()
+ const { updateURL, openUserModal, openPostDetail } = props
+
+ return (
+
+
+
+ ID
+ 제목
+ 작성자
+ 반응
+ 작업
+
+
+
+ {posts.map((post) => (
+
+ {post.id}
+
+
+
{highlightText(post.title, searchQuery)}
+
+
+ {post.tags?.map((tag) => (
+ {
+ setSelectedTag(tag)
+ updateURL()
+ }}
+ >
+ {tag}
+
+ ))}
+
+
+
+
+ openUserModal(post.author)}>
+

+
{post.author?.username}
+
+
+
+
+
+ {post.reactions?.likes || 0}
+
+ {post.reactions?.dislikes || 0}
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ )
+}
diff --git a/src/entities/user/api/user-api.ts b/src/entities/user/api/user-api.ts
new file mode 100644
index 000000000..888fef48d
--- /dev/null
+++ b/src/entities/user/api/user-api.ts
@@ -0,0 +1,8 @@
+// 게시물 목록을 가져오는 API 함수
+export const getUserApi = 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/user/hooks/useUser.ts b/src/entities/user/hooks/useUser.ts
new file mode 100644
index 000000000..f4b041141
--- /dev/null
+++ b/src/entities/user/hooks/useUser.ts
@@ -0,0 +1,22 @@
+import { showUserModalAtom, selectedUserAtom } from "../model/store"
+import { useAtom } from "jotai"
+import { SelectedUser, User } from "../model/types"
+
+export const useUser = () => {
+ const [showUserModal, setShowUserModal] = useAtom(showUserModalAtom)
+ const [selectedUser, setSelectedUser] = useAtom(selectedUserAtom)
+
+ // 사용자 모달 열기
+ const openUserModal = async (user: User) => {
+ try {
+ const response = await fetch(`/api/users/${user.id}`)
+ const userData = await response.json()
+ setSelectedUser(userData)
+ setShowUserModal(true)
+ } catch (error) {
+ console.error("사용자 정보 가져오기 오류:", error)
+ }
+ }
+
+ return { showUserModal, setShowUserModal, selectedUser, setSelectedUser, openUserModal }
+}
diff --git a/src/entities/user/model/store.ts b/src/entities/user/model/store.ts
new file mode 100644
index 000000000..e8d850be4
--- /dev/null
+++ b/src/entities/user/model/store.ts
@@ -0,0 +1,8 @@
+import { atom } from "jotai"
+import { SelectedUser } from "./types"
+
+// 사용자 정보 모달 상태 atom
+export const showUserModalAtom = atom(false)
+
+// 선택된 사용자 정보 atom
+export const selectedUserAtom = atom(null)
diff --git a/src/entities/user/model/types.ts b/src/entities/user/model/types.ts
new file mode 100644
index 000000000..448da7ba9
--- /dev/null
+++ b/src/entities/user/model/types.ts
@@ -0,0 +1,28 @@
+// 사용자 정보 타입 정의
+
+export interface UserAddress {
+ address: string
+ city: string
+ state: string
+}
+
+export interface UserCompany {
+ name: string
+ title: string
+}
+
+export interface User {
+ id: number
+ username: string
+ firstName: string
+ lastName: string
+ age: number
+ email: string
+ phone: string
+ image: string
+ address?: UserAddress
+ company?: UserCompany
+}
+
+// selectedUser 타입
+export type SelectedUser = User | null
diff --git a/src/entities/user/ui/UserInfoDialog.tsx b/src/entities/user/ui/UserInfoDialog.tsx
new file mode 100644
index 000000000..de15c39fd
--- /dev/null
+++ b/src/entities/user/ui/UserInfoDialog.tsx
@@ -0,0 +1,42 @@
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../../../widgets/ui"
+import { useUser } from "../hooks/useUser"
+
+// 게시물 테이블 렌더링
+export const UserInfoDialog = () => {
+ const { showUserModal, setShowUserModal, selectedUser } = useUser()
+
+ return (
+
+ )
+}
diff --git a/src/features/post/comment/api/comment-api.ts b/src/features/post/comment/api/comment-api.ts
new file mode 100644
index 000000000..cdad458b1
--- /dev/null
+++ b/src/features/post/comment/api/comment-api.ts
@@ -0,0 +1,60 @@
+import { Comment } from "../model/types"
+
+// 특정 게시물의 댓글을 가져오는 API 함수
+export const getCommentsByPostIdApi = async (postId: number) => {
+ const response = await fetch(`/api/comments/post/${postId}`)
+ if (!response.ok) {
+ throw new Error("댓글을 가져오는데 실패했습니다.")
+ }
+ return response
+}
+
+// 댓글 추가 API 함수
+export const addCommentApi = async (newComment: Comment) => {
+ const response = await fetch("/api/comments/add", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(newComment),
+ })
+ if (!response.ok) {
+ throw new Error("댓글 추가에 실패했습니다.")
+ }
+ return response
+}
+
+// 댓글 수정 API 함수
+export const updateCommentApi = async (comment: Comment) => {
+ const response = await fetch(`/api/comments/${comment.id}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ body: comment.body }),
+ })
+ if (!response.ok) {
+ throw new Error("댓글 수정에 실패했습니다.")
+ }
+ return response
+}
+
+// 댓글 삭제 API 함수
+export const deleteCommentApi = async (id: string) => {
+ const response = await fetch(`/api/comments/${id}`, {
+ method: "DELETE",
+ })
+ if (!response.ok) {
+ throw new Error("댓글 삭제에 실패했습니다.")
+ }
+ return response
+}
+
+// 댓글 좋아요 API 함수
+export const likeCommentApi = async (id: number, likes: number) => {
+ const response = await fetch(`/api/comments/${id}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ likes }),
+ })
+ if (!response.ok) {
+ throw new Error("댓글 좋아요에 실패했습니다.")
+ }
+ return response
+}
diff --git a/src/features/post/comment/hooks/useComment.ts b/src/features/post/comment/hooks/useComment.ts
new file mode 100644
index 000000000..6ef464315
--- /dev/null
+++ b/src/features/post/comment/hooks/useComment.ts
@@ -0,0 +1,127 @@
+import { useState } from "react"
+import {
+ addCommentApi,
+ deleteCommentApi,
+ getCommentsByPostIdApi,
+ likeCommentApi,
+ updateCommentApi,
+} from "../api/comment-api"
+import { loadingAtom } from "../../../../shared/model/store"
+import {
+ commentsAtom,
+ selectedCommentAtom,
+ newCommentAtom,
+ showAddCommentDialogAtom,
+ showEditCommentDialogAtom,
+} from "../model/store"
+import { useAtom } from "jotai"
+
+export const useComment = () => {
+ const [loading, setLoading] = useAtom(loadingAtom)
+
+ const [comments, setComments] = useAtom(commentsAtom)
+ const [selectedComment, setSelectedComment] = useAtom(selectedCommentAtom)
+ const [newComment, setNewComment] = useAtom(newCommentAtom)
+ const [showAddCommentDialog, setShowAddCommentDialog] = useAtom(showAddCommentDialogAtom)
+ const [showEditCommentDialog, setShowEditCommentDialog] = useAtom(showEditCommentDialogAtom)
+
+ // 댓글 가져오기
+ const fetchComments = async (postId) => {
+ if (comments[postId]) return
+ setLoading(true)
+ try {
+ const response = await getCommentsByPostIdApi(postId)
+ const data = await response.json()
+ setComments((prev) => ({ ...prev, [postId]: data.comments }))
+ } catch (error) {
+ console.error("댓글 가져오기 오류:", error)
+ }
+ setLoading(false)
+ }
+
+ // 댓글 추가
+ const addComment = async () => {
+ setLoading(true)
+ try {
+ const response = await addCommentApi(newComment)
+ const data = await response.json()
+ setComments((prev) => ({
+ ...prev,
+ [data.postId]: [...(prev[data.postId] || []), data],
+ }))
+ setShowAddCommentDialog(false)
+ setNewComment({ body: "", postId: null, userId: 1 })
+ } catch (error) {
+ console.error("댓글 추가 오류:", error)
+ }
+ setLoading(false)
+ }
+
+ // 댓글 수정
+ const updateComment = async () => {
+ setLoading(true)
+ try {
+ const response = await updateCommentApi(selectedComment)
+ const data = await response.json()
+ setComments((prev) => ({
+ ...prev,
+ [data.postId]: prev[data.postId].map((comment) => (comment.id === data.id ? data : comment)),
+ }))
+ setShowEditCommentDialog(false)
+ } catch (error) {
+ console.error("댓글 수정 오류:", error)
+ }
+ setLoading(false)
+ }
+
+ // 댓글 삭제
+ const deleteComment = async (id, postId) => {
+ setLoading(true)
+ try {
+ await deleteCommentApi(id)
+ setComments((prev) => ({
+ ...prev,
+ [postId]: prev[postId].filter((comment) => comment.id !== id),
+ }))
+ } catch (error) {
+ console.error("댓글 삭제 오류:", error)
+ }
+ setLoading(false)
+ }
+
+ // 댓글 좋아요
+ const likeComment = async (id, postId) => {
+ setLoading(true)
+ try {
+ const currentLikes = Array.isArray(comments[postId]) ? (comments[postId].find((c) => c.id === id)?.likes ?? 0) : 0
+ const response = await likeCommentApi(id, currentLikes + 1)
+ const data = await response.json()
+ setComments((prev) => ({
+ ...prev,
+ [postId]: prev[postId].map((comment) =>
+ comment.id === data.id ? { ...data, likes: comment.likes + 1 } : comment,
+ ),
+ }))
+ } catch (error) {
+ console.error("댓글 좋아요 오류:", error)
+ }
+ setLoading(false)
+ }
+
+ return {
+ comments,
+ selectedComment,
+ setSelectedComment,
+ newComment,
+ setNewComment,
+ showAddCommentDialog,
+ setShowAddCommentDialog,
+ showEditCommentDialog,
+ setShowEditCommentDialog,
+ fetchComments,
+ addComment,
+ updateComment,
+ deleteComment,
+ likeComment,
+ }
+}
diff --git a/src/features/post/comment/model/store.ts b/src/features/post/comment/model/store.ts
new file mode 100644
index 000000000..8e9388dfa
--- /dev/null
+++ b/src/features/post/comment/model/store.ts
@@ -0,0 +1,16 @@
+import { atom } from "jotai"
+
+// 댓글 목록을 저장하는 atom
+export const commentsAtom = atom({})
+
+// 선택된 댓글을 저장하는 atom
+export const selectedCommentAtom = atom(null)
+
+// 새 댓글 정보를 저장하는 atom
+export const newCommentAtom = atom({ body: "", postId: null, userId: 1 })
+
+// 댓글 추가 다이얼로그 표시 여부 atom
+export const showAddCommentDialogAtom = atom(false)
+
+// 댓글 수정 다이얼로그 표시 여부 atom
+export const showEditCommentDialogAtom = atom(false)
diff --git a/src/features/post/comment/model/types.ts b/src/features/post/comment/model/types.ts
new file mode 100644
index 000000000..9abf1dc7d
--- /dev/null
+++ b/src/features/post/comment/model/types.ts
@@ -0,0 +1,17 @@
+// 댓글(Comment) 타입 정의
+
+export interface Comment {
+ id: number
+ postId: number
+ userId: number
+ body: string
+ likes?: number
+ // author 필드는 API에서 join해서 넘길 때만 사용 (옵션)
+ author?: {
+ id: number
+ username: string
+ image?: string
+ }
+ createdAt?: string
+ updatedAt?: string
+}
diff --git a/src/features/post/comment/ui/CommentAddDialog.tsx b/src/features/post/comment/ui/CommentAddDialog.tsx
new file mode 100644
index 000000000..a39a9cdb0
--- /dev/null
+++ b/src/features/post/comment/ui/CommentAddDialog.tsx
@@ -0,0 +1,25 @@
+import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, Textarea } from "../../../widgets/ui"
+import { useComment } from "../../../features/post/comment/hooks/useComment"
+
+// 게시물 테이블 렌더링
+export const CommentAddDialog = () => {
+ const { newComment, setNewComment, showAddCommentDialog, setShowAddCommentDialog, addComment } = useComment()
+
+ return (
+
+ )
+}
diff --git a/src/features/post/comment/ui/CommentEditDialog.tsx b/src/features/post/comment/ui/CommentEditDialog.tsx
new file mode 100644
index 000000000..ed9aa34a4
--- /dev/null
+++ b/src/features/post/comment/ui/CommentEditDialog.tsx
@@ -0,0 +1,26 @@
+import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, Input, Textarea } from "../../../widgets/ui"
+import { useComment } from "../../../features/post/comment/hooks/useComment"
+
+// 게시물 테이블 렌더링
+export const CommentEditDialog = () => {
+ const { selectedComment, setSelectedComment, showEditCommentDialog, setShowEditCommentDialog, updateComment } =
+ useComment()
+
+ return (
+
+ )
+}
diff --git a/src/features/post/comment/ui/CommentListDialog.tsx b/src/features/post/comment/ui/CommentListDialog.tsx
new file mode 100644
index 000000000..ebac6b86f
--- /dev/null
+++ b/src/features/post/comment/ui/CommentListDialog.tsx
@@ -0,0 +1,76 @@
+import { highlightText } from "../../../shared/lib/highlight-text"
+import { Button, Dialog, DialogContent, DialogHeader, DialogTitle } from "../../../widgets/ui"
+import { Edit2, Plus, ThumbsUp, Trash2 } from "lucide-react"
+import { useComment } from "../../../features/post/comment/hooks/useComment"
+
+// 게시물 테이블 렌더링
+export const CommentListDialog = (props) => {
+ const {
+ comments,
+ setSelectedComment,
+ setNewComment,
+ setShowAddCommentDialog,
+ setShowEditCommentDialog,
+ deleteComment,
+ likeComment,
+ } = useComment()
+
+ const { showPostDetailDialog, setShowPostDetailDialog, selectedPost, searchQuery } = props
+
+ return (
+
+ )
+}
diff --git a/src/features/post/tag/api/tag-api.ts b/src/features/post/tag/api/tag-api.ts
new file mode 100644
index 000000000..1a17582f1
--- /dev/null
+++ b/src/features/post/tag/api/tag-api.ts
@@ -0,0 +1,16 @@
+export const getTagApi = async () => {
+ const response = await fetch("/api/posts/tags")
+ if (!response.ok) {
+ throw new Error("태그 추가에 실패했습니다.")
+ }
+ return response
+}
+
+// 특정 태그로 게시물 목록을 가져오는 API 함수
+export const getPostsByTagApi = async (tag: string) => {
+ const response = await fetch(`/api/posts/tag/${tag}`)
+ if (!response.ok) {
+ throw new Error("해당 태그의 게시물 목록을 가져오는데 실패했습니다.")
+ }
+ return response
+}
diff --git a/src/features/post/tag/hooks/useTag.ts b/src/features/post/tag/hooks/useTag.ts
new file mode 100644
index 000000000..a93f3b143
--- /dev/null
+++ b/src/features/post/tag/hooks/useTag.ts
@@ -0,0 +1,55 @@
+import { getTagApi, getPostsByTagApi } from "../api/tag-api"
+import { loadingAtom } from "../../../../shared/model/store"
+import { tagsAtom, selectedTagAtom } from "../model/store"
+import { useAtom } from "jotai"
+import { limitAtom, postsAtom, skipAtom, totalPostsAtom } from "../../../../entities/post/model/store"
+import { PostTag } from "../model/types"
+import { getUserApi } from "../../../../entities/user/api/user-api"
+
+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 [skip, setSkip] = useAtom(skipAtom)
+ const [limit, setLimit] = useAtom(limitAtom)
+
+ // 태그 목록 가져오기
+ const fetchTags = async () => {
+ try {
+ const response = await getTagApi()
+ const data = await response.json()
+ setTags(data)
+ } catch (error) {
+ console.error("태그 가져오기 오류:", error)
+ }
+ }
+
+ // 태그별 게시물 가져오기
+ const fetchPostsByTag = async (tag: PostTag) => {
+ 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)
+ }
+
+ return {
+ tags,
+ selectedTag,
+ setSelectedTag,
+ fetchPostsByTag,
+ fetchTags,
+ }
+}
diff --git a/src/features/post/tag/model/store.ts b/src/features/post/tag/model/store.ts
new file mode 100644
index 000000000..2f9d2fe44
--- /dev/null
+++ b/src/features/post/tag/model/store.ts
@@ -0,0 +1,8 @@
+import { atom } from "jotai"
+import { PostTag } from "./types"
+
+// 태그 목록 atom
+export const tagsAtom = atom([])
+
+// 선택된 태그 atom
+export const selectedTagAtom = atom("")
diff --git a/src/features/post/tag/model/types.ts b/src/features/post/tag/model/types.ts
new file mode 100644
index 000000000..fd56ba15d
--- /dev/null
+++ b/src/features/post/tag/model/types.ts
@@ -0,0 +1,5 @@
+// 게시물 태그 타입 정의
+export interface PostTag {
+ url: string
+ slug: string
+}
diff --git a/src/pages/PostsManagerPage.tsx b/src/pages/PostsManagerPage.tsx
index f80eb91ef..2f89e18b5 100644
--- a/src/pages/PostsManagerPage.tsx
+++ b/src/pages/PostsManagerPage.tsx
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"
-import { Edit2, MessageSquare, Plus, Search, ThumbsDown, ThumbsUp, Trash2 } from "lucide-react"
+import { Plus, Search } from "lucide-react"
import { useLocation, useNavigate } from "react-router-dom"
import {
Button,
@@ -7,53 +7,60 @@ import {
CardContent,
CardHeader,
CardTitle,
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
Input,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
- Textarea,
-} from "../components"
+} from "../widgets/ui"
+import { PostTable } from "../entities/post/ui/PostTable"
+import { PostAddDialog } from "../entities/post/ui/PostAddDialog"
+import { PostEditDialog } from "../entities/post/ui/PostEditDialog"
+import { CommentListDialog } from "../entities/comment/ui/CommentListDialog"
+import { CommentAddDialog } from "../entities/comment/ui/CommentAddDialog"
+import { CommentEditDialog } from "../entities/comment/ui/CommentEditDialog"
+import { UserInfoDialog } from "../entities/user/ui/UserInfoDialog"
+import { usePost } from "../entities/post/hooks/usePost"
+import { useComment } from "../features/post/comment/hooks/useComment"
+import { loadingAtom } from "../shared/model/store"
+import { useAtom } from "jotai"
+import { useUser } from "../entities/user/hooks/useUser"
const PostsManager = () => {
const navigate = useNavigate()
const location = useLocation()
const queryParams = new URLSearchParams(location.search)
- // 상태 관리
- const [posts, setPosts] = useState([])
- const [total, setTotal] = useState(0)
- const [skip, setSkip] = useState(parseInt(queryParams.get("skip") || "0"))
- const [limit, setLimit] = useState(parseInt(queryParams.get("limit") || "10"))
- const [searchQuery, setSearchQuery] = useState(queryParams.get("search") || "")
- const [selectedPost, setSelectedPost] = useState(null)
+ const [loading, setLoading] = useAtom(loadingAtom)
+
+ const {
+ total,
+ tags,
+ selectedTag,
+ setSelectedTag,
+ searchQuery,
+ setSearchQuery,
+ skip,
+ setSkip,
+ limit,
+ setLimit,
+ selectedPost,
+ setSelectedPost,
+ setShowAddDialog,
+ fetchPosts,
+ fetchPostsByTag,
+ searchPosts,
+ fetchTags,
+ } = usePost()
+
+ const { fetchComments } = useComment()
+
+ const { openUserModal } = useUser()
+
const [sortBy, setSortBy] = useState(queryParams.get("sortBy") || "")
const [sortOrder, setSortOrder] = useState(queryParams.get("sortOrder") || "asc")
- const [showAddDialog, setShowAddDialog] = useState(false)
- const [showEditDialog, setShowEditDialog] = useState(false)
- const [newPost, setNewPost] = useState({ title: "", body: "", userId: 1 })
- const [loading, setLoading] = useState(false)
- const [tags, setTags] = useState([])
- const [selectedTag, setSelectedTag] = useState(queryParams.get("tag") || "")
- const [comments, setComments] = useState({})
- const [selectedComment, setSelectedComment] = useState(null)
- const [newComment, setNewComment] = useState({ body: "", postId: null, userId: 1 })
- const [showAddCommentDialog, setShowAddCommentDialog] = useState(false)
- const [showEditCommentDialog, setShowEditCommentDialog] = useState(false)
const [showPostDetailDialog, setShowPostDetailDialog] = useState(false)
- const [showUserModal, setShowUserModal] = useState(false)
- const [selectedUser, setSelectedUser] = useState(null)
// URL 업데이트 함수
const updateURL = () => {
@@ -67,223 +74,6 @@ const PostsManager = () => {
navigate(`?${params.toString()}`)
}
- // 게시물 가져오기
- const fetchPosts = () => {
- setLoading(true)
- let postsData
- let usersData
-
- fetch(`/api/posts?limit=${limit}&skip=${skip}`)
- .then((response) => response.json())
- .then((data) => {
- postsData = data
- return fetch("/api/users?limit=0&select=username,image")
- })
- .then((response) => response.json())
- .then((users) => {
- usersData = users.users
- const postsWithUsers = postsData.posts.map((post) => ({
- ...post,
- author: usersData.find((user) => user.id === post.userId),
- }))
- setPosts(postsWithUsers)
- setTotal(postsData.total)
- })
- .catch((error) => {
- console.error("게시물 가져오기 오류:", error)
- })
- .finally(() => {
- setLoading(false)
- })
- }
-
- // 태그 가져오기
- const fetchTags = async () => {
- try {
- const response = await fetch("/api/posts/tags")
- const data = await response.json()
- setTags(data)
- } catch (error) {
- console.error("태그 가져오기 오류:", error)
- }
- }
-
- // 게시물 검색
- const searchPosts = async () => {
- if (!searchQuery) {
- fetchPosts()
- return
- }
- setLoading(true)
- try {
- const response = await fetch(`/api/posts/search?q=${searchQuery}`)
- const data = await response.json()
- setPosts(data.posts)
- setTotal(data.total)
- } catch (error) {
- console.error("게시물 검색 오류:", error)
- }
- setLoading(false)
- }
-
- // 태그별 게시물 가져오기
- const fetchPostsByTag = async (tag) => {
- if (!tag || tag === "all") {
- fetchPosts()
- return
- }
- setLoading(true)
- try {
- const [postsResponse, usersResponse] = await Promise.all([
- fetch(`/api/posts/tag/${tag}`),
- fetch("/api/users?limit=0&select=username,image"),
- ])
- 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 response = await fetch("/api/posts/add", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(newPost),
- })
- const data = await response.json()
- setPosts([data, ...posts])
- setShowAddDialog(false)
- setNewPost({ title: "", body: "", userId: 1 })
- } catch (error) {
- console.error("게시물 추가 오류:", error)
- }
- }
-
- // 게시물 업데이트
- const updatePost = async () => {
- try {
- const response = await fetch(`/api/posts/${selectedPost.id}`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(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 fetch(`/api/posts/${id}`, {
- method: "DELETE",
- })
- setPosts(posts.filter((post) => post.id !== id))
- } catch (error) {
- console.error("게시물 삭제 오류:", error)
- }
- }
-
- // 댓글 가져오기
- const fetchComments = async (postId) => {
- if (comments[postId]) return // 이미 불러온 댓글이 있으면 다시 불러오지 않음
- try {
- const response = await fetch(`/api/comments/post/${postId}`)
- const data = await response.json()
- setComments((prev) => ({ ...prev, [postId]: data.comments }))
- } catch (error) {
- console.error("댓글 가져오기 오류:", error)
- }
- }
-
- // 댓글 추가
- const addComment = async () => {
- try {
- const response = await fetch("/api/comments/add", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify(newComment),
- })
- const data = await response.json()
- setComments((prev) => ({
- ...prev,
- [data.postId]: [...(prev[data.postId] || []), data],
- }))
- setShowAddCommentDialog(false)
- setNewComment({ body: "", postId: null, userId: 1 })
- } catch (error) {
- console.error("댓글 추가 오류:", error)
- }
- }
-
- // 댓글 업데이트
- const updateComment = async () => {
- try {
- const response = await fetch(`/api/comments/${selectedComment.id}`, {
- method: "PUT",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ body: selectedComment.body }),
- })
- const data = await response.json()
- setComments((prev) => ({
- ...prev,
- [data.postId]: prev[data.postId].map((comment) => (comment.id === data.id ? data : comment)),
- }))
- setShowEditCommentDialog(false)
- } catch (error) {
- console.error("댓글 업데이트 오류:", error)
- }
- }
-
- // 댓글 삭제
- const deleteComment = async (id, postId) => {
- try {
- await fetch(`/api/comments/${id}`, {
- method: "DELETE",
- })
- setComments((prev) => ({
- ...prev,
- [postId]: prev[postId].filter((comment) => comment.id !== id),
- }))
- } catch (error) {
- console.error("댓글 삭제 오류:", error)
- }
- }
-
- // 댓글 좋아요
- const likeComment = async (id, postId) => {
- try {
-
- const response = await fetch(`/api/comments/${id}`, {
- method: "PATCH",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ likes: comments[postId].find((c) => c.id === id).likes + 1 }),
- })
- const data = await response.json()
- setComments((prev) => ({
- ...prev,
- [postId]: prev[postId].map((comment) => (comment.id === data.id ? {...data, likes: comment.likes + 1} : comment)),
- }))
- } catch (error) {
- console.error("댓글 좋아요 오류:", error)
- }
- }
-
// 게시물 상세 보기
const openPostDetail = (post) => {
setSelectedPost(post)
@@ -291,22 +81,6 @@ const PostsManager = () => {
setShowPostDetailDialog(true)
}
- // 사용자 모달 열기
- const openUserModal = async (user) => {
- try {
- const response = await fetch(`/api/users/${user.id}`)
- const userData = await response.json()
- setSelectedUser(userData)
- setShowUserModal(true)
- } catch (error) {
- console.error("사용자 정보 가져오기 오류:", error)
- }
- }
-
- useEffect(() => {
- fetchTags()
- }, [])
-
useEffect(() => {
if (selectedTag) {
fetchPostsByTag(selectedTag)
@@ -326,148 +100,9 @@ const PostsManager = () => {
setSelectedTag(params.get("tag") || "")
}, [location.search])
- // 하이라이트 함수 추가
- const highlightText = (text: string, highlight: string) => {
- if (!text) return null
- if (!highlight.trim()) {
- return {text}
- }
- const regex = new RegExp(`(${highlight})`, "gi")
- const parts = text.split(regex)
- return (
-
- {parts.map((part, i) => (regex.test(part) ? {part} : {part}))}
-
- )
- }
-
- // 게시물 테이블 렌더링
- const renderPostTable = () => (
-
-
-
- ID
- 제목
- 작성자
- 반응
- 작업
-
-
-
- {posts.map((post) => (
-
- {post.id}
-
-
-
{highlightText(post.title, searchQuery)}
-
-
- {post.tags?.map((tag) => (
- {
- setSelectedTag(tag)
- updateURL()
- }}
- >
- {tag}
-
- ))}
-
-
-
-
- openUserModal(post.author)}>
-

-
{post.author?.username}
-
-
-
-
-
- {post.reactions?.likes || 0}
-
- {post.reactions?.dislikes || 0}
-
-
-
-
-
-
-
-
-
-
- ))}
-
-
- )
-
- // 댓글 렌더링
- const renderComments = (postId) => (
-
-
-
댓글
-
-
-
- {comments[postId]?.map((comment) => (
-
-
- {comment.user.username}:
- {highlightText(comment.body, searchQuery)}
-
-
-
-
-
-
-
- ))}
-
-
- )
+ useEffect(() => {
+ fetchTags()
+ }, [])
return (
@@ -539,7 +174,11 @@ const PostsManager = () => {
{/* 게시물 테이블 */}
- {loading ? 로딩 중...
: renderPostTable()}
+ {!loading ? (
+
+ ) : (
+ 로딩 중...
+ )}
{/* 페이지네이션 */}
@@ -570,137 +209,27 @@ const PostsManager = () => {
{/* 게시물 추가 대화상자 */}
-
+
{/* 게시물 수정 대화상자 */}
-
+
+
+ {/* 게시물 상세 보기 대화상자 */}
+
{/* 댓글 추가 대화상자 */}
-
+
{/* 댓글 수정 대화상자 */}
-
-
- {/* 게시물 상세 보기 대화상자 */}
-
+
{/* 사용자 모달 */}
-
+
)
}
diff --git a/src/shared/lib/highlight-text.tsx b/src/shared/lib/highlight-text.tsx
new file mode 100644
index 000000000..7148da99b
--- /dev/null
+++ b/src/shared/lib/highlight-text.tsx
@@ -0,0 +1,35 @@
+import React from "react"
+
+/**
+ * 텍스트에서 특정 문자열을 찾아 하이라이트(mark 태그) 처리합니다.
+ * @param text - 하이라이트할 원본 텍스트
+ * @param highlight - 하이라이트할 문자열
+ * @returns React.ReactNode - 하이라이트된 텍스트 노드
+ */
+export const highlightText = (text: string, highlight: string): React.ReactNode => {
+ // 텍스트가 없거나 하이라이트할 문자열이 비어있으면 원본 텍스트를 그대로 반환합니다.
+ if (!text || !highlight.trim()) {
+ return
{text}
+ }
+
+ // 대소문자를 구분하지 않고 검색하기 위해 정규식을 생성합니다.
+ const regex = new RegExp(`(${highlight})`, "gi")
+ // 정규식의 캡처 그룹을 사용하여 텍스트를 나눕니다.
+ // 예: "Hello World"를 "o"로 나누면 ["Hell", "o", " W", "o", "rld"]가 됩니다.
+ const parts = text.split(regex)
+
+ // 나눠진 부분을 순회하며 하이라이트 처리합니다.
+ return (
+ <>
+ {parts.map((part, i) =>
+ regex.test(part) ? (
+ // 정규식에 일치하는 부분은
태그로 감싸 하이라이트합니다.
+ {part}
+ ) : (
+ // 일치하지 않는 부분은 일반 태그로 렌더링합니다.
+ {part}
+ ),
+ )}
+ >
+ )
+}
diff --git a/src/shared/model/store.ts b/src/shared/model/store.ts
new file mode 100644
index 000000000..b7c3cffda
--- /dev/null
+++ b/src/shared/model/store.ts
@@ -0,0 +1,3 @@
+import { atom } from "jotai"
+
+export const loadingAtom = atom(false)
diff --git a/src/components/Footer.tsx b/src/widgets/ui/Footer.tsx
similarity index 100%
rename from src/components/Footer.tsx
rename to src/widgets/ui/Footer.tsx
diff --git a/src/components/Header.tsx b/src/widgets/ui/Header.tsx
similarity index 100%
rename from src/components/Header.tsx
rename to src/widgets/ui/Header.tsx
diff --git a/src/components/index.tsx b/src/widgets/ui/index.tsx
similarity index 100%
rename from src/components/index.tsx
rename to src/widgets/ui/index.tsx