diff --git a/.env.development b/.env.development
new file mode 100644
index 000000000..14ea4ad49
--- /dev/null
+++ b/.env.development
@@ -0,0 +1 @@
+VITE_API_BASE_URL=/api
diff --git a/.env.production b/.env.production
new file mode 100644
index 000000000..5ac6043d1
--- /dev/null
+++ b/.env.production
@@ -0,0 +1 @@
+VITE_API_BASE_URL=https://dummyjson.com
diff --git a/.prettierrc b/.prettierrc
index d9ae6b1fb..cdd6f7227 100644
--- a/.prettierrc
+++ b/.prettierrc
@@ -1,9 +1,9 @@
{
- "semi": false,
+ "semi": true,
"printWidth": 120,
"tabWidth": 2,
"singleQuote": false,
"quoteProps": "consistent",
"trailingComma": "all",
"singleAttributePerLine": false
-}
\ No newline at end of file
+}
diff --git a/index.html b/index.html
index f89d6d351..684475041 100644
--- a/index.html
+++ b/index.html
@@ -2,13 +2,13 @@
-
+
Chapter 2-3. 관심사 분리와 폴더구조
-
+
diff --git a/package.json b/package.json
index e014c5272..435faaee5 100644
--- a/package.json
+++ b/package.json
@@ -5,13 +5,20 @@
"type": "module",
"scripts": {
"dev": "vite",
- "build": "tsc -b && vite build",
+ "build": "vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest",
- "coverage": "vitest run --coverage"
+ "coverage": "vitest run --coverage",
+ "postbuild": "cp dist/index.html dist/404.html",
+ "predeploy": "npm run build",
+ "deploy": "gh-pages -d dist"
},
"dependencies": {
+ "@tanstack/react-query": "^5.85.3",
+ "@tanstack/react-query-devtools": "^5.85.3",
+ "gh-pages": "^6.3.0",
+ "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..660271b48 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,6 +8,18 @@ importers:
.:
dependencies:
+ '@tanstack/react-query':
+ specifier: ^5.85.3
+ version: 5.85.3(react@19.1.1)
+ '@tanstack/react-query-devtools':
+ specifier: ^5.85.3
+ version: 5.85.3(@tanstack/react-query@5.85.3(react@19.1.1))(react@19.1.1)
+ gh-pages:
+ specifier: ^6.3.0
+ version: 6.3.0
+ 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 +115,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'}
@@ -930,6 +938,23 @@ packages:
cpu: [x64]
os: [win32]
+ '@tanstack/query-core@5.85.3':
+ resolution: {integrity: sha512-9Ne4USX83nHmRuEYs78LW+3lFEEO2hBDHu7mrdIgAFx5Zcrs7ker3n/i8p4kf6OgKExmaDN5oR0efRD7i2J0DQ==}
+
+ '@tanstack/query-devtools@5.84.0':
+ resolution: {integrity: sha512-fbF3n+z1rqhvd9EoGp5knHkv3p5B2Zml1yNRjh7sNXklngYI5RVIWUrUjZ1RIcEoscarUb0+bOvIs5x9dwzOXQ==}
+
+ '@tanstack/react-query-devtools@5.85.3':
+ resolution: {integrity: sha512-WSVweCE1Kh1BVvPDHAmLgGT+GGTJQ9+a7bVqzD+zUiUTht+salJjYm5nikpMNaHFPJV102TCYdvgHgBXtURRNg==}
+ peerDependencies:
+ '@tanstack/react-query': ^5.85.3
+ react: ^18 || ^19
+
+ '@tanstack/react-query@5.85.3':
+ resolution: {integrity: sha512-AqU8TvNh5GVIE8I+TUU0noryBRy7gOY0XhSayVXmOPll4UkZeLWKDwi0rtWOZbwLRCbyxorfJ5DIjDqE7GXpcQ==}
+ peerDependencies:
+ react: ^18 || ^19
+
'@testing-library/dom@10.4.0':
resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==}
engines: {node: '>=18'}
@@ -1179,10 +1204,17 @@ packages:
aria-query@5.3.0:
resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
+ array-union@2.1.0:
+ resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==}
+ engines: {node: '>=8'}
+
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
+ async@3.2.6:
+ resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==}
+
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
@@ -1260,6 +1292,13 @@ packages:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
+ commander@13.1.0:
+ resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
+ engines: {node: '>=18'}
+
+ commondir@1.0.1:
+ resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==}
+
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
@@ -1331,6 +1370,10 @@ packages:
detect-node-es@1.1.0:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
+ dir-glob@3.0.1:
+ resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==}
+ engines: {node: '>=8'}
+
dom-accessibility-api@0.5.16:
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
@@ -1344,6 +1387,9 @@ packages:
electron-to-chromium@1.5.45:
resolution: {integrity: sha512-vOzZS6uZwhhbkZbcRyiy99Wg+pYFV5hk+5YaECvx0+Z31NR3Tt5zS6dze2OepT6PCTzVzT0dIJItti+uAW5zmw==}
+ email-addresses@5.0.0:
+ resolution: {integrity: sha512-4OIPYlA6JXqtVn8zpHpGiI7vE6EQOAg16aGnDMIAlZVinnoZ8208tW1hAbjWydgN/4PLTT9q+O1K6AH/vALJGw==}
+
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
@@ -1379,6 +1425,10 @@ packages:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
+ escape-string-regexp@1.0.5:
+ resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==}
+ engines: {node: '>=0.8.0'}
+
escape-string-regexp@4.0.0:
resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
engines: {node: '>=10'}
@@ -1471,10 +1521,26 @@ packages:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'}
+ filename-reserved-regex@2.0.0:
+ resolution: {integrity: sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==}
+ engines: {node: '>=4'}
+
+ filenamify@4.3.0:
+ resolution: {integrity: sha512-hcFKyUG57yWGAzu1CMt/dPzYZuv+jAJUT85bL8mrXvNe6hWj6yEHEc4EdcgiA6Z3oi1/9wXJdZPXF2dZNgwgOg==}
+ engines: {node: '>=8'}
+
fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
+ find-cache-dir@3.3.2:
+ resolution: {integrity: sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==}
+ engines: {node: '>=8'}
+
+ find-up@4.1.0:
+ resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
+ engines: {node: '>=8'}
+
find-up@5.0.0:
resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==}
engines: {node: '>=10'}
@@ -1499,6 +1565,10 @@ packages:
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
engines: {node: '>= 6'}
+ fs-extra@11.3.1:
+ resolution: {integrity: sha512-eXvGGwZ5CL17ZSwHWd3bbgk7UUpF6IFHtP57NYYakPvHOs8GDgDe5KJI36jIJzDkJ6eJjuzRA8eBQb6SkKue0g==}
+ engines: {node: '>=14.14'}
+
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1527,6 +1597,11 @@ packages:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
+ gh-pages@6.3.0:
+ resolution: {integrity: sha512-Ot5lU6jK0Eb+sszG8pciXdjMXdBJ5wODvgjR+imihTqsUWF2K6dJ9HST55lgqcs8wWcw6o6wAsUzfcYRhJPXbA==}
+ engines: {node: '>=10'}
+ hasBin: true
+
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
@@ -1543,10 +1618,17 @@ packages:
resolution: {integrity: sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==}
engines: {node: '>=18'}
+ globby@11.1.0:
+ resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==}
+ engines: {node: '>=10'}
+
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
+ graceful-fs@4.2.11:
+ resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
+
graphemer@1.4.0:
resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
@@ -1634,6 +1716,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==}
@@ -1672,6 +1772,9 @@ packages:
engines: {node: '>=6'}
hasBin: true
+ jsonfile@6.2.0:
+ resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
+
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@@ -1679,6 +1782,10 @@ packages:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
+ locate-path@5.0.0:
+ resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
+ engines: {node: '>=8'}
+
locate-path@6.0.0:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'}
@@ -1713,6 +1820,10 @@ packages:
magic-string@0.30.17:
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
+ make-dir@3.1.0:
+ resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==}
+ engines: {node: '>=8'}
+
math-intrinsics@1.1.0:
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
engines: {node: '>= 0.4'}
@@ -1786,14 +1897,26 @@ packages:
outvariant@1.4.3:
resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==}
+ p-limit@2.3.0:
+ resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
+ engines: {node: '>=6'}
+
p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
+ p-locate@4.1.0:
+ resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
+ engines: {node: '>=8'}
+
p-locate@5.0.0:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
+ p-try@2.2.0:
+ resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
+ engines: {node: '>=6'}
+
parent-module@1.0.1:
resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==}
engines: {node: '>=6'}
@@ -1812,6 +1935,10 @@ packages:
path-to-regexp@6.3.0:
resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==}
+ path-type@4.0.0:
+ resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
+ engines: {node: '>=8'}
+
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@@ -1834,6 +1961,10 @@ packages:
resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==}
engines: {node: '>=12'}
+ pkg-dir@4.2.0:
+ resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==}
+ engines: {node: '>=8'}
+
postcss@8.5.6:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
@@ -2004,6 +2135,10 @@ packages:
resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==}
engines: {node: '>= 10'}
+ slash@3.0.0:
+ resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
+ engines: {node: '>=8'}
+
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -2040,6 +2175,10 @@ packages:
strip-literal@3.0.0:
resolution: {integrity: sha512-TcccoMhJOM3OebGhSBEmp3UZ2SfDMZUEBdRA/9ynfLi8yYajyWX3JiXArcJt4Umh4vISpspkQIY8ZZoCqjbviA==}
+ strip-outer@1.0.1:
+ resolution: {integrity: sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==}
+ engines: {node: '>=0.10.0'}
+
supports-color@7.2.0:
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
engines: {node: '>=8'}
@@ -2100,6 +2239,10 @@ packages:
resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==}
engines: {node: '>=18'}
+ trim-repeated@1.0.0:
+ resolution: {integrity: sha512-pkonvlKk8/ZuR0D5tLW8ljt5I8kmxp2XKymhepUeOdCEfKpZaktSArkLHZt76OB1ZvO9bssUsDty4SWhLvZpLg==}
+ engines: {node: '>=0.10.0'}
+
ts-api-utils@2.1.0:
resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==}
engines: {node: '>=18.12'}
@@ -2140,6 +2283,10 @@ packages:
resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==}
engines: {node: '>= 4.0.0'}
+ universalify@2.0.1:
+ resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
+ engines: {node: '>= 10.0.0'}
+
update-browserslist-db@1.1.1:
resolution: {integrity: sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==}
hasBin: true
@@ -2362,12 +2509,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
@@ -3081,9 +3222,24 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.46.2':
optional: true
+ '@tanstack/query-core@5.85.3': {}
+
+ '@tanstack/query-devtools@5.84.0': {}
+
+ '@tanstack/react-query-devtools@5.85.3(@tanstack/react-query@5.85.3(react@19.1.1))(react@19.1.1)':
+ dependencies:
+ '@tanstack/query-devtools': 5.84.0
+ '@tanstack/react-query': 5.85.3(react@19.1.1)
+ react: 19.1.1
+
+ '@tanstack/react-query@5.85.3(react@19.1.1)':
+ dependencies:
+ '@tanstack/query-core': 5.85.3
+ react: 19.1.1
+
'@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 +3503,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':
@@ -3393,8 +3549,12 @@ snapshots:
dependencies:
dequal: 2.0.3
+ array-union@2.1.0: {}
+
assertion-error@2.0.1: {}
+ async@3.2.6: {}
+
asynckit@0.4.0: {}
axios@1.11.0:
@@ -3477,6 +3637,10 @@ snapshots:
dependencies:
delayed-stream: 1.0.0
+ commander@13.1.0: {}
+
+ commondir@1.0.1: {}
+
concat-map@0.0.1: {}
convert-source-map@2.0.0: {}
@@ -3525,6 +3689,10 @@ snapshots:
detect-node-es@1.1.0: {}
+ dir-glob@3.0.1:
+ dependencies:
+ path-type: 4.0.0
+
dom-accessibility-api@0.5.16: {}
dom-accessibility-api@0.6.3: {}
@@ -3537,6 +3705,8 @@ snapshots:
electron-to-chromium@1.5.45: {}
+ email-addresses@5.0.0: {}
+
emoji-regex@8.0.0: {}
entities@4.5.0: {}
@@ -3588,6 +3758,8 @@ snapshots:
escalade@3.2.0: {}
+ escape-string-regexp@1.0.5: {}
+
escape-string-regexp@4.0.0: {}
eslint-plugin-react-hooks@5.2.0(eslint@9.33.0):
@@ -3697,10 +3869,29 @@ snapshots:
dependencies:
flat-cache: 4.0.1
+ filename-reserved-regex@2.0.0: {}
+
+ filenamify@4.3.0:
+ dependencies:
+ filename-reserved-regex: 2.0.0
+ strip-outer: 1.0.1
+ trim-repeated: 1.0.0
+
fill-range@7.1.1:
dependencies:
to-regex-range: 5.0.1
+ find-cache-dir@3.3.2:
+ dependencies:
+ commondir: 1.0.1
+ make-dir: 3.1.0
+ pkg-dir: 4.2.0
+
+ find-up@4.1.0:
+ dependencies:
+ locate-path: 5.0.0
+ path-exists: 4.0.0
+
find-up@5.0.0:
dependencies:
locate-path: 6.0.0
@@ -3723,6 +3914,12 @@ snapshots:
hasown: 2.0.2
mime-types: 2.1.35
+ fs-extra@11.3.1:
+ dependencies:
+ graceful-fs: 4.2.11
+ jsonfile: 6.2.0
+ universalify: 2.0.1
+
fsevents@2.3.3:
optional: true
@@ -3752,6 +3949,16 @@ snapshots:
dunder-proto: 1.0.1
es-object-atoms: 1.1.1
+ gh-pages@6.3.0:
+ dependencies:
+ async: 3.2.6
+ commander: 13.1.0
+ email-addresses: 5.0.0
+ filenamify: 4.3.0
+ find-cache-dir: 3.3.2
+ fs-extra: 11.3.1
+ globby: 11.1.0
+
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
@@ -3764,8 +3971,19 @@ snapshots:
globals@16.3.0: {}
+ globby@11.1.0:
+ dependencies:
+ array-union: 2.1.0
+ dir-glob: 3.0.1
+ fast-glob: 3.3.2
+ ignore: 5.3.2
+ merge2: 1.4.1
+ slash: 3.0.0
+
gopd@1.2.0: {}
+ graceful-fs@4.2.11: {}
+
graphemer@1.4.0: {}
graphql@16.9.0: {}
@@ -3835,6 +4053,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: {}
@@ -3880,6 +4105,12 @@ snapshots:
json5@2.2.3: {}
+ jsonfile@6.2.0:
+ dependencies:
+ universalify: 2.0.1
+ optionalDependencies:
+ graceful-fs: 4.2.11
+
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@@ -3889,6 +4120,10 @@ snapshots:
prelude-ls: 1.2.1
type-check: 0.4.0
+ locate-path@5.0.0:
+ dependencies:
+ p-locate: 4.1.0
+
locate-path@6.0.0:
dependencies:
p-locate: 5.0.0
@@ -3917,6 +4152,10 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.0
+ make-dir@3.1.0:
+ dependencies:
+ semver: 6.3.1
+
math-intrinsics@1.1.0: {}
merge2@1.4.1: {}
@@ -3992,14 +4231,24 @@ snapshots:
outvariant@1.4.3: {}
+ p-limit@2.3.0:
+ dependencies:
+ p-try: 2.2.0
+
p-limit@3.1.0:
dependencies:
yocto-queue: 0.1.0
+ p-locate@4.1.0:
+ dependencies:
+ p-limit: 2.3.0
+
p-locate@5.0.0:
dependencies:
p-limit: 3.1.0
+ p-try@2.2.0: {}
+
parent-module@1.0.1:
dependencies:
callsites: 3.1.0
@@ -4014,6 +4263,8 @@ snapshots:
path-to-regexp@6.3.0: {}
+ path-type@4.0.0: {}
+
pathe@2.0.3: {}
pathval@2.0.0: {}
@@ -4026,6 +4277,10 @@ snapshots:
picomatch@4.0.3: {}
+ pkg-dir@4.2.0:
+ dependencies:
+ find-up: 4.1.0
+
postcss@8.5.6:
dependencies:
nanoid: 3.3.11
@@ -4181,6 +4436,8 @@ snapshots:
mrmime: 2.0.0
totalist: 3.0.1
+ slash@3.0.0: {}
+
source-map-js@1.2.1: {}
stackback@0.0.2: {}
@@ -4211,6 +4468,10 @@ snapshots:
dependencies:
js-tokens: 9.0.1
+ strip-outer@1.0.1:
+ dependencies:
+ escape-string-regexp: 1.0.5
+
supports-color@7.2.0:
dependencies:
has-flag: 4.0.0
@@ -4261,6 +4522,10 @@ snapshots:
dependencies:
punycode: 2.3.1
+ trim-repeated@1.0.0:
+ dependencies:
+ escape-string-regexp: 1.0.5
+
ts-api-utils@2.1.0(typescript@5.9.2):
dependencies:
typescript: 5.9.2
@@ -4292,6 +4557,8 @@ snapshots:
universalify@0.2.0: {}
+ universalify@2.0.1: {}
+
update-browserslist-db@1.1.1(browserslist@4.24.2):
dependencies:
browserslist: 4.24.2
diff --git a/src/App.tsx b/src/App.tsx
deleted file mode 100644
index 0c0032aab..000000000
--- a/src/App.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import { BrowserRouter as Router } from "react-router-dom"
-import Header from "./components/Header.tsx"
-import Footer from "./components/Footer.tsx"
-import PostsManagerPage from "./pages/PostsManagerPage.tsx"
-
-const App = () => {
- return (
-
-
-
- )
-}
-
-export default App
diff --git a/src/index.css b/src/app/entry.css
similarity index 100%
rename from src/index.css
rename to src/app/entry.css
diff --git a/src/app/entry.tsx b/src/app/entry.tsx
new file mode 100644
index 000000000..8f291e570
--- /dev/null
+++ b/src/app/entry.tsx
@@ -0,0 +1,18 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import "./entry.css";
+import App from "./layout/BaseLayout.tsx";
+import { BrowserRouter as Router } from "react-router-dom";
+import { QueryProvider } from "./provider/QueryClientProvider";
+import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
+
+createRoot(document.getElementById("root")!).render(
+
+
+
+
+
+
+
+ ,
+);
diff --git a/src/app/layout/BaseLayout.tsx b/src/app/layout/BaseLayout.tsx
new file mode 100644
index 000000000..90b9efdb2
--- /dev/null
+++ b/src/app/layout/BaseLayout.tsx
@@ -0,0 +1,17 @@
+import Header from "../../shared/ui/Header.tsx";
+import Footer from "../../shared/ui/Footer.tsx";
+import PostsManagerPage from "../../pages/PostsManagerPage.tsx";
+
+const App = () => {
+ return (
+
+ );
+};
+
+export default App;
diff --git a/src/app/provider/QueryClientProvider.tsx b/src/app/provider/QueryClientProvider.tsx
new file mode 100644
index 000000000..89f6f97a3
--- /dev/null
+++ b/src/app/provider/QueryClientProvider.tsx
@@ -0,0 +1,6 @@
+import { QueryClientProvider } from "@tanstack/react-query";
+import { queryClient } from "../../shared/lib/react-query";
+
+export function QueryProvider({ children }: { children: React.ReactNode }) {
+ return {children};
+}
diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx
deleted file mode 100644
index 91af02f8c..000000000
--- a/src/components/Footer.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import React from 'react';
-
-const Footer: React.FC = () => {
- return (
-
- );
-};
-
-export default Footer;
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
deleted file mode 100644
index 63ecec168..000000000
--- a/src/components/Header.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import React from 'react';
-import { MessageSquare } from 'lucide-react';
-
-const Header: React.FC = () => {
- return (
-
-
-
-
-
게시물 관리 시스템
-
-
-
-
- );
-};
-
-export default Header;
-
diff --git a/src/components/index.tsx b/src/components/index.tsx
deleted file mode 100644
index 8495817d3..000000000
--- a/src/components/index.tsx
+++ /dev/null
@@ -1,214 +0,0 @@
-import * as React from "react"
-import { forwardRef } from "react"
-import * as SelectPrimitive from "@radix-ui/react-select"
-import * as DialogPrimitive from "@radix-ui/react-dialog"
-import { Check, ChevronDown, X } from "lucide-react"
-import { cva, VariantProps } from "class-variance-authority"
-
-const buttonVariants = cva(
- "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background",
- {
- variants: {
- variant: {
- default: "bg-blue-500 text-white hover:bg-blue-600",
- destructive: "bg-red-500 text-white hover:bg-red-600",
- outline: "border border-gray-300 bg-transparent text-gray-700 hover:bg-gray-100",
- secondary: "bg-gray-200 text-gray-800 hover:bg-gray-300",
- ghost: "bg-transparent text-gray-700 hover:bg-gray-100",
- link: "underline-offset-4 hover:underline text-blue-500",
- },
- size: {
- default: "h-10 py-2 px-4",
- sm: "h-8 px-3 rounded-md text-xs",
- lg: "h-11 px-8 rounded-md",
- icon: "h-9 w-9",
- },
- },
- defaultVariants: {
- variant: "default",
- size: "default",
- },
- },
-)
-
-interface ButtonProps extends React.ButtonHTMLAttributes, VariantProps {
- className?: string
-}
-
-export const Button = forwardRef(({ className, variant, size, ...props }, ref) => {
- return
-})
-
-Button.displayName = "Button"
-
-// 입력 컴포넌트
-export const Input = forwardRef(({ className, type, ...props }, ref) => {
- return (
-
- )
-})
-Input.displayName = "Input"
-
-// 카드 컴포넌트
-export const Card = forwardRef(({ className, ...props }, ref) => (
-
-))
-Card.displayName = "Card"
-
-export const CardHeader = forwardRef(({ className, ...props }, ref) => (
-
-))
-CardHeader.displayName = "CardHeader"
-
-export const CardTitle = forwardRef(({ className, ...props }, ref) => (
-
-))
-CardTitle.displayName = "CardTitle"
-
-export const CardContent = forwardRef(({ className, ...props }, ref) => (
-
-))
-CardContent.displayName = "CardContent"
-
-// 텍스트 영역 컴포넌트
-export const Textarea = forwardRef(({ className, ...props }, ref) => {
- return (
-
- )
-})
-Textarea.displayName = "Textarea"
-
-// 선택 컴포넌트
-export const Select = SelectPrimitive.Root
-export const SelectGroup = SelectPrimitive.Group
-export const SelectValue = SelectPrimitive.Value
-
-export const SelectTrigger = forwardRef(({ className, children, ...props }, ref) => (
-
- {children}
-
-
-))
-SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
-
-export const SelectContent = forwardRef(({ className, children, position = "popper", ...props }, ref) => (
-
-
- {children}
-
-
-))
-SelectContent.displayName = SelectPrimitive.Content.displayName
-
-export const SelectItem = forwardRef(({ className, children, ...props }, ref) => (
-
-
-
-
-
-
- {children}
-
-))
-SelectItem.displayName = SelectPrimitive.Item.displayName
-
-// 대화상자 컴포넌트
-export const Dialog = DialogPrimitive.Root
-export const DialogTrigger = DialogPrimitive.Trigger
-export const DialogPortal = DialogPrimitive.Portal
-export const DialogOverlay = DialogPrimitive.Overlay
-
-export const DialogContent = forwardRef(({ className, children, ...props }, ref) => (
-
-
-
- {children}
-
-
- 닫기
-
-
-
-))
-DialogContent.displayName = DialogPrimitive.Content.displayName
-
-export const DialogHeader = ({ className, ...props }) => (
-
-)
-DialogHeader.displayName = "DialogHeader"
-
-export const DialogTitle = forwardRef(({ className, ...props }, ref) => (
-
-))
-DialogTitle.displayName = DialogPrimitive.Title.displayName
-
-// 테이블 컴포넌트
-export const Table = forwardRef(({ className, ...props }, ref) => (
-
-))
-Table.displayName = "Table"
-
-export const TableHeader = forwardRef(({ className, ...props }, ref) => (
-
-))
-TableHeader.displayName = "TableHeader"
-
-export const TableBody = forwardRef(({ className, ...props }, ref) => (
-
-))
-TableBody.displayName = "TableBody"
-
-export const TableRow = forwardRef(({ className, ...props }, ref) => (
-
-))
-TableRow.displayName = "TableRow"
-
-export const TableHead = forwardRef(({ className, ...props }, ref) => (
- |
-))
-TableHead.displayName = "TableHead"
-
-export const TableCell = forwardRef(({ className, ...props }, ref) => (
- |
-))
-TableCell.displayName = "TableCell"
diff --git a/src/entities/comment/models/api.ts b/src/entities/comment/models/api.ts
new file mode 100644
index 000000000..cdf742630
--- /dev/null
+++ b/src/entities/comment/models/api.ts
@@ -0,0 +1,52 @@
+import { api } from "../../../shared/api/client";
+import type { Comment, CommentsResponse } from "./types";
+
+interface GetCommentsParams {
+ postId: number;
+}
+export const getComments = async ({ postId }: GetCommentsParams): Promise => {
+ return api(`/comments/post/${postId}`);
+};
+
+interface AddCommentParams {
+ comment: Partial;
+}
+export const addComment = async ({ comment }: AddCommentParams): Promise => {
+ return api>(`/comments/add`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(comment),
+ });
+};
+
+interface UpdateCommentParams {
+ comment: Comment;
+}
+export const updateComment = async ({ comment }: UpdateCommentParams): Promise => {
+ return api(`/comments/${comment.id}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ body: comment.body }),
+ });
+};
+
+interface DeleteCommentParams {
+ id: number;
+}
+export const deleteComment = async ({ id }: DeleteCommentParams): Promise => {
+ return api(`/comments/${id}`, {
+ method: "DELETE",
+ });
+};
+
+interface LikeCommentParams {
+ id: number;
+ likes: number;
+}
+export const likeComment = async ({ id, likes }: LikeCommentParams): Promise => {
+ return api(`/comments/${id}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ likes }),
+ });
+};
diff --git a/src/entities/comment/models/queries.ts b/src/entities/comment/models/queries.ts
new file mode 100644
index 000000000..5dbe0cfa3
--- /dev/null
+++ b/src/entities/comment/models/queries.ts
@@ -0,0 +1,81 @@
+import { useCallback } from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { getComments, addComment, updateComment, deleteComment, likeComment } from "./api";
+import type { Comment, CommentsResponse } from "./types";
+import { commentsKeys } from "../../../shared/query-keys/comments";
+
+export function useCommentsByPostQuery(postId: number, enabled = true) {
+ return useQuery({
+ queryKey: commentsKeys.byPost(postId),
+ queryFn: () => getComments({ postId }),
+ enabled,
+ staleTime: 30_000,
+ gcTime: 5 * 60_000,
+ refetchOnWindowFocus: false,
+ retry: 1,
+ });
+}
+
+export function useEnsureCommentsByPost() {
+ const qc = useQueryClient();
+ return useCallback(
+ async (postId: number) => {
+ const data = await qc.ensureQueryData({
+ queryKey: commentsKeys.byPost(postId),
+ queryFn: () => getComments({ postId }),
+ });
+ return data;
+ },
+ [qc],
+ );
+}
+
+export function useAddCommentMutation() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: ({ comment }: { comment: Partial }) => addComment({ comment }),
+ onSuccess: (_data, vars) => {
+ const postId = Number(vars.comment.postId);
+ if (Number.isFinite(postId)) {
+ qc.invalidateQueries({ queryKey: commentsKeys.byPost(postId) });
+ } else {
+ qc.invalidateQueries({ queryKey: commentsKeys.all });
+ }
+ },
+ });
+}
+
+export function useUpdateCommentMutation() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: ({ comment }: { comment: Comment }) => updateComment({ comment }),
+ onSuccess: (data) => {
+ const postId = Number((data as { postId: number }).postId);
+ if (Number.isFinite(postId)) {
+ qc.invalidateQueries({ queryKey: commentsKeys.byPost(postId) });
+ } else {
+ qc.invalidateQueries({ queryKey: commentsKeys.all });
+ }
+ },
+ });
+}
+
+export function useDeleteCommentMutation() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: ({ id }: { id: number; postId: number }) => deleteComment({ id }),
+ onSuccess: (_d, vars) => {
+ qc.invalidateQueries({ queryKey: commentsKeys.byPost(vars.postId) });
+ },
+ });
+}
+
+export function useLikeCommentMutation() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: ({ id, likes }: { id: number; likes: number; postId: number }) => likeComment({ id, likes }),
+ onSuccess: (_d, vars) => {
+ qc.invalidateQueries({ queryKey: commentsKeys.byPost(vars.postId) });
+ },
+ });
+}
diff --git a/src/entities/comment/models/types.ts b/src/entities/comment/models/types.ts
new file mode 100644
index 000000000..e12b60ced
--- /dev/null
+++ b/src/entities/comment/models/types.ts
@@ -0,0 +1,23 @@
+interface CommentUser {
+ id: number;
+ username: string;
+ fullName: string;
+}
+
+interface Comment {
+ id: number;
+ body: string;
+ postId: number | null;
+ likes?: number;
+ user: CommentUser;
+ userId: number | null;
+}
+
+interface CommentsResponse {
+ comments: Comment[];
+ total: number;
+ skip: number;
+ limit: number;
+}
+
+export type { CommentUser, Comment, CommentsResponse };
diff --git a/src/entities/comment/ui/Comment.tsx b/src/entities/comment/ui/Comment.tsx
new file mode 100644
index 000000000..655156ca0
--- /dev/null
+++ b/src/entities/comment/ui/Comment.tsx
@@ -0,0 +1,41 @@
+import { Edit2, ThumbsUp, Trash2 } from "lucide-react";
+import { Button } from "../../../shared/ui";
+import { Comment as CommentType } from "../models/types";
+import { HighlightText } from "../../../shared/ui/HighlightText";
+
+export const Comment = ({
+ comment,
+ searchQuery,
+ handleLikeComment,
+ handleDeleteComment,
+ handleEditComment,
+}: {
+ comment: CommentType;
+ searchQuery: string;
+ handleLikeComment: () => void;
+ handleDeleteComment: () => void;
+ handleEditComment: () => void;
+}) => {
+ return (
+
+
+ {comment.user.username}:
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/src/entities/post/models/api.ts b/src/entities/post/models/api.ts
new file mode 100644
index 000000000..03f53b2dd
--- /dev/null
+++ b/src/entities/post/models/api.ts
@@ -0,0 +1,55 @@
+import { api } from "../../../shared/api/client";
+import type { Post, PostsResponse } from "./types";
+
+interface GetPostsParams {
+ limit: number;
+ skip: number;
+}
+export const getPosts = async ({ limit, skip }: GetPostsParams): Promise => {
+ return api(`/posts?limit=${limit}&skip=${skip}`);
+};
+
+interface GetPostBySearchParams {
+ search: string;
+}
+export const getPostBySearch = async ({ search }: GetPostBySearchParams): Promise => {
+ return api(`/posts/search?q=${search}`);
+};
+
+interface GetPostByTagParams {
+ tag: string;
+}
+export const getPostByTag = async ({ tag }: GetPostByTagParams): Promise => {
+ return api(`/posts/tag/${tag}`);
+};
+
+interface AddPostParams {
+ post: Partial;
+}
+export const addPost = async ({ post }: AddPostParams): Promise => {
+ return api(`/posts/add`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(post),
+ });
+};
+
+interface UpdatePostParams {
+ post: Post;
+}
+export const updatePost = async ({ post }: UpdatePostParams): Promise => {
+ return api(`/posts/${post.id}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(post),
+ });
+};
+
+interface DeletePostParams {
+ id: number;
+}
+export const deletePost = async ({ id }: DeletePostParams): Promise => {
+ return api(`/posts/${id}`, {
+ method: "DELETE",
+ });
+};
diff --git a/src/entities/post/models/queries.ts b/src/entities/post/models/queries.ts
new file mode 100644
index 000000000..b1527fbcd
--- /dev/null
+++ b/src/entities/post/models/queries.ts
@@ -0,0 +1,97 @@
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+import { postsKeys } from "../../../shared/query-keys/post";
+import { getPosts, getPostBySearch, getPostByTag, addPost, updatePost, deletePost } from "./api";
+import type { PageParams } from "../../../shared/query-keys/post";
+import type { Post, PostsResponse } from "./types";
+
+export function usePostsPageQuery(params: PageParams) {
+ return useQuery({
+ queryKey: postsKeys.page(params),
+ queryFn: () => getPosts(params),
+ placeholderData: (prev) => prev,
+ staleTime: 30_000,
+ gcTime: 5 * 60_000,
+ refetchOnWindowFocus: false,
+ retry: 1,
+ });
+}
+
+export function usePostsSearchQuery(search: string, enabled = true) {
+ return useQuery({
+ queryKey: postsKeys.search(search),
+ queryFn: () => getPostBySearch({ search }),
+ enabled: enabled && !!search,
+ staleTime: 30_000,
+ gcTime: 5 * 60_000,
+ });
+}
+
+export function usePostsByTagQuery(tag: string, enabled = true) {
+ return useQuery({
+ queryKey: postsKeys.tag(tag),
+ queryFn: () => getPostByTag({ tag }),
+ enabled: enabled && !!tag && tag !== "all",
+ staleTime: 30_000,
+ gcTime: 5 * 60_000,
+ });
+}
+
+function updateAllPostLists(qc: ReturnType, updater: (prev: PostsResponse) => PostsResponse) {
+ const lists = qc.getQueriesData({ queryKey: postsKeys.all });
+ lists.forEach(([key, prev]) => {
+ if (!prev) return;
+ qc.setQueryData(key, updater(prev));
+ });
+}
+
+export function useAddPostMutation() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: ({ post }: { post: Partial }) => addPost({ post }),
+ onSuccess: (created) => {
+ updateAllPostLists(qc, (prev) => ({
+ ...prev,
+ posts: [created as Post, ...(prev.posts ?? [])],
+ total: (prev.total ?? 0) + 1,
+ }));
+ },
+ });
+}
+
+export function useUpdatePostMutation() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: ({ post }: { post: Post }) => updatePost({ post }),
+ onSuccess: (updated) => {
+ updateAllPostLists(qc, (prev) => ({
+ ...prev,
+ posts: (prev.posts ?? []).map((p) => (p.id === (updated as Post).id ? (updated as Post) : p)),
+ }));
+ qc.setQueryData(postsKeys.detail((updated as Post).id), updated);
+ },
+ });
+}
+
+export function useDeletePostMutation() {
+ const qc = useQueryClient();
+ return useMutation({
+ mutationFn: ({ id }: { id: number }) => deletePost({ id }),
+ onMutate: async ({ id }) => {
+ await qc.cancelQueries({ queryKey: postsKeys.all });
+ const snapshots = qc.getQueriesData({ queryKey: postsKeys.all });
+ snapshots.forEach(([key, prev]) => {
+ if (!prev) return;
+ qc.setQueryData(key, {
+ ...prev,
+ posts: (prev.posts ?? []).filter((p) => p.id !== id),
+ total: Math.max(0, (prev.total ?? 0) - 1),
+ });
+ });
+ return { snapshots };
+ },
+ onError: (_e, _v, ctx) => {
+ ctx?.snapshots?.forEach(([key, data]) => qc.setQueryData(key, data));
+ },
+ onSettled: () => {},
+ });
+}
diff --git a/src/entities/post/models/types.ts b/src/entities/post/models/types.ts
new file mode 100644
index 000000000..93769b851
--- /dev/null
+++ b/src/entities/post/models/types.ts
@@ -0,0 +1,30 @@
+interface Reaction {
+ likes: number;
+ dislikes: number;
+}
+
+interface Author {
+ id: number;
+ username: string;
+ image: string;
+}
+
+interface Post {
+ id: number;
+ title: string;
+ body: string;
+ tags: string[];
+ reactions: Reaction;
+ views: number;
+ userId: number;
+ author?: Author;
+}
+
+interface PostsResponse {
+ posts: Post[];
+ total: number;
+ skip: number;
+ limit: number;
+}
+
+export type { Post, PostsResponse };
diff --git a/src/entities/post/ui/PostTable.tsx b/src/entities/post/ui/PostTable.tsx
new file mode 100644
index 000000000..efbf42842
--- /dev/null
+++ b/src/entities/post/ui/PostTable.tsx
@@ -0,0 +1,116 @@
+import { Edit2, MessageSquare, ThumbsDown, ThumbsUp, Trash2 } from "lucide-react";
+import { Button, Table, TableBody, TableCell, TableHead, TableRow } from "../../../shared/ui";
+import { TableHeader } from "../../../shared/ui";
+import { HighlightText } from "../../../shared/ui/HighlightText";
+import { User } from "../../user/models/types";
+import { Post } from "../models/types";
+
+interface PostTableProps {
+ posts: Post[];
+ searchQuery: string;
+ selectedTag: string;
+ setSelectedTag: (tag: string) => void;
+ openUserModal: (user: User) => void;
+ openPostDetail: (post: Post) => void;
+ handleDeletePost: (id: number) => void;
+ setSelectedPost: (post: Post) => void;
+ setShowEditDialog: (show: boolean) => void;
+ updateURL: () => void;
+}
+
+export const PostTable = ({
+ posts,
+ searchQuery,
+ selectedTag,
+ setSelectedTag,
+ openUserModal,
+ openPostDetail,
+ handleDeletePost,
+ setSelectedPost,
+ setShowEditDialog,
+ updateURL,
+}: PostTableProps) => {
+ return (
+
+
+
+ ID
+ 제목
+ 작성자
+ 반응
+ 작업
+
+
+
+ {posts.map((post) => (
+
+ {post.id}
+
+
+
+
+
+
+
+ {post.tags?.map((tag) => (
+ {
+ setSelectedTag(tag);
+ updateURL();
+ }}
+ >
+ {tag}
+
+ ))}
+
+
+
+
+ post.author && openUserModal(post.author)}
+ >
+

+
{post.author?.username}
+
+
+
+
+
+ {post.reactions?.likes || 0}
+
+ {post.reactions?.dislikes || 0}
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ );
+};
diff --git a/src/entities/tag/models/api.ts b/src/entities/tag/models/api.ts
new file mode 100644
index 000000000..bf6afaa01
--- /dev/null
+++ b/src/entities/tag/models/api.ts
@@ -0,0 +1,6 @@
+import { api } from "../../../shared/api/client";
+import type { Tag } from "./types";
+
+export const getTags = async (): Promise => {
+ return api(`/posts/tags`);
+};
diff --git a/src/entities/tag/models/index.ts b/src/entities/tag/models/index.ts
new file mode 100644
index 000000000..0f6beeab6
--- /dev/null
+++ b/src/entities/tag/models/index.ts
@@ -0,0 +1,11 @@
+import { useTagsQuery } from "./queries";
+
+export function useTags() {
+ const { data, isLoading, isError } = useTagsQuery();
+
+ return {
+ tags: data ?? [],
+ isLoading,
+ isError,
+ } as const;
+}
diff --git a/src/entities/tag/models/queries.ts b/src/entities/tag/models/queries.ts
new file mode 100644
index 000000000..806840aeb
--- /dev/null
+++ b/src/entities/tag/models/queries.ts
@@ -0,0 +1,25 @@
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import { getTags } from "./api";
+import { tagsKeys } from "../../../shared/query-keys/tags";
+import type { Tag } from "./types";
+
+export function useTagsQuery(enabled = true) {
+ return useQuery({
+ queryKey: tagsKeys.all,
+ queryFn: getTags,
+ enabled,
+ staleTime: 60 * 60 * 1000,
+ gcTime: 24 * 60 * 60 * 1000,
+ refetchOnWindowFocus: false,
+ retry: 1,
+ });
+}
+
+export function useEnsureTags() {
+ const qc = useQueryClient();
+ return () =>
+ qc.ensureQueryData({
+ queryKey: tagsKeys.all,
+ queryFn: getTags,
+ });
+}
diff --git a/src/entities/tag/models/types.ts b/src/entities/tag/models/types.ts
new file mode 100644
index 000000000..0d066ec88
--- /dev/null
+++ b/src/entities/tag/models/types.ts
@@ -0,0 +1,7 @@
+interface Tag {
+ name: string;
+ slug: string;
+ url: string;
+}
+
+export type { Tag };
diff --git a/src/entities/user/models/api.ts b/src/entities/user/models/api.ts
new file mode 100644
index 000000000..3c68d8e8d
--- /dev/null
+++ b/src/entities/user/models/api.ts
@@ -0,0 +1,13 @@
+import { api } from "../../../shared/api/client";
+import type { AllUsersResponse, UserResponse } from "./types";
+
+interface GetUserParams {
+ id: number;
+}
+export const getUser = async ({ id }: GetUserParams): Promise => {
+ return api(`/users/${id}`);
+};
+
+export const getAllUsers = async (): Promise => {
+ return api(`/users?limit=0&select=username,image`);
+};
diff --git a/src/entities/user/models/queries.ts b/src/entities/user/models/queries.ts
new file mode 100644
index 000000000..e16368fd4
--- /dev/null
+++ b/src/entities/user/models/queries.ts
@@ -0,0 +1,12 @@
+import { useQuery } from "@tanstack/react-query";
+import { getAllUsers } from "./api";
+import { usersKeys } from "../../../shared/query-keys/users";
+
+export function useUsersQuery() {
+ return useQuery({
+ queryKey: usersKeys.all,
+ queryFn: () => getAllUsers(),
+ staleTime: 5 * 60_000,
+ gcTime: 10 * 60_000,
+ });
+}
diff --git a/src/entities/user/models/types.ts b/src/entities/user/models/types.ts
new file mode 100644
index 000000000..9cac51b6f
--- /dev/null
+++ b/src/entities/user/models/types.ts
@@ -0,0 +1,86 @@
+type User = {
+ id: number;
+ image: string;
+ username: string;
+};
+
+interface AllUsersResponse {
+ users: User[];
+ limit: number;
+ skip: number;
+ total: number;
+}
+
+type Coordinates = {
+ lat: number;
+ lng: number;
+};
+
+type Address = {
+ address: string;
+ city: string;
+ state: string;
+ stateCode: string;
+ postalCode: string;
+ coordinates: Coordinates;
+ country: string;
+};
+
+type Hair = {
+ color: string;
+ type: string;
+};
+
+type Bank = {
+ cardExpire: string;
+ cardNumber: string;
+ cardType: string;
+ currency: string;
+ iban: string;
+};
+
+type Company = {
+ department: string;
+ name: string;
+ title: string;
+ address: Address;
+};
+
+type Crypto = {
+ coin: string;
+ wallet: string;
+ network: string;
+};
+
+interface UserResponse {
+ id: number;
+ firstName: string;
+ lastName: string;
+ maidenName: string;
+ age: number;
+ gender: string;
+ email: string;
+ phone: string;
+ username: string;
+ password: string;
+ birthDate: string; // YYYY-M-D 형식
+ image: string;
+ bloodGroup: string;
+ height: number;
+ weight: number;
+ eyeColor: string;
+ hair: Hair;
+ ip: string;
+ address: Address;
+ macAddress: string;
+ university: string;
+ bank: Bank;
+ company: Company;
+ ein: string;
+ ssn: string;
+ userAgent: string;
+ crypto: Crypto;
+ role: string;
+}
+
+export type { User, AllUsersResponse, UserResponse };
diff --git a/src/entities/user/ui/UserModal.tsx b/src/entities/user/ui/UserModal.tsx
new file mode 100644
index 000000000..3a270e4c7
--- /dev/null
+++ b/src/entities/user/ui/UserModal.tsx
@@ -0,0 +1,31 @@
+import { UserResponse } from "../models/types";
+
+export const UserInfo = ({ selectedUser }: { selectedUser: UserResponse | null }) => {
+ return (
+
+

+
{selectedUser?.username}
+
+
+ 이름: {selectedUser?.firstName} {selectedUser?.lastName}
+
+
+ 나이: {selectedUser?.age}
+
+
+ 이메일: {selectedUser?.email}
+
+
+ 전화번호: {selectedUser?.phone}
+
+
+ 주소: {selectedUser?.address?.address}, {selectedUser?.address?.city},{" "}
+ {selectedUser?.address?.state}
+
+
+ 직장: {selectedUser?.company?.name} - {selectedUser?.company?.title}
+
+
+
+ );
+};
diff --git a/src/features/comment/models/dialog.atoms.ts b/src/features/comment/models/dialog.atoms.ts
new file mode 100644
index 000000000..79b5dac3e
--- /dev/null
+++ b/src/features/comment/models/dialog.atoms.ts
@@ -0,0 +1,4 @@
+import { atom } from "jotai";
+
+export const showAddCommentDialogAtom = atom(false);
+export const showEditCommentDialogAtom = atom(false);
diff --git a/src/features/comment/models/useComment.tsx b/src/features/comment/models/useComment.tsx
new file mode 100644
index 000000000..c563671b7
--- /dev/null
+++ b/src/features/comment/models/useComment.tsx
@@ -0,0 +1,153 @@
+import { useCallback, useState } from "react";
+import type { Comment as CommentType } from "../../../entities/comment/models/types";
+
+import {
+ useEnsureCommentsByPost,
+ useAddCommentMutation,
+ useUpdateCommentMutation,
+ useDeleteCommentMutation,
+ useLikeCommentMutation,
+} from "../../../entities/comment/models/queries";
+
+type PostId = number;
+type CommentId = number;
+type CommentsState = Record;
+type UIOptions = {
+ newComment: Partial;
+ setNewComment: React.Dispatch>>;
+ selectedComment: CommentType | null;
+
+ setShowAddCommentDialog?: (open: boolean) => void;
+ setShowEditCommentDialog?: (open: boolean) => void;
+};
+
+export function useComments({
+ newComment,
+ setNewComment,
+ selectedComment,
+ setShowAddCommentDialog,
+ setShowEditCommentDialog,
+}: UIOptions) {
+ const [comments, setComments] = useState({});
+
+ const ensureCommentsByPost = useEnsureCommentsByPost();
+ const addMutation = useAddCommentMutation();
+ const updateMutation = useUpdateCommentMutation();
+ const deleteMutation = useDeleteCommentMutation();
+ const likeMutation = useLikeCommentMutation();
+
+ const fetchComments = useCallback(
+ async (postId: PostId) => {
+ if (comments[postId]) return;
+ try {
+ const data = await ensureCommentsByPost(postId);
+ setComments((prev) => ({ ...prev, [postId]: data.comments ?? [] }));
+ } catch (error) {
+ console.error("댓글 가져오기 오류:", error);
+ }
+ },
+ [comments, ensureCommentsByPost],
+ );
+
+ const handleAddComment = useCallback(async () => {
+ try {
+ const { body, postId, userId } = newComment;
+ if (!body || !postId || !userId) return;
+
+ const created = await addMutation.mutateAsync({
+ comment: { body, postId, userId },
+ });
+
+ const normalized: CommentType = { likes: 0, ...(created as CommentType) };
+
+ setComments((prev) => ({
+ ...prev,
+ [postId]: [...(prev[postId] ?? []), normalized],
+ }));
+
+ setShowAddCommentDialog?.(false);
+ setNewComment({ body: "", postId: null, userId: 1 });
+ } catch (error) {
+ console.error("댓글 추가 오류:", error);
+ }
+ }, [newComment, setNewComment, setShowAddCommentDialog, addMutation]);
+
+ const handleUpdateComment = useCallback(async () => {
+ try {
+ if (!selectedComment) return;
+
+ setComments((prev) => {
+ const list = prev[selectedComment.postId ?? 0] ?? [];
+ return {
+ ...prev,
+ [selectedComment.postId ?? 0]: list.map((c) => (c.id === selectedComment.id ? selectedComment : c)),
+ };
+ });
+
+ const saved = await updateMutation.mutateAsync({ comment: selectedComment });
+
+ setComments((prev) => {
+ const list = prev[saved.postId ?? 0] ?? [];
+ return {
+ ...prev,
+ [saved.postId ?? 0]: list.map((c) => (c.id === saved.id ? saved : c)),
+ };
+ });
+
+ setShowEditCommentDialog?.(false);
+ } catch (error) {
+ console.error("댓글 업데이트 오류:", error);
+ }
+ }, [selectedComment, setShowEditCommentDialog, updateMutation]);
+
+ const handleDeleteComment = useCallback(
+ async (id: CommentId, postId: PostId) => {
+ try {
+ setComments((prev) => ({
+ ...prev,
+ [postId]: (prev[postId] ?? []).filter((c) => c.id !== id),
+ }));
+
+ await deleteMutation.mutateAsync({ id, postId });
+ } catch (error) {
+ console.error("댓글 삭제 오류:", error);
+ }
+ },
+ [deleteMutation],
+ );
+
+ const handleLikeComment = useCallback(
+ async (id: CommentId, postId: PostId) => {
+ try {
+ const current = comments[postId]?.find((c) => c.id === id);
+ if (!current) return;
+
+ const nextLikes = (current.likes ?? 0) + 1;
+
+ setComments((prev) => ({
+ ...prev,
+ [postId]: (prev[postId] ?? []).map((c) => (c.id === id ? { ...c, likes: nextLikes } : c)),
+ }));
+
+ const updated = await likeMutation.mutateAsync({ id, likes: nextLikes, postId });
+
+ setComments((prev) => ({
+ ...prev,
+ [postId]: (prev[postId] ?? []).map((c) => (c.id === id ? { ...updated, likes: nextLikes } : c)),
+ }));
+ } catch (error) {
+ console.error("댓글 좋아요 오류:", error);
+ }
+ },
+ [comments, likeMutation],
+ );
+
+ return {
+ comments,
+ fetchComments,
+ handleAddComment,
+ handleUpdateComment,
+ handleDeleteComment,
+ handleLikeComment,
+ };
+}
diff --git a/src/features/comment/ui/CommentAddDialog.tsx b/src/features/comment/ui/CommentAddDialog.tsx
new file mode 100644
index 000000000..14006887f
--- /dev/null
+++ b/src/features/comment/ui/CommentAddDialog.tsx
@@ -0,0 +1,36 @@
+import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, Textarea } from "../../../shared/ui";
+import { Comment } from "../../../entities/comment/models/types";
+import { Dispatch, SetStateAction } from "react";
+import { showAddCommentDialogAtom } from "../models/dialog.atoms";
+import { useAtom } from "jotai";
+
+export const CommentAddDialog = ({
+ newComment,
+ setNewComment,
+ handleAddComment,
+}: {
+ newComment: Partial;
+ setNewComment: Dispatch>>;
+ handleAddComment: () => void;
+}) => {
+ const [showAddCommentDialog, setShowAddCommentDialog] = useAtom(showAddCommentDialogAtom);
+ return (
+
+ );
+};
diff --git a/src/features/comment/ui/CommentUpdateDialog.tsx b/src/features/comment/ui/CommentUpdateDialog.tsx
new file mode 100644
index 000000000..d3699beef
--- /dev/null
+++ b/src/features/comment/ui/CommentUpdateDialog.tsx
@@ -0,0 +1,35 @@
+import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, Textarea } from "../../../shared/ui";
+import { Comment } from "../../../entities/comment/models/types";
+import { Dispatch, SetStateAction } from "react";
+import { showEditCommentDialogAtom } from "../models/dialog.atoms";
+import { useAtom } from "jotai";
+
+export const CommentUpdateDialog = ({
+ selectedComment,
+ setSelectedComment,
+ handleUpdateComment,
+}: {
+ selectedComment: Comment | null;
+ setSelectedComment: Dispatch>;
+ handleUpdateComment: () => void;
+}) => {
+ const [showEditCommentDialog, setShowEditCommentDialog] = useAtom(showEditCommentDialogAtom);
+
+ return (
+
+ );
+};
diff --git a/src/features/comment/ui/Comments.tsx b/src/features/comment/ui/Comments.tsx
new file mode 100644
index 000000000..81266ef7c
--- /dev/null
+++ b/src/features/comment/ui/Comments.tsx
@@ -0,0 +1,46 @@
+import { Button } from "../../../shared/ui";
+import { Plus } from "lucide-react";
+import { Comment } from "../../../entities/comment/ui/Comment";
+import { Comment as CommentType } from "../../../entities/comment/models/types";
+
+export const Comments = ({
+ comments,
+ searchQuery,
+ handleAddComment,
+ handleLikeComment,
+ handleDeleteComment,
+ handleEditComment,
+}: {
+ comments: CommentType[];
+ searchQuery: string;
+ handleAddComment: () => void;
+ handleLikeComment: (id: number, postId: number) => void;
+ handleDeleteComment: (id: number, postId: number) => void;
+ handleEditComment: (comment: CommentType) => void;
+}) => {
+ return (
+
+
+
+ {comments.map((comment) => {
+ return (
+ handleLikeComment(comment.id, comment.postId ?? 0)}
+ handleDeleteComment={() => handleDeleteComment(comment.id, comment.postId ?? 0)}
+ handleEditComment={() => handleEditComment(comment)}
+ />
+ );
+ })}
+
+
+ );
+};
diff --git a/src/features/post/models/dialog.atoms.ts b/src/features/post/models/dialog.atoms.ts
new file mode 100644
index 000000000..249013bb6
--- /dev/null
+++ b/src/features/post/models/dialog.atoms.ts
@@ -0,0 +1,5 @@
+import { atom } from "jotai";
+
+export const showAddPostDialogAtom = atom(false);
+export const showEditPostDialogAtom = atom(false);
+export const showPostDetailDialogAtom = atom(false);
diff --git a/src/features/post/models/usePostQuery.ts b/src/features/post/models/usePostQuery.ts
new file mode 100644
index 000000000..b864b1fe5
--- /dev/null
+++ b/src/features/post/models/usePostQuery.ts
@@ -0,0 +1,55 @@
+import { useEffect, useState } from "react";
+import { useLocation, useNavigate } from "react-router-dom";
+
+export function usePostQuery() {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const queryParams = new URLSearchParams(location.search);
+
+ 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 [sortBy, setSortBy] = useState(queryParams.get("sortBy") || "");
+ const [sortOrder, setSortOrder] = useState(queryParams.get("sortOrder") || "asc");
+ const [selectedTag, setSelectedTag] = useState(queryParams.get("tag") || "");
+
+ useEffect(() => {
+ const params = new URLSearchParams(location.search);
+ setSkip(parseInt(params.get("skip") || "0"));
+ setLimit(parseInt(params.get("limit") || "10"));
+ setSearchQuery(params.get("search") || "");
+ setSortBy(params.get("sortBy") || "");
+ setSortOrder(params.get("sortOrder") || "asc");
+ setSelectedTag(params.get("tag") || "");
+ }, [location.search]);
+
+ // URL 업데이트 함수
+ const updateURL = () => {
+ const params = new URLSearchParams();
+ if (skip) params.set("skip", skip.toString());
+ if (limit) params.set("limit", limit.toString());
+ if (searchQuery) params.set("search", searchQuery);
+ if (sortBy) params.set("sortBy", sortBy);
+ if (sortOrder) params.set("sortOrder", sortOrder);
+ if (selectedTag) params.set("tag", selectedTag);
+ navigate(`?${params.toString()}`);
+ };
+
+ return {
+ postQuery: {
+ skip,
+ limit,
+ searchQuery,
+ sortBy,
+ sortOrder,
+ selectedTag,
+ },
+ setSkip,
+ setLimit,
+ setSearchQuery,
+ setSortBy,
+ setSortOrder,
+ setSelectedTag,
+ updateURL,
+ } as const;
+}
diff --git a/src/features/post/models/usePosts.ts b/src/features/post/models/usePosts.ts
new file mode 100644
index 000000000..0bd00ba1d
--- /dev/null
+++ b/src/features/post/models/usePosts.ts
@@ -0,0 +1,102 @@
+import { useMemo, useEffect } from "react";
+import type { Post } from "../../../entities/post/models/types";
+
+import {
+ usePostsPageQuery,
+ usePostsSearchQuery,
+ usePostsByTagQuery,
+ useAddPostMutation,
+ useUpdatePostMutation,
+ useDeletePostMutation,
+} from "../../../entities/post/models/queries";
+
+import { useUsersQuery } from "../../../entities/user/models/queries";
+
+export function usePosts({
+ limit,
+ skip,
+ selectedTag,
+ searchQuery,
+ sortBy,
+ sortOrder,
+ newPost,
+ selectedPost,
+ updateURL,
+ setNewPost,
+ setShowAddDialog,
+ setShowEditDialog,
+}: {
+ limit: number;
+ skip: number;
+ selectedTag: string;
+ searchQuery: string;
+ newPost: Partial;
+ selectedPost: Post | null;
+ sortBy: string;
+ sortOrder: string;
+ updateURL: () => void;
+ setNewPost: (post: Partial) => void;
+ setShowAddDialog: (show: boolean) => void;
+ setShowEditDialog: (show: boolean) => void;
+}) {
+ const pageQ = usePostsPageQuery({ skip, limit });
+ const searchQ = usePostsSearchQuery(searchQuery, !!searchQuery);
+ const tagQ = usePostsByTagQuery(selectedTag, !!selectedTag && selectedTag !== "all");
+ const usersQ = useUsersQuery();
+
+ const active = searchQuery ? searchQ : selectedTag && selectedTag !== "all" ? tagQ : pageQ;
+
+ const posts = useMemo(() => {
+ const raw = active.data?.posts ?? [];
+ const users = usersQ.data?.users ?? [];
+ if (!users.length) return raw;
+ return raw.map((p) => ({
+ ...p,
+ author: users.find((u) => u.id === p.userId),
+ }));
+ }, [active.data, usersQ.data]);
+
+ const total = active.data?.total ?? 0;
+ const loading = active.isPending || usersQ.isPending;
+
+ const addMutation = useAddPostMutation();
+ const updateMutation = useUpdatePostMutation();
+ const deleteMutation = useDeletePostMutation();
+
+ const handleAddPost = async () => {
+ await addMutation.mutateAsync({ post: newPost });
+ setShowAddDialog(false);
+ setNewPost({ title: "", body: "", userId: 1 });
+ updateURL();
+ };
+
+ const handleUpdatePost = async () => {
+ if (!selectedPost) return;
+ await updateMutation.mutateAsync({ post: selectedPost });
+ setShowEditDialog(false);
+ updateURL();
+ };
+
+ const handleDeletePost = async (id: number) => {
+ await deleteMutation.mutateAsync({ id });
+ updateURL();
+ };
+
+ useEffect(() => {
+ updateURL();
+ }, [skip, limit, sortBy, sortOrder, selectedTag]);
+
+ const searchPosts = () => updateURL();
+ const fetchPostsByTag = () => updateURL();
+
+ return {
+ posts,
+ total,
+ loading,
+ searchPosts,
+ fetchPostsByTag,
+ handleAddPost,
+ handleUpdatePost,
+ handleDeletePost,
+ } as const;
+}
diff --git a/src/features/post/ui/PostAddDialog.tsx b/src/features/post/ui/PostAddDialog.tsx
new file mode 100644
index 000000000..0d5c2dc9f
--- /dev/null
+++ b/src/features/post/ui/PostAddDialog.tsx
@@ -0,0 +1,47 @@
+import { Dialog, DialogContent, DialogHeader, DialogTitle, Input, Textarea, Button } from "../../../shared/ui";
+import { Post } from "../../../entities/post/models/types";
+import { showAddPostDialogAtom } from "../models/dialog.atoms";
+import { useAtom } from "jotai";
+
+export const PostAddDialog = ({
+ newPost,
+ setNewPost,
+ handleAddPost,
+}: {
+ newPost: Partial;
+ setNewPost: (post: Partial) => void;
+ handleAddPost: () => void;
+}) => {
+ const [showAddDialog, setShowAddDialog] = useAtom(showAddPostDialogAtom);
+ return (
+
+ );
+};
diff --git a/src/features/post/ui/PostDetailDialog.tsx b/src/features/post/ui/PostDetailDialog.tsx
new file mode 100644
index 000000000..4425e0a19
--- /dev/null
+++ b/src/features/post/ui/PostDetailDialog.tsx
@@ -0,0 +1,34 @@
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../../../shared/ui";
+import { Post } from "../../../entities/post/models/types";
+import { HighlightText } from "../../../shared/ui/HighlightText";
+import { showPostDetailDialogAtom } from "../models/dialog.atoms";
+import { useAtom } from "jotai";
+
+export const PostDetailDialog = ({
+ selectedPost,
+ searchQuery,
+ bottom,
+}: {
+ selectedPost: Post | null;
+ searchQuery: string;
+ bottom: React.ReactNode;
+}) => {
+ const [showPostDetailDialog, setShowPostDetailDialog] = useAtom(showPostDetailDialogAtom);
+ return (
+
+ );
+};
diff --git a/src/features/post/ui/PostUpdateDialog.tsx b/src/features/post/ui/PostUpdateDialog.tsx
new file mode 100644
index 000000000..55cc892b7
--- /dev/null
+++ b/src/features/post/ui/PostUpdateDialog.tsx
@@ -0,0 +1,44 @@
+import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, Input, Textarea } from "../../../shared/ui";
+import { Post } from "../../../entities/post/models/types";
+import { Dispatch, SetStateAction } from "react";
+import { showEditPostDialogAtom } from "../models/dialog.atoms";
+import { useAtom } from "jotai";
+
+export const PostUpdateDialog = ({
+ selectedPost,
+ setSelectedPost,
+ handleUpdatePost,
+}: {
+ selectedPost: Post | null;
+ setSelectedPost: Dispatch>;
+ handleUpdatePost: () => void;
+}) => {
+ const [showEditDialog, setShowEditDialog] = useAtom(showEditPostDialogAtom);
+ return (
+
+ );
+};
diff --git a/src/index.tsx b/src/index.tsx
deleted file mode 100644
index 369e197bb..000000000
--- a/src/index.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import React from "react"
-import ReactDOM from "react-dom/client"
-import { BrowserRouter as Router } from "react-router-dom"
-import App from "./App"
-
-ReactDOM.createRoot(document.getElementById("root")!).render(
-
-
-
-
- ,
-)
diff --git a/src/main.tsx b/src/main.tsx
deleted file mode 100644
index bef5202a3..000000000
--- a/src/main.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import { StrictMode } from 'react'
-import { createRoot } from 'react-dom/client'
-import './index.css'
-import App from './App.tsx'
-
-createRoot(document.getElementById('root')!).render(
-
-
- ,
-)
diff --git a/src/pages/PostsManagerPage.tsx b/src/pages/PostsManagerPage.tsx
index f80eb91ef..37f8cb5c2 100644
--- a/src/pages/PostsManagerPage.tsx
+++ b/src/pages/PostsManagerPage.tsx
@@ -1,473 +1,83 @@
-import { useEffect, useState } from "react"
-import { Edit2, MessageSquare, Plus, Search, ThumbsDown, ThumbsUp, Trash2 } from "lucide-react"
-import { useLocation, useNavigate } from "react-router-dom"
+import { useState } from "react";
+import { Plus } from "lucide-react";
+import { Button, Card, CardContent, CardHeader, CardTitle } from "../shared/ui";
+import { getUser } from "../entities/user/models/api";
+import { UserModal } from "../widgets/user-modal/ui/UserModal";
+import { Pagination } from "../shared/ui/Pagination";
+import { PostAddDialog } from "../features/post/ui/PostAddDialog";
+import { PostUpdateDialog } from "../features/post/ui/PostUpdateDialog";
+import { CommentAddDialog } from "../features/comment/ui/CommentAddDialog";
+import { Comment as CommentType } from "../entities/comment/models/types";
+import { CommentUpdateDialog } from "../features/comment/ui/CommentUpdateDialog";
+import { PostDetailDialog } from "../features/post/ui/PostDetailDialog";
+import { Comments } from "../features/comment/ui/Comments";
+import { useComments } from "../features/comment/models/useComment";
+import { usePosts } from "../features/post/models/usePosts";
+import { Post } from "../entities/post/models/types";
+import { PostTable } from "../entities/post/ui/PostTable";
+import { useTags } from "../entities/tag/models";
+import { PostFiltersBar } from "../widgets/post-filters/ui/PostFiltersBar";
+import { usePostQuery } from "../features/post/models/usePostQuery";
+import { User, UserResponse } from "../entities/user/models/types";
+import { showUserModalAtom } from "../widgets/user-modal/model/atoms";
+import { useAtom } from "jotai";
+import { showAddCommentDialogAtom, showEditCommentDialogAtom } from "../features/comment/models/dialog.atoms";
import {
- Button,
- Card,
- CardContent,
- CardHeader,
- CardTitle,
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- Input,
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
- Textarea,
-} from "../components"
+ showAddPostDialogAtom,
+ showEditPostDialogAtom,
+ showPostDetailDialogAtom,
+} from "../features/post/models/dialog.atoms";
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 [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 = () => {
- const params = new URLSearchParams()
- if (skip) params.set("skip", skip.toString())
- if (limit) params.set("limit", limit.toString())
- if (searchQuery) params.set("search", searchQuery)
- if (sortBy) params.set("sortBy", sortBy)
- if (sortOrder) params.set("sortOrder", sortOrder)
- if (selectedTag) params.set("tag", selectedTag)
- 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 [selectedPost, setSelectedPost] = useState(null);
+ const [selectedComment, setSelectedComment] = useState(null);
+ const [selectedUser, setSelectedUser] = useState(null);
+
+ const [newPost, setNewPost] = useState>({ title: "", body: "", userId: 1 });
+ const [newComment, setNewComment] = useState>({ body: "", postId: null, userId: 1 });
+
+ const [, setShowUserModal] = useAtom(showUserModalAtom);
+ const [, setShowEditCommentDialog] = useAtom(showEditCommentDialogAtom);
+ const [, setShowAddCommentDialog] = useAtom(showAddCommentDialogAtom);
+ const [, setShowAddDialog] = useAtom(showAddPostDialogAtom);
+ const [, setShowEditDialog] = useAtom(showEditPostDialogAtom);
+ const [, setShowPostDetailDialog] = useAtom(showPostDetailDialogAtom);
+
+ const { postQuery, setSkip, setLimit, setSearchQuery, setSortBy, setSortOrder, setSelectedTag, updateURL } =
+ usePostQuery();
+ const { skip, limit, searchQuery, sortBy, sortOrder, selectedTag } = postQuery;
+
+ const { comments, fetchComments, handleAddComment, handleLikeComment, handleDeleteComment, handleUpdateComment } =
+ useComments({
+ newComment,
+ setNewComment,
+ selectedComment,
+ setShowAddCommentDialog,
+ setShowEditCommentDialog,
+ });
+
+ const { posts, total, loading, searchPosts, fetchPostsByTag, handleAddPost, handleUpdatePost, handleDeletePost } =
+ usePosts({ ...postQuery, newPost, updateURL, setNewPost, selectedPost, setShowAddDialog, setShowEditDialog });
+
+ const { tags } = useTags();
// 게시물 상세 보기
- const openPostDetail = (post) => {
- setSelectedPost(post)
- fetchComments(post.id)
- setShowPostDetailDialog(true)
- }
+ const openPostDetail = (post: Post) => {
+ setSelectedPost(post);
+ fetchComments(post.id);
+ setShowPostDetailDialog(true);
+ };
// 사용자 모달 열기
- const openUserModal = async (user) => {
+ const openUserModal = async (user: User) => {
try {
- const response = await fetch(`/api/users/${user.id}`)
- const userData = await response.json()
- setSelectedUser(userData)
- setShowUserModal(true)
+ const userData = await getUser({ id: user.id });
+ setSelectedUser(userData);
+ setShowUserModal(true);
} catch (error) {
- console.error("사용자 정보 가져오기 오류:", error)
+ console.error("사용자 정보 가져오기 오류:", error);
}
- }
-
- useEffect(() => {
- fetchTags()
- }, [])
-
- useEffect(() => {
- if (selectedTag) {
- fetchPostsByTag(selectedTag)
- } else {
- fetchPosts()
- }
- updateURL()
- }, [skip, limit, sortBy, sortOrder, selectedTag])
-
- useEffect(() => {
- const params = new URLSearchParams(location.search)
- setSkip(parseInt(params.get("skip") || "0"))
- setLimit(parseInt(params.get("limit") || "10"))
- setSearchQuery(params.get("search") || "")
- setSortBy(params.get("sortBy") || "")
- setSortOrder(params.get("sortOrder") || "asc")
- 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)}
-
-
-
-
-
-
-
- ))}
-
-
- )
+ };
return (
@@ -482,227 +92,90 @@ const PostsManager = () => {
- {/* 검색 및 필터 컨트롤 */}
-
-
-
-
- setSearchQuery(e.target.value)}
- onKeyPress={(e) => e.key === "Enter" && searchPosts()}
- />
-
-
-
-
-
-
+
{/* 게시물 테이블 */}
- {loading ?
로딩 중...
: renderPostTable()}
+ {loading ? (
+
로딩 중...
+ ) : (
+
+ )}
{/* 페이지네이션 */}
-
-
- 표시
-
- 항목
-
-
-
-
-
-
+
{/* 게시물 추가 대화상자 */}
-
+
{/* 게시물 수정 대화상자 */}
-
+
{/* 댓글 추가 대화상자 */}
-
+
{/* 댓글 수정 대화상자 */}
-
+
{/* 게시물 상세 보기 대화상자 */}
-
+ {
+ setNewComment((prev) => ({ ...prev, postId: selectedPost?.id ?? 0 }));
+ setShowAddCommentDialog(true);
+ }}
+ handleLikeComment={handleLikeComment}
+ handleDeleteComment={handleDeleteComment}
+ handleEditComment={(comment) => {
+ setSelectedComment(comment);
+ setShowEditCommentDialog(true);
+ }}
+ />
+ }
+ />
{/* 사용자 모달 */}
-
+
- )
-}
+ );
+};
-export default PostsManager
+export default PostsManager;
diff --git a/src/shared/api/client.ts b/src/shared/api/client.ts
new file mode 100644
index 000000000..5f8a07632
--- /dev/null
+++ b/src/shared/api/client.ts
@@ -0,0 +1,7 @@
+const API_BASE = import.meta.env.VITE_API_BASE_URL;
+
+export async function api(path: string, init?: RequestInit): Promise {
+ const res = await fetch(`${API_BASE}${path}`, init);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ return res.json() as Promise;
+}
diff --git a/src/assets/react.svg b/src/shared/assets/react.svg
similarity index 100%
rename from src/assets/react.svg
rename to src/shared/assets/react.svg
diff --git a/src/shared/lib/react-query.ts b/src/shared/lib/react-query.ts
new file mode 100644
index 000000000..66fe28f01
--- /dev/null
+++ b/src/shared/lib/react-query.ts
@@ -0,0 +1,13 @@
+// shared/lib/react-query.ts
+import { QueryClient } from "@tanstack/react-query";
+
+export const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 30_000,
+ gcTime: 5 * 60_000,
+ retry: 1,
+ refetchOnWindowFocus: false,
+ },
+ },
+});
diff --git a/src/shared/query-keys/comments.ts b/src/shared/query-keys/comments.ts
new file mode 100644
index 000000000..76856ac1d
--- /dev/null
+++ b/src/shared/query-keys/comments.ts
@@ -0,0 +1,5 @@
+export const commentsKeys = {
+ all: ["comments"] as const,
+ byPost: (postId: number) => [...commentsKeys.all, "byPost", postId] as const,
+ detail: (id: number) => [...commentsKeys.all, "detail", id] as const,
+};
diff --git a/src/shared/query-keys/post.ts b/src/shared/query-keys/post.ts
new file mode 100644
index 000000000..1a3c82b42
--- /dev/null
+++ b/src/shared/query-keys/post.ts
@@ -0,0 +1,10 @@
+// shared/query-keys/posts.ts
+export type PageParams = { limit: number; skip: number };
+
+export const postsKeys = {
+ all: ["posts"] as const,
+ page: (p: PageParams) => [...postsKeys.all, "page", { limit: p.limit, skip: p.skip }] as const,
+ search: (q: string) => [...postsKeys.all, "search", q] as const,
+ tag: (tag: string) => [...postsKeys.all, "tag", tag] as const,
+ detail: (id: number) => [...postsKeys.all, "detail", id] as const,
+};
diff --git a/src/shared/query-keys/tags.ts b/src/shared/query-keys/tags.ts
new file mode 100644
index 000000000..3af838d9d
--- /dev/null
+++ b/src/shared/query-keys/tags.ts
@@ -0,0 +1,3 @@
+export const tagsKeys = {
+ all: ["tags"] as const,
+};
diff --git a/src/shared/query-keys/users.ts b/src/shared/query-keys/users.ts
new file mode 100644
index 000000000..e2efadc4e
--- /dev/null
+++ b/src/shared/query-keys/users.ts
@@ -0,0 +1,3 @@
+export const usersKeys = {
+ all: ["users"] as const,
+};
diff --git a/src/shared/ui/DropdownSelect.tsx b/src/shared/ui/DropdownSelect.tsx
new file mode 100644
index 000000000..66eda4e43
--- /dev/null
+++ b/src/shared/ui/DropdownSelect.tsx
@@ -0,0 +1,28 @@
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./index";
+
+export const DropdownSelect = ({
+ value,
+ onChange,
+ options,
+ placeholder,
+}: {
+ value: string;
+ onChange: (value: string) => void;
+ options: { label: string | number; value: string; key?: string }[];
+ placeholder: string;
+}) => {
+ return (
+
+ );
+};
diff --git a/src/shared/ui/Footer.tsx b/src/shared/ui/Footer.tsx
new file mode 100644
index 000000000..d7b716530
--- /dev/null
+++ b/src/shared/ui/Footer.tsx
@@ -0,0 +1,13 @@
+import React from "react";
+
+const Footer: React.FC = () => {
+ return (
+
+ );
+};
+
+export default Footer;
diff --git a/src/shared/ui/Header.tsx b/src/shared/ui/Header.tsx
new file mode 100644
index 000000000..f98cec7f0
--- /dev/null
+++ b/src/shared/ui/Header.tsx
@@ -0,0 +1,36 @@
+import React from "react";
+import { MessageSquare } from "lucide-react";
+
+const Header: React.FC = () => {
+ return (
+
+
+
+
+
게시물 관리 시스템
+
+
+
+
+ );
+};
+
+export default Header;
diff --git a/src/shared/ui/HighlightText.tsx b/src/shared/ui/HighlightText.tsx
new file mode 100644
index 000000000..aa1657630
--- /dev/null
+++ b/src/shared/ui/HighlightText.tsx
@@ -0,0 +1,13 @@
+export const HighlightText = ({ text, highlight }: { 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}))}
+
+ );
+};
diff --git a/src/shared/ui/Pagination.tsx b/src/shared/ui/Pagination.tsx
new file mode 100644
index 000000000..d2d943f5b
--- /dev/null
+++ b/src/shared/ui/Pagination.tsx
@@ -0,0 +1,43 @@
+import { Button } from ".";
+import { DropdownSelect } from "./DropdownSelect";
+
+export const Pagination = ({
+ limit,
+ setLimit,
+ skip,
+ setSkip,
+ total,
+}: {
+ limit: number;
+ setLimit: (limit: number) => void;
+ skip: number;
+ setSkip: (skip: number) => void;
+ total: number;
+}) => {
+ return (
+
+
+ 표시
+ setLimit(Number(value))}
+ options={[
+ { key: "10", label: 10, value: "10" },
+ { key: "20", label: 20, value: "20" },
+ { key: "30", label: 30, value: "30" },
+ ]}
+ placeholder="10"
+ />
+ 항목
+
+
+
+
+
+
+ );
+};
diff --git a/src/shared/ui/index.tsx b/src/shared/ui/index.tsx
new file mode 100644
index 000000000..a548259c0
--- /dev/null
+++ b/src/shared/ui/index.tsx
@@ -0,0 +1,242 @@
+import * as React from "react";
+import { forwardRef } from "react";
+import * as SelectPrimitive from "@radix-ui/react-select";
+import * as DialogPrimitive from "@radix-ui/react-dialog";
+import { Check, ChevronDown, X } from "lucide-react";
+import { cva, VariantProps } from "class-variance-authority";
+
+const buttonVariants = cva(
+ "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background",
+ {
+ variants: {
+ variant: {
+ default: "bg-blue-500 text-white hover:bg-blue-600",
+ destructive: "bg-red-500 text-white hover:bg-red-600",
+ outline: "border border-gray-300 bg-transparent text-gray-700 hover:bg-gray-100",
+ secondary: "bg-gray-200 text-gray-800 hover:bg-gray-300",
+ ghost: "bg-transparent text-gray-700 hover:bg-gray-100",
+ link: "underline-offset-4 hover:underline text-blue-500",
+ },
+ size: {
+ default: "h-10 py-2 px-4",
+ sm: "h-8 px-3 rounded-md text-xs",
+ lg: "h-11 px-8 rounded-md",
+ icon: "h-9 w-9",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+interface ButtonProps extends React.ButtonHTMLAttributes, VariantProps {
+ className?: string;
+}
+
+export const Button = forwardRef(({ className, variant, size, ...props }, ref) => {
+ return ;
+});
+
+Button.displayName = "Button";
+
+// 입력 컴포넌트
+export const Input = forwardRef>(
+ ({ className, type, ...props }, ref) => {
+ return (
+
+ );
+ },
+);
+Input.displayName = "Input";
+
+// 카드 컴포넌트
+export const Card = forwardRef>(({ className, ...props }, ref) => (
+
+));
+Card.displayName = "Card";
+
+export const CardHeader = forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+CardHeader.displayName = "CardHeader";
+
+export const CardTitle = forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+CardTitle.displayName = "CardTitle";
+
+export const CardContent = forwardRef>(
+ ({ className, ...props }, ref) => ,
+);
+CardContent.displayName = "CardContent";
+
+// 텍스트 영역 컴포넌트
+export const Textarea = forwardRef>(
+ ({ className, ...props }, ref) => {
+ return (
+
+ );
+ },
+);
+Textarea.displayName = "Textarea";
+
+// 선택 컴포넌트
+export const Select = SelectPrimitive.Root;
+export const SelectGroup = SelectPrimitive.Group;
+export const SelectValue = SelectPrimitive.Value;
+
+export const SelectTrigger = forwardRef>(
+ ({ className, children, ...props }, ref) => (
+
+ {children}
+
+
+ ),
+);
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
+
+export const SelectContent = forwardRef(
+ ({ className, children, position = "popper", ...props }, ref) => (
+
+
+ {children}
+
+
+ ),
+);
+SelectContent.displayName = SelectPrimitive.Content.displayName;
+
+export const SelectItem = forwardRef(
+ ({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+ ),
+);
+SelectItem.displayName = SelectPrimitive.Item.displayName;
+
+// 대화상자 컴포넌트
+export const Dialog = DialogPrimitive.Root;
+export const DialogTrigger = DialogPrimitive.Trigger;
+export const DialogPortal = DialogPrimitive.Portal;
+export const DialogOverlay = DialogPrimitive.Overlay;
+
+export const DialogContent = forwardRef>(
+ ({ className, children, ...props }, ref) => (
+
+
+
+ {children}
+
+
+ 닫기
+
+
+
+ ),
+);
+DialogContent.displayName = DialogPrimitive.Content.displayName;
+
+export const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => (
+
+);
+DialogHeader.displayName = "DialogHeader";
+
+export const DialogTitle = forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+DialogTitle.displayName = DialogPrimitive.Title.displayName;
+
+// 테이블 컴포넌트
+export const Table = forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+Table.displayName = "Table";
+
+export const TableHeader = forwardRef>(
+ ({ className, ...props }, ref) => ,
+);
+TableHeader.displayName = "TableHeader";
+
+export const TableBody = forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+TableBody.displayName = "TableBody";
+
+export const TableRow = forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+TableRow.displayName = "TableRow";
+
+export const TableHead = forwardRef>(
+ ({ className, ...props }, ref) => (
+ |
+ ),
+);
+TableHead.displayName = "TableHead";
+
+export const TableCell = forwardRef>(
+ ({ className, ...props }, ref) => (
+ |
+ ),
+);
+TableCell.displayName = "TableCell";
diff --git a/src/widgets/post-filters/ui/PostFiltersBar.tsx b/src/widgets/post-filters/ui/PostFiltersBar.tsx
new file mode 100644
index 000000000..1b2f06342
--- /dev/null
+++ b/src/widgets/post-filters/ui/PostFiltersBar.tsx
@@ -0,0 +1,89 @@
+import { DropdownSelect } from "../../../shared/ui/DropdownSelect";
+import { Input } from "../../../shared/ui";
+import { Search } from "lucide-react";
+import { Tag } from "../../../entities/tag/models/types";
+
+interface PostFiltersBarProps {
+ searchQuery: string;
+ setSearchQuery: (query: string) => void;
+ searchPosts: () => void;
+ selectedTag: string;
+ setSelectedTag: (tag: string) => void;
+ fetchPostsByTag: (tag: string) => void;
+ updateURL: () => void;
+ sortBy: string;
+ setSortBy: (sortBy: string) => void;
+ sortOrder: string;
+ setSortOrder: (sortOrder: string) => void;
+ tags: Tag[];
+}
+
+export const PostFiltersBar = ({
+ searchQuery,
+ setSearchQuery,
+ searchPosts,
+ selectedTag,
+ setSelectedTag,
+ fetchPostsByTag,
+ updateURL,
+ sortBy,
+ setSortBy,
+ sortOrder,
+ setSortOrder,
+ tags,
+}: PostFiltersBarProps) => {
+ return (
+
+
+
+
+ setSearchQuery(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && searchPosts()}
+ />
+
+
+
+
{
+ setSelectedTag(value);
+ fetchPostsByTag(value);
+ updateURL();
+ }}
+ options={[
+ { key: "all", label: "모든 태그", value: "all" },
+ ...tags.map((tag) => ({ key: tag.url, label: tag.slug, value: tag.slug })),
+ ]}
+ placeholder="태그 선택"
+ />
+ {
+ setSortBy(value);
+ }}
+ options={[
+ { key: "none", label: "없음", value: "none" },
+ { key: "id", label: "ID", value: "id" },
+ { key: "title", label: "제목", value: "title" },
+ { key: "reactions", label: "반응", value: "reactions" },
+ ]}
+ placeholder="정렬 기준"
+ />
+ {
+ setSortOrder(value);
+ }}
+ options={[
+ { key: "asc", label: "오름차순", value: "asc" },
+ { key: "desc", label: "내림차순", value: "desc" },
+ ]}
+ placeholder="정렬 순서"
+ />
+
+ );
+};
diff --git a/src/widgets/user-modal/model/atoms.ts b/src/widgets/user-modal/model/atoms.ts
new file mode 100644
index 000000000..5565a80c4
--- /dev/null
+++ b/src/widgets/user-modal/model/atoms.ts
@@ -0,0 +1,3 @@
+import { atom } from "jotai";
+
+export const showUserModalAtom = atom(false);
diff --git a/src/widgets/user-modal/ui/UserModal.tsx b/src/widgets/user-modal/ui/UserModal.tsx
new file mode 100644
index 000000000..da0cc35c3
--- /dev/null
+++ b/src/widgets/user-modal/ui/UserModal.tsx
@@ -0,0 +1,20 @@
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../../../shared/ui";
+import { UserInfo } from "../../../entities/user/ui/UserModal";
+import { UserResponse } from "../../../entities/user/models/types";
+import { showUserModalAtom } from "../model/atoms";
+import { useAtom } from "jotai";
+
+export const UserModal = ({ selectedUser }: { selectedUser: UserResponse | null }) => {
+ const [showUserModal, setShowUserModal] = useAtom(showUserModalAtom);
+
+ return (
+
+ );
+};
diff --git a/tsconfig.app.json b/tsconfig.app.json
index 5a2def4b7..1381ace91 100644
--- a/tsconfig.app.json
+++ b/tsconfig.app.json
@@ -21,5 +21,5 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
- "include": ["src"]
+ "include": ["src", "vite-env.d.ts"]
}
diff --git a/vite.config.ts b/vite.config.ts
index be7b7a3d4..f3a75ab9a 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,9 +1,10 @@
-import { defineConfig } from "vite"
-import react from "@vitejs/plugin-react"
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
+ base: "/front_6th_chapter2-3/",
server: {
proxy: {
"/api": {
@@ -14,4 +15,4 @@ export default defineConfig({
},
},
},
-})
+});
diff --git a/vitest.config.ts b/vitest.config.ts
index fa9992fb4..b2a4cd5f0 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,6 +1,6 @@
///
-import { defineConfig } from "vitest/config"
-import react from "@vitejs/plugin-react"
+import { defineConfig } from "vitest/config";
+import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
@@ -8,4 +8,4 @@ export default defineConfig({
globals: true,
environment: "jsdom",
},
-})
+});