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..776d639ec 100644
--- a/.prettierrc
+++ b/.prettierrc
@@ -1,9 +1,9 @@
{
- "semi": false,
- "printWidth": 120,
+ "semi": true,
"tabWidth": 2,
- "singleQuote": false,
"quoteProps": "consistent",
"trailingComma": "all",
- "singleAttributePerLine": false
-}
\ No newline at end of file
+ "singleAttributePerLine": false,
+ "singleQuote": true,
+
+}
diff --git a/convention.md b/convention.md
new file mode 100644
index 000000000..8017ebb10
--- /dev/null
+++ b/convention.md
@@ -0,0 +1,6 @@
+### naminge 컨벤션
+
+- CRUD Naming 채
+
+### function 키워드 쓰기 ? 화살표 함수 쓰기?
+화살표 함수 사용하기
\ No newline at end of file
diff --git a/eslint.config.js b/eslint.config.js
index 092408a9f..1a938657f 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -1,14 +1,15 @@
-import js from '@eslint/js'
-import globals from 'globals'
-import reactHooks from 'eslint-plugin-react-hooks'
-import reactRefresh from 'eslint-plugin-react-refresh'
-import tseslint from 'typescript-eslint'
+import js from '@eslint/js';
+import fsdPlugin from 'eslint-plugin-fsd-lint';
+import reactHooks from 'eslint-plugin-react-hooks';
+import reactRefresh from 'eslint-plugin-react-refresh';
+import globals from 'globals';
+import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
- files: ['**/*.{ts,tsx}'],
+ files: ['**/*.{ts,tsx,css}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
@@ -16,6 +17,7 @@ export default tseslint.config(
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
+ 'fsd': fsdPlugin,
},
rules: {
...reactHooks.configs.recommended.rules,
@@ -23,6 +25,32 @@ export default tseslint.config(
'warn',
{ allowConstantExport: true },
],
+ // Enforces FSD layer import rules (e.g., features cannot import pages)
+ 'fsd/forbidden-imports': 'error',
+
+ // Disallows relative imports between slices/layers, use aliases (@)
+ // Allows relative imports within the same slice by default (configurable)
+ 'fsd/no-relative-imports': [
+ 'error',
+ {
+ allowSameSlice: true,
+ },
+ ],
+
+ // Enforces importing only via public API (index files)
+ // 'fsd/no-public-api-sidestep': 'error',
+
+ // Prevents direct imports between slices in the same layer
+ // 'fsd/no-cross-slice-dependency': 'error',
+
+ // Prevents UI imports in business logic layers (e.g., entities)
+ // 'fsd/no-ui-in-business-logic': 'error',
+
+ // Forbids direct import of the global store
+ // 'fsd/no-global-store-imports': 'error',
+
+ // Enforces import order based on FSD layers (disabled due to prettier conflict)
+ // 'fsd/ordered-imports': 'warn',
},
},
-)
+);
diff --git a/global.d.ts b/global.d.ts
new file mode 100644
index 000000000..577d508da
--- /dev/null
+++ b/global.d.ts
@@ -0,0 +1,2 @@
+declare module '*.css';
+declare module '*.module.css';
diff --git a/index_final.html b/index_final.html
new file mode 100644
index 000000000..a466b11c5
--- /dev/null
+++ b/index_final.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+
+
+ Vite + React + TS
+
+
+
+
+
+
+
diff --git a/package.json b/package.json
index e014c5272..e7bd69de5 100644
--- a/package.json
+++ b/package.json
@@ -1,19 +1,24 @@
{
+ "homepage": "https://yangs1s.github.io/front_6th_chapter2-3",
"name": "front-end-2-3",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
- "build": "tsc -b && vite build",
+ "build": "tsc -b && vite build --mode production",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest",
+ "deploy": "gh-pages -d dist",
+ "predeploy": "npm run build",
"coverage": "vitest run --coverage"
},
"dependencies": {
+ "@tanstack/react-query": "^5.84.2",
"react": "^19.1.1",
- "react-dom": "^19.1.1"
+ "react-dom": "^19.1.1",
+ "zustand": "^5.0.7"
},
"devDependencies": {
"@eslint/js": "^9.33.0",
@@ -22,14 +27,17 @@
"@testing-library/jest-dom": "^6.6.4",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
+ "@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^5.0.0",
"axios": "^1.11.0",
"class-variance-authority": "^0.7.1",
"eslint": "^9.33.0",
+ "eslint-plugin-fsd-lint": "^1.0.9",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
+ "gh-pages": "^6.3.0",
"globals": "^16.3.0",
"jsdom": "^26.1.0",
"lucide-react": "^0.539.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6b2a40d18..e1b617c26 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,12 +8,18 @@ importers:
.:
dependencies:
+ '@tanstack/react-query':
+ specifier: ^5.84.2
+ version: 5.84.2(react@19.1.1)
react:
specifier: ^19.1.1
version: 19.1.1
react-dom:
specifier: ^19.1.1
version: 19.1.1(react@19.1.1)
+ zustand:
+ specifier: ^5.0.7
+ version: 5.0.7(@types/react@19.1.9)(react@19.1.1)
devDependencies:
'@eslint/js':
specifier: ^9.33.0
@@ -33,6 +39,9 @@ importers:
'@testing-library/user-event':
specifier: ^14.6.1
version: 14.6.1(@testing-library/dom@10.4.0)
+ '@trivago/prettier-plugin-sort-imports':
+ specifier: ^5.2.2
+ version: 5.2.2(prettier@3.6.2)
'@types/react':
specifier: ^19.1.9
version: 19.1.9
@@ -51,12 +60,18 @@ importers:
eslint:
specifier: ^9.33.0
version: 9.33.0
+ eslint-plugin-fsd-lint:
+ specifier: ^1.0.9
+ version: 1.0.9(eslint@9.33.0)
eslint-plugin-react-hooks:
specifier: ^5.2.0
version: 5.2.0(eslint@9.33.0)
eslint-plugin-react-refresh:
specifier: ^0.4.20
version: 0.4.20(eslint@9.33.0)
+ gh-pages:
+ specifier: ^6.3.0
+ version: 6.3.0
globals:
specifier: ^16.3.0
version: 16.3.0
@@ -103,10 +118,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 +941,14 @@ packages:
cpu: [x64]
os: [win32]
+ '@tanstack/query-core@5.83.1':
+ resolution: {integrity: sha512-OG69LQgT7jSp+5pPuCfzltq/+7l2xoweggjme9vlbCPa/d7D7zaqv5vN/S82SzSYZ4EDLTxNO1PWrv49RAS64Q==}
+
+ '@tanstack/react-query@5.84.2':
+ resolution: {integrity: sha512-cZadySzROlD2+o8zIfbD978p0IphuQzRWiiH3I2ugnTmz4jbjc0+TdibpwqxlzynEen8OulgAg+rzdNF37s7XQ==}
+ peerDependencies:
+ react: ^18 || ^19
+
'@testing-library/dom@10.4.0':
resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==}
engines: {node: '>=18'}
@@ -959,6 +978,22 @@ packages:
peerDependencies:
'@testing-library/dom': '>=7.21.4'
+ '@trivago/prettier-plugin-sort-imports@5.2.2':
+ resolution: {integrity: sha512-fYDQA9e6yTNmA13TLVSA+WMQRc5Bn/c0EUBditUHNfMMxN7M82c38b1kEggVE3pLpZ0FwkwJkUEKMiOi52JXFA==}
+ engines: {node: '>18.12'}
+ peerDependencies:
+ '@vue/compiler-sfc': 3.x
+ prettier: 2.x - 3.x
+ prettier-plugin-svelte: 3.x
+ svelte: 4.x || 5.x
+ peerDependenciesMeta:
+ '@vue/compiler-sfc':
+ optional: true
+ prettier-plugin-svelte:
+ optional: true
+ svelte:
+ optional: true
+
'@types/aria-query@5.0.4':
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
@@ -1179,10 +1214,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 +1302,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 +1380,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 +1397,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,10 +1435,19 @@ 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'}
+ eslint-plugin-fsd-lint@1.0.9:
+ resolution: {integrity: sha512-D5Rh40tX9oqD63uD4RfYK6ZfbsMOGEstTC/nbRWmE5S4AK+WB2dibISyw9LQ5BIoP77yew/SmJ7fZ1iIH5IneA==}
+ peerDependencies:
+ eslint: '>=9.0.0'
+
eslint-plugin-react-hooks@5.2.0:
resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==}
engines: {node: '>=10'}
@@ -1471,10 +1536,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 +1580,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 +1612,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 +1633,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 +1731,9 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
+ javascript-natural-sort@0.7.1:
+ resolution: {integrity: sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==}
+
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
@@ -2345,6 +2492,24 @@ packages:
resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==}
engines: {node: '>=18'}
+ zustand@5.0.7:
+ resolution: {integrity: sha512-Ot6uqHDW/O2VdYsKLLU8GQu8sCOM1LcoE8RwvLv9uuRT9s6SOHCKs0ZEOhxg+I1Ld+A1Q5lwx+UlKXXUoCZITg==}
+ engines: {node: '>=12.20.0'}
+ peerDependencies:
+ '@types/react': '>=18.0.0'
+ immer: '>=9.0.6'
+ react: '>=18.0.0'
+ use-sync-external-store: '>=1.2.0'
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ immer:
+ optional: true
+ react:
+ optional: true
+ use-sync-external-store:
+ optional: true
+
snapshots:
'@adobe/css-tools@4.4.0': {}
@@ -2362,12 +2527,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 +3240,16 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.46.2':
optional: true
+ '@tanstack/query-core@5.83.1': {}
+
+ '@tanstack/react-query@5.84.2(react@19.1.1)':
+ dependencies:
+ '@tanstack/query-core': 5.83.1
+ 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
@@ -3116,6 +3282,18 @@ snapshots:
dependencies:
'@testing-library/dom': 10.4.0
+ '@trivago/prettier-plugin-sort-imports@5.2.2(prettier@3.6.2)':
+ dependencies:
+ '@babel/generator': 7.28.0
+ '@babel/parser': 7.28.0
+ '@babel/traverse': 7.28.0
+ '@babel/types': 7.28.2
+ javascript-natural-sort: 0.7.1
+ lodash: 4.17.21
+ prettier: 3.6.2
+ transitivePeerDependencies:
+ - supports-color
+
'@types/aria-query@5.0.4': {}
'@types/babel__core@7.20.5':
@@ -3347,7 +3525,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 +3571,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 +3659,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 +3711,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 +3727,8 @@ snapshots:
electron-to-chromium@1.5.45: {}
+ email-addresses@5.0.0: {}
+
emoji-regex@8.0.0: {}
entities@4.5.0: {}
@@ -3588,8 +3780,14 @@ snapshots:
escalade@3.2.0: {}
+ escape-string-regexp@1.0.5: {}
+
escape-string-regexp@4.0.0: {}
+ eslint-plugin-fsd-lint@1.0.9(eslint@9.33.0):
+ dependencies:
+ eslint: 9.33.0
+
eslint-plugin-react-hooks@5.2.0(eslint@9.33.0):
dependencies:
eslint: 9.33.0
@@ -3697,10 +3895,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 +3940,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 +3975,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 +3997,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 +4079,8 @@ snapshots:
isexe@2.0.0: {}
+ javascript-natural-sort@0.7.1: {}
+
js-tokens@4.0.0: {}
js-tokens@9.0.1: {}
@@ -3880,6 +4126,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 +4141,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 +4173,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 +4252,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 +4284,8 @@ snapshots:
path-to-regexp@6.3.0: {}
+ path-type@4.0.0: {}
+
pathe@2.0.3: {}
pathval@2.0.0: {}
@@ -4026,6 +4298,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 +4457,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 +4489,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 +4543,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 +4578,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
@@ -4473,3 +4761,8 @@ snapshots:
yocto-queue@0.1.0: {}
yoctocolors-cjs@2.1.2: {}
+
+ zustand@5.0.7(@types/react@19.1.9)(react@19.1.1):
+ optionalDependencies:
+ '@types/react': 19.1.9
+ react: 19.1.1
diff --git a/src/App.tsx b/src/App.tsx
index 0c0032aab..f446ab52d 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,20 +1,26 @@
-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"
+import { BrowserRouter as Router } from 'react-router-dom';
+
+import PostsManagerPage from '@/pages/PostsManagerPage.tsx';
+import Footer from '@/shared/ui/Footer.tsx';
+import Header from '@/shared/ui/Header.tsx';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+
+const queryClient = new QueryClient();
const App = () => {
return (
-
-
-
- )
-}
+
+
+
+
+
+ );
+};
-export default App
+export default App;
diff --git a/src/assets/react.svg b/src/app/assets/react.svg
similarity index 100%
rename from src/assets/react.svg
rename to src/app/assets/react.svg
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/comments/api/index.ts b/src/entities/comments/api/index.ts
new file mode 100644
index 000000000..9008da523
--- /dev/null
+++ b/src/entities/comments/api/index.ts
@@ -0,0 +1,56 @@
+import {
+ Comments,
+ CommentsResponse,
+ PostAddComment,
+ PutCommentsDetail,
+} from '../model/types';
+import { api } from '@/shared/lib/api.ts';
+
+/**
+ * Comments API
+ */
+export const commentsApi = {
+ /**
+ * 댓글 조회
+ */
+ getComments: async (postId: number) => {
+ return api.get<{ comments: Comments[] }>(`/comments/post/${postId}`);
+ },
+
+ /**
+ * 댓글 추가
+ */
+ addComment: async (comment: PostAddComment): Promise => {
+ return api.post('/comments/add', comment);
+ },
+
+ /**
+ * 댓글 수정
+ */
+ updateComment: async (
+ commentData: PutCommentsDetail,
+ ): Promise => {
+ return api.put(
+ `/comments/${commentData.id}`,
+ {
+ body: commentData.body,
+ },
+ );
+ },
+
+ /**
+ * 댓글 삭제
+ */
+ deleteComment: async (id: number): Promise => {
+ return api.delete(`/comments/${id}`);
+ },
+
+ /**
+ * 댓글 좋아요
+ */
+ likeComment: async (id: number, likes: number): Promise => {
+ return api.patch(`/comments/${id}`, {
+ likes,
+ });
+ },
+};
diff --git a/src/entities/comments/index.ts b/src/entities/comments/index.ts
new file mode 100644
index 000000000..bed443678
--- /dev/null
+++ b/src/entities/comments/index.ts
@@ -0,0 +1,2 @@
+export * from './api';
+export * from './model';
diff --git a/src/entities/comments/model/index.ts b/src/entities/comments/model/index.ts
new file mode 100644
index 000000000..5fadfaf0c
--- /dev/null
+++ b/src/entities/comments/model/index.ts
@@ -0,0 +1,2 @@
+export * from '../../../features/comments/model/hooks.ts';
+export * from './types';
diff --git a/src/entities/comments/model/types.ts b/src/entities/comments/model/types.ts
new file mode 100644
index 000000000..fba527f33
--- /dev/null
+++ b/src/entities/comments/model/types.ts
@@ -0,0 +1,45 @@
+//http://localhost:5173/api/comments/post
+interface CommentsUser {
+ fullName: string;
+ id: number;
+ username: string;
+}
+export interface Comments {
+ id: number;
+ body: string;
+ postId: number;
+ likes: number;
+ user: CommentsUser;
+}
+
+export interface CommentsResponse {
+ postId: number;
+ comments: Comments;
+ limit: number;
+ skip: number;
+ total: number;
+}
+//POST http://localhost:5173/api/comments/add
+export interface PostAddComment {
+ body: string;
+ postId: number;
+ userId: number;
+}
+//DELETE http://localhost:5173/api/comments/84
+export interface DeleteComment {
+ deleteOn: string;
+ isDeleted: true;
+ id: number;
+ body: string;
+ likes: number;
+ postId: number;
+ user: CommentsUser;
+}
+
+// PATCH http://localhost:5173/api/comments/196
+export type PatchCommentsDetailRequest = Pick;
+export type PatchCommentsDetailResponse = Comments;
+
+// PUT http://localhost:5173/api/comments/196
+export type PutCommentsDetail = Pick;
+export type PutCommentsDetailResponse = Comments;
diff --git a/src/entities/posts/api/api.ts b/src/entities/posts/api/api.ts
new file mode 100644
index 000000000..c5445e8aa
--- /dev/null
+++ b/src/entities/posts/api/api.ts
@@ -0,0 +1,92 @@
+import {
+ Post,
+ PostsAddRequest,
+ PostsResponse,
+ PostTag,
+} from '../model/types.ts';
+import { api } from '@/shared/lib/api.ts';
+
+/**
+ * Posts API - 게시물 관련 로직만 처리 (사용자 데이터 제외)
+ */
+const postsApi = {
+ /**
+ * 게시물 목록 조회 (순수 게시물 데이터만)
+ */
+ getPosts: async ({
+ limit = 0,
+ skip = 0,
+ }: {
+ limit?: number;
+ skip?: number;
+ } = {}): Promise => {
+ return api.get('/posts', { params: { limit, skip } });
+ },
+
+ /**
+ * 단일 게시물 조회
+ */
+ getPost: async (id: number): Promise => {
+ return api.get(`/posts/${id}`);
+ },
+
+ /**
+ * 게시물 생성
+ */
+ createPost: async (postData: PostsAddRequest): Promise => {
+ return api.post('/posts/add', postData);
+ },
+
+ /**
+ * 게시물 수정
+ */
+ updatePost: async (postData: Partial): Promise => {
+ return api.put>(`/posts/${postData.id}`, postData);
+ },
+
+ /**
+ * 게시물 부분 수정
+ */
+ patchPost: async (id: number, partialData: Partial): Promise => {
+ return api.patch>(`/posts/${id}`, partialData);
+ },
+
+ /**
+ * 게시물 삭제
+ */
+ deletePost: async (id: number): Promise => {
+ return api.delete(`/posts/${id}`);
+ },
+
+ /**
+ * 게시물 검색
+ */
+ searchPosts: async (query: string): Promise => {
+ return api.get('/posts/search', { params: { q: query } });
+ },
+
+ /**
+ * 태그별 게시물 조회
+ */
+ getPostsByTag: async (tag: string): Promise => {
+ return api.get(`/posts/tag/${tag}`);
+ },
+
+ /**
+ * 특정 사용자의 게시물 조회
+ */
+ getPostsByUser: async (userId: number): Promise => {
+ return api.get(`/posts/user/${userId}`);
+ },
+
+ /**
+ * 사용 가능한 태그 목록 조회
+ */
+ getTags: async (): Promise => {
+ return api.get('/posts/tags');
+ },
+};
+
+// 기존 getPosts 함수는 제거하고 postsApi.getPosts 사용
+
+export default postsApi;
diff --git a/src/entities/posts/api/index.ts b/src/entities/posts/api/index.ts
new file mode 100644
index 000000000..bf6457bab
--- /dev/null
+++ b/src/entities/posts/api/index.ts
@@ -0,0 +1 @@
+export { default as postsApi } from './api';
diff --git a/src/entities/posts/index.ts b/src/entities/posts/index.ts
new file mode 100644
index 000000000..6285e6224
--- /dev/null
+++ b/src/entities/posts/index.ts
@@ -0,0 +1,3 @@
+// export { default as PostTable } from '@/widgets/posts/posts-table/ui/PostTable.tsx';
+export * from './api';
+export * from './model';
diff --git a/src/entities/posts/model/hooks.ts b/src/entities/posts/model/hooks.ts
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/entities/posts/model/index.ts b/src/entities/posts/model/index.ts
new file mode 100644
index 000000000..998a67ee1
--- /dev/null
+++ b/src/entities/posts/model/index.ts
@@ -0,0 +1,2 @@
+export * from './types.ts';
+export * from './hooks.ts';
diff --git a/src/entities/posts/model/types.ts b/src/entities/posts/model/types.ts
new file mode 100644
index 000000000..a8b54833c
--- /dev/null
+++ b/src/entities/posts/model/types.ts
@@ -0,0 +1,86 @@
+// http://localhost:5173/api/posts?limit=10&skip=0
+import { BasicUser } from '@/shared/model/types.ts';
+
+export interface PostsResponse {
+ posts: Post[];
+ total: number;
+ skip: number;
+ limit: number;
+}
+// http://localhost:5173/api/posts/search?q=all
+export interface PostsSearchResponse {
+ posts: Post[];
+ total: number;
+ skip: number;
+ limit: number;
+}
+
+// http://localhost:5173/api/posts/tag/mystery
+export interface PostsTagDetailResponse {
+ posts: Post[];
+ total: number;
+ skip: number;
+ limit: number;
+}
+
+// http://localhost:5173/api/posts/tags
+export type PostsTagsResponse = PostTag[];
+
+// POST http://localhost:5173/api/posts/add
+type RequiredPostsAddKey = 'title' | 'body' | 'userId';
+export type PostsAddRequest = Pick &
+ Partial>;
+
+// PUT http://localhost:5173/api/posts/252
+type RequiredPutPostsDetailRequestKey = 'title' | 'body' | 'userId' | 'id';
+export type PutPostsDetailRequest<
+ T extends Pick &
+ Partial>,
+> = T;
+
+export type PostAuthor = BasicUser;
+// DELETE http://localhost:5173/api/posts/252
+export type DeletePostsDetailResponse = Pick;
+
+export type PostsAddResponse = Pick;
+
+export interface PostTag {
+ name: string;
+ slug: string;
+ url: string;
+}
+
+export interface Post {
+ // 게시글 아이디
+ id: number;
+
+ // 게시글 내용
+ title: string;
+ body: string;
+
+ // 게시글 태그
+ tags: PostTag['slug'][];
+
+ // 게시글 반응
+ reactions: {
+ likes: number;
+ dislikes: number;
+ };
+ author?: PostAuthor;
+ // 조회수
+ views: number;
+
+ // 작성자 유저 아이디
+ userId: number;
+}
+
+export interface NewPostAddedRequest {
+ newPost: PostsAddRequest;
+ setNewPost: (post: PostsAddRequest) => void;
+ resetNewPost: () => void;
+}
+
+export interface SelectedPost {
+ title: string;
+ body?: string;
+}
diff --git a/src/entities/tags/api/fetch.ts b/src/entities/tags/api/fetch.ts
new file mode 100644
index 000000000..c83048849
--- /dev/null
+++ b/src/entities/tags/api/fetch.ts
@@ -0,0 +1,7 @@
+export const fetchTags = async () => {
+ try {
+ return await fetch('/api/posts/tags');
+ } catch (error) {
+ console.error('태그 가져오기 오류:', error);
+ }
+};
diff --git a/src/entities/users/api/index.ts b/src/entities/users/api/index.ts
new file mode 100644
index 000000000..0fd00c21f
--- /dev/null
+++ b/src/entities/users/api/index.ts
@@ -0,0 +1,14 @@
+import { api } from '@/shared/lib/api.ts';
+
+import { UserInit, UserResponse } from '../model/types.ts';
+
+const userApi = {
+ getUser: async (userId: number): Promise => {
+ return api.get(`/users/${userId}`);
+ },
+ getUserPost: async (): Promise => {
+ return api.get('/users?limit=0&select=username,image');
+ },
+};
+
+export default userApi;
diff --git a/src/entities/users/index.ts b/src/entities/users/index.ts
new file mode 100644
index 000000000..ac668e0ef
--- /dev/null
+++ b/src/entities/users/index.ts
@@ -0,0 +1,3 @@
+// export { useGetPost, useGetUser } from './hooks';
+export { default as userApi } from './api';
+export * from './model'; // 타입들을 공개 API로 export
diff --git a/src/entities/users/model/hooks.ts b/src/entities/users/model/hooks.ts
new file mode 100644
index 000000000..90cfe6d43
--- /dev/null
+++ b/src/entities/users/model/hooks.ts
@@ -0,0 +1,21 @@
+import { useQuery } from '@tanstack/react-query';
+
+import userApi from '../api';
+
+export const useGetUser = (userId: number) => {
+ const { data, isLoading, error } = useQuery({
+ queryKey: ['user', userId],
+ queryFn: () => userApi.getUser(userId),
+ });
+
+ return { data, isLoading, error };
+};
+
+export const useGetPost = () => {
+ const { data, isLoading, error } = useQuery({
+ queryKey: ['posts'],
+ queryFn: () => userApi.getUserPost(),
+ });
+
+ return { data, isLoading, error };
+};
diff --git a/src/entities/users/model/index.ts b/src/entities/users/model/index.ts
new file mode 100644
index 000000000..998a67ee1
--- /dev/null
+++ b/src/entities/users/model/index.ts
@@ -0,0 +1,2 @@
+export * from './types.ts';
+export * from './hooks.ts';
diff --git a/src/entities/users/model/types.ts b/src/entities/users/model/types.ts
new file mode 100644
index 000000000..3eac12a18
--- /dev/null
+++ b/src/entities/users/model/types.ts
@@ -0,0 +1,112 @@
+import { BasicUser } from '@/shared/model/types.ts';
+
+type Gender = 'male' | 'femail';
+
+interface Hair {
+ color: string;
+ type: string;
+}
+
+// 주소 타입
+interface Address {
+ address: string;
+ city: string;
+ state: string;
+ stateCode: string;
+ postalCode: string;
+ coordinates: {
+ lat: number;
+ lng: number;
+ };
+ country: string;
+}
+
+interface Bank {
+ cardExpire: string;
+ cardNumber: string;
+ cardType: string;
+ iban: string;
+}
+interface Company {
+ address: Address;
+ department: string;
+ name: string;
+ title: string;
+}
+interface Crypto {
+ coin: string;
+ wallet: string;
+ network: string;
+}
+// ============================================
+// 메인 사용자 인터페이스
+// ============================================
+
+// http://localhost:5173/api/users/${user.id}
+export interface UserResponse {
+ // ============================================
+ // 기본 식별 정보
+ // ============================================
+ id: number;
+ firstName: string;
+ lastName: string;
+ maidenName: string;
+ username: string;
+ email: string;
+ phone: string;
+
+ // ============================================
+ // 개인 정보
+ // ============================================
+ age: number;
+ gender: Gender;
+ birthDate: string;
+ bloodGroup: string;
+
+ // ============================================
+ // 신체 정보
+ // ============================================
+ height: number;
+ weight: number;
+ eyeColor: string;
+ hair: Hair;
+ image: string;
+
+ // ============================================
+ // 주소 정보
+ // ============================================
+ address: Address;
+
+ // ============================================
+ // 보안/시스템 정보
+ // ============================================
+ password: string;
+ role: string;
+ ip: string;
+ macAddress: string;
+ userAgent: string;
+
+ // ============================================
+ // 금융 정보
+ // ============================================
+ bank: Bank;
+ crypto: Crypto;
+
+ // ============================================
+ // 기타 정보
+ // ============================================
+ university: string;
+ company: Company;
+ ein: string;
+ ssn: string;
+}
+//http://localhost:5173/api/users
+export type UserInComment = BasicUser;
+
+//http://localhost:5173/api/users?limit=0&select=username,image
+export interface UserInit {
+ limit: number;
+ skip: number;
+ total: number;
+ users: UserInComment[];
+}
diff --git a/src/features/comments/index.ts b/src/features/comments/index.ts
new file mode 100644
index 000000000..c51f0c574
--- /dev/null
+++ b/src/features/comments/index.ts
@@ -0,0 +1,2 @@
+export * from './ui';
+export * from './model';
diff --git a/src/features/comments/model/hooks.ts b/src/features/comments/model/hooks.ts
new file mode 100644
index 000000000..9bf567df6
--- /dev/null
+++ b/src/features/comments/model/hooks.ts
@@ -0,0 +1,71 @@
+import { useQuery } from '@tanstack/react-query';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import {
+ Comments,
+ commentsApi,
+ PostAddComment,
+ PutCommentsDetail,
+} from '@/entities/comments';
+
+export const useGetComment = (id: number) => {
+ return useQuery({
+ queryKey: ['comments', id],
+ queryFn: () => commentsApi.getComments(id),
+ });
+};
+export const useComments = () => {
+ const queryClient = useQueryClient();
+
+ return {
+ createComments: useMutation({
+ mutationFn: (comment: PostAddComment) => commentsApi.addComment(comment),
+ onSuccess: (newComment) => {
+ const postId = newComment.postId;
+
+ console.log(postId, newComment);
+ queryClient.setQueryData(
+ ['comments', postId],
+ (oldData: { comments: Comments[] } | undefined) => {
+ if (!oldData) {
+ return { comments: [newComment] };
+ }
+ return {
+ comments: [...oldData.comments, newComment],
+ };
+ },
+ );
+ },
+ onError: (error) => {
+ console.error('댓글 생성 실패:', error);
+ },
+ }),
+ updateComment: useMutation({
+ mutationFn: (comment: PutCommentsDetail) =>
+ commentsApi.updateComment(comment),
+ onSuccess: (newComment) => {
+ queryClient.invalidateQueries({
+ queryKey: ['comments', newComment.postId],
+ });
+ },
+ onError: (error) => {
+ console.error('댓글 생성 실패:', error);
+ },
+ }),
+ deleteComment: useMutation({
+ mutationFn: ({ id }: { id: number; postId: number }) =>
+ commentsApi.deleteComment(id),
+ onSuccess: (_, { id, postId }) => {
+ // ✅ 방법 2: 캐시 직접 업데이트 (더 빠름)
+ queryClient.setQueryData(
+ ['comments', postId],
+ (oldData: { comments: Comments[] } | undefined) => {
+ if (!oldData) return { comments: [] };
+ return {
+ comments: oldData.comments.filter((comment) => comment.id !== id),
+ };
+ },
+ );
+ },
+ }),
+ };
+};
diff --git a/src/features/comments/model/index.ts b/src/features/comments/model/index.ts
new file mode 100644
index 000000000..0f3b06115
--- /dev/null
+++ b/src/features/comments/model/index.ts
@@ -0,0 +1,2 @@
+export * from './store';
+export * from './hooks';
diff --git a/src/features/comments/model/store/index.ts b/src/features/comments/model/store/index.ts
new file mode 100644
index 000000000..63ee2686c
--- /dev/null
+++ b/src/features/comments/model/store/index.ts
@@ -0,0 +1 @@
+export * from './useCommentStore.ts';
diff --git a/src/features/comments/model/store/useCommentDialog.ts b/src/features/comments/model/store/useCommentDialog.ts
new file mode 100644
index 000000000..4838a32e1
--- /dev/null
+++ b/src/features/comments/model/store/useCommentDialog.ts
@@ -0,0 +1,30 @@
+import { create } from 'zustand';
+import {
+ Comments,
+ PostAddComment,
+} from '../../../../entities/comments/model/types.ts';
+
+interface CommentState {
+ selectedComment: Comments | null;
+ showAddCommentDialog: boolean;
+ showEditCommentDialog: boolean;
+ newComment: PostAddComment;
+ setSelectedComment: (comment: Comments | null) => void;
+ setShowAddCommentDialog: (show: boolean) => void;
+ setShowEditCommentDialog: (show: boolean) => void;
+ setNewComment: (comment: PostAddComment) => void;
+ resetNewComment: () => void;
+}
+
+export const useCommentStore = create((set) => ({
+ selectedComment: null,
+ showAddCommentDialog: false,
+ showEditCommentDialog: false,
+ newComment: { body: '', postId: 0, userId: 1 },
+ setSelectedComment: (comment) => set({ selectedComment: comment }),
+ setShowAddCommentDialog: (show) => set({ showAddCommentDialog: show }),
+ setShowEditCommentDialog: (show) => set({ showEditCommentDialog: show }),
+ setNewComment: (comment) => set({ newComment: comment }),
+ resetNewComment: () =>
+ set({ newComment: { body: '', postId: 0, userId: 1 } }),
+}));
diff --git a/src/features/comments/model/store/useCommentStore.ts b/src/features/comments/model/store/useCommentStore.ts
new file mode 100644
index 000000000..4838a32e1
--- /dev/null
+++ b/src/features/comments/model/store/useCommentStore.ts
@@ -0,0 +1,30 @@
+import { create } from 'zustand';
+import {
+ Comments,
+ PostAddComment,
+} from '../../../../entities/comments/model/types.ts';
+
+interface CommentState {
+ selectedComment: Comments | null;
+ showAddCommentDialog: boolean;
+ showEditCommentDialog: boolean;
+ newComment: PostAddComment;
+ setSelectedComment: (comment: Comments | null) => void;
+ setShowAddCommentDialog: (show: boolean) => void;
+ setShowEditCommentDialog: (show: boolean) => void;
+ setNewComment: (comment: PostAddComment) => void;
+ resetNewComment: () => void;
+}
+
+export const useCommentStore = create((set) => ({
+ selectedComment: null,
+ showAddCommentDialog: false,
+ showEditCommentDialog: false,
+ newComment: { body: '', postId: 0, userId: 1 },
+ setSelectedComment: (comment) => set({ selectedComment: comment }),
+ setShowAddCommentDialog: (show) => set({ showAddCommentDialog: show }),
+ setShowEditCommentDialog: (show) => set({ showEditCommentDialog: show }),
+ setNewComment: (comment) => set({ newComment: comment }),
+ resetNewComment: () =>
+ set({ newComment: { body: '', postId: 0, userId: 1 } }),
+}));
diff --git a/src/features/comments/ui/AddComment.tsx b/src/features/comments/ui/AddComment.tsx
new file mode 100644
index 000000000..d8e8f34cd
--- /dev/null
+++ b/src/features/comments/ui/AddComment.tsx
@@ -0,0 +1,66 @@
+import { Button } from '@/shared/ui/button';
+import { Dialog } from '@/shared/ui/dialog';
+import { Input } from '@/shared/ui/input';
+import { useCommentStore } from '../model/store';
+import { useComments } from '../model/hooks.ts';
+// eslint-disable-next-line fsd/no-relative-imports
+import { useSelectedPostStore } from '../../../features/posts/model/store';
+
+const AddComment = () => {
+ const { showAddCommentDialog, setShowAddCommentDialog } = useCommentStore();
+ const { selectedPost } = useSelectedPostStore();
+ const { newComment, setNewComment, resetNewComment } = useCommentStore();
+ const { createComments } = useComments();
+ const handelAddComment = async () => {
+ // ✅ 검증 추가
+ if (!selectedPost?.id) {
+ console.error('선택된 포스트가 없습니다');
+ return;
+ }
+
+ if (!newComment.body.trim()) {
+ console.error('댓글 내용을 입력해주세요');
+ return;
+ }
+
+ try {
+ // ✅ 올바른 데이터로 mutation 실행
+ await createComments.mutateAsync({
+ body: newComment.body.trim(), // ✅ 댓글 내용 (포스트 내용 아님!)
+ postId: selectedPost.id,
+ userId: 1, // ✅ 현재 사용자 ID (selectedPost.userId 아님!)
+ });
+
+ setShowAddCommentDialog(false);
+ resetNewComment();
+ } catch (error) {
+ console.error('댓글 추가 실패:', error);
+ }
+ };
+ return (
+
+ );
+};
+
+export default AddComment;
diff --git a/src/features/comments/ui/Comments.tsx b/src/features/comments/ui/Comments.tsx
new file mode 100644
index 000000000..3c513bf5a
--- /dev/null
+++ b/src/features/comments/ui/Comments.tsx
@@ -0,0 +1,86 @@
+import { Edit2, Plus, ThumbsUp, Trash2 } from 'lucide-react';
+import { useCommentStore } from '../model/store';
+import { useComments, useGetComment } from '../model/hooks.ts';
+import { Button } from '@/shared/ui/button';
+import { highlightText } from '@/shared/lib/highlightText.tsx';
+
+const Comments = ({
+ postId,
+ searchValue,
+}: {
+ postId: number;
+ searchValue: string;
+}) => {
+ const {
+ setSelectedComment,
+ setShowAddCommentDialog,
+ setShowEditCommentDialog,
+ resetNewComment,
+ } = useCommentStore();
+ const { data } = useGetComment(postId);
+
+ const { deleteComment } = useComments();
+ return (
+
+
+
댓글
+
+
+
+ {data?.comments.map((comment) => (
+
+
+
+ {comment.user.username}:
+
+
+ {highlightText(comment.body, searchValue)}
+
+
+
+
+
+
+
+
+ ))}
+
+
+ );
+};
+
+export default Comments;
diff --git a/src/features/comments/ui/EditComment.tsx b/src/features/comments/ui/EditComment.tsx
new file mode 100644
index 000000000..723167fee
--- /dev/null
+++ b/src/features/comments/ui/EditComment.tsx
@@ -0,0 +1,59 @@
+import { Button } from '@/shared/ui/button';
+import { Dialog } from '@/shared/ui/dialog';
+import { Input } from '@/shared/ui/input';
+import { useCommentStore } from '../model/store';
+import { useComments } from '../model/hooks.ts';
+
+const EditComment = () => {
+ const { showEditCommentDialog, setShowEditCommentDialog } = useCommentStore();
+ const { selectedComment, setSelectedComment } = useCommentStore();
+ const { updateComment } = useComments();
+ const handelEditComment = () => {
+ if (selectedComment?.id) {
+ // ✅ API가 기대하는 형태로 전달
+ updateComment.mutate(
+ {
+ id: selectedComment.id,
+ body: selectedComment.body,
+ },
+ {
+ onSuccess: () => {
+ setShowEditCommentDialog(false);
+ },
+ },
+ );
+ }
+ };
+
+ return (
+
+ );
+};
+
+export default EditComment;
diff --git a/src/features/comments/ui/index.ts b/src/features/comments/ui/index.ts
new file mode 100644
index 000000000..52602d5ab
--- /dev/null
+++ b/src/features/comments/ui/index.ts
@@ -0,0 +1 @@
+export { default as Comments } from './Comments';
diff --git a/src/features/posts/index.ts b/src/features/posts/index.ts
new file mode 100644
index 000000000..c51f0c574
--- /dev/null
+++ b/src/features/posts/index.ts
@@ -0,0 +1,2 @@
+export * from './ui';
+export * from './model';
diff --git a/src/features/posts/lib/useURLSync.ts b/src/features/posts/lib/useURLSync.ts
new file mode 100644
index 000000000..c9765a57a
--- /dev/null
+++ b/src/features/posts/lib/useURLSync.ts
@@ -0,0 +1,35 @@
+import { useLocation, useNavigate } from 'react-router-dom';
+import { usePostFilterStore, useSearchStore } from '../model';
+import { useEffect } from 'react';
+
+export const useURLSync = () => {
+ const navigate = useNavigate();
+ const location = useLocation();
+
+ const { skip, limit, selectedTag, sortBy, sortOrder, setFilter } =
+ usePostFilterStore();
+ const { searchQuery, setSearchQuery } = useSearchStore();
+
+ useEffect(() => {
+ const params = new URLSearchParams(location.search);
+ setFilter('skip', parseInt(params.get('skip') || '0'));
+ setFilter('limit', parseInt(params.get('limit') || '10'));
+ setFilter('sortBy', params.get('sortBy') || '');
+ setFilter('sortOrder', params.get('sortOrder') || 'asc');
+ setFilter('selectedTag', params.get('tag') || '');
+ if (params.get('search')) {
+ setSearchQuery(params.get('search') || '');
+ }
+ }, [location.search, setFilter, setSearchQuery]);
+
+ useEffect(() => {
+ const params = new URLSearchParams();
+ if (skip) params.set('skip', skip.toString());
+ if (limit) params.set('limit', limit.toString());
+ if (sortBy) params.set('sortBy', sortBy);
+ if (sortOrder) params.set('sortOrder', sortOrder);
+ if (selectedTag) params.set('tag', selectedTag);
+ if (searchQuery) params.set('search', searchQuery);
+ navigate(`?${params.toString()}`);
+ }, [skip, limit, sortBy, sortOrder, selectedTag, searchQuery, navigate]);
+};
diff --git a/src/features/posts/model/hooks.ts b/src/features/posts/model/hooks.ts
new file mode 100644
index 000000000..c6db0ad83
--- /dev/null
+++ b/src/features/posts/model/hooks.ts
@@ -0,0 +1,111 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+
+import { PostsAddRequest, postsApi, PostsResponse } from '@/entities/posts';
+import { Post } from '@/entities/posts';
+import { userApi } from '@/entities/users';
+
+interface UsePostsQueryProps {
+ limit: number;
+ skip: number;
+ tag?: string;
+ searchQuery?: string;
+}
+export const useGetPosts = ({
+ limit = 10,
+ skip = 0,
+ tag,
+ searchQuery,
+}: UsePostsQueryProps) =>
+ useQuery({
+ queryKey: ['posts', skip, limit, tag, searchQuery],
+ queryFn: async () => {
+ // 구조상 여라개를 할수 없어서 그냥 분기처리만 함.
+ let postData;
+ // 검색
+ if (searchQuery) {
+ postData = await postsApi.searchPosts(searchQuery);
+ } else if (tag && tag !== 'all') {
+ // 태그
+ postData = await postsApi.getPostsByTag(tag);
+ } else {
+ // 기존 필터
+ postData = await postsApi.getPosts({ limit, skip });
+ }
+
+ const userData = await userApi.getUserPost();
+ const postsWithUsers = postData.posts.map((post) => ({
+ ...post,
+ author: userData.users.find((user) => user.id === post.userId),
+ }));
+ return {
+ posts: postsWithUsers,
+ total: postData.total,
+ };
+ },
+ });
+
+export const usePosts = () => {
+ const queryClient = useQueryClient();
+
+ return {
+ // Query
+
+ usePostsByTag: (tag: string) => {
+ return useQuery({
+ queryKey: ['posts', 'tag', tag],
+ queryFn: async () => {
+ const postsData = await postsApi.getPostsByTag(tag);
+ const usersData = await userApi.getUserPost();
+ const postsWithUsers = postsData.posts.map((post) => ({
+ ...post,
+ author: usersData.users.find((user) => user.id === post.userId),
+ }));
+ return {
+ posts: postsWithUsers,
+ total: postsData.total,
+ };
+ },
+ enabled: !!tag && tag !== 'all',
+ });
+ },
+ // Mutations
+ createPost: useMutation({
+ mutationFn: async (newPost: PostsAddRequest) =>
+ await postsApi.createPost(newPost),
+ onSuccess: (newPost) => {
+ // queryClient.invalidateQueries({ queryKey: ['posts'] });
+
+ const queries = queryClient.getQueriesData({ queryKey: ['posts'] });
+ queries.forEach(([queryKey, data]) => {
+ if (data && typeof data === 'object' && 'posts' in data) {
+ const currentData = data as PostsResponse;
+ const updatedData: PostsResponse = {
+ ...currentData,
+ posts: [newPost, ...currentData.posts],
+ total: currentData.total + 1,
+ };
+ queryClient.setQueryData(queryKey, updatedData);
+ }
+ });
+ // onSuccess?.();
+ // queryClient.setQueryData(['posts', newPost.id], newPost);
+ },
+ }),
+
+ updatePost: useMutation({
+ mutationFn: ({ data }: { data: Post }) => postsApi.updatePost(data),
+ onSuccess: (updatedPost) => {
+ queryClient.invalidateQueries({ queryKey: ['posts'] });
+ queryClient.setQueryData(['posts', updatedPost.id], updatedPost);
+ },
+ }),
+
+ deletePost: useMutation({
+ mutationFn: (id: number) => postsApi.deletePost(id),
+ onSuccess: (_, deletedId) => {
+ queryClient.invalidateQueries({ queryKey: ['posts'] });
+ queryClient.removeQueries({ queryKey: ['posts', deletedId] });
+ },
+ }),
+ };
+};
diff --git a/src/features/posts/model/index.ts b/src/features/posts/model/index.ts
new file mode 100644
index 000000000..0f3b06115
--- /dev/null
+++ b/src/features/posts/model/index.ts
@@ -0,0 +1,2 @@
+export * from './store';
+export * from './hooks';
diff --git a/src/features/posts/model/store/index.ts b/src/features/posts/model/store/index.ts
new file mode 100644
index 000000000..815eeee88
--- /dev/null
+++ b/src/features/posts/model/store/index.ts
@@ -0,0 +1,5 @@
+export * from './useDialogStore';
+export * from './usePostStore';
+export * from './useSearchStore';
+export * from './useNewPostStore';
+export * from './useSelectedPostStore';
diff --git a/src/features/posts/model/store/useDialogStore.ts b/src/features/posts/model/store/useDialogStore.ts
new file mode 100644
index 000000000..317ac26b1
--- /dev/null
+++ b/src/features/posts/model/store/useDialogStore.ts
@@ -0,0 +1,21 @@
+import { create } from 'zustand';
+
+interface DialogState {
+ showAddDialog: boolean;
+ showEditDialog: boolean;
+ showDetailDialog: boolean;
+
+ setShowAddDialog: (show: boolean) => void;
+ setShowEditDialog: (show: boolean) => void;
+ setShowDetailDialog: (show: boolean) => void;
+}
+
+export const useDialogStore = create((set) => ({
+ showAddDialog: false,
+ showEditDialog: false,
+ showDetailDialog: false,
+
+ setShowAddDialog: (show) => set({ showAddDialog: show }),
+ setShowEditDialog: (show) => set({ showEditDialog: show }),
+ setShowDetailDialog: (show) => set({ showDetailDialog: show }),
+}));
diff --git a/src/features/posts/model/store/useNewPostStore.ts b/src/features/posts/model/store/useNewPostStore.ts
new file mode 100644
index 000000000..f61a73c1b
--- /dev/null
+++ b/src/features/posts/model/store/useNewPostStore.ts
@@ -0,0 +1,8 @@
+import { create } from 'zustand';
+import { NewPostAddedRequest } from '@/entities/posts';
+
+export const useNewPostStore = create((set) => ({
+ newPost: { title: '', body: '', userId: 1 },
+ setNewPost: (post) => set({ newPost: post }),
+ resetNewPost: () => set({ newPost: { title: '', body: '', userId: 1 } }),
+}));
diff --git a/src/features/posts/model/store/usePostStore.ts b/src/features/posts/model/store/usePostStore.ts
new file mode 100644
index 000000000..bf958d069
--- /dev/null
+++ b/src/features/posts/model/store/usePostStore.ts
@@ -0,0 +1,27 @@
+import { create } from 'zustand';
+
+interface PostFilterState {
+ skip: number;
+ limit: number;
+ sortBy: string;
+ sortOrder: string;
+ selectedTag: string;
+}
+
+interface PostFilterActions {
+ setFilter: (
+ key: K,
+ value: PostFilterState[K],
+ ) => void;
+}
+
+export const usePostFilterStore = create(
+ (set) => ({
+ skip: 0,
+ limit: 10,
+ sortBy: '',
+ sortOrder: 'asc',
+ selectedTag: '',
+ setFilter: (key, value) => set((state) => ({ ...state, [key]: value })),
+ }),
+);
diff --git a/src/features/posts/model/store/useSearchStore.ts b/src/features/posts/model/store/useSearchStore.ts
new file mode 100644
index 000000000..e9a8cae84
--- /dev/null
+++ b/src/features/posts/model/store/useSearchStore.ts
@@ -0,0 +1,17 @@
+import { create } from 'zustand';
+
+interface SearchState {
+ searchValue: string;
+ searchQuery: string;
+ setSearchQuery: (query: string) => void;
+ setSearchValue: (value: string) => void;
+}
+
+export const useSearchStore = create((set) => ({
+ searchValue: '',
+ searchQuery: '',
+ setSearchQuery: (query) => set({ searchQuery: query }),
+ setSearchValue: (value) => {
+ set({ searchValue: value });
+ },
+}));
diff --git a/src/features/posts/model/store/useSelectedPostStore.ts b/src/features/posts/model/store/useSelectedPostStore.ts
new file mode 100644
index 000000000..46fe9cbab
--- /dev/null
+++ b/src/features/posts/model/store/useSelectedPostStore.ts
@@ -0,0 +1,12 @@
+import { create } from 'zustand';
+import { Post } from '@/entities/posts';
+
+interface SelectedPostState {
+ selectedPost: Post | null;
+ setSelectedPost: (post: Post | null) => void;
+}
+
+export const useSelectedPostStore = create((set) => ({
+ selectedPost: null,
+ setSelectedPost: (post) => set({ selectedPost: post }),
+}));
diff --git a/src/features/posts/ui/Pagination.tsx b/src/features/posts/ui/Pagination.tsx
new file mode 100644
index 000000000..f453c29ff
--- /dev/null
+++ b/src/features/posts/ui/Pagination.tsx
@@ -0,0 +1,44 @@
+import { Select } from '@/shared/ui/select';
+import { Button } from '@/shared/ui/button';
+import { usePostFilterStore } from '../model/store';
+
+const Pagination = ({ total }: { total: number }) => {
+ const { skip, limit, setFilter } = usePostFilterStore();
+ return (
+
+
+ 표시
+
+ 항목
+
+
+
+
+
+
+ );
+};
+
+export default Pagination;
diff --git a/src/features/posts/ui/PostAddDialog.tsx b/src/features/posts/ui/PostAddDialog.tsx
new file mode 100644
index 000000000..7f35e1000
--- /dev/null
+++ b/src/features/posts/ui/PostAddDialog.tsx
@@ -0,0 +1,56 @@
+import { useDialogStore, useNewPostStore } from '../model/store';
+import { usePosts } from '../model';
+
+import { Button } from '@/shared/ui/button';
+import { Dialog } from '@/shared/ui/dialog';
+import { Input } from '@/shared/ui/input';
+import { TextArea } from '@/shared/ui/textarea';
+
+const PostAddDialog = () => {
+ const { showAddDialog, setShowAddDialog } = useDialogStore();
+ const { newPost, setNewPost } = useNewPostStore();
+
+ const { createPost } = usePosts();
+ const handelAddPost = async () => {
+ return createPost.mutate(newPost, {
+ onSuccess: async () => {
+ setShowAddDialog(false);
+ setNewPost({ title: '', body: '', userId: 1 });
+ },
+ });
+ };
+ return (
+
+ );
+};
+
+export default PostAddDialog;
diff --git a/src/features/posts/ui/PostAddTrigger.tsx b/src/features/posts/ui/PostAddTrigger.tsx
new file mode 100644
index 000000000..56842fa2c
--- /dev/null
+++ b/src/features/posts/ui/PostAddTrigger.tsx
@@ -0,0 +1,16 @@
+import { Plus } from 'lucide-react';
+import { useDialogStore } from '../model';
+
+import { Button } from '@/shared/ui/button';
+
+const PostAddTrigger = () => {
+ const { setShowAddDialog } = useDialogStore();
+ return (
+
+ );
+};
+
+export default PostAddTrigger;
diff --git a/src/features/posts/ui/PostEditDialog.tsx b/src/features/posts/ui/PostEditDialog.tsx
new file mode 100644
index 000000000..25e74b7d5
--- /dev/null
+++ b/src/features/posts/ui/PostEditDialog.tsx
@@ -0,0 +1,62 @@
+import { useDialogStore, useSelectedPostStore } from '../model/store';
+import { usePosts } from '../model';
+import { Button } from '@/shared/ui/button';
+import { Dialog } from '@/shared/ui/dialog';
+import { Input } from '@/shared/ui/input';
+import { TextArea } from '@/shared/ui/textarea';
+
+const PostEditDialog = () => {
+ const { showEditDialog, setShowEditDialog } = useDialogStore();
+ const { selectedPost, setSelectedPost } = useSelectedPostStore();
+ const { updatePost } = usePosts();
+
+ const handleEditPost = async () => {
+ if (selectedPost) {
+ updatePost.mutate(
+ {
+ data: selectedPost, // ✅ 이제 Post 타입
+ },
+ {
+ onSuccess: () => {
+ setShowEditDialog(false);
+ },
+ },
+ );
+ }
+ };
+ return (
+
+ );
+};
+
+export default PostEditDialog;
diff --git a/src/features/posts/ui/PostFilterSortBy.tsx b/src/features/posts/ui/PostFilterSortBy.tsx
new file mode 100644
index 000000000..bd4ad30df
--- /dev/null
+++ b/src/features/posts/ui/PostFilterSortBy.tsx
@@ -0,0 +1,24 @@
+import { usePostFilterStore } from '../model/store';
+import { Select } from '@/shared/ui/select';
+
+const PostFilterSortBy = () => {
+ const { sortBy, setFilter } = usePostFilterStore();
+ return (
+
+ );
+};
+
+export default PostFilterSortBy;
diff --git a/src/features/posts/ui/PostFilterSortOrder.tsx b/src/features/posts/ui/PostFilterSortOrder.tsx
new file mode 100644
index 000000000..7a6b6ed4a
--- /dev/null
+++ b/src/features/posts/ui/PostFilterSortOrder.tsx
@@ -0,0 +1,24 @@
+import { usePostFilterStore } from '../model/store';
+
+import { Select } from '@/shared/ui/select';
+
+const PostFilterSortOrder = () => {
+ const { sortOrder, sortBy, setFilter } = usePostFilterStore();
+ return (
+
+ );
+};
+
+export default PostFilterSortOrder;
diff --git a/src/features/posts/ui/PostSearchBar.tsx b/src/features/posts/ui/PostSearchBar.tsx
new file mode 100644
index 000000000..70bd1fe5b
--- /dev/null
+++ b/src/features/posts/ui/PostSearchBar.tsx
@@ -0,0 +1,34 @@
+import { Search } from 'lucide-react';
+import { useSearchStore } from '../model/store';
+import { Input } from '@/shared/ui/input';
+
+interface PostSearchBarProps {
+ placeholder?: string;
+}
+
+const PostSearchBar = ({
+ placeholder = '게시물 검색...',
+}: PostSearchBarProps) => {
+ const { searchValue, setSearchValue, setSearchQuery } = useSearchStore();
+
+ return (
+
+
+
+ setSearchValue(e.target.value)}
+ onKeyDown={(e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ setSearchQuery(searchValue);
+ }
+ }}
+ />
+
+
+ );
+};
+
+export default PostSearchBar;
diff --git a/src/features/posts/ui/PostTable.tsx b/src/features/posts/ui/PostTable.tsx
new file mode 100644
index 000000000..d066db73a
--- /dev/null
+++ b/src/features/posts/ui/PostTable.tsx
@@ -0,0 +1,50 @@
+import PostTableItem from './PostTableItem.tsx';
+import { useGetPosts, usePostFilterStore, useSearchStore } from '../model';
+
+import { Table } from '@/shared/ui/table';
+import Pagination from './Pagination.tsx';
+
+const PostListTable = () => {
+ const { skip, limit, selectedTag } = usePostFilterStore();
+ const { searchQuery } = useSearchStore();
+
+ const { data, isLoading } = useGetPosts({
+ limit,
+ skip,
+ tag: selectedTag,
+ searchQuery,
+ });
+ return (
+ <>
+ {!isLoading ? (
+
+
+
+
+ ID
+ 제목
+ 작성자
+ 반응
+ 작업
+
+
+
+ {data?.posts.map((post) => (
+
+ ))}
+
+
+
+ ) : (
+ 로딩 중...
+ )}
+
+ >
+ );
+};
+
+export default PostListTable;
diff --git a/src/features/posts/ui/PostTableDeleteButton.tsx b/src/features/posts/ui/PostTableDeleteButton.tsx
new file mode 100644
index 000000000..0ba3f9a8f
--- /dev/null
+++ b/src/features/posts/ui/PostTableDeleteButton.tsx
@@ -0,0 +1,20 @@
+import { Trash2 } from 'lucide-react';
+import { usePosts } from '../model';
+import { Post } from '@/entities/posts';
+import { Button } from '@/shared/ui/button';
+
+const PostTableDeleteButton = ({ post }: { post: Post }) => {
+ const { deletePost } = usePosts();
+
+ return (
+
+ );
+};
+
+export default PostTableDeleteButton;
diff --git a/src/features/posts/ui/PostTableDetailButton.tsx b/src/features/posts/ui/PostTableDetailButton.tsx
new file mode 100644
index 000000000..775a7b7e8
--- /dev/null
+++ b/src/features/posts/ui/PostTableDetailButton.tsx
@@ -0,0 +1,25 @@
+import { MessageSquare } from 'lucide-react';
+import { useDialogStore, useSelectedPostStore } from '../model';
+import { Post } from '@/entities/posts';
+import { Button } from '@/shared/ui/button';
+
+const PostTableDetailButton = ({ post }: { post: Post }) => {
+ const { setShowDetailDialog } = useDialogStore();
+ const { setSelectedPost } = useSelectedPostStore();
+
+ console.log('SelectedPost', post);
+ return (
+
+ );
+};
+
+export default PostTableDetailButton;
diff --git a/src/features/posts/ui/PostTableEditingButton.tsx b/src/features/posts/ui/PostTableEditingButton.tsx
new file mode 100644
index 000000000..4834373ca
--- /dev/null
+++ b/src/features/posts/ui/PostTableEditingButton.tsx
@@ -0,0 +1,24 @@
+import { Edit2 } from 'lucide-react';
+import { useDialogStore, useSelectedPostStore } from '../model';
+import { Post } from '@/entities/posts';
+import { Button } from '@/shared/ui/button';
+
+const PostTableEditingButton = ({ post }: { post: Post }) => {
+ const { setSelectedPost } = useSelectedPostStore();
+ const { setShowEditDialog } = useDialogStore();
+
+ return (
+
+ );
+};
+
+export default PostTableEditingButton;
diff --git a/src/features/posts/ui/PostTableItem.tsx b/src/features/posts/ui/PostTableItem.tsx
new file mode 100644
index 000000000..c544012b7
--- /dev/null
+++ b/src/features/posts/ui/PostTableItem.tsx
@@ -0,0 +1,48 @@
+import { ThumbsDown, ThumbsUp } from 'lucide-react';
+import TableTag from '../ui/TableTag';
+import PostUserTrigger from './PostUserTrigger.tsx';
+import PostTableDetailButton from './PostTableDetailButton.tsx';
+import PostTableEditingButton from './PostTableEditingButton.tsx';
+import PostTableDeleteButton from './PostTableDeleteButton.tsx';
+import { Post } from '@/entities/posts';
+import { Table } from '@/shared/ui/table';
+import { highlightText } from '@/shared/lib/highlightText.tsx';
+interface PostTableItemProps {
+ post: Post;
+ searchQuery: string;
+}
+
+const PostTableItem = ({ post, searchQuery }: PostTableItemProps) => {
+ return (
+
+ {post.id}
+
+
+
{highlightText(post.title, searchQuery)}
+
+
+
+
+
+
+
+ {post.reactions?.likes || 0}
+
+ {post.reactions?.dislikes || 0}
+
+
+
+
+ {/*디테일*/}
+
+ {/*에디팅*/}
+
+ {/*삭제*/}
+
+
+
+
+ );
+};
+
+export default PostTableItem;
diff --git a/src/features/posts/ui/PostTagFilter.tsx b/src/features/posts/ui/PostTagFilter.tsx
new file mode 100644
index 000000000..bea2a8979
--- /dev/null
+++ b/src/features/posts/ui/PostTagFilter.tsx
@@ -0,0 +1,29 @@
+import { usePostFilterStore } from '../model/store';
+import { useTags } from '@/features/tags/model/hooks';
+import { Select } from '@/shared/ui/select';
+
+const PostTagFilter = () => {
+ const { selectedTag, setFilter } = usePostFilterStore();
+ const { data: tags } = useTags();
+
+ return (
+
+ );
+};
+
+export default PostTagFilter;
diff --git a/src/features/posts/ui/PostUserTrigger.tsx b/src/features/posts/ui/PostUserTrigger.tsx
new file mode 100644
index 000000000..f4fec0a7c
--- /dev/null
+++ b/src/features/posts/ui/PostUserTrigger.tsx
@@ -0,0 +1,29 @@
+// eslint-disable-next-line fsd/no-relative-imports
+import { useUserModalStore } from '../../../features/users/model/store';
+import { PostAuthor } from '@/entities/posts';
+import { Table } from '@/shared/ui/table';
+
+interface UserTriggerProps {
+ author?: PostAuthor;
+}
+
+const PostUserTrigger = ({ author }: UserTriggerProps) => {
+ const { openUserModal } = useUserModalStore();
+ return (
+
+ openUserModal(author)}
+ >
+

+
{author?.username}
+
+
+ );
+};
+
+export default PostUserTrigger;
diff --git a/src/features/posts/ui/TableTag.tsx b/src/features/posts/ui/TableTag.tsx
new file mode 100644
index 000000000..ab6c534be
--- /dev/null
+++ b/src/features/posts/ui/TableTag.tsx
@@ -0,0 +1,29 @@
+import { usePostFilterStore } from '../model';
+import { PostTag } from '@/entities/posts';
+
+const TableTag = ({ tags }: { tags: PostTag['slug'][] }) => {
+ const { selectedTag, setFilter } = usePostFilterStore();
+ return (
+
+ {tags?.map((tag) => (
+ {
+ // setSelectedTag(tag);
+ setFilter('selectedTag', tag);
+ // updateURL();
+ }}
+ >
+ {tag}
+
+ ))}
+
+ );
+};
+
+export default TableTag;
diff --git a/src/features/posts/ui/index.ts b/src/features/posts/ui/index.ts
new file mode 100644
index 000000000..28a1cf92e
--- /dev/null
+++ b/src/features/posts/ui/index.ts
@@ -0,0 +1,12 @@
+export { default as PostAddDialog } from './PostAddDialog';
+export { default as PostAddTrigger } from './PostAddTrigger';
+export { default as PostDetailDialog } from '../../../widgets/PostDetailDialog.tsx';
+export { default as PostEditDialog } from './PostEditDialog';
+export { default as PostFilterSortBy } from './PostFilterSortBy';
+export { default as PostFilterSortOrder } from './PostFilterSortOrder';
+export { default as PostSearchBar } from './PostSearchBar';
+export { default as PostTable } from './PostTable';
+export { default as PostTagFilter } from './PostTagFilter';
+export { default as TableTag } from './TableTag';
+export { default as PostTableItem } from './PostTableItem';
+export { default as Pagination } from './Pagination';
diff --git a/src/features/tags/index.ts b/src/features/tags/index.ts
new file mode 100644
index 000000000..9f8ccaddf
--- /dev/null
+++ b/src/features/tags/index.ts
@@ -0,0 +1 @@
+export * from './model';
diff --git a/src/features/tags/model/hooks.ts b/src/features/tags/model/hooks.ts
new file mode 100644
index 000000000..81ec0f3bb
--- /dev/null
+++ b/src/features/tags/model/hooks.ts
@@ -0,0 +1,13 @@
+import { useQuery } from '@tanstack/react-query';
+
+import { postsApi, PostTag } from '@/entities/posts';
+
+export const useTags = () => {
+ return useQuery({
+ queryKey: ['tags'],
+ queryFn: async () => {
+ const tagsData = await postsApi.getTags();
+ return tagsData;
+ },
+ });
+};
diff --git a/src/features/tags/model/index.ts b/src/features/tags/model/index.ts
new file mode 100644
index 000000000..4cc90d02b
--- /dev/null
+++ b/src/features/tags/model/index.ts
@@ -0,0 +1 @@
+export * from './hooks';
diff --git a/src/features/users/model/store.ts b/src/features/users/model/store.ts
new file mode 100644
index 000000000..89a7a2c47
--- /dev/null
+++ b/src/features/users/model/store.ts
@@ -0,0 +1,25 @@
+// src/features/users/model/userModalStore.ts
+import { create } from 'zustand';
+import { PostAuthor } from '@/entities/posts';
+
+interface UserModalState {
+ showUserModal: boolean;
+ selectedUser: PostAuthor | null;
+ openUserModal: (user: PostAuthor | undefined) => void;
+ closeUserModal: () => void;
+}
+
+export const useUserModalStore = create((set) => ({
+ showUserModal: false,
+ selectedUser: null,
+ openUserModal: (user) =>
+ set({
+ showUserModal: true,
+ selectedUser: user,
+ }),
+ closeUserModal: () =>
+ set({
+ showUserModal: false,
+ selectedUser: null,
+ }),
+}));
diff --git a/src/features/users/ui/UserInfoDialog.tsx b/src/features/users/ui/UserInfoDialog.tsx
new file mode 100644
index 000000000..fa8dc3567
--- /dev/null
+++ b/src/features/users/ui/UserInfoDialog.tsx
@@ -0,0 +1,52 @@
+import { useUserModalStore } from '../model/store';
+import { useGetUser } from '@/entities/users';
+import { Dialog } from '@/shared/ui/dialog';
+
+const UserInfoDialog = () => {
+ const { showUserModal, selectedUser, closeUserModal } = useUserModalStore();
+
+ const { data } = useGetUser(selectedUser?.id as number);
+ return (
+
+ );
+};
+
+export default UserInfoDialog;
diff --git a/src/index.tsx b/src/index.tsx
index 369e197bb..eb21be7cc 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,12 +1,14 @@
-import React from "react"
-import ReactDOM from "react-dom/client"
-import { BrowserRouter as Router } from "react-router-dom"
-import App from "./App"
+import React from 'react';
-ReactDOM.createRoot(document.getElementById("root")!).render(
+import { BrowserRouter as Router } from 'react-router-dom';
+
+import App from '@/App.tsx';
+import ReactDOM from 'react-dom/client';
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
,
-)
+);
diff --git a/src/main.tsx b/src/main.tsx
index bef5202a3..450482f0b 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,10 +1,11 @@
-import { StrictMode } from 'react'
-import { createRoot } from 'react-dom/client'
-import './index.css'
-import App from './App.tsx'
+import { StrictMode } from 'react';
+
+import App from '@/App.tsx';
+import '@/index.css';
+import { createRoot } from 'react-dom/client';
createRoot(document.getElementById('root')!).render(
,
-)
+);
diff --git a/src/pages/PostsManagerPage.tsx b/src/pages/PostsManagerPage.tsx
index f80eb91ef..75c5669e1 100644
--- a/src/pages/PostsManagerPage.tsx
+++ b/src/pages/PostsManagerPage.tsx
@@ -1,708 +1,20 @@
-import { useEffect, useState } from "react"
-import { Edit2, MessageSquare, Plus, Search, ThumbsDown, ThumbsUp, Trash2 } from "lucide-react"
-import { useLocation, useNavigate } from "react-router-dom"
-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"
+import { Card } from '@/shared/ui/card';
+import PostHeaderAdd from '@/widgets/PostHeaderAdd.tsx';
+import PostContent from '@/widgets/PostContent.tsx';
+import { useURLSync } from '@/features/posts/lib/useURLSync.ts';
+import Dialog from '@/widgets/Dialog.tsx';
+// 쥬스탄드 탄스탁 적용끝
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 openPostDetail = (post) => {
- setSelectedPost(post)
- fetchComments(post.id)
- 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)
- } 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)}
-
-
-
-
-
-
-
- ))}
-
-
- )
+ useURLSync();
return (
-
-
- 게시물 관리자
-
-
-
-
-
- {/* 검색 및 필터 컨트롤 */}
-
-
-
-
- setSearchQuery(e.target.value)}
- onKeyPress={(e) => e.key === "Enter" && searchPosts()}
- />
-
-
-
-
-
-
-
- {/* 게시물 테이블 */}
- {loading ?
로딩 중...
: renderPostTable()}
-
- {/* 페이지네이션 */}
-
-
- 표시
-
- 항목
-
-
-
-
-
-
-
-
-
- {/* 게시물 추가 대화상자 */}
-
-
- {/* 게시물 수정 대화상자 */}
-
-
- {/* 댓글 추가 대화상자 */}
-
-
- {/* 댓글 수정 대화상자 */}
-
-
- {/* 게시물 상세 보기 대화상자 */}
-
-
- {/* 사용자 모달 */}
-
+
+
+
- )
-}
+ );
+};
-export default PostsManager
+export default PostsManager;
diff --git a/src/shared/lib/api.ts b/src/shared/lib/api.ts
new file mode 100644
index 000000000..0c03af455
--- /dev/null
+++ b/src/shared/lib/api.ts
@@ -0,0 +1,67 @@
+type Params = Record;
+type BaseOpts = { params?: Params; headers?: Record };
+
+const BASE_URL = import.meta.env.VITE_API_BASE_URL;
+
+/**
+ * 쿼리 스트링 생성
+ * @param params - 쿼리 파라미터
+ * @returns 쿼리 스트링
+ */
+function queryString(params?: Params) {
+ if (!params) return '';
+ const urlSearchParams = new URLSearchParams();
+ Object.entries(params).forEach(([k, v]) => {
+ if (v !== undefined && v !== null) urlSearchParams.set(k, String(v));
+ });
+ const queryString = urlSearchParams.toString();
+ return queryString ? `?${queryString}` : '';
+}
+
+/**
+ * 요청 처리
+ * @param path - 요청 경로
+ * @param init - 요청 옵션
+ * @returns 요청 결과
+ */
+async function request(path: string, init: RequestInit): Promise {
+ const res = await fetch(BASE_URL + path, init);
+ if (!res.ok) throw new Error(`API ${res.status} ${res.statusText}`);
+ if (res.status === 204) return undefined as unknown as T;
+ return res.json() as Promise;
+}
+
+/**
+ * API 요청 함수
+ */
+export const api = {
+ get: (path: string, opts: BaseOpts = {}) =>
+ request(`${path}${queryString(opts.params)}`, {
+ method: 'GET',
+ headers: opts.headers,
+ }),
+
+ post: (path: string, body?: B, opts: BaseOpts = {}) =>
+ request(path, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
+ body: body == null ? undefined : JSON.stringify(body),
+ }),
+
+ put: (path: string, body?: B, opts: BaseOpts = {}) =>
+ request(path, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
+ body: body == null ? undefined : JSON.stringify(body),
+ }),
+
+ patch: (path: string, body?: B, opts: BaseOpts = {}) =>
+ request(path, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
+ body: body == null ? undefined : JSON.stringify(body),
+ }),
+
+ delete: (path: string, opts: BaseOpts = {}) =>
+ request(path, { method: 'DELETE', headers: opts.headers }),
+};
diff --git a/src/shared/lib/auto-mutation.ts b/src/shared/lib/auto-mutation.ts
new file mode 100644
index 000000000..9b4e3f0f6
--- /dev/null
+++ b/src/shared/lib/auto-mutation.ts
@@ -0,0 +1,33 @@
+import { QueryKey, useMutation, useQueryClient } from '@tanstack/react-query';
+
+interface AutoMutationProps {
+ fn: (v: TVariable) => Promise;
+ invalidateKeys: QueryKey[];
+ updateKey?: (data: TData) => { key: QueryKey; data: TData };
+ successMessage?: string;
+ errorMessage?: string;
+}
+
+export const useAutoMutation = ({
+ fn,
+ invalidateKeys,
+ updateKey,
+}: AutoMutationProps) => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: fn,
+ onSuccess: (data) => {
+ invalidateKeys.forEach((key) => {
+ queryClient.invalidateQueries({ queryKey: key });
+ });
+
+ if (updateKey) {
+ const { key, data: newData } = updateKey(data);
+ queryClient.setQueryData(key, newData);
+ }
+ },
+ onError: (error) => {
+ throw new Error(error.message);
+ },
+ });
+};
diff --git a/src/shared/lib/highlightText.tsx b/src/shared/lib/highlightText.tsx
new file mode 100644
index 000000000..460d0b4ca
--- /dev/null
+++ b/src/shared/lib/highlightText.tsx
@@ -0,0 +1,20 @@
+// 하이라이트 함수 추가
+export 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}
+ ),
+ )}
+
+ );
+};
diff --git a/src/shared/model/types.ts b/src/shared/model/types.ts
new file mode 100644
index 000000000..910372456
--- /dev/null
+++ b/src/shared/model/types.ts
@@ -0,0 +1,5 @@
+export interface BasicUser {
+ id: number | null;
+ image?: string;
+ username?: string;
+}
diff --git a/src/components/Footer.tsx b/src/shared/ui/Footer.tsx
similarity index 100%
rename from src/components/Footer.tsx
rename to src/shared/ui/Footer.tsx
diff --git a/src/components/Header.tsx b/src/shared/ui/Header.tsx
similarity index 100%
rename from src/components/Header.tsx
rename to src/shared/ui/Header.tsx
diff --git a/src/shared/ui/button/index.tsx b/src/shared/ui/button/index.tsx
new file mode 100644
index 000000000..7a9fdddca
--- /dev/null
+++ b/src/shared/ui/button/index.tsx
@@ -0,0 +1,53 @@
+import * as React from 'react';
+
+import { VariantProps, cva } 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;
+ ref?: React.Ref;
+}
+
+export const Button = ({
+ className,
+ variant,
+ size,
+ ref,
+ ...props
+}: ButtonProps) => {
+ return (
+
+ );
+};
diff --git a/src/shared/ui/card/index.tsx b/src/shared/ui/card/index.tsx
new file mode 100644
index 000000000..2d6395ff2
--- /dev/null
+++ b/src/shared/ui/card/index.tsx
@@ -0,0 +1,43 @@
+import * as React from 'react';
+
+interface CardProps extends React.HTMLAttributes {
+ className?: string;
+ ref?: React.Ref;
+}
+
+const CardRoot = ({ className, ...props }: CardProps) => (
+
+);
+
+const CardHeader = ({ className, ...props }: CardProps) => (
+
+);
+
+const CardTitle = ({ className, ...props }: CardProps) => (
+
+);
+
+const CardContent = ({ className, ...props }: CardProps) => (
+
+);
+
+// ============================================
+// 컴파운드 패턴 구성
+// ============================================
+export const Card = Object.assign(CardRoot, {
+ Header: CardHeader,
+ Title: CardTitle,
+ Content: CardContent,
+});
+
+// 개별 export도 제공 (하위 호환성)
+export { CardHeader, CardTitle, CardContent };
diff --git a/src/shared/ui/dialog/index.tsx b/src/shared/ui/dialog/index.tsx
new file mode 100644
index 000000000..00ad4a756
--- /dev/null
+++ b/src/shared/ui/dialog/index.tsx
@@ -0,0 +1,65 @@
+import { HTMLAttributes } from 'react';
+
+import * as DialogPrimitive from '@radix-ui/react-dialog';
+import { X } from 'lucide-react';
+
+interface DialogContentProps
+ extends React.ComponentProps {
+ ref?: React.Ref;
+}
+interface DialogTitleProps
+ extends React.ComponentProps {
+ ref?: React.Ref;
+}
+interface DialogHeaderProps extends HTMLAttributes {
+ className?: string;
+}
+
+export const DialogRoot = DialogPrimitive.Root;
+export const DialogTrigger = DialogPrimitive.Trigger;
+export const DialogPortal = DialogPrimitive.Portal;
+export const DialogOverlay = DialogPrimitive.Overlay;
+
+export const DialogContent = ({
+ className,
+ children,
+ ref,
+ ...props
+}: DialogContentProps) => (
+
+
+
+ {children}
+
+
+ 닫기
+
+
+
+);
+
+export const DialogHeader = ({ className, ...props }: DialogHeaderProps) => (
+
+);
+
+export const DialogTitle = ({ className, ref, ...props }: DialogTitleProps) => (
+
+);
+
+export const Dialog = Object.assign(DialogRoot, {
+ Content: DialogContent,
+ Header: DialogHeader,
+ Title: DialogTitle,
+ Trigger: DialogTrigger,
+});
diff --git a/src/shared/ui/input/index.tsx b/src/shared/ui/input/index.tsx
new file mode 100644
index 000000000..db15bf9bc
--- /dev/null
+++ b/src/shared/ui/input/index.tsx
@@ -0,0 +1,16 @@
+import { Ref } from 'react';
+
+interface InputProps extends React.InputHTMLAttributes {
+ className?: string;
+ ref?: Ref;
+}
+
+export const Input = ({ className, ref, ...rest }: InputProps) => {
+ return (
+
+ );
+};
diff --git a/src/shared/ui/select/index.tsx b/src/shared/ui/select/index.tsx
new file mode 100644
index 000000000..19ffa486f
--- /dev/null
+++ b/src/shared/ui/select/index.tsx
@@ -0,0 +1,141 @@
+import * as React from 'react';
+
+import * as SelectPrimitive from '@radix-ui/react-select';
+import { Check, ChevronDown } from 'lucide-react';
+
+// ============================================
+// 타입 정의 (ref 포함)
+// ============================================
+interface SelectTriggerProps
+ extends React.ComponentProps {
+ ref?: React.Ref;
+}
+
+interface SelectContentProps
+ extends React.ComponentProps {
+ position?: 'popper' | 'item-aligned';
+ ref?: React.Ref;
+}
+
+interface SelectItemProps
+ extends React.ComponentProps {
+ ref?: React.Ref;
+}
+
+interface SelectSeparatorProps
+ extends React.ComponentProps {
+ ref?: React.Ref;
+}
+
+interface SelectLabelProps
+ extends React.ComponentProps {
+ ref?: React.Ref;
+}
+
+// ============================================
+// 개별 컴포넌트들 (React 19 스타일)
+// ============================================
+const SelectRoot = SelectPrimitive.Root;
+const SelectGroup = SelectPrimitive.Group;
+const SelectValue = SelectPrimitive.Value;
+
+const SelectTrigger = ({
+ className,
+ children,
+ ref,
+ ...props
+}: SelectTriggerProps) => (
+
+ {children}
+
+
+);
+
+const SelectContent = ({
+ className,
+ children,
+ position = 'popper',
+ ref,
+ ...props
+}: SelectContentProps) => (
+
+
+
+ {children}
+
+
+
+);
+
+const SelectItem = ({
+ className,
+ children,
+ ref,
+ ...props
+}: SelectItemProps) => (
+
+
+
+
+
+
+ {children}
+
+);
+
+const SelectSeparator = ({
+ className,
+ ref,
+ ...props
+}: SelectSeparatorProps) => (
+
+);
+
+const SelectLabel = ({ className, ref, ...props }: SelectLabelProps) => (
+
+);
+
+// ============================================
+// 컴파운드 패턴 구성
+// ============================================
+export const Select = Object.assign(SelectRoot, {
+ Group: SelectGroup,
+ Value: SelectValue,
+ Trigger: SelectTrigger,
+ Content: SelectContent,
+ Item: SelectItem,
+ Separator: SelectSeparator,
+ Label: SelectLabel,
+});
+
+// 개별 export도 제공 (하위 호환성)
+export {
+ SelectGroup,
+ SelectValue,
+ SelectTrigger,
+ SelectContent,
+ SelectItem,
+ SelectSeparator,
+ SelectLabel,
+};
diff --git a/src/shared/ui/table/index.tsx b/src/shared/ui/table/index.tsx
new file mode 100644
index 000000000..0ca1d0305
--- /dev/null
+++ b/src/shared/ui/table/index.tsx
@@ -0,0 +1,85 @@
+import * as React from 'react';
+
+// ============================================
+// 타입 정의
+// ============================================
+type TableProps = React.ComponentProps<'table'>;
+type TableHeaderProps = React.ComponentProps<'thead'>;
+type TableBodyProps = React.ComponentProps<'tbody'>;
+type TableRowProps = React.ComponentProps<'tr'>;
+type TableHeadProps = React.ComponentProps<'th'>;
+type TableCellProps = React.ComponentProps<'td'>;
+type TableRootProps = React.ComponentProps<'div'>;
+
+const TableRoot = ({ className, ref, children, ...props }: TableRootProps) => (
+
+ {children}
+
+);
+
+const TableElement = ({ className, ref, ...props }: TableProps) => (
+
+);
+
+const TableHeader = ({ className, ref, ...props }: TableHeaderProps) => (
+
+);
+
+const TableBody = ({ className, ref, ...props }: TableBodyProps) => (
+
+);
+
+const TableRow = ({ className, ref, ...props }: TableRowProps) => (
+
+);
+
+const TableHead = ({ className, ref, ...props }: TableHeadProps) => (
+ |
+);
+
+const TableCell = ({ className, ref, ...props }: TableCellProps) => (
+ |
+);
+
+// ============================================
+// 컴파운드 패턴 구성
+// ============================================
+export const Table = Object.assign(TableElement, {
+ Root: TableRoot,
+ Header: TableHeader,
+ Body: TableBody,
+ Row: TableRow,
+ Head: TableHead,
+ Cell: TableCell,
+});
+
+// 개별 export도 제공 (하위 호환성)
+export { TableElement, TableHeader, TableBody, TableRow, TableHead, TableCell };
diff --git a/src/shared/ui/textarea/index.tsx b/src/shared/ui/textarea/index.tsx
new file mode 100644
index 000000000..b0a4b64f1
--- /dev/null
+++ b/src/shared/ui/textarea/index.tsx
@@ -0,0 +1,15 @@
+interface TextAreaProps
+ extends React.TextareaHTMLAttributes {
+ className?: string;
+ ref?: React.Ref;
+}
+
+export const TextArea = ({ className, ref, ...props }: TextAreaProps) => {
+ return (
+
+ );
+};
diff --git a/src/widgets/Dialog.tsx b/src/widgets/Dialog.tsx
new file mode 100644
index 000000000..3c5e4c65a
--- /dev/null
+++ b/src/widgets/Dialog.tsx
@@ -0,0 +1,23 @@
+import {
+ PostAddDialog,
+ PostDetailDialog,
+ PostEditDialog,
+} from '@/features/posts';
+import AddComment from '@/features/comments/ui/AddComment.tsx';
+import EditComment from '@/features/comments/ui/EditComment.tsx';
+import UserInfoDialog from '@/features/users/ui/UserInfoDialog.tsx';
+
+const Dialog = () => {
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+};
+
+export default Dialog;
diff --git a/src/widgets/PostContent.tsx b/src/widgets/PostContent.tsx
new file mode 100644
index 000000000..7445480c9
--- /dev/null
+++ b/src/widgets/PostContent.tsx
@@ -0,0 +1,17 @@
+import PostFilter from './PostFilter.tsx';
+
+import { Card } from '@/shared/ui/card';
+import { PostTable } from '@/features/posts';
+
+const PostContent = () => {
+ return (
+
+
+
+ );
+};
+
+export default PostContent;
diff --git a/src/widgets/PostDetailDialog.tsx b/src/widgets/PostDetailDialog.tsx
new file mode 100644
index 000000000..900dd511d
--- /dev/null
+++ b/src/widgets/PostDetailDialog.tsx
@@ -0,0 +1,35 @@
+import {
+ useDialogStore,
+ useSearchStore,
+ useSelectedPostStore,
+} from '../features/posts/model/store';
+
+import { highlightText } from '@/shared/lib/highlightText.tsx';
+import { Dialog } from '@/shared/ui/dialog';
+import { Comments } from '@/features/comments';
+
+const PostDetailDialog = () => {
+ const { showDetailDialog, setShowDetailDialog } = useDialogStore();
+ const { selectedPost } = useSelectedPostStore();
+ const { searchValue } = useSearchStore();
+ return (
+
+ );
+};
+
+export default PostDetailDialog;
diff --git a/src/widgets/PostFilter.tsx b/src/widgets/PostFilter.tsx
new file mode 100644
index 000000000..f59058466
--- /dev/null
+++ b/src/widgets/PostFilter.tsx
@@ -0,0 +1,19 @@
+import {
+ PostSearchBar,
+ PostTagFilter,
+ PostFilterSortBy,
+ PostFilterSortOrder,
+} from '@/features/posts';
+// 필터 위젯
+const PostFilter = () => {
+ return (
+
+ );
+};
+
+export default PostFilter;
diff --git a/src/widgets/PostHeaderAdd.tsx b/src/widgets/PostHeaderAdd.tsx
new file mode 100644
index 000000000..138d601df
--- /dev/null
+++ b/src/widgets/PostHeaderAdd.tsx
@@ -0,0 +1,15 @@
+import PostAddTrigger from '../features/posts/ui/PostAddTrigger.tsx';
+import { Card } from '@/shared/ui/card';
+
+const PostHeaderAdd = () => {
+ return (
+
+
+ 게시물 관리자
+
+
+
+ );
+};
+
+export default PostHeaderAdd;
diff --git a/src_final/App.tsx b/src_final/App.tsx
new file mode 100644
index 000000000..4fdde707e
--- /dev/null
+++ b/src_final/App.tsx
@@ -0,0 +1,26 @@
+import { BrowserRouter as Router } from 'react-router-dom';
+
+import PostsManagerPage from '@/pages/PostsManagerPage';
+import Footer from '@/shared/ui/Footer.tsx';
+import Header from '@/shared/ui/Header.tsx';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+
+const queryClient = new QueryClient();
+
+const App = () => {
+ return (
+
+
+
+
+
+ );
+};
+
+export default App;
diff --git a/src_final/app/assets/react.svg b/src_final/app/assets/react.svg
new file mode 100644
index 000000000..6c87de9bb
--- /dev/null
+++ b/src_final/app/assets/react.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src_final/entities/comments/api/index.ts b/src_final/entities/comments/api/index.ts
new file mode 100644
index 000000000..9008da523
--- /dev/null
+++ b/src_final/entities/comments/api/index.ts
@@ -0,0 +1,56 @@
+import {
+ Comments,
+ CommentsResponse,
+ PostAddComment,
+ PutCommentsDetail,
+} from '../model/types';
+import { api } from '@/shared/lib/api.ts';
+
+/**
+ * Comments API
+ */
+export const commentsApi = {
+ /**
+ * 댓글 조회
+ */
+ getComments: async (postId: number) => {
+ return api.get<{ comments: Comments[] }>(`/comments/post/${postId}`);
+ },
+
+ /**
+ * 댓글 추가
+ */
+ addComment: async (comment: PostAddComment): Promise => {
+ return api.post('/comments/add', comment);
+ },
+
+ /**
+ * 댓글 수정
+ */
+ updateComment: async (
+ commentData: PutCommentsDetail,
+ ): Promise => {
+ return api.put(
+ `/comments/${commentData.id}`,
+ {
+ body: commentData.body,
+ },
+ );
+ },
+
+ /**
+ * 댓글 삭제
+ */
+ deleteComment: async (id: number): Promise => {
+ return api.delete(`/comments/${id}`);
+ },
+
+ /**
+ * 댓글 좋아요
+ */
+ likeComment: async (id: number, likes: number): Promise => {
+ return api.patch(`/comments/${id}`, {
+ likes,
+ });
+ },
+};
diff --git a/src_final/entities/comments/index.ts b/src_final/entities/comments/index.ts
new file mode 100644
index 000000000..bed443678
--- /dev/null
+++ b/src_final/entities/comments/index.ts
@@ -0,0 +1,2 @@
+export * from './api';
+export * from './model';
diff --git a/src_final/entities/comments/model/index.ts b/src_final/entities/comments/model/index.ts
new file mode 100644
index 000000000..5fadfaf0c
--- /dev/null
+++ b/src_final/entities/comments/model/index.ts
@@ -0,0 +1,2 @@
+export * from '../../../features/comments/model/hooks.ts';
+export * from './types';
diff --git a/src_final/entities/comments/model/types.ts b/src_final/entities/comments/model/types.ts
new file mode 100644
index 000000000..b4aafcde7
--- /dev/null
+++ b/src_final/entities/comments/model/types.ts
@@ -0,0 +1,44 @@
+//http://localhost:5173/api/comments/post
+interface CommentsUser {
+ fullName: string;
+ id: number;
+ username: string;
+}
+export interface Comments {
+ id: number;
+ body: string;
+ postId: number;
+ likes: number;
+ user: CommentsUser;
+}
+
+export interface CommentsResponse {
+ comments: Comments;
+ limit: number;
+ skip: number;
+ total: number;
+}
+//POST http://localhost:5173/api/comments/add
+export interface PostAddComment {
+ body: string;
+ postId: number;
+ userId: number;
+}
+//DELETE http://localhost:5173/api/comments/84
+export interface DeleteComment {
+ deleteOn: string;
+ isDeleted: true;
+ id: number;
+ body: string;
+ likes: number;
+ postId: number;
+ user: CommentsUser;
+}
+
+// PATCH http://localhost:5173/api/comments/196
+export type PatchCommentsDetailRequest = Pick;
+export type PatchCommentsDetailResponse = Comments;
+
+// PUT http://localhost:5173/api/comments/196
+export type PutCommentsDetail = Pick;
+export type PutCommentsDetailResponse = Comments;
diff --git a/src_final/entities/posts/api/api.ts b/src_final/entities/posts/api/api.ts
new file mode 100644
index 000000000..c5445e8aa
--- /dev/null
+++ b/src_final/entities/posts/api/api.ts
@@ -0,0 +1,92 @@
+import {
+ Post,
+ PostsAddRequest,
+ PostsResponse,
+ PostTag,
+} from '../model/types.ts';
+import { api } from '@/shared/lib/api.ts';
+
+/**
+ * Posts API - 게시물 관련 로직만 처리 (사용자 데이터 제외)
+ */
+const postsApi = {
+ /**
+ * 게시물 목록 조회 (순수 게시물 데이터만)
+ */
+ getPosts: async ({
+ limit = 0,
+ skip = 0,
+ }: {
+ limit?: number;
+ skip?: number;
+ } = {}): Promise => {
+ return api.get('/posts', { params: { limit, skip } });
+ },
+
+ /**
+ * 단일 게시물 조회
+ */
+ getPost: async (id: number): Promise => {
+ return api.get(`/posts/${id}`);
+ },
+
+ /**
+ * 게시물 생성
+ */
+ createPost: async (postData: PostsAddRequest): Promise => {
+ return api.post('/posts/add', postData);
+ },
+
+ /**
+ * 게시물 수정
+ */
+ updatePost: async (postData: Partial): Promise => {
+ return api.put>(`/posts/${postData.id}`, postData);
+ },
+
+ /**
+ * 게시물 부분 수정
+ */
+ patchPost: async (id: number, partialData: Partial): Promise => {
+ return api.patch>(`/posts/${id}`, partialData);
+ },
+
+ /**
+ * 게시물 삭제
+ */
+ deletePost: async (id: number): Promise => {
+ return api.delete(`/posts/${id}`);
+ },
+
+ /**
+ * 게시물 검색
+ */
+ searchPosts: async (query: string): Promise => {
+ return api.get('/posts/search', { params: { q: query } });
+ },
+
+ /**
+ * 태그별 게시물 조회
+ */
+ getPostsByTag: async (tag: string): Promise => {
+ return api.get(`/posts/tag/${tag}`);
+ },
+
+ /**
+ * 특정 사용자의 게시물 조회
+ */
+ getPostsByUser: async (userId: number): Promise => {
+ return api.get(`/posts/user/${userId}`);
+ },
+
+ /**
+ * 사용 가능한 태그 목록 조회
+ */
+ getTags: async (): Promise => {
+ return api.get('/posts/tags');
+ },
+};
+
+// 기존 getPosts 함수는 제거하고 postsApi.getPosts 사용
+
+export default postsApi;
diff --git a/src_final/entities/posts/api/index.ts b/src_final/entities/posts/api/index.ts
new file mode 100644
index 000000000..bf6457bab
--- /dev/null
+++ b/src_final/entities/posts/api/index.ts
@@ -0,0 +1 @@
+export { default as postsApi } from './api';
diff --git a/src_final/entities/posts/index.ts b/src_final/entities/posts/index.ts
new file mode 100644
index 000000000..6285e6224
--- /dev/null
+++ b/src_final/entities/posts/index.ts
@@ -0,0 +1,3 @@
+// export { default as PostTable } from '@/widgets/posts/posts-table/ui/PostTable.tsx';
+export * from './api';
+export * from './model';
diff --git a/src_final/entities/posts/model/hooks.ts b/src_final/entities/posts/model/hooks.ts
new file mode 100644
index 000000000..e69de29bb
diff --git a/src_final/entities/posts/model/index.ts b/src_final/entities/posts/model/index.ts
new file mode 100644
index 000000000..998a67ee1
--- /dev/null
+++ b/src_final/entities/posts/model/index.ts
@@ -0,0 +1,2 @@
+export * from './types.ts';
+export * from './hooks.ts';
diff --git a/src_final/entities/posts/model/types.ts b/src_final/entities/posts/model/types.ts
new file mode 100644
index 000000000..a8b54833c
--- /dev/null
+++ b/src_final/entities/posts/model/types.ts
@@ -0,0 +1,86 @@
+// http://localhost:5173/api/posts?limit=10&skip=0
+import { BasicUser } from '@/shared/model/types.ts';
+
+export interface PostsResponse {
+ posts: Post[];
+ total: number;
+ skip: number;
+ limit: number;
+}
+// http://localhost:5173/api/posts/search?q=all
+export interface PostsSearchResponse {
+ posts: Post[];
+ total: number;
+ skip: number;
+ limit: number;
+}
+
+// http://localhost:5173/api/posts/tag/mystery
+export interface PostsTagDetailResponse {
+ posts: Post[];
+ total: number;
+ skip: number;
+ limit: number;
+}
+
+// http://localhost:5173/api/posts/tags
+export type PostsTagsResponse = PostTag[];
+
+// POST http://localhost:5173/api/posts/add
+type RequiredPostsAddKey = 'title' | 'body' | 'userId';
+export type PostsAddRequest = Pick &
+ Partial>;
+
+// PUT http://localhost:5173/api/posts/252
+type RequiredPutPostsDetailRequestKey = 'title' | 'body' | 'userId' | 'id';
+export type PutPostsDetailRequest<
+ T extends Pick &
+ Partial>,
+> = T;
+
+export type PostAuthor = BasicUser;
+// DELETE http://localhost:5173/api/posts/252
+export type DeletePostsDetailResponse = Pick;
+
+export type PostsAddResponse = Pick;
+
+export interface PostTag {
+ name: string;
+ slug: string;
+ url: string;
+}
+
+export interface Post {
+ // 게시글 아이디
+ id: number;
+
+ // 게시글 내용
+ title: string;
+ body: string;
+
+ // 게시글 태그
+ tags: PostTag['slug'][];
+
+ // 게시글 반응
+ reactions: {
+ likes: number;
+ dislikes: number;
+ };
+ author?: PostAuthor;
+ // 조회수
+ views: number;
+
+ // 작성자 유저 아이디
+ userId: number;
+}
+
+export interface NewPostAddedRequest {
+ newPost: PostsAddRequest;
+ setNewPost: (post: PostsAddRequest) => void;
+ resetNewPost: () => void;
+}
+
+export interface SelectedPost {
+ title: string;
+ body?: string;
+}
diff --git a/src_final/entities/tags/api/fetch.ts b/src_final/entities/tags/api/fetch.ts
new file mode 100644
index 000000000..c83048849
--- /dev/null
+++ b/src_final/entities/tags/api/fetch.ts
@@ -0,0 +1,7 @@
+export const fetchTags = async () => {
+ try {
+ return await fetch('/api/posts/tags');
+ } catch (error) {
+ console.error('태그 가져오기 오류:', error);
+ }
+};
diff --git a/src_final/entities/users/api/index.ts b/src_final/entities/users/api/index.ts
new file mode 100644
index 000000000..0fd00c21f
--- /dev/null
+++ b/src_final/entities/users/api/index.ts
@@ -0,0 +1,14 @@
+import { api } from '@/shared/lib/api.ts';
+
+import { UserInit, UserResponse } from '../model/types.ts';
+
+const userApi = {
+ getUser: async (userId: number): Promise => {
+ return api.get(`/users/${userId}`);
+ },
+ getUserPost: async (): Promise => {
+ return api.get('/users?limit=0&select=username,image');
+ },
+};
+
+export default userApi;
diff --git a/src_final/entities/users/index.ts b/src_final/entities/users/index.ts
new file mode 100644
index 000000000..ac668e0ef
--- /dev/null
+++ b/src_final/entities/users/index.ts
@@ -0,0 +1,3 @@
+// export { useGetPost, useGetUser } from './hooks';
+export { default as userApi } from './api';
+export * from './model'; // 타입들을 공개 API로 export
diff --git a/src_final/entities/users/model/hooks.ts b/src_final/entities/users/model/hooks.ts
new file mode 100644
index 000000000..90cfe6d43
--- /dev/null
+++ b/src_final/entities/users/model/hooks.ts
@@ -0,0 +1,21 @@
+import { useQuery } from '@tanstack/react-query';
+
+import userApi from '../api';
+
+export const useGetUser = (userId: number) => {
+ const { data, isLoading, error } = useQuery({
+ queryKey: ['user', userId],
+ queryFn: () => userApi.getUser(userId),
+ });
+
+ return { data, isLoading, error };
+};
+
+export const useGetPost = () => {
+ const { data, isLoading, error } = useQuery({
+ queryKey: ['posts'],
+ queryFn: () => userApi.getUserPost(),
+ });
+
+ return { data, isLoading, error };
+};
diff --git a/src_final/entities/users/model/index.ts b/src_final/entities/users/model/index.ts
new file mode 100644
index 000000000..998a67ee1
--- /dev/null
+++ b/src_final/entities/users/model/index.ts
@@ -0,0 +1,2 @@
+export * from './types.ts';
+export * from './hooks.ts';
diff --git a/src_final/entities/users/model/types.ts b/src_final/entities/users/model/types.ts
new file mode 100644
index 000000000..3eac12a18
--- /dev/null
+++ b/src_final/entities/users/model/types.ts
@@ -0,0 +1,112 @@
+import { BasicUser } from '@/shared/model/types.ts';
+
+type Gender = 'male' | 'femail';
+
+interface Hair {
+ color: string;
+ type: string;
+}
+
+// 주소 타입
+interface Address {
+ address: string;
+ city: string;
+ state: string;
+ stateCode: string;
+ postalCode: string;
+ coordinates: {
+ lat: number;
+ lng: number;
+ };
+ country: string;
+}
+
+interface Bank {
+ cardExpire: string;
+ cardNumber: string;
+ cardType: string;
+ iban: string;
+}
+interface Company {
+ address: Address;
+ department: string;
+ name: string;
+ title: string;
+}
+interface Crypto {
+ coin: string;
+ wallet: string;
+ network: string;
+}
+// ============================================
+// 메인 사용자 인터페이스
+// ============================================
+
+// http://localhost:5173/api/users/${user.id}
+export interface UserResponse {
+ // ============================================
+ // 기본 식별 정보
+ // ============================================
+ id: number;
+ firstName: string;
+ lastName: string;
+ maidenName: string;
+ username: string;
+ email: string;
+ phone: string;
+
+ // ============================================
+ // 개인 정보
+ // ============================================
+ age: number;
+ gender: Gender;
+ birthDate: string;
+ bloodGroup: string;
+
+ // ============================================
+ // 신체 정보
+ // ============================================
+ height: number;
+ weight: number;
+ eyeColor: string;
+ hair: Hair;
+ image: string;
+
+ // ============================================
+ // 주소 정보
+ // ============================================
+ address: Address;
+
+ // ============================================
+ // 보안/시스템 정보
+ // ============================================
+ password: string;
+ role: string;
+ ip: string;
+ macAddress: string;
+ userAgent: string;
+
+ // ============================================
+ // 금융 정보
+ // ============================================
+ bank: Bank;
+ crypto: Crypto;
+
+ // ============================================
+ // 기타 정보
+ // ============================================
+ university: string;
+ company: Company;
+ ein: string;
+ ssn: string;
+}
+//http://localhost:5173/api/users
+export type UserInComment = BasicUser;
+
+//http://localhost:5173/api/users?limit=0&select=username,image
+export interface UserInit {
+ limit: number;
+ skip: number;
+ total: number;
+ users: UserInComment[];
+}
diff --git a/src_final/features/comments/index.ts b/src_final/features/comments/index.ts
new file mode 100644
index 000000000..c51f0c574
--- /dev/null
+++ b/src_final/features/comments/index.ts
@@ -0,0 +1,2 @@
+export * from './ui';
+export * from './model';
diff --git a/src_final/features/comments/model/hooks.ts b/src_final/features/comments/model/hooks.ts
new file mode 100644
index 000000000..89013d822
--- /dev/null
+++ b/src_final/features/comments/model/hooks.ts
@@ -0,0 +1,67 @@
+import { useQuery } from '@tanstack/react-query';
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import {
+ Comments,
+ commentsApi,
+ PostAddComment,
+ PutCommentsDetail,
+} from '@/entities/comments';
+
+export const useGetComment = (id: number) => {
+ return useQuery({
+ queryKey: ['comments'],
+ queryFn: () => commentsApi.getComments(id),
+ });
+};
+export const useComments = () => {
+ const queryClient = useQueryClient();
+
+ return {
+ createComments: useMutation({
+ mutationFn: (comment: PostAddComment) => commentsApi.addComment(comment),
+ onSuccess: (newComment) => {
+ // 댓글 목록 쿼리 무효화
+ queryClient.invalidateQueries({ queryKey: ['comments'] });
+
+ // 새 댓글을 캐시에 설정
+ queryClient.setQueryData(
+ ['comments', newComment.comments.postId],
+ newComment,
+ );
+ },
+ onError: (error) => {
+ console.error('댓글 생성 실패:', error);
+ },
+ }),
+ updateComment: useMutation({
+ mutationFn: (comment: PutCommentsDetail) =>
+ commentsApi.updateComment(comment),
+ onSuccess: (newComment) => {
+ // 댓글 목록 쿼리 무효화
+ queryClient.invalidateQueries({ queryKey: ['comments'] });
+
+ // 새 댓글을 캐시에 설정
+ queryClient.setQueryData(
+ ['comments', newComment.comments.postId],
+ newComment,
+ );
+ },
+ onError: (error) => {
+ console.error('댓글 생성 실패:', error);
+ },
+ }),
+ deleteComment: useMutation({
+ mutationFn: ({ id }: { id: number; postId: number }) =>
+ commentsApi.deleteComment(id),
+ onSuccess: (_, { id, postId }) => {
+ // ✅ 방법 2: 캐시 직접 업데이트 (더 빠름)
+ queryClient.setQueryData(
+ ['comments', postId],
+ (oldComments: Comments[]) => {
+ return oldComments.filter((comment) => comment.id !== id);
+ },
+ );
+ },
+ }),
+ };
+};
diff --git a/src_final/features/comments/model/index.ts b/src_final/features/comments/model/index.ts
new file mode 100644
index 000000000..0f3b06115
--- /dev/null
+++ b/src_final/features/comments/model/index.ts
@@ -0,0 +1,2 @@
+export * from './store';
+export * from './hooks';
diff --git a/src_final/features/comments/model/store/index.ts b/src_final/features/comments/model/store/index.ts
new file mode 100644
index 000000000..63ee2686c
--- /dev/null
+++ b/src_final/features/comments/model/store/index.ts
@@ -0,0 +1 @@
+export * from './useCommentStore.ts';
diff --git a/src_final/features/comments/model/store/useCommentDialog.ts b/src_final/features/comments/model/store/useCommentDialog.ts
new file mode 100644
index 000000000..4838a32e1
--- /dev/null
+++ b/src_final/features/comments/model/store/useCommentDialog.ts
@@ -0,0 +1,30 @@
+import { create } from 'zustand';
+import {
+ Comments,
+ PostAddComment,
+} from '../../../../entities/comments/model/types.ts';
+
+interface CommentState {
+ selectedComment: Comments | null;
+ showAddCommentDialog: boolean;
+ showEditCommentDialog: boolean;
+ newComment: PostAddComment;
+ setSelectedComment: (comment: Comments | null) => void;
+ setShowAddCommentDialog: (show: boolean) => void;
+ setShowEditCommentDialog: (show: boolean) => void;
+ setNewComment: (comment: PostAddComment) => void;
+ resetNewComment: () => void;
+}
+
+export const useCommentStore = create((set) => ({
+ selectedComment: null,
+ showAddCommentDialog: false,
+ showEditCommentDialog: false,
+ newComment: { body: '', postId: 0, userId: 1 },
+ setSelectedComment: (comment) => set({ selectedComment: comment }),
+ setShowAddCommentDialog: (show) => set({ showAddCommentDialog: show }),
+ setShowEditCommentDialog: (show) => set({ showEditCommentDialog: show }),
+ setNewComment: (comment) => set({ newComment: comment }),
+ resetNewComment: () =>
+ set({ newComment: { body: '', postId: 0, userId: 1 } }),
+}));
diff --git a/src_final/features/comments/model/store/useCommentStore.ts b/src_final/features/comments/model/store/useCommentStore.ts
new file mode 100644
index 000000000..4838a32e1
--- /dev/null
+++ b/src_final/features/comments/model/store/useCommentStore.ts
@@ -0,0 +1,30 @@
+import { create } from 'zustand';
+import {
+ Comments,
+ PostAddComment,
+} from '../../../../entities/comments/model/types.ts';
+
+interface CommentState {
+ selectedComment: Comments | null;
+ showAddCommentDialog: boolean;
+ showEditCommentDialog: boolean;
+ newComment: PostAddComment;
+ setSelectedComment: (comment: Comments | null) => void;
+ setShowAddCommentDialog: (show: boolean) => void;
+ setShowEditCommentDialog: (show: boolean) => void;
+ setNewComment: (comment: PostAddComment) => void;
+ resetNewComment: () => void;
+}
+
+export const useCommentStore = create((set) => ({
+ selectedComment: null,
+ showAddCommentDialog: false,
+ showEditCommentDialog: false,
+ newComment: { body: '', postId: 0, userId: 1 },
+ setSelectedComment: (comment) => set({ selectedComment: comment }),
+ setShowAddCommentDialog: (show) => set({ showAddCommentDialog: show }),
+ setShowEditCommentDialog: (show) => set({ showEditCommentDialog: show }),
+ setNewComment: (comment) => set({ newComment: comment }),
+ resetNewComment: () =>
+ set({ newComment: { body: '', postId: 0, userId: 1 } }),
+}));
diff --git a/src_final/features/comments/ui/AddComment.tsx b/src_final/features/comments/ui/AddComment.tsx
new file mode 100644
index 000000000..499cf6ece
--- /dev/null
+++ b/src_final/features/comments/ui/AddComment.tsx
@@ -0,0 +1,35 @@
+import { Button } from '@/shared/ui/button';
+import { Dialog } from '@/shared/ui/dialog';
+import { Input } from '@/shared/ui/input';
+import { useCommentStore } from '../model/store';
+import { useComments } from '../model/hooks.ts';
+
+const AddComment = () => {
+ const { showAddCommentDialog, setShowAddCommentDialog } = useCommentStore();
+ const { newComment, setNewComment } = useCommentStore();
+ const { createComments } = useComments();
+ const handelAddComment = () => {
+ createComments.mutate(newComment);
+ };
+ return (
+
+ );
+};
+
+export default AddComment;
diff --git a/src_final/features/comments/ui/Comments.tsx b/src_final/features/comments/ui/Comments.tsx
new file mode 100644
index 000000000..3c513bf5a
--- /dev/null
+++ b/src_final/features/comments/ui/Comments.tsx
@@ -0,0 +1,86 @@
+import { Edit2, Plus, ThumbsUp, Trash2 } from 'lucide-react';
+import { useCommentStore } from '../model/store';
+import { useComments, useGetComment } from '../model/hooks.ts';
+import { Button } from '@/shared/ui/button';
+import { highlightText } from '@/shared/lib/highlightText.tsx';
+
+const Comments = ({
+ postId,
+ searchValue,
+}: {
+ postId: number;
+ searchValue: string;
+}) => {
+ const {
+ setSelectedComment,
+ setShowAddCommentDialog,
+ setShowEditCommentDialog,
+ resetNewComment,
+ } = useCommentStore();
+ const { data } = useGetComment(postId);
+
+ const { deleteComment } = useComments();
+ return (
+
+
+
댓글
+
+
+
+ {data?.comments.map((comment) => (
+
+
+
+ {comment.user.username}:
+
+
+ {highlightText(comment.body, searchValue)}
+
+
+
+
+
+
+
+
+ ))}
+
+
+ );
+};
+
+export default Comments;
diff --git a/src_final/features/comments/ui/EditComment.tsx b/src_final/features/comments/ui/EditComment.tsx
new file mode 100644
index 000000000..723167fee
--- /dev/null
+++ b/src_final/features/comments/ui/EditComment.tsx
@@ -0,0 +1,59 @@
+import { Button } from '@/shared/ui/button';
+import { Dialog } from '@/shared/ui/dialog';
+import { Input } from '@/shared/ui/input';
+import { useCommentStore } from '../model/store';
+import { useComments } from '../model/hooks.ts';
+
+const EditComment = () => {
+ const { showEditCommentDialog, setShowEditCommentDialog } = useCommentStore();
+ const { selectedComment, setSelectedComment } = useCommentStore();
+ const { updateComment } = useComments();
+ const handelEditComment = () => {
+ if (selectedComment?.id) {
+ // ✅ API가 기대하는 형태로 전달
+ updateComment.mutate(
+ {
+ id: selectedComment.id,
+ body: selectedComment.body,
+ },
+ {
+ onSuccess: () => {
+ setShowEditCommentDialog(false);
+ },
+ },
+ );
+ }
+ };
+
+ return (
+
+ );
+};
+
+export default EditComment;
diff --git a/src_final/features/comments/ui/index.ts b/src_final/features/comments/ui/index.ts
new file mode 100644
index 000000000..52602d5ab
--- /dev/null
+++ b/src_final/features/comments/ui/index.ts
@@ -0,0 +1 @@
+export { default as Comments } from './Comments';
diff --git a/src_final/features/posts/index.ts b/src_final/features/posts/index.ts
new file mode 100644
index 000000000..c51f0c574
--- /dev/null
+++ b/src_final/features/posts/index.ts
@@ -0,0 +1,2 @@
+export * from './ui';
+export * from './model';
diff --git a/src_final/features/posts/lib/useURLSync.ts b/src_final/features/posts/lib/useURLSync.ts
new file mode 100644
index 000000000..c9765a57a
--- /dev/null
+++ b/src_final/features/posts/lib/useURLSync.ts
@@ -0,0 +1,35 @@
+import { useLocation, useNavigate } from 'react-router-dom';
+import { usePostFilterStore, useSearchStore } from '../model';
+import { useEffect } from 'react';
+
+export const useURLSync = () => {
+ const navigate = useNavigate();
+ const location = useLocation();
+
+ const { skip, limit, selectedTag, sortBy, sortOrder, setFilter } =
+ usePostFilterStore();
+ const { searchQuery, setSearchQuery } = useSearchStore();
+
+ useEffect(() => {
+ const params = new URLSearchParams(location.search);
+ setFilter('skip', parseInt(params.get('skip') || '0'));
+ setFilter('limit', parseInt(params.get('limit') || '10'));
+ setFilter('sortBy', params.get('sortBy') || '');
+ setFilter('sortOrder', params.get('sortOrder') || 'asc');
+ setFilter('selectedTag', params.get('tag') || '');
+ if (params.get('search')) {
+ setSearchQuery(params.get('search') || '');
+ }
+ }, [location.search, setFilter, setSearchQuery]);
+
+ useEffect(() => {
+ const params = new URLSearchParams();
+ if (skip) params.set('skip', skip.toString());
+ if (limit) params.set('limit', limit.toString());
+ if (sortBy) params.set('sortBy', sortBy);
+ if (sortOrder) params.set('sortOrder', sortOrder);
+ if (selectedTag) params.set('tag', selectedTag);
+ if (searchQuery) params.set('search', searchQuery);
+ navigate(`?${params.toString()}`);
+ }, [skip, limit, sortBy, sortOrder, selectedTag, searchQuery, navigate]);
+};
diff --git a/src_final/features/posts/model/hooks.ts b/src_final/features/posts/model/hooks.ts
new file mode 100644
index 000000000..e82ba1ffb
--- /dev/null
+++ b/src_final/features/posts/model/hooks.ts
@@ -0,0 +1,97 @@
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+
+import { PostsAddRequest, postsApi } from '@/entities/posts';
+import { Post } from '@/entities/posts';
+import { userApi } from '@/entities/users';
+
+interface UsePostsQueryProps {
+ limit: number;
+ skip: number;
+ tag?: string;
+ searchQuery?: string;
+}
+export const useGetPosts = ({
+ limit = 10,
+ skip = 0,
+ tag,
+ searchQuery,
+}: UsePostsQueryProps) =>
+ useQuery({
+ queryKey: ['posts', skip, limit, tag, searchQuery],
+ queryFn: async () => {
+ // 구조상 여라개를 할수 없어서 그냥 분기처리만 함.
+ let postData;
+ // 검색
+ if (searchQuery) {
+ postData = await postsApi.searchPosts(searchQuery);
+ } else if (tag && tag !== 'all') {
+ // 태그
+ postData = await postsApi.getPostsByTag(tag);
+ } else {
+ // 기존 필터
+ postData = await postsApi.getPosts({ limit, skip });
+ }
+
+ const userData = await userApi.getUserPost();
+ const postsWithUsers = postData.posts.map((post) => ({
+ ...post,
+ author: userData.users.find((user) => user.id === post.userId),
+ }));
+ return {
+ posts: postsWithUsers,
+ total: postData.total,
+ };
+ },
+ });
+
+export const usePosts = () => {
+ const queryClient = useQueryClient();
+
+ return {
+ // Query
+
+ usePostsByTag: (tag: string) => {
+ return useQuery({
+ queryKey: ['posts', 'tag', tag],
+ queryFn: async () => {
+ const postsData = await postsApi.getPostsByTag(tag);
+ const usersData = await userApi.getUserPost();
+ const postsWithUsers = postsData.posts.map((post) => ({
+ ...post,
+ author: usersData.users.find((user) => user.id === post.userId),
+ }));
+ return {
+ posts: postsWithUsers,
+ total: postsData.total,
+ };
+ },
+ enabled: !!tag && tag !== 'all',
+ });
+ },
+ // Mutations
+ createPost: useMutation({
+ mutationFn: async (newPost: PostsAddRequest) =>
+ await postsApi.createPost(newPost),
+ onSuccess: (newPost) => {
+ queryClient.invalidateQueries({ queryKey: ['posts'] });
+ queryClient.setQueryData(['posts', newPost.id], newPost);
+ },
+ }),
+
+ updatePost: useMutation({
+ mutationFn: ({ data }: { data: Post }) => postsApi.updatePost(data),
+ onSuccess: (updatedPost) => {
+ queryClient.invalidateQueries({ queryKey: ['posts'] });
+ queryClient.setQueryData(['posts', updatedPost.id], updatedPost);
+ },
+ }),
+
+ deletePost: useMutation({
+ mutationFn: (id: number) => postsApi.deletePost(id),
+ onSuccess: (_, deletedId) => {
+ queryClient.invalidateQueries({ queryKey: ['posts'] });
+ queryClient.removeQueries({ queryKey: ['posts', deletedId] });
+ },
+ }),
+ };
+};
diff --git a/src_final/features/posts/model/index.ts b/src_final/features/posts/model/index.ts
new file mode 100644
index 000000000..0f3b06115
--- /dev/null
+++ b/src_final/features/posts/model/index.ts
@@ -0,0 +1,2 @@
+export * from './store';
+export * from './hooks';
diff --git a/src_final/features/posts/model/store/index.ts b/src_final/features/posts/model/store/index.ts
new file mode 100644
index 000000000..815eeee88
--- /dev/null
+++ b/src_final/features/posts/model/store/index.ts
@@ -0,0 +1,5 @@
+export * from './useDialogStore';
+export * from './usePostStore';
+export * from './useSearchStore';
+export * from './useNewPostStore';
+export * from './useSelectedPostStore';
diff --git a/src_final/features/posts/model/store/useDialogStore.ts b/src_final/features/posts/model/store/useDialogStore.ts
new file mode 100644
index 000000000..317ac26b1
--- /dev/null
+++ b/src_final/features/posts/model/store/useDialogStore.ts
@@ -0,0 +1,21 @@
+import { create } from 'zustand';
+
+interface DialogState {
+ showAddDialog: boolean;
+ showEditDialog: boolean;
+ showDetailDialog: boolean;
+
+ setShowAddDialog: (show: boolean) => void;
+ setShowEditDialog: (show: boolean) => void;
+ setShowDetailDialog: (show: boolean) => void;
+}
+
+export const useDialogStore = create((set) => ({
+ showAddDialog: false,
+ showEditDialog: false,
+ showDetailDialog: false,
+
+ setShowAddDialog: (show) => set({ showAddDialog: show }),
+ setShowEditDialog: (show) => set({ showEditDialog: show }),
+ setShowDetailDialog: (show) => set({ showDetailDialog: show }),
+}));
diff --git a/src_final/features/posts/model/store/useNewPostStore.ts b/src_final/features/posts/model/store/useNewPostStore.ts
new file mode 100644
index 000000000..f61a73c1b
--- /dev/null
+++ b/src_final/features/posts/model/store/useNewPostStore.ts
@@ -0,0 +1,8 @@
+import { create } from 'zustand';
+import { NewPostAddedRequest } from '@/entities/posts';
+
+export const useNewPostStore = create((set) => ({
+ newPost: { title: '', body: '', userId: 1 },
+ setNewPost: (post) => set({ newPost: post }),
+ resetNewPost: () => set({ newPost: { title: '', body: '', userId: 1 } }),
+}));
diff --git a/src_final/features/posts/model/store/usePostStore.ts b/src_final/features/posts/model/store/usePostStore.ts
new file mode 100644
index 000000000..bf958d069
--- /dev/null
+++ b/src_final/features/posts/model/store/usePostStore.ts
@@ -0,0 +1,27 @@
+import { create } from 'zustand';
+
+interface PostFilterState {
+ skip: number;
+ limit: number;
+ sortBy: string;
+ sortOrder: string;
+ selectedTag: string;
+}
+
+interface PostFilterActions {
+ setFilter: (
+ key: K,
+ value: PostFilterState[K],
+ ) => void;
+}
+
+export const usePostFilterStore = create(
+ (set) => ({
+ skip: 0,
+ limit: 10,
+ sortBy: '',
+ sortOrder: 'asc',
+ selectedTag: '',
+ setFilter: (key, value) => set((state) => ({ ...state, [key]: value })),
+ }),
+);
diff --git a/src_final/features/posts/model/store/useSearchStore.ts b/src_final/features/posts/model/store/useSearchStore.ts
new file mode 100644
index 000000000..e9a8cae84
--- /dev/null
+++ b/src_final/features/posts/model/store/useSearchStore.ts
@@ -0,0 +1,17 @@
+import { create } from 'zustand';
+
+interface SearchState {
+ searchValue: string;
+ searchQuery: string;
+ setSearchQuery: (query: string) => void;
+ setSearchValue: (value: string) => void;
+}
+
+export const useSearchStore = create((set) => ({
+ searchValue: '',
+ searchQuery: '',
+ setSearchQuery: (query) => set({ searchQuery: query }),
+ setSearchValue: (value) => {
+ set({ searchValue: value });
+ },
+}));
diff --git a/src_final/features/posts/model/store/useSelectedPostStore.ts b/src_final/features/posts/model/store/useSelectedPostStore.ts
new file mode 100644
index 000000000..46fe9cbab
--- /dev/null
+++ b/src_final/features/posts/model/store/useSelectedPostStore.ts
@@ -0,0 +1,12 @@
+import { create } from 'zustand';
+import { Post } from '@/entities/posts';
+
+interface SelectedPostState {
+ selectedPost: Post | null;
+ setSelectedPost: (post: Post | null) => void;
+}
+
+export const useSelectedPostStore = create((set) => ({
+ selectedPost: null,
+ setSelectedPost: (post) => set({ selectedPost: post }),
+}));
diff --git a/src_final/features/posts/ui/Pagination.tsx b/src_final/features/posts/ui/Pagination.tsx
new file mode 100644
index 000000000..f453c29ff
--- /dev/null
+++ b/src_final/features/posts/ui/Pagination.tsx
@@ -0,0 +1,44 @@
+import { Select } from '@/shared/ui/select';
+import { Button } from '@/shared/ui/button';
+import { usePostFilterStore } from '../model/store';
+
+const Pagination = ({ total }: { total: number }) => {
+ const { skip, limit, setFilter } = usePostFilterStore();
+ return (
+
+
+ 표시
+
+ 항목
+
+
+
+
+
+
+ );
+};
+
+export default Pagination;
diff --git a/src_final/features/posts/ui/PostAddDialog.tsx b/src_final/features/posts/ui/PostAddDialog.tsx
new file mode 100644
index 000000000..7f35e1000
--- /dev/null
+++ b/src_final/features/posts/ui/PostAddDialog.tsx
@@ -0,0 +1,56 @@
+import { useDialogStore, useNewPostStore } from '../model/store';
+import { usePosts } from '../model';
+
+import { Button } from '@/shared/ui/button';
+import { Dialog } from '@/shared/ui/dialog';
+import { Input } from '@/shared/ui/input';
+import { TextArea } from '@/shared/ui/textarea';
+
+const PostAddDialog = () => {
+ const { showAddDialog, setShowAddDialog } = useDialogStore();
+ const { newPost, setNewPost } = useNewPostStore();
+
+ const { createPost } = usePosts();
+ const handelAddPost = async () => {
+ return createPost.mutate(newPost, {
+ onSuccess: async () => {
+ setShowAddDialog(false);
+ setNewPost({ title: '', body: '', userId: 1 });
+ },
+ });
+ };
+ return (
+
+ );
+};
+
+export default PostAddDialog;
diff --git a/src_final/features/posts/ui/PostAddTrigger.tsx b/src_final/features/posts/ui/PostAddTrigger.tsx
new file mode 100644
index 000000000..56842fa2c
--- /dev/null
+++ b/src_final/features/posts/ui/PostAddTrigger.tsx
@@ -0,0 +1,16 @@
+import { Plus } from 'lucide-react';
+import { useDialogStore } from '../model';
+
+import { Button } from '@/shared/ui/button';
+
+const PostAddTrigger = () => {
+ const { setShowAddDialog } = useDialogStore();
+ return (
+
+ );
+};
+
+export default PostAddTrigger;
diff --git a/src_final/features/posts/ui/PostEditDialog.tsx b/src_final/features/posts/ui/PostEditDialog.tsx
new file mode 100644
index 000000000..25e74b7d5
--- /dev/null
+++ b/src_final/features/posts/ui/PostEditDialog.tsx
@@ -0,0 +1,62 @@
+import { useDialogStore, useSelectedPostStore } from '../model/store';
+import { usePosts } from '../model';
+import { Button } from '@/shared/ui/button';
+import { Dialog } from '@/shared/ui/dialog';
+import { Input } from '@/shared/ui/input';
+import { TextArea } from '@/shared/ui/textarea';
+
+const PostEditDialog = () => {
+ const { showEditDialog, setShowEditDialog } = useDialogStore();
+ const { selectedPost, setSelectedPost } = useSelectedPostStore();
+ const { updatePost } = usePosts();
+
+ const handleEditPost = async () => {
+ if (selectedPost) {
+ updatePost.mutate(
+ {
+ data: selectedPost, // ✅ 이제 Post 타입
+ },
+ {
+ onSuccess: () => {
+ setShowEditDialog(false);
+ },
+ },
+ );
+ }
+ };
+ return (
+
+ );
+};
+
+export default PostEditDialog;
diff --git a/src_final/features/posts/ui/PostFilterSortBy.tsx b/src_final/features/posts/ui/PostFilterSortBy.tsx
new file mode 100644
index 000000000..bd4ad30df
--- /dev/null
+++ b/src_final/features/posts/ui/PostFilterSortBy.tsx
@@ -0,0 +1,24 @@
+import { usePostFilterStore } from '../model/store';
+import { Select } from '@/shared/ui/select';
+
+const PostFilterSortBy = () => {
+ const { sortBy, setFilter } = usePostFilterStore();
+ return (
+
+ );
+};
+
+export default PostFilterSortBy;
diff --git a/src_final/features/posts/ui/PostFilterSortOrder.tsx b/src_final/features/posts/ui/PostFilterSortOrder.tsx
new file mode 100644
index 000000000..7a6b6ed4a
--- /dev/null
+++ b/src_final/features/posts/ui/PostFilterSortOrder.tsx
@@ -0,0 +1,24 @@
+import { usePostFilterStore } from '../model/store';
+
+import { Select } from '@/shared/ui/select';
+
+const PostFilterSortOrder = () => {
+ const { sortOrder, sortBy, setFilter } = usePostFilterStore();
+ return (
+
+ );
+};
+
+export default PostFilterSortOrder;
diff --git a/src_final/features/posts/ui/PostSearchBar.tsx b/src_final/features/posts/ui/PostSearchBar.tsx
new file mode 100644
index 000000000..70bd1fe5b
--- /dev/null
+++ b/src_final/features/posts/ui/PostSearchBar.tsx
@@ -0,0 +1,34 @@
+import { Search } from 'lucide-react';
+import { useSearchStore } from '../model/store';
+import { Input } from '@/shared/ui/input';
+
+interface PostSearchBarProps {
+ placeholder?: string;
+}
+
+const PostSearchBar = ({
+ placeholder = '게시물 검색...',
+}: PostSearchBarProps) => {
+ const { searchValue, setSearchValue, setSearchQuery } = useSearchStore();
+
+ return (
+
+
+
+ setSearchValue(e.target.value)}
+ onKeyDown={(e: React.KeyboardEvent) => {
+ if (e.key === 'Enter') {
+ setSearchQuery(searchValue);
+ }
+ }}
+ />
+
+
+ );
+};
+
+export default PostSearchBar;
diff --git a/src_final/features/posts/ui/PostTable.tsx b/src_final/features/posts/ui/PostTable.tsx
new file mode 100644
index 000000000..d066db73a
--- /dev/null
+++ b/src_final/features/posts/ui/PostTable.tsx
@@ -0,0 +1,50 @@
+import PostTableItem from './PostTableItem.tsx';
+import { useGetPosts, usePostFilterStore, useSearchStore } from '../model';
+
+import { Table } from '@/shared/ui/table';
+import Pagination from './Pagination.tsx';
+
+const PostListTable = () => {
+ const { skip, limit, selectedTag } = usePostFilterStore();
+ const { searchQuery } = useSearchStore();
+
+ const { data, isLoading } = useGetPosts({
+ limit,
+ skip,
+ tag: selectedTag,
+ searchQuery,
+ });
+ return (
+ <>
+ {!isLoading ? (
+
+
+
+
+ ID
+ 제목
+ 작성자
+ 반응
+ 작업
+
+
+
+ {data?.posts.map((post) => (
+
+ ))}
+
+
+
+ ) : (
+ 로딩 중...
+ )}
+
+ >
+ );
+};
+
+export default PostListTable;
diff --git a/src_final/features/posts/ui/PostTableDeleteButton.tsx b/src_final/features/posts/ui/PostTableDeleteButton.tsx
new file mode 100644
index 000000000..0ba3f9a8f
--- /dev/null
+++ b/src_final/features/posts/ui/PostTableDeleteButton.tsx
@@ -0,0 +1,20 @@
+import { Trash2 } from 'lucide-react';
+import { usePosts } from '../model';
+import { Post } from '@/entities/posts';
+import { Button } from '@/shared/ui/button';
+
+const PostTableDeleteButton = ({ post }: { post: Post }) => {
+ const { deletePost } = usePosts();
+
+ return (
+
+ );
+};
+
+export default PostTableDeleteButton;
diff --git a/src_final/features/posts/ui/PostTableDetailButton.tsx b/src_final/features/posts/ui/PostTableDetailButton.tsx
new file mode 100644
index 000000000..c2ef6268d
--- /dev/null
+++ b/src_final/features/posts/ui/PostTableDetailButton.tsx
@@ -0,0 +1,23 @@
+import { MessageSquare } from 'lucide-react';
+import { useDialogStore, useSelectedPostStore } from '../model';
+import { Post } from '@/entities/posts';
+import { Button } from '@/shared/ui/button';
+
+const PostTableDetailButton = ({ post }: { post: Post }) => {
+ const { setShowDetailDialog } = useDialogStore();
+ const { setSelectedPost } = useSelectedPostStore();
+ return (
+
+ );
+};
+
+export default PostTableDetailButton;
diff --git a/src_final/features/posts/ui/PostTableEditingButton.tsx b/src_final/features/posts/ui/PostTableEditingButton.tsx
new file mode 100644
index 000000000..4834373ca
--- /dev/null
+++ b/src_final/features/posts/ui/PostTableEditingButton.tsx
@@ -0,0 +1,24 @@
+import { Edit2 } from 'lucide-react';
+import { useDialogStore, useSelectedPostStore } from '../model';
+import { Post } from '@/entities/posts';
+import { Button } from '@/shared/ui/button';
+
+const PostTableEditingButton = ({ post }: { post: Post }) => {
+ const { setSelectedPost } = useSelectedPostStore();
+ const { setShowEditDialog } = useDialogStore();
+
+ return (
+
+ );
+};
+
+export default PostTableEditingButton;
diff --git a/src_final/features/posts/ui/PostTableItem.tsx b/src_final/features/posts/ui/PostTableItem.tsx
new file mode 100644
index 000000000..c544012b7
--- /dev/null
+++ b/src_final/features/posts/ui/PostTableItem.tsx
@@ -0,0 +1,48 @@
+import { ThumbsDown, ThumbsUp } from 'lucide-react';
+import TableTag from '../ui/TableTag';
+import PostUserTrigger from './PostUserTrigger.tsx';
+import PostTableDetailButton from './PostTableDetailButton.tsx';
+import PostTableEditingButton from './PostTableEditingButton.tsx';
+import PostTableDeleteButton from './PostTableDeleteButton.tsx';
+import { Post } from '@/entities/posts';
+import { Table } from '@/shared/ui/table';
+import { highlightText } from '@/shared/lib/highlightText.tsx';
+interface PostTableItemProps {
+ post: Post;
+ searchQuery: string;
+}
+
+const PostTableItem = ({ post, searchQuery }: PostTableItemProps) => {
+ return (
+
+ {post.id}
+
+
+
{highlightText(post.title, searchQuery)}
+
+
+
+
+
+
+
+ {post.reactions?.likes || 0}
+
+ {post.reactions?.dislikes || 0}
+
+
+
+
+ {/*디테일*/}
+
+ {/*에디팅*/}
+
+ {/*삭제*/}
+
+
+
+
+ );
+};
+
+export default PostTableItem;
diff --git a/src_final/features/posts/ui/PostTagFilter.tsx b/src_final/features/posts/ui/PostTagFilter.tsx
new file mode 100644
index 000000000..bea2a8979
--- /dev/null
+++ b/src_final/features/posts/ui/PostTagFilter.tsx
@@ -0,0 +1,29 @@
+import { usePostFilterStore } from '../model/store';
+import { useTags } from '@/features/tags/model/hooks';
+import { Select } from '@/shared/ui/select';
+
+const PostTagFilter = () => {
+ const { selectedTag, setFilter } = usePostFilterStore();
+ const { data: tags } = useTags();
+
+ return (
+
+ );
+};
+
+export default PostTagFilter;
diff --git a/src_final/features/posts/ui/PostUserTrigger.tsx b/src_final/features/posts/ui/PostUserTrigger.tsx
new file mode 100644
index 000000000..f4fec0a7c
--- /dev/null
+++ b/src_final/features/posts/ui/PostUserTrigger.tsx
@@ -0,0 +1,29 @@
+// eslint-disable-next-line fsd/no-relative-imports
+import { useUserModalStore } from '../../../features/users/model/store';
+import { PostAuthor } from '@/entities/posts';
+import { Table } from '@/shared/ui/table';
+
+interface UserTriggerProps {
+ author?: PostAuthor;
+}
+
+const PostUserTrigger = ({ author }: UserTriggerProps) => {
+ const { openUserModal } = useUserModalStore();
+ return (
+
+ openUserModal(author)}
+ >
+

+
{author?.username}
+
+
+ );
+};
+
+export default PostUserTrigger;
diff --git a/src_final/features/posts/ui/TableTag.tsx b/src_final/features/posts/ui/TableTag.tsx
new file mode 100644
index 000000000..ab6c534be
--- /dev/null
+++ b/src_final/features/posts/ui/TableTag.tsx
@@ -0,0 +1,29 @@
+import { usePostFilterStore } from '../model';
+import { PostTag } from '@/entities/posts';
+
+const TableTag = ({ tags }: { tags: PostTag['slug'][] }) => {
+ const { selectedTag, setFilter } = usePostFilterStore();
+ return (
+
+ {tags?.map((tag) => (
+ {
+ // setSelectedTag(tag);
+ setFilter('selectedTag', tag);
+ // updateURL();
+ }}
+ >
+ {tag}
+
+ ))}
+
+ );
+};
+
+export default TableTag;
diff --git a/src_final/features/posts/ui/index.ts b/src_final/features/posts/ui/index.ts
new file mode 100644
index 000000000..28a1cf92e
--- /dev/null
+++ b/src_final/features/posts/ui/index.ts
@@ -0,0 +1,12 @@
+export { default as PostAddDialog } from './PostAddDialog';
+export { default as PostAddTrigger } from './PostAddTrigger';
+export { default as PostDetailDialog } from '../../../widgets/PostDetailDialog.tsx';
+export { default as PostEditDialog } from './PostEditDialog';
+export { default as PostFilterSortBy } from './PostFilterSortBy';
+export { default as PostFilterSortOrder } from './PostFilterSortOrder';
+export { default as PostSearchBar } from './PostSearchBar';
+export { default as PostTable } from './PostTable';
+export { default as PostTagFilter } from './PostTagFilter';
+export { default as TableTag } from './TableTag';
+export { default as PostTableItem } from './PostTableItem';
+export { default as Pagination } from './Pagination';
diff --git a/src_final/features/tags/index.ts b/src_final/features/tags/index.ts
new file mode 100644
index 000000000..9f8ccaddf
--- /dev/null
+++ b/src_final/features/tags/index.ts
@@ -0,0 +1 @@
+export * from './model';
diff --git a/src_final/features/tags/model/hooks.ts b/src_final/features/tags/model/hooks.ts
new file mode 100644
index 000000000..81ec0f3bb
--- /dev/null
+++ b/src_final/features/tags/model/hooks.ts
@@ -0,0 +1,13 @@
+import { useQuery } from '@tanstack/react-query';
+
+import { postsApi, PostTag } from '@/entities/posts';
+
+export const useTags = () => {
+ return useQuery({
+ queryKey: ['tags'],
+ queryFn: async () => {
+ const tagsData = await postsApi.getTags();
+ return tagsData;
+ },
+ });
+};
diff --git a/src_final/features/tags/model/index.ts b/src_final/features/tags/model/index.ts
new file mode 100644
index 000000000..4cc90d02b
--- /dev/null
+++ b/src_final/features/tags/model/index.ts
@@ -0,0 +1 @@
+export * from './hooks';
diff --git a/src_final/features/users/model/store.ts b/src_final/features/users/model/store.ts
new file mode 100644
index 000000000..89a7a2c47
--- /dev/null
+++ b/src_final/features/users/model/store.ts
@@ -0,0 +1,25 @@
+// src/features/users/model/userModalStore.ts
+import { create } from 'zustand';
+import { PostAuthor } from '@/entities/posts';
+
+interface UserModalState {
+ showUserModal: boolean;
+ selectedUser: PostAuthor | null;
+ openUserModal: (user: PostAuthor | undefined) => void;
+ closeUserModal: () => void;
+}
+
+export const useUserModalStore = create((set) => ({
+ showUserModal: false,
+ selectedUser: null,
+ openUserModal: (user) =>
+ set({
+ showUserModal: true,
+ selectedUser: user,
+ }),
+ closeUserModal: () =>
+ set({
+ showUserModal: false,
+ selectedUser: null,
+ }),
+}));
diff --git a/src_final/features/users/ui/UserInfoDialog.tsx b/src_final/features/users/ui/UserInfoDialog.tsx
new file mode 100644
index 000000000..fa8dc3567
--- /dev/null
+++ b/src_final/features/users/ui/UserInfoDialog.tsx
@@ -0,0 +1,52 @@
+import { useUserModalStore } from '../model/store';
+import { useGetUser } from '@/entities/users';
+import { Dialog } from '@/shared/ui/dialog';
+
+const UserInfoDialog = () => {
+ const { showUserModal, selectedUser, closeUserModal } = useUserModalStore();
+
+ const { data } = useGetUser(selectedUser?.id as number);
+ return (
+
+ );
+};
+
+export default UserInfoDialog;
diff --git a/src_final/index.css b/src_final/index.css
new file mode 100644
index 000000000..62d46a326
--- /dev/null
+++ b/src_final/index.css
@@ -0,0 +1,4 @@
+html, body {
+ background: #fff;
+ color: #000;
+}
\ No newline at end of file
diff --git a/src_final/index.tsx b/src_final/index.tsx
new file mode 100644
index 000000000..317f510f5
--- /dev/null
+++ b/src_final/index.tsx
@@ -0,0 +1,14 @@
+import React from 'react';
+
+import { BrowserRouter as Router } from 'react-router-dom';
+
+import App from '@/App';
+import ReactDOM from 'react-dom/client';
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+
+
+ ,
+);
diff --git a/src_final/main.tsx b/src_final/main.tsx
new file mode 100644
index 000000000..450482f0b
--- /dev/null
+++ b/src_final/main.tsx
@@ -0,0 +1,11 @@
+import { StrictMode } from 'react';
+
+import App from '@/App.tsx';
+import '@/index.css';
+import { createRoot } from 'react-dom/client';
+
+createRoot(document.getElementById('root')!).render(
+
+
+ ,
+);
diff --git a/src_final/pages/PostsManagerPage.tsx b/src_final/pages/PostsManagerPage.tsx
new file mode 100644
index 000000000..86775e731
--- /dev/null
+++ b/src_final/pages/PostsManagerPage.tsx
@@ -0,0 +1,19 @@
+import { Card } from '@/shared/ui/card';
+import PostHeaderAdd from '@/widgets/PostHeaderAdd.tsx';
+import PostContent from '@/widgets/PostContent.tsx';
+import { useURLSync } from '@/features/posts/lib/useURLSync.ts';
+import Dialog from '@/widgets/Dialog.tsx';
+
+const PostsManager = () => {
+ useURLSync();
+
+ return (
+
+
+
+
+
+ );
+};
+
+export default PostsManager;
diff --git a/src_final/shared/lib/api.ts b/src_final/shared/lib/api.ts
new file mode 100644
index 000000000..f6877fc96
--- /dev/null
+++ b/src_final/shared/lib/api.ts
@@ -0,0 +1,67 @@
+type Params = Record;
+type BaseOpts = { params?: Params; headers?: Record };
+
+const BASE_URL = '/api';
+
+/**
+ * 쿼리 스트링 생성
+ * @param params - 쿼리 파라미터
+ * @returns 쿼리 스트링
+ */
+function queryString(params?: Params) {
+ if (!params) return '';
+ const urlSearchParams = new URLSearchParams();
+ Object.entries(params).forEach(([k, v]) => {
+ if (v !== undefined && v !== null) urlSearchParams.set(k, String(v));
+ });
+ const queryString = urlSearchParams.toString();
+ return queryString ? `?${queryString}` : '';
+}
+
+/**
+ * 요청 처리
+ * @param path - 요청 경로
+ * @param init - 요청 옵션
+ * @returns 요청 결과
+ */
+async function request(path: string, init: RequestInit): Promise {
+ const res = await fetch(BASE_URL + path, init);
+ if (!res.ok) throw new Error(`API ${res.status} ${res.statusText}`);
+ if (res.status === 204) return undefined as unknown as T;
+ return res.json() as Promise;
+}
+
+/**
+ * API 요청 함수
+ */
+export const api = {
+ get: (path: string, opts: BaseOpts = {}) =>
+ request(`${path}${queryString(opts.params)}`, {
+ method: 'GET',
+ headers: opts.headers,
+ }),
+
+ post: (path: string, body?: B, opts: BaseOpts = {}) =>
+ request(path, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
+ body: body == null ? undefined : JSON.stringify(body),
+ }),
+
+ put: (path: string, body?: B, opts: BaseOpts = {}) =>
+ request(path, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
+ body: body == null ? undefined : JSON.stringify(body),
+ }),
+
+ patch: (path: string, body?: B, opts: BaseOpts = {}) =>
+ request(path, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
+ body: body == null ? undefined : JSON.stringify(body),
+ }),
+
+ delete: (path: string, opts: BaseOpts = {}) =>
+ request(path, { method: 'DELETE', headers: opts.headers }),
+};
diff --git a/src_final/shared/lib/auto-mutation.ts b/src_final/shared/lib/auto-mutation.ts
new file mode 100644
index 000000000..9b4e3f0f6
--- /dev/null
+++ b/src_final/shared/lib/auto-mutation.ts
@@ -0,0 +1,33 @@
+import { QueryKey, useMutation, useQueryClient } from '@tanstack/react-query';
+
+interface AutoMutationProps {
+ fn: (v: TVariable) => Promise;
+ invalidateKeys: QueryKey[];
+ updateKey?: (data: TData) => { key: QueryKey; data: TData };
+ successMessage?: string;
+ errorMessage?: string;
+}
+
+export const useAutoMutation = ({
+ fn,
+ invalidateKeys,
+ updateKey,
+}: AutoMutationProps) => {
+ const queryClient = useQueryClient();
+ return useMutation({
+ mutationFn: fn,
+ onSuccess: (data) => {
+ invalidateKeys.forEach((key) => {
+ queryClient.invalidateQueries({ queryKey: key });
+ });
+
+ if (updateKey) {
+ const { key, data: newData } = updateKey(data);
+ queryClient.setQueryData(key, newData);
+ }
+ },
+ onError: (error) => {
+ throw new Error(error.message);
+ },
+ });
+};
diff --git a/src_final/shared/lib/highlightText.tsx b/src_final/shared/lib/highlightText.tsx
new file mode 100644
index 000000000..460d0b4ca
--- /dev/null
+++ b/src_final/shared/lib/highlightText.tsx
@@ -0,0 +1,20 @@
+// 하이라이트 함수 추가
+export 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}
+ ),
+ )}
+
+ );
+};
diff --git a/src_final/shared/model/types.ts b/src_final/shared/model/types.ts
new file mode 100644
index 000000000..910372456
--- /dev/null
+++ b/src_final/shared/model/types.ts
@@ -0,0 +1,5 @@
+export interface BasicUser {
+ id: number | null;
+ image?: string;
+ username?: string;
+}
diff --git a/src_final/shared/ui/Footer.tsx b/src_final/shared/ui/Footer.tsx
new file mode 100644
index 000000000..91af02f8c
--- /dev/null
+++ b/src_final/shared/ui/Footer.tsx
@@ -0,0 +1,13 @@
+import React from 'react';
+
+const Footer: React.FC = () => {
+ return (
+
+ );
+};
+
+export default Footer;
diff --git a/src_final/shared/ui/Header.tsx b/src_final/shared/ui/Header.tsx
new file mode 100644
index 000000000..63ecec168
--- /dev/null
+++ b/src_final/shared/ui/Header.tsx
@@ -0,0 +1,25 @@
+import React from 'react';
+import { MessageSquare } from 'lucide-react';
+
+const Header: React.FC = () => {
+ return (
+
+
+
+
+
게시물 관리 시스템
+
+
+
+
+ );
+};
+
+export default Header;
+
diff --git a/src_final/shared/ui/button/index.tsx b/src_final/shared/ui/button/index.tsx
new file mode 100644
index 000000000..7a9fdddca
--- /dev/null
+++ b/src_final/shared/ui/button/index.tsx
@@ -0,0 +1,53 @@
+import * as React from 'react';
+
+import { VariantProps, cva } 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;
+ ref?: React.Ref;
+}
+
+export const Button = ({
+ className,
+ variant,
+ size,
+ ref,
+ ...props
+}: ButtonProps) => {
+ return (
+
+ );
+};
diff --git a/src_final/shared/ui/card/index.tsx b/src_final/shared/ui/card/index.tsx
new file mode 100644
index 000000000..2d6395ff2
--- /dev/null
+++ b/src_final/shared/ui/card/index.tsx
@@ -0,0 +1,43 @@
+import * as React from 'react';
+
+interface CardProps extends React.HTMLAttributes {
+ className?: string;
+ ref?: React.Ref;
+}
+
+const CardRoot = ({ className, ...props }: CardProps) => (
+
+);
+
+const CardHeader = ({ className, ...props }: CardProps) => (
+
+);
+
+const CardTitle = ({ className, ...props }: CardProps) => (
+
+);
+
+const CardContent = ({ className, ...props }: CardProps) => (
+
+);
+
+// ============================================
+// 컴파운드 패턴 구성
+// ============================================
+export const Card = Object.assign(CardRoot, {
+ Header: CardHeader,
+ Title: CardTitle,
+ Content: CardContent,
+});
+
+// 개별 export도 제공 (하위 호환성)
+export { CardHeader, CardTitle, CardContent };
diff --git a/src_final/shared/ui/dialog/index.tsx b/src_final/shared/ui/dialog/index.tsx
new file mode 100644
index 000000000..00ad4a756
--- /dev/null
+++ b/src_final/shared/ui/dialog/index.tsx
@@ -0,0 +1,65 @@
+import { HTMLAttributes } from 'react';
+
+import * as DialogPrimitive from '@radix-ui/react-dialog';
+import { X } from 'lucide-react';
+
+interface DialogContentProps
+ extends React.ComponentProps {
+ ref?: React.Ref;
+}
+interface DialogTitleProps
+ extends React.ComponentProps {
+ ref?: React.Ref;
+}
+interface DialogHeaderProps extends HTMLAttributes {
+ className?: string;
+}
+
+export const DialogRoot = DialogPrimitive.Root;
+export const DialogTrigger = DialogPrimitive.Trigger;
+export const DialogPortal = DialogPrimitive.Portal;
+export const DialogOverlay = DialogPrimitive.Overlay;
+
+export const DialogContent = ({
+ className,
+ children,
+ ref,
+ ...props
+}: DialogContentProps) => (
+
+
+
+ {children}
+
+
+ 닫기
+
+
+
+);
+
+export const DialogHeader = ({ className, ...props }: DialogHeaderProps) => (
+
+);
+
+export const DialogTitle = ({ className, ref, ...props }: DialogTitleProps) => (
+
+);
+
+export const Dialog = Object.assign(DialogRoot, {
+ Content: DialogContent,
+ Header: DialogHeader,
+ Title: DialogTitle,
+ Trigger: DialogTrigger,
+});
diff --git a/src_final/shared/ui/input/index.tsx b/src_final/shared/ui/input/index.tsx
new file mode 100644
index 000000000..db15bf9bc
--- /dev/null
+++ b/src_final/shared/ui/input/index.tsx
@@ -0,0 +1,16 @@
+import { Ref } from 'react';
+
+interface InputProps extends React.InputHTMLAttributes {
+ className?: string;
+ ref?: Ref;
+}
+
+export const Input = ({ className, ref, ...rest }: InputProps) => {
+ return (
+
+ );
+};
diff --git a/src_final/shared/ui/select/index.tsx b/src_final/shared/ui/select/index.tsx
new file mode 100644
index 000000000..19ffa486f
--- /dev/null
+++ b/src_final/shared/ui/select/index.tsx
@@ -0,0 +1,141 @@
+import * as React from 'react';
+
+import * as SelectPrimitive from '@radix-ui/react-select';
+import { Check, ChevronDown } from 'lucide-react';
+
+// ============================================
+// 타입 정의 (ref 포함)
+// ============================================
+interface SelectTriggerProps
+ extends React.ComponentProps {
+ ref?: React.Ref;
+}
+
+interface SelectContentProps
+ extends React.ComponentProps {
+ position?: 'popper' | 'item-aligned';
+ ref?: React.Ref;
+}
+
+interface SelectItemProps
+ extends React.ComponentProps {
+ ref?: React.Ref;
+}
+
+interface SelectSeparatorProps
+ extends React.ComponentProps {
+ ref?: React.Ref;
+}
+
+interface SelectLabelProps
+ extends React.ComponentProps {
+ ref?: React.Ref;
+}
+
+// ============================================
+// 개별 컴포넌트들 (React 19 스타일)
+// ============================================
+const SelectRoot = SelectPrimitive.Root;
+const SelectGroup = SelectPrimitive.Group;
+const SelectValue = SelectPrimitive.Value;
+
+const SelectTrigger = ({
+ className,
+ children,
+ ref,
+ ...props
+}: SelectTriggerProps) => (
+
+ {children}
+
+
+);
+
+const SelectContent = ({
+ className,
+ children,
+ position = 'popper',
+ ref,
+ ...props
+}: SelectContentProps) => (
+
+
+
+ {children}
+
+
+
+);
+
+const SelectItem = ({
+ className,
+ children,
+ ref,
+ ...props
+}: SelectItemProps) => (
+
+
+
+
+
+
+ {children}
+
+);
+
+const SelectSeparator = ({
+ className,
+ ref,
+ ...props
+}: SelectSeparatorProps) => (
+
+);
+
+const SelectLabel = ({ className, ref, ...props }: SelectLabelProps) => (
+
+);
+
+// ============================================
+// 컴파운드 패턴 구성
+// ============================================
+export const Select = Object.assign(SelectRoot, {
+ Group: SelectGroup,
+ Value: SelectValue,
+ Trigger: SelectTrigger,
+ Content: SelectContent,
+ Item: SelectItem,
+ Separator: SelectSeparator,
+ Label: SelectLabel,
+});
+
+// 개별 export도 제공 (하위 호환성)
+export {
+ SelectGroup,
+ SelectValue,
+ SelectTrigger,
+ SelectContent,
+ SelectItem,
+ SelectSeparator,
+ SelectLabel,
+};
diff --git a/src_final/shared/ui/table/index.tsx b/src_final/shared/ui/table/index.tsx
new file mode 100644
index 000000000..0ca1d0305
--- /dev/null
+++ b/src_final/shared/ui/table/index.tsx
@@ -0,0 +1,85 @@
+import * as React from 'react';
+
+// ============================================
+// 타입 정의
+// ============================================
+type TableProps = React.ComponentProps<'table'>;
+type TableHeaderProps = React.ComponentProps<'thead'>;
+type TableBodyProps = React.ComponentProps<'tbody'>;
+type TableRowProps = React.ComponentProps<'tr'>;
+type TableHeadProps = React.ComponentProps<'th'>;
+type TableCellProps = React.ComponentProps<'td'>;
+type TableRootProps = React.ComponentProps<'div'>;
+
+const TableRoot = ({ className, ref, children, ...props }: TableRootProps) => (
+
+ {children}
+
+);
+
+const TableElement = ({ className, ref, ...props }: TableProps) => (
+
+);
+
+const TableHeader = ({ className, ref, ...props }: TableHeaderProps) => (
+
+);
+
+const TableBody = ({ className, ref, ...props }: TableBodyProps) => (
+
+);
+
+const TableRow = ({ className, ref, ...props }: TableRowProps) => (
+
+);
+
+const TableHead = ({ className, ref, ...props }: TableHeadProps) => (
+ |
+);
+
+const TableCell = ({ className, ref, ...props }: TableCellProps) => (
+ |
+);
+
+// ============================================
+// 컴파운드 패턴 구성
+// ============================================
+export const Table = Object.assign(TableElement, {
+ Root: TableRoot,
+ Header: TableHeader,
+ Body: TableBody,
+ Row: TableRow,
+ Head: TableHead,
+ Cell: TableCell,
+});
+
+// 개별 export도 제공 (하위 호환성)
+export { TableElement, TableHeader, TableBody, TableRow, TableHead, TableCell };
diff --git a/src_final/shared/ui/textarea/index.tsx b/src_final/shared/ui/textarea/index.tsx
new file mode 100644
index 000000000..b0a4b64f1
--- /dev/null
+++ b/src_final/shared/ui/textarea/index.tsx
@@ -0,0 +1,15 @@
+interface TextAreaProps
+ extends React.TextareaHTMLAttributes {
+ className?: string;
+ ref?: React.Ref;
+}
+
+export const TextArea = ({ className, ref, ...props }: TextAreaProps) => {
+ return (
+
+ );
+};
diff --git a/src_final/widgets/Dialog.tsx b/src_final/widgets/Dialog.tsx
new file mode 100644
index 000000000..3c5e4c65a
--- /dev/null
+++ b/src_final/widgets/Dialog.tsx
@@ -0,0 +1,23 @@
+import {
+ PostAddDialog,
+ PostDetailDialog,
+ PostEditDialog,
+} from '@/features/posts';
+import AddComment from '@/features/comments/ui/AddComment.tsx';
+import EditComment from '@/features/comments/ui/EditComment.tsx';
+import UserInfoDialog from '@/features/users/ui/UserInfoDialog.tsx';
+
+const Dialog = () => {
+ return (
+ <>
+
+
+
+
+
+
+ >
+ );
+};
+
+export default Dialog;
diff --git a/src_final/widgets/PostContent.tsx b/src_final/widgets/PostContent.tsx
new file mode 100644
index 000000000..7445480c9
--- /dev/null
+++ b/src_final/widgets/PostContent.tsx
@@ -0,0 +1,17 @@
+import PostFilter from './PostFilter.tsx';
+
+import { Card } from '@/shared/ui/card';
+import { PostTable } from '@/features/posts';
+
+const PostContent = () => {
+ return (
+
+
+
+ );
+};
+
+export default PostContent;
diff --git a/src_final/widgets/PostDetailDialog.tsx b/src_final/widgets/PostDetailDialog.tsx
new file mode 100644
index 000000000..900dd511d
--- /dev/null
+++ b/src_final/widgets/PostDetailDialog.tsx
@@ -0,0 +1,35 @@
+import {
+ useDialogStore,
+ useSearchStore,
+ useSelectedPostStore,
+} from '../features/posts/model/store';
+
+import { highlightText } from '@/shared/lib/highlightText.tsx';
+import { Dialog } from '@/shared/ui/dialog';
+import { Comments } from '@/features/comments';
+
+const PostDetailDialog = () => {
+ const { showDetailDialog, setShowDetailDialog } = useDialogStore();
+ const { selectedPost } = useSelectedPostStore();
+ const { searchValue } = useSearchStore();
+ return (
+
+ );
+};
+
+export default PostDetailDialog;
diff --git a/src_final/widgets/PostFilter.tsx b/src_final/widgets/PostFilter.tsx
new file mode 100644
index 000000000..3753b2269
--- /dev/null
+++ b/src_final/widgets/PostFilter.tsx
@@ -0,0 +1,19 @@
+import {
+ PostSearchBar,
+ PostTagFilter,
+ PostFilterSortBy,
+ PostFilterSortOrder,
+} from '@/features/posts';
+
+const PostFilter = () => {
+ return (
+
+ );
+};
+
+export default PostFilter;
diff --git a/src_final/widgets/PostHeaderAdd.tsx b/src_final/widgets/PostHeaderAdd.tsx
new file mode 100644
index 000000000..138d601df
--- /dev/null
+++ b/src_final/widgets/PostHeaderAdd.tsx
@@ -0,0 +1,15 @@
+import PostAddTrigger from '../features/posts/ui/PostAddTrigger.tsx';
+import { Card } from '@/shared/ui/card';
+
+const PostHeaderAdd = () => {
+ return (
+
+
+ 게시물 관리자
+
+
+
+ );
+};
+
+export default PostHeaderAdd;
diff --git a/tsconfig.app.json b/tsconfig.app.json
index 5a2def4b7..521e00c1a 100644
--- a/tsconfig.app.json
+++ b/tsconfig.app.json
@@ -19,7 +19,13 @@
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
- "noUncheckedSideEffectImports": true
+ "noUncheckedSideEffectImports": true,
+
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"],
+ "@/index.css": ["src/index.css"]
+ }
},
- "include": ["src"]
+ "include": ["src", "global.d.ts","vite-env.d.ts"]
}
diff --git a/vite-env.d.ts b/vite-env.d.ts
index 11f02fe2a..8ac5a7ff7 100644
--- a/vite-env.d.ts
+++ b/vite-env.d.ts
@@ -1 +1,10 @@
///
+interface ImportMetaEnv {
+ readonly VITE_API_BASE_URL: string;
+ // 필요한 VITE_ 변수들 추가
+ // readonly VITE_SOME_FLAG: string;
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
diff --git a/vite.config.ts b/vite.config.ts
index be7b7a3d4..eadc6c83b 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,17 +1,24 @@
-import { defineConfig } from "vite"
-import react from "@vitejs/plugin-react"
+import react from '@vitejs/plugin-react';
+import path from 'node:path';
+import { defineConfig } from 'vite';
// https://vite.dev/config/
-export default defineConfig({
+export default defineConfig(({ mode }) => ({
+ base: mode === 'production' ? '/front_6th_chapter2-3/' : '/',
plugins: [react()],
server: {
proxy: {
- "/api": {
+ '/api': {
// target: 'https://jsonplaceholder.typicode.com',
- target: "https://dummyjson.com",
+ target: 'https://dummyjson.com',
changeOrigin: true,
- rewrite: (path) => path.replace(/^\/api/, ""),
+ rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
-})
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, './src'),
+ },
+ },
+}));
diff --git a/vitest.config.ts b/vitest.config.ts
index fa9992fb4..2c0bdd471 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,11 +1,12 @@
///
-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()],
+ base: '/front_6th_chapter2-3/',
test: {
globals: true,
- environment: "jsdom",
+ environment: 'jsdom',
},
-})
+});