diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 000000000..1ec6de853
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,55 @@
+name: Deploy to GitHub Pages
+
+on:
+ push:
+ branches: [main]
+ workflow_dispatch:
+
+permissions:
+ contents: write
+
+jobs:
+ build-and-deploy:
+ if: github.event_name == 'push'
+ runs-on: ubuntu-latest
+ concurrency:
+ group: pages-deploy-${{ github.ref }}
+ cancel-in-progress: true
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ - name: Enable corepack (ensure pnpm shim)
+ run: corepack enable
+
+ - name: Setup pnpm
+ uses: pnpm/action-setup@v2
+ with:
+ version: 8
+ run_install: false
+
+ - name: Install dependencies
+ run: pnpm install --no-frozen-lockfile
+
+ - name: Build
+ run: pnpm run build
+
+ - name: Add 404.html (SPA fallback)
+ run: |
+ if [ -f "dist/index.html" ]; then
+ cp dist/index.html dist/404.html
+ echo "Added 404.html for SPA routing"
+ fi
+
+ - name: Deploy to GitHub Pages
+ uses: peaceiris/actions-gh-pages@v3
+ with:
+ github_token: ${{ secrets.GITHUB_TOKEN }}
+ publish_dir: ./dist
+ force_orphan: true
+ publish_branch: gh-pages
diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 000000000..37f4cb4e8
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1,8 @@
+node_modules
+dist
+build
+coverage
+*.log
+pnpm-lock.yaml
+package-lock.json
+yarn.lock
\ No newline at end of file
diff --git a/.prettierrc b/.prettierrc
index d9ae6b1fb..391e48180 100644
--- a/.prettierrc
+++ b/.prettierrc
@@ -1,9 +1,11 @@
{
- "semi": false,
- "printWidth": 120,
+ "semi": true,
+ "singleQuote": true,
+ "jsxSingleQuote": true,
"tabWidth": 2,
- "singleQuote": false,
- "quoteProps": "consistent",
- "trailingComma": "all",
- "singleAttributePerLine": false
+ "bracketSpacing": true,
+ "bracketSameLine": false,
+ "arrowParens": "always",
+ "printWidth": 100,
+ "endOfLine": "auto"
}
\ No newline at end of file
diff --git a/eslint.config.js b/eslint.config.js
index 092408a9f..4834d7280 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -1,28 +1,130 @@
-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'
-
-export default tseslint.config(
- { ignores: ['dist'] },
+import typescript from "@typescript-eslint/eslint-plugin"
+import typescriptParser from "@typescript-eslint/parser"
+import { defineConfig } from "eslint/config"
+import prettier from "eslint-config-prettier"
+import compat from "eslint-plugin-compat"
+import importPlugin from "eslint-plugin-import"
+import eslintPluginPrettier from "eslint-plugin-prettier"
+import react from "eslint-plugin-react"
+import reactHooks from "eslint-plugin-react-hooks"
+import reactRefresh from "eslint-plugin-react-refresh"
+
+export default defineConfig([
+ {
+ ignores: ["**/node_modules/**", "dist/**", ".eslintrc.cjs"],
+ },
{
- extends: [js.configs.recommended, ...tseslint.configs.recommended],
- files: ['**/*.{ts,tsx}'],
+ files: ["**/*.{js,jsx,ts,tsx}"],
languageOptions: {
- ecmaVersion: 2020,
- globals: globals.browser,
+ ecmaVersion: "latest",
+ sourceType: "module",
+ parser: typescriptParser,
+ parserOptions: {
+ ecmaFeatures: {
+ jsx: true,
+ },
+ tsconfigRootDir: ".",
+ },
},
plugins: {
- 'react-hooks': reactHooks,
- 'react-refresh': reactRefresh,
+ "prettier": eslintPluginPrettier,
+ react,
+ "react-hooks": reactHooks,
+ "react-refresh": reactRefresh,
+ "@typescript-eslint": typescript,
+ compat,
+ "import": importPlugin,
+ },
+ settings: {
+ react: {
+ version: "detect",
+ },
+ browsers: "> 0.5%, last 2 versions, not op_mini all, Firefox ESR, not dead",
},
rules: {
- ...reactHooks.configs.recommended.rules,
- 'react-refresh/only-export-components': [
- 'warn',
- { allowConstantExport: true },
+ // 기존 규칙 유지
+ "react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
+
+ // Prettier 통합 규칙
+ "comma-dangle": [
+ "error",
+ {
+ arrays: "always-multiline",
+ objects: "always-multiline",
+ imports: "always-multiline",
+ exports: "always-multiline",
+ functions: "never",
+ },
+ ],
+
+ // React 관련 규칙
+ "react/prop-types": "off",
+ "react/react-in-jsx-scope": "off",
+ "react-hooks/rules-of-hooks": "error",
+
+ // TypeScript 관련 규칙
+ "@typescript-eslint/no-explicit-any": "warn",
+
+ // 팀 컨벤션 - var 사용 금지
+ "no-var": "error",
+ "@typescript-eslint/no-unused-vars": "error",
+
+ // 팀 컨벤션 - 동등 연산자 (==, !=) 금지
+ "eqeqeq": ["error", "always", { null: "ignore" }],
+
+ // 팀 컨벤션 - 얼리 리턴 권장
+ "consistent-return": "error",
+ "no-else-return": ["error", { allowElseIf: false }],
+
+ // 팀 컨벤션 - 템플릿 리터럴 규칙
+ "prefer-template": "error",
+
+ // 팀 컨벤션 - 상수는 대문자
+ "camelcase": [
+ "error",
+ {
+ properties: "never",
+ ignoreDestructuring: false,
+ ignoreImports: false,
+ ignoreGlobals: false,
+ allow: ["^[A-Z][A-Z0-9_]*$"],
+ },
+ ],
+
+ // 팀 컨벤션 - 구조분해할당 권장
+ "prefer-destructuring": [
+ "error",
+ {
+ array: true,
+ object: true,
+ },
+ {
+ enforceForRenamedProperties: false,
+ },
+ ],
+
+ // 기본 코드 품질 규칙
+ "prefer-const": "error",
+ "object-shorthand": "error",
+ "no-multiple-empty-lines": ["error", { max: 1, maxEOF: 0 }],
+ "no-console": ["warn", { allow: ["warn", "error"] }],
+ "no-debugger": "warn",
+ "no-undef": "off",
+
+ // import 순서 규칙
+ "import/order": [
+ "error",
+ {
+ "groups": ["builtin", "external", ["parent", "sibling"], "index"],
+ "alphabetize": {
+ order: "asc",
+ caseInsensitive: true,
+ },
+ "newlines-between": "always",
+ },
],
+ "import/extensions": "off",
},
},
-)
+ prettier,
+])
\ No newline at end of file
diff --git a/package.json b/package.json
index e014c5272..794bb44a0 100644
--- a/package.json
+++ b/package.json
@@ -12,16 +12,20 @@
"coverage": "vitest run --coverage"
},
"dependencies": {
+ "@tanstack/react-query": "^5.85.0",
"react": "^19.1.1",
- "react-dom": "^19.1.1"
+ "react-dom": "^19.1.1",
+ "zustand": "^5.0.7"
},
"devDependencies": {
"@eslint/js": "^9.33.0",
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-select": "^2.2.5",
+ "@tanstack/react-query-devtools": "^5.85.3",
"@testing-library/jest-dom": "^6.6.4",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
+ "@types/node": "^24.2.1",
"@types/react": "^19.1.9",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^5.0.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6b2a40d18..4c6a04c52 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,12 +8,18 @@ importers:
.:
dependencies:
+ '@tanstack/react-query':
+ specifier: ^5.85.0
+ version: 5.85.0(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
@@ -24,6 +30,9 @@ importers:
'@radix-ui/react-select':
specifier: ^2.2.5
version: 2.2.5(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ '@tanstack/react-query-devtools':
+ specifier: ^5.85.3
+ version: 5.85.3(@tanstack/react-query@5.85.0(react@19.1.1))(react@19.1.1)
'@testing-library/jest-dom':
specifier: ^6.6.4
version: 6.6.4
@@ -33,6 +42,9 @@ importers:
'@testing-library/user-event':
specifier: ^14.6.1
version: 14.6.1(@testing-library/dom@10.4.0)
+ '@types/node':
+ specifier: ^24.2.1
+ version: 24.2.1
'@types/react':
specifier: ^19.1.9
version: 19.1.9
@@ -41,7 +53,7 @@ importers:
version: 19.1.7(@types/react@19.1.9)
'@vitejs/plugin-react':
specifier: ^5.0.0
- version: 5.0.0(vite@7.1.1(@types/node@22.8.1))
+ version: 5.0.0(vite@7.1.1(@types/node@24.2.1))
axios:
specifier: ^1.11.0
version: 1.11.0
@@ -68,7 +80,7 @@ importers:
version: 0.539.0(react@19.1.1)
msw:
specifier: ^2.10.4
- version: 2.10.4(@types/node@22.8.1)(typescript@5.9.2)
+ version: 2.10.4(@types/node@24.2.1)(typescript@5.9.2)
prettier:
specifier: ^3.6.2
version: 3.6.2
@@ -83,10 +95,10 @@ importers:
version: 8.39.0(eslint@9.33.0)(typescript@5.9.2)
vite:
specifier: ^7.1.1
- version: 7.1.1(@types/node@22.8.1)
+ version: 7.1.1(@types/node@24.2.1)
vitest:
specifier: ^3.2.4
- version: 3.2.4(@types/node@22.8.1)(@vitest/browser@2.1.3)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.8.1)(typescript@5.9.2))
+ version: 3.2.4(@types/node@24.2.1)(@vitest/browser@2.1.3)(jsdom@26.1.0)(msw@2.10.4(@types/node@24.2.1)(typescript@5.9.2))
vitest-browser-react:
specifier: ^1.0.1
version: 1.0.1(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(@vitest/browser@2.1.3)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(vitest@3.2.4)
@@ -103,10 +115,6 @@ packages:
'@asamuzakjp/css-color@2.8.3':
resolution: {integrity: sha512-GIc76d9UI1hCvOATjZPyHFmE5qhRccp3/zGfMPapK3jBi+yocEzp6BBB0UnfRYP9NP4FANqUZYb0hnfs3TM3hw==}
- '@babel/code-frame@7.26.2':
- resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==}
- engines: {node: '>=6.9.0'}
-
'@babel/code-frame@7.27.1':
resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==}
engines: {node: '>=6.9.0'}
@@ -930,6 +938,23 @@ packages:
cpu: [x64]
os: [win32]
+ '@tanstack/query-core@5.83.1':
+ resolution: {integrity: sha512-OG69LQgT7jSp+5pPuCfzltq/+7l2xoweggjme9vlbCPa/d7D7zaqv5vN/S82SzSYZ4EDLTxNO1PWrv49RAS64Q==}
+
+ '@tanstack/query-devtools@5.84.0':
+ resolution: {integrity: sha512-fbF3n+z1rqhvd9EoGp5knHkv3p5B2Zml1yNRjh7sNXklngYI5RVIWUrUjZ1RIcEoscarUb0+bOvIs5x9dwzOXQ==}
+
+ '@tanstack/react-query-devtools@5.85.3':
+ resolution: {integrity: sha512-WSVweCE1Kh1BVvPDHAmLgGT+GGTJQ9+a7bVqzD+zUiUTht+salJjYm5nikpMNaHFPJV102TCYdvgHgBXtURRNg==}
+ peerDependencies:
+ '@tanstack/react-query': ^5.85.3
+ react: ^18 || ^19
+
+ '@tanstack/react-query@5.85.0':
+ resolution: {integrity: sha512-t1HMfToVMGfwEJRya6GG7gbK0luZJd+9IySFNePL1BforU1F3LqQ3tBC2Rpvr88bOrlU6PXyMLgJD0Yzn4ztUw==}
+ peerDependencies:
+ react: ^18 || ^19
+
'@testing-library/dom@10.4.0':
resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==}
engines: {node: '>=18'}
@@ -992,8 +1017,8 @@ packages:
'@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
- '@types/node@22.8.1':
- resolution: {integrity: sha512-k6Gi8Yyo8EtrNtkHXutUu2corfDf9su95VYVP10aGYMMROM6SAItZi0w1XszA6RtWTHSVp5OeFof37w0IEqCQg==}
+ '@types/node@24.2.1':
+ resolution: {integrity: sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ==}
'@types/react-dom@19.1.7':
resolution: {integrity: sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==}
@@ -2133,8 +2158,8 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
- undici-types@6.19.8:
- resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==}
+ undici-types@7.10.0:
+ resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==}
universalify@0.2.0:
resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==}
@@ -2345,6 +2370,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 +2405,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
@@ -2684,16 +2721,16 @@ snapshots:
'@humanwhocodes/retry@0.4.2': {}
- '@inquirer/confirm@5.0.1(@types/node@22.8.1)':
+ '@inquirer/confirm@5.0.1(@types/node@24.2.1)':
dependencies:
- '@inquirer/core': 10.0.1(@types/node@22.8.1)
- '@inquirer/type': 3.0.0(@types/node@22.8.1)
- '@types/node': 22.8.1
+ '@inquirer/core': 10.0.1(@types/node@24.2.1)
+ '@inquirer/type': 3.0.0(@types/node@24.2.1)
+ '@types/node': 24.2.1
- '@inquirer/core@10.0.1(@types/node@22.8.1)':
+ '@inquirer/core@10.0.1(@types/node@24.2.1)':
dependencies:
'@inquirer/figures': 1.0.7
- '@inquirer/type': 3.0.0(@types/node@22.8.1)
+ '@inquirer/type': 3.0.0(@types/node@24.2.1)
ansi-escapes: 4.3.2
cli-width: 4.1.0
mute-stream: 2.0.0
@@ -2706,9 +2743,9 @@ snapshots:
'@inquirer/figures@1.0.7': {}
- '@inquirer/type@3.0.0(@types/node@22.8.1)':
+ '@inquirer/type@3.0.0(@types/node@24.2.1)':
dependencies:
- '@types/node': 22.8.1
+ '@types/node': 24.2.1
'@jridgewell/gen-mapping@0.3.12':
dependencies:
@@ -3081,9 +3118,24 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.46.2':
optional: true
+ '@tanstack/query-core@5.83.1': {}
+
+ '@tanstack/query-devtools@5.84.0': {}
+
+ '@tanstack/react-query-devtools@5.85.3(@tanstack/react-query@5.85.0(react@19.1.1))(react@19.1.1)':
+ dependencies:
+ '@tanstack/query-devtools': 5.84.0
+ '@tanstack/react-query': 5.85.0(react@19.1.1)
+ react: 19.1.1
+
+ '@tanstack/react-query@5.85.0(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
@@ -3153,9 +3205,9 @@ snapshots:
'@types/json-schema@7.0.15': {}
- '@types/node@22.8.1':
+ '@types/node@24.2.1':
dependencies:
- undici-types: 6.19.8
+ undici-types: 7.10.0
'@types/react-dom@19.1.7(@types/react@19.1.9)':
dependencies:
@@ -3262,7 +3314,7 @@ snapshots:
'@typescript-eslint/types': 8.39.0
eslint-visitor-keys: 4.2.1
- '@vitejs/plugin-react@5.0.0(vite@7.1.1(@types/node@22.8.1))':
+ '@vitejs/plugin-react@5.0.0(vite@7.1.1(@types/node@24.2.1))':
dependencies:
'@babel/core': 7.28.0
'@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.0)
@@ -3270,21 +3322,21 @@ snapshots:
'@rolldown/pluginutils': 1.0.0-beta.30
'@types/babel__core': 7.20.5
react-refresh: 0.17.0
- vite: 7.1.1(@types/node@22.8.1)
+ vite: 7.1.1(@types/node@24.2.1)
transitivePeerDependencies:
- supports-color
- '@vitest/browser@2.1.3(@types/node@22.8.1)(@vitest/spy@3.2.4)(typescript@5.9.2)(vite@7.1.1(@types/node@22.8.1))(vitest@3.2.4)':
+ '@vitest/browser@2.1.3(@types/node@24.2.1)(@vitest/spy@3.2.4)(typescript@5.9.2)(vite@7.1.1(@types/node@24.2.1))(vitest@3.2.4)':
dependencies:
'@testing-library/dom': 10.4.0
'@testing-library/user-event': 14.6.1(@testing-library/dom@10.4.0)
- '@vitest/mocker': 2.1.3(@vitest/spy@3.2.4)(msw@2.10.4(@types/node@22.8.1)(typescript@5.9.2))(vite@7.1.1(@types/node@22.8.1))
+ '@vitest/mocker': 2.1.3(@vitest/spy@3.2.4)(msw@2.10.4(@types/node@24.2.1)(typescript@5.9.2))(vite@7.1.1(@types/node@24.2.1))
'@vitest/utils': 2.1.3
magic-string: 0.30.17
- msw: 2.10.4(@types/node@22.8.1)(typescript@5.9.2)
+ msw: 2.10.4(@types/node@24.2.1)(typescript@5.9.2)
sirv: 2.0.4
tinyrainbow: 1.2.0
- vitest: 3.2.4(@types/node@22.8.1)(@vitest/browser@2.1.3)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.8.1)(typescript@5.9.2))
+ vitest: 3.2.4(@types/node@24.2.1)(@vitest/browser@2.1.3)(jsdom@26.1.0)(msw@2.10.4(@types/node@24.2.1)(typescript@5.9.2))
ws: 8.18.0
transitivePeerDependencies:
- '@types/node'
@@ -3302,23 +3354,23 @@ snapshots:
chai: 5.2.0
tinyrainbow: 2.0.0
- '@vitest/mocker@2.1.3(@vitest/spy@3.2.4)(msw@2.10.4(@types/node@22.8.1)(typescript@5.9.2))(vite@7.1.1(@types/node@22.8.1))':
+ '@vitest/mocker@2.1.3(@vitest/spy@3.2.4)(msw@2.10.4(@types/node@24.2.1)(typescript@5.9.2))(vite@7.1.1(@types/node@24.2.1))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.17
optionalDependencies:
- msw: 2.10.4(@types/node@22.8.1)(typescript@5.9.2)
- vite: 7.1.1(@types/node@22.8.1)
+ msw: 2.10.4(@types/node@24.2.1)(typescript@5.9.2)
+ vite: 7.1.1(@types/node@24.2.1)
- '@vitest/mocker@3.2.4(msw@2.10.4(@types/node@22.8.1)(typescript@5.9.2))(vite@7.1.1(@types/node@22.8.1))':
+ '@vitest/mocker@3.2.4(msw@2.10.4(@types/node@24.2.1)(typescript@5.9.2))(vite@7.1.1(@types/node@24.2.1))':
dependencies:
'@vitest/spy': 3.2.4
estree-walker: 3.0.3
magic-string: 0.30.17
optionalDependencies:
- msw: 2.10.4(@types/node@22.8.1)(typescript@5.9.2)
- vite: 7.1.1(@types/node@22.8.1)
+ msw: 2.10.4(@types/node@24.2.1)(typescript@5.9.2)
+ vite: 7.1.1(@types/node@24.2.1)
'@vitest/pretty-format@2.1.3':
dependencies:
@@ -3347,7 +3399,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':
@@ -3946,12 +3998,12 @@ snapshots:
ms@2.1.3: {}
- msw@2.10.4(@types/node@22.8.1)(typescript@5.9.2):
+ msw@2.10.4(@types/node@24.2.1)(typescript@5.9.2):
dependencies:
'@bundled-es-modules/cookie': 2.0.1
'@bundled-es-modules/statuses': 1.0.1
'@bundled-es-modules/tough-cookie': 0.1.6
- '@inquirer/confirm': 5.0.1(@types/node@22.8.1)
+ '@inquirer/confirm': 5.0.1(@types/node@24.2.1)
'@mswjs/interceptors': 0.39.5
'@open-draft/deferred-promise': 2.2.0
'@open-draft/until': 2.1.0
@@ -4288,7 +4340,7 @@ snapshots:
typescript@5.9.2: {}
- undici-types@6.19.8: {}
+ undici-types@7.10.0: {}
universalify@0.2.0: {}
@@ -4322,13 +4374,13 @@ snapshots:
optionalDependencies:
'@types/react': 19.1.9
- vite-node@3.2.4(@types/node@22.8.1):
+ vite-node@3.2.4(@types/node@24.2.1):
dependencies:
cac: 6.7.14
debug: 4.4.1
es-module-lexer: 1.7.0
pathe: 2.0.3
- vite: 7.1.1(@types/node@22.8.1)
+ vite: 7.1.1(@types/node@24.2.1)
transitivePeerDependencies:
- '@types/node'
- jiti
@@ -4343,7 +4395,7 @@ snapshots:
- tsx
- yaml
- vite@7.1.1(@types/node@22.8.1):
+ vite@7.1.1(@types/node@24.2.1):
dependencies:
esbuild: 0.25.3
fdir: 6.4.6(picomatch@4.0.3)
@@ -4352,24 +4404,24 @@ snapshots:
rollup: 4.46.2
tinyglobby: 0.2.14
optionalDependencies:
- '@types/node': 22.8.1
+ '@types/node': 24.2.1
fsevents: 2.3.3
vitest-browser-react@1.0.1(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(@vitest/browser@2.1.3)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)(vitest@3.2.4):
dependencies:
- '@vitest/browser': 2.1.3(@types/node@22.8.1)(@vitest/spy@3.2.4)(typescript@5.9.2)(vite@7.1.1(@types/node@22.8.1))(vitest@3.2.4)
+ '@vitest/browser': 2.1.3(@types/node@24.2.1)(@vitest/spy@3.2.4)(typescript@5.9.2)(vite@7.1.1(@types/node@24.2.1))(vitest@3.2.4)
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
- vitest: 3.2.4(@types/node@22.8.1)(@vitest/browser@2.1.3)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.8.1)(typescript@5.9.2))
+ vitest: 3.2.4(@types/node@24.2.1)(@vitest/browser@2.1.3)(jsdom@26.1.0)(msw@2.10.4(@types/node@24.2.1)(typescript@5.9.2))
optionalDependencies:
'@types/react': 19.1.9
'@types/react-dom': 19.1.7(@types/react@19.1.9)
- vitest@3.2.4(@types/node@22.8.1)(@vitest/browser@2.1.3)(jsdom@26.1.0)(msw@2.10.4(@types/node@22.8.1)(typescript@5.9.2)):
+ vitest@3.2.4(@types/node@24.2.1)(@vitest/browser@2.1.3)(jsdom@26.1.0)(msw@2.10.4(@types/node@24.2.1)(typescript@5.9.2)):
dependencies:
'@types/chai': 5.2.2
'@vitest/expect': 3.2.4
- '@vitest/mocker': 3.2.4(msw@2.10.4(@types/node@22.8.1)(typescript@5.9.2))(vite@7.1.1(@types/node@22.8.1))
+ '@vitest/mocker': 3.2.4(msw@2.10.4(@types/node@24.2.1)(typescript@5.9.2))(vite@7.1.1(@types/node@24.2.1))
'@vitest/pretty-format': 3.2.4
'@vitest/runner': 3.2.4
'@vitest/snapshot': 3.2.4
@@ -4387,12 +4439,12 @@ snapshots:
tinyglobby: 0.2.14
tinypool: 1.1.1
tinyrainbow: 2.0.0
- vite: 7.1.1(@types/node@22.8.1)
- vite-node: 3.2.4(@types/node@22.8.1)
+ vite: 7.1.1(@types/node@24.2.1)
+ vite-node: 3.2.4(@types/node@24.2.1)
why-is-node-running: 2.3.0
optionalDependencies:
- '@types/node': 22.8.1
- '@vitest/browser': 2.1.3(@types/node@22.8.1)(@vitest/spy@3.2.4)(typescript@5.9.2)(vite@7.1.1(@types/node@22.8.1))(vitest@3.2.4)
+ '@types/node': 24.2.1
+ '@vitest/browser': 2.1.3(@types/node@24.2.1)(@vitest/spy@3.2.4)(typescript@5.9.2)(vite@7.1.1(@types/node@24.2.1))(vitest@3.2.4)
jsdom: 26.1.0
transitivePeerDependencies:
- jiti
@@ -4473,3 +4525,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/pnpm-workspace.yaml b/pnpm-workspace.yaml
new file mode 100644
index 000000000..a817d1c60
--- /dev/null
+++ b/pnpm-workspace.yaml
@@ -0,0 +1,8 @@
+packages:
+ - '.'
+
+ignoredBuiltDependencies:
+ - esbuild
+
+onlyBuiltDependencies:
+ - '@swc/core'
diff --git a/public/404.html b/public/404.html
new file mode 100644
index 000000000..a90fd3c6e
--- /dev/null
+++ b/public/404.html
@@ -0,0 +1,33 @@
+
+
+
+
+ Posts Manager
+
+
+
+
diff --git a/src/App.tsx b/src/App.tsx
index 0c0032aab..a78257e3b 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,20 +1,38 @@
-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 { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
+import { Header } from './widgets/Header';
+import { Footer } from './widgets/Footer';
+import PostsManagerPage from './pages/PostsManagerPage.tsx';
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ staleTime: 5 * 60 * 1000, // 5분
+ gcTime: 10 * 60 * 1000, // 10분
+ retry: 1,
+ },
+ mutations: {
+ retry: 1,
+ },
+ },
+});
const App = () => {
return (
-
-
-
- )
-}
+
+
+
+
+
+
+ );
+};
-export default App
+export default App;
diff --git a/src/components/index.tsx b/src/components/index.tsx
deleted file mode 100644
index 8495817d3..000000000
--- a/src/components/index.tsx
+++ /dev/null
@@ -1,214 +0,0 @@
-import * as React from "react"
-import { forwardRef } from "react"
-import * as SelectPrimitive from "@radix-ui/react-select"
-import * as DialogPrimitive from "@radix-ui/react-dialog"
-import { Check, ChevronDown, X } from "lucide-react"
-import { cva, VariantProps } from "class-variance-authority"
-
-const buttonVariants = cva(
- "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background",
- {
- variants: {
- variant: {
- default: "bg-blue-500 text-white hover:bg-blue-600",
- destructive: "bg-red-500 text-white hover:bg-red-600",
- outline: "border border-gray-300 bg-transparent text-gray-700 hover:bg-gray-100",
- secondary: "bg-gray-200 text-gray-800 hover:bg-gray-300",
- ghost: "bg-transparent text-gray-700 hover:bg-gray-100",
- link: "underline-offset-4 hover:underline text-blue-500",
- },
- size: {
- default: "h-10 py-2 px-4",
- sm: "h-8 px-3 rounded-md text-xs",
- lg: "h-11 px-8 rounded-md",
- icon: "h-9 w-9",
- },
- },
- defaultVariants: {
- variant: "default",
- size: "default",
- },
- },
-)
-
-interface ButtonProps extends React.ButtonHTMLAttributes, VariantProps {
- className?: string
-}
-
-export const Button = forwardRef(({ className, variant, size, ...props }, ref) => {
- return
-})
-
-Button.displayName = "Button"
-
-// 입력 컴포넌트
-export const Input = forwardRef(({ className, type, ...props }, ref) => {
- return (
-
- )
-})
-Input.displayName = "Input"
-
-// 카드 컴포넌트
-export const Card = forwardRef(({ className, ...props }, ref) => (
-
-))
-Card.displayName = "Card"
-
-export const CardHeader = forwardRef(({ className, ...props }, ref) => (
-
-))
-CardHeader.displayName = "CardHeader"
-
-export const CardTitle = forwardRef(({ className, ...props }, ref) => (
-
-))
-CardTitle.displayName = "CardTitle"
-
-export const CardContent = forwardRef(({ className, ...props }, ref) => (
-
-))
-CardContent.displayName = "CardContent"
-
-// 텍스트 영역 컴포넌트
-export const Textarea = forwardRef(({ className, ...props }, ref) => {
- return (
-
- )
-})
-Textarea.displayName = "Textarea"
-
-// 선택 컴포넌트
-export const Select = SelectPrimitive.Root
-export const SelectGroup = SelectPrimitive.Group
-export const SelectValue = SelectPrimitive.Value
-
-export const SelectTrigger = forwardRef(({ className, children, ...props }, ref) => (
-
- {children}
-
-
-))
-SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
-
-export const SelectContent = forwardRef(({ className, children, position = "popper", ...props }, ref) => (
-
-
- {children}
-
-
-))
-SelectContent.displayName = SelectPrimitive.Content.displayName
-
-export const SelectItem = forwardRef(({ className, children, ...props }, ref) => (
-
-
-
-
-
-
- {children}
-
-))
-SelectItem.displayName = SelectPrimitive.Item.displayName
-
-// 대화상자 컴포넌트
-export const Dialog = DialogPrimitive.Root
-export const DialogTrigger = DialogPrimitive.Trigger
-export const DialogPortal = DialogPrimitive.Portal
-export const DialogOverlay = DialogPrimitive.Overlay
-
-export const DialogContent = forwardRef(({ className, children, ...props }, ref) => (
-
-
-
- {children}
-
-
- 닫기
-
-
-
-))
-DialogContent.displayName = DialogPrimitive.Content.displayName
-
-export const DialogHeader = ({ className, ...props }) => (
-
-)
-DialogHeader.displayName = "DialogHeader"
-
-export const DialogTitle = forwardRef(({ className, ...props }, ref) => (
-
-))
-DialogTitle.displayName = DialogPrimitive.Title.displayName
-
-// 테이블 컴포넌트
-export const Table = forwardRef(({ className, ...props }, ref) => (
-
-))
-Table.displayName = "Table"
-
-export const TableHeader = forwardRef(({ className, ...props }, ref) => (
-
-))
-TableHeader.displayName = "TableHeader"
-
-export const TableBody = forwardRef(({ className, ...props }, ref) => (
-
-))
-TableBody.displayName = "TableBody"
-
-export const TableRow = forwardRef(({ className, ...props }, ref) => (
-
-))
-TableRow.displayName = "TableRow"
-
-export const TableHead = forwardRef(({ className, ...props }, ref) => (
- |
-))
-TableHead.displayName = "TableHead"
-
-export const TableCell = forwardRef(({ className, ...props }, ref) => (
- |
-))
-TableCell.displayName = "TableCell"
diff --git a/src/entities/comment/api/index.ts b/src/entities/comment/api/index.ts
new file mode 100644
index 000000000..5b172776d
--- /dev/null
+++ b/src/entities/comment/api/index.ts
@@ -0,0 +1,32 @@
+import { NewComment } from '../types';
+import { httpClient } from '../../../shared/config/httpClient';
+
+// 특정 게시글의 댓글 목록 조회
+export const fetchComments = async (postId: number) => {
+ const response = await httpClient.get(`/api/comments/post/${postId}`);
+ return response.json();
+};
+
+// 새 댓글 추가
+export const addComment = async (comment: NewComment) => {
+ const response = await httpClient.post('/api/comments/add', comment);
+ return response.json();
+};
+
+// 댓글 내용 수정
+export const updateComment = async (id: number, body: string) => {
+ const response = await httpClient.put(`/api/comments/${id}`, { body });
+ return response.json();
+};
+
+// 댓글 삭제
+export const deleteComment = async (id: number) => {
+ const response = await httpClient.delete(`/api/comments/${id}`);
+ return response.json();
+};
+
+// 댓글 좋아요 (좋아요 수 증가)
+export const likeComment = async (id: number, likes: number) => {
+ const response = await httpClient.patch(`/api/comments/${id}`, { likes: likes + 1 });
+ return response.json();
+};
diff --git a/src/entities/comment/index.ts b/src/entities/comment/index.ts
new file mode 100644
index 000000000..f3b202c9c
--- /dev/null
+++ b/src/entities/comment/index.ts
@@ -0,0 +1,2 @@
+export * from './types';
+export * from './api';
diff --git a/src/entities/comment/model/commentUtils.ts b/src/entities/comment/model/commentUtils.ts
new file mode 100644
index 000000000..0bb5bd204
--- /dev/null
+++ b/src/entities/comment/model/commentUtils.ts
@@ -0,0 +1,33 @@
+import { NewComment } from '../types';
+
+// 댓글 추가 시 postId 설정
+export const handleAddCommentWithPostId = (
+ selectedPost: any,
+ newComment: NewComment,
+ setNewComment: (comment: NewComment) => void,
+ handleAddCommentWithData: (commentData: NewComment) => void,
+) => {
+ if (selectedPost) {
+ const updatedComment = {
+ ...newComment,
+ postId: selectedPost.id,
+ userId: newComment.userId || 1,
+ };
+ setNewComment(updatedComment);
+ handleAddCommentWithData(updatedComment);
+ }
+};
+
+// 특정 데이터로 댓글 추가하는 함수
+export const handleAddCommentWithData = (
+ commentData: NewComment,
+ setShowAddCommentDialog: (show: boolean) => void,
+ setNewComment: (comment: NewComment) => void,
+) => {
+ // 이 함수는 실제로는 features/comment/api에서 처리되어야 함
+ // 여기서는 비즈니스 로직만 담당
+ if (commentData.postId) {
+ setShowAddCommentDialog(false);
+ setNewComment({ body: '', postId: null, userId: 1 });
+ }
+};
diff --git a/src/entities/comment/model/index.ts b/src/entities/comment/model/index.ts
new file mode 100644
index 000000000..48b86fd7d
--- /dev/null
+++ b/src/entities/comment/model/index.ts
@@ -0,0 +1 @@
+export * from './commentUtils';
diff --git a/src/entities/comment/types/index.ts b/src/entities/comment/types/index.ts
new file mode 100644
index 000000000..3cf9dc2e8
--- /dev/null
+++ b/src/entities/comment/types/index.ts
@@ -0,0 +1,17 @@
+// PostsManagerPage.tsx에서 추론한 Comment 관련 타입 정의들
+export interface Comment {
+ id: number;
+ body: string;
+ postId: number | null;
+ userId: number;
+ likes: number;
+ user: {
+ username: string;
+ };
+}
+
+export interface NewComment {
+ body: string;
+ postId: number | null;
+ userId: number;
+}
diff --git a/src/entities/index.ts b/src/entities/index.ts
new file mode 100644
index 000000000..1ebd8865d
--- /dev/null
+++ b/src/entities/index.ts
@@ -0,0 +1,4 @@
+export * from './post';
+export * from './comment';
+export * from './user';
+export * from './tag';
diff --git a/src/entities/post/api/index.ts b/src/entities/post/api/index.ts
new file mode 100644
index 000000000..0e8d739ac
--- /dev/null
+++ b/src/entities/post/api/index.ts
@@ -0,0 +1,38 @@
+import { Post, NewPost } from '../types';
+import { httpClient } from '../../../shared/config/httpClient';
+
+// 게시글 목록 조회 (페이지네이션 지원)
+export const fetchPosts = async (limit: number, skip: number) => {
+ const response = await httpClient.get(`/api/posts?limit=${limit}&skip=${skip}`);
+ return response.json();
+};
+
+// 게시글 검색 (제목/내용 기반)
+export const searchPosts = async (query: string) => {
+ const response = await httpClient.get(`/api/posts/search?q=${query}`);
+ return response.json();
+};
+
+// 특정 태그의 게시글 조회
+export const fetchPostsByTag = async (tag: string) => {
+ const response = await httpClient.get(`/api/posts/tag/${tag}`);
+ return response.json();
+};
+
+// 새 게시글 생성
+export const addPost = async (post: NewPost) => {
+ const response = await httpClient.post('/api/posts/add', post);
+ return response.json();
+};
+
+// 게시글 수정
+export const updatePost = async (id: number, post: Partial) => {
+ const response = await httpClient.put(`/api/posts/${id}`, post);
+ return response.json();
+};
+
+// 게시글 삭제
+export const deletePost = async (id: number) => {
+ const response = await httpClient.delete(`/api/posts/${id}`);
+ return response;
+};
diff --git a/src/entities/post/index.ts b/src/entities/post/index.ts
new file mode 100644
index 000000000..f3b202c9c
--- /dev/null
+++ b/src/entities/post/index.ts
@@ -0,0 +1,2 @@
+export * from './types';
+export * from './api';
diff --git a/src/entities/post/model/index.ts b/src/entities/post/model/index.ts
new file mode 100644
index 000000000..0e42dee4a
--- /dev/null
+++ b/src/entities/post/model/index.ts
@@ -0,0 +1 @@
+export * from './postUtils';
diff --git a/src/entities/post/model/postUtils.ts b/src/entities/post/model/postUtils.ts
new file mode 100644
index 000000000..e7b8dc2fe
--- /dev/null
+++ b/src/entities/post/model/postUtils.ts
@@ -0,0 +1,65 @@
+import { Post } from '../types';
+import { httpClient } from '../../../shared/config/httpClient';
+
+// 게시물 검색 (제목/내용 기반)
+export const searchPosts = async (
+ setLoading: (loading: boolean) => void,
+ setPosts: (posts: any[]) => void,
+ setTotal: (total: number) => void,
+ searchQuery: string,
+ handleFetchPosts: () => void,
+) => {
+ if (!searchQuery) {
+ handleFetchPosts();
+ return;
+ }
+ setLoading(true);
+ try {
+ const response = await httpClient.get(`/api/posts/search?q=${searchQuery}`);
+ const data = await response.json();
+ setPosts(data.posts);
+ setTotal(data.total);
+ } catch (error) {
+ console.error('게시물 검색 오류:', error);
+ }
+ setLoading(false);
+};
+
+// 특정 태그의 게시물 조회 (사용자 정보 포함)
+export const fetchPostsByTag = async (
+ setLoading: (loading: boolean) => void,
+ setPosts: (posts: any[]) => void,
+ setTotal: (total: number) => void,
+ tag: string,
+ handleFetchPosts: () => void,
+) => {
+ if (!tag || tag === 'all') {
+ handleFetchPosts();
+ return;
+ }
+ setLoading(true);
+ try {
+ const [postsResponse, usersResponse] = await Promise.all([
+ httpClient.get(`/api/posts/tag/${tag}`),
+ httpClient.get('/api/users?limit=0&select=username,image'),
+ ]);
+ const postsData = await postsResponse.json();
+ const usersData = await usersResponse.json();
+
+ const postsWithUsers = postsData.posts.map((post: any) => ({
+ ...post,
+ author: usersData.users.find((user: any) => user.id === post.userId),
+ }));
+
+ setPosts(postsWithUsers);
+ setTotal(postsData.total);
+ } catch (error) {
+ console.error('태그별 게시물 가져오기 오류:', error);
+ }
+ setLoading(false);
+};
+
+// 게시물 상세 보기 (댓글은 TanStack Query가 자동으로 처리)
+export const openPostDetailWithComments = (post: Post, openPostDetail: (post: Post) => void) => {
+ openPostDetail(post);
+};
diff --git a/src/entities/post/types/index.ts b/src/entities/post/types/index.ts
new file mode 100644
index 000000000..a169b474c
--- /dev/null
+++ b/src/entities/post/types/index.ts
@@ -0,0 +1,26 @@
+import { User } from '../../../shared/types/common';
+
+export interface Post {
+ id: number;
+ title: string;
+ body: string;
+ userId: number;
+ tags: string[];
+ reactions: {
+ likes: number;
+ dislikes: number;
+ };
+ author?: User; // author 필드 추가 (선택적)
+}
+
+export interface NewPost {
+ title: string;
+ body: string;
+ userId: number;
+}
+
+export interface PostWithUser extends Post {
+ author: User | undefined;
+}
+
+export type { User };
diff --git a/src/entities/tag/api/index.ts b/src/entities/tag/api/index.ts
new file mode 100644
index 000000000..eaffc99cf
--- /dev/null
+++ b/src/entities/tag/api/index.ts
@@ -0,0 +1,13 @@
+import { Tag } from '../types';
+import { httpClient } from '../../../shared/config/httpClient';
+
+// 모든 태그 목록 조회
+export const fetchTags = async (setTags: (tags: Tag[]) => void) => {
+ try {
+ const response = await httpClient.get('/api/posts/tags');
+ const data = await response.json();
+ setTags(data);
+ } catch (error) {
+ console.error('태그 가져오기 오류:', error);
+ }
+};
diff --git a/src/entities/tag/index.ts b/src/entities/tag/index.ts
new file mode 100644
index 000000000..f3b202c9c
--- /dev/null
+++ b/src/entities/tag/index.ts
@@ -0,0 +1,2 @@
+export * from './types';
+export * from './api';
diff --git a/src/entities/tag/types/index.ts b/src/entities/tag/types/index.ts
new file mode 100644
index 000000000..4a7b4ba15
--- /dev/null
+++ b/src/entities/tag/types/index.ts
@@ -0,0 +1,6 @@
+export interface Tag {
+ url: string;
+ slug: string;
+}
+
+export type TagsResponse = Tag[];
diff --git a/src/entities/user/api/index.ts b/src/entities/user/api/index.ts
new file mode 100644
index 000000000..964f4f480
--- /dev/null
+++ b/src/entities/user/api/index.ts
@@ -0,0 +1,13 @@
+import { httpClient } from '../../../shared/config/httpClient';
+
+// 특정 사용자 정보 조회
+export const fetchUser = async (userId: number) => {
+ const response = await httpClient.get(`/api/users/${userId}`);
+ return response.json();
+};
+
+// 사용자 목록 조회 (이름과 이미지만 선택)
+export const fetchUsers = async () => {
+ const response = await httpClient.get('/api/users?limit=0&select=username,image');
+ return response.json();
+};
diff --git a/src/entities/user/index.ts b/src/entities/user/index.ts
new file mode 100644
index 000000000..f3b202c9c
--- /dev/null
+++ b/src/entities/user/index.ts
@@ -0,0 +1,2 @@
+export * from './types';
+export * from './api';
diff --git a/src/entities/user/model/index.ts b/src/entities/user/model/index.ts
new file mode 100644
index 000000000..566256611
--- /dev/null
+++ b/src/entities/user/model/index.ts
@@ -0,0 +1,30 @@
+import { User } from '../types';
+
+// 사용자 기본 정보를 찾는 함수
+export const findUserById = (users: User[], userId: number): User | undefined => {
+ return users.find((user) => user.id === userId);
+};
+
+// 사용자 이름을 포맷팅하는 함수
+export const formatUserName = (user: User): string => {
+ if (user.firstName && user.lastName) {
+ return `${user.firstName} ${user.lastName}`;
+ }
+ return user.username;
+};
+
+// 사용자 주소를 포맷팅하는 함수
+export const formatUserAddress = (user: User): string => {
+ if (user.address) {
+ return `${user.address.address}, ${user.address.city}, ${user.address.state}`;
+ }
+ return '주소 정보 없음';
+};
+
+// 사용자 직장 정보를 포맷팅하는 함수
+export const formatUserCompany = (user: User): string => {
+ if (user.company) {
+ return `${user.company.name} - ${user.company.title}`;
+ }
+ return '직장 정보 없음';
+};
diff --git a/src/entities/user/types/index.ts b/src/entities/user/types/index.ts
new file mode 100644
index 000000000..1cada4f12
--- /dev/null
+++ b/src/entities/user/types/index.ts
@@ -0,0 +1 @@
+export type { User } from '../../../shared/types/common';
diff --git a/src/features/comment/api/index.ts b/src/features/comment/api/index.ts
new file mode 100644
index 000000000..1df7f6ecc
--- /dev/null
+++ b/src/features/comment/api/index.ts
@@ -0,0 +1,124 @@
+import { Comment, NewComment } from '../../../entities/comment';
+import * as commentAPI from '../../../entities/comment/api';
+
+// 상태 관리와 결합된 API 로직
+export const useCommentAPI = () => {
+ // 댓글 가져오기
+ const fetchCommentsWithState = async (
+ postId: number,
+ comments: Record,
+ setComments: (comments: Record) => void,
+ ) => {
+ if (comments[postId]) return; // 이미 불러온 댓글이 있으면 다시 불러오지 않음
+ try {
+ const data = await commentAPI.fetchComments(postId);
+ const updatedComments = { ...comments, [postId]: data.comments };
+ setComments(updatedComments);
+ } catch (error) {
+ console.error('댓글 가져오기 오류:', error);
+ }
+ };
+
+ // 댓글 추가
+ const addCommentWithState = async (
+ setComments: (comments: Record) => void,
+ comments: Record,
+ setShowAddCommentDialog: (show: boolean) => void,
+ setNewComment: (comment: NewComment) => void,
+ newComment: NewComment,
+ ) => {
+ try {
+ const data = await commentAPI.addComment(newComment);
+
+ const updatedComments = {
+ ...comments,
+ [data.postId]: [...(comments[data.postId] || []), data],
+ };
+
+ setComments(updatedComments);
+ setShowAddCommentDialog(false);
+ setNewComment({ body: '', postId: null, userId: 1 });
+ } catch (error) {
+ console.error('댓글 추가 오류:', error);
+ }
+ };
+
+ // 댓글 업데이트
+ const updateCommentWithState = async (
+ setComments: (comments: Record) => void,
+ comments: Record,
+ setShowEditCommentDialog: (show: boolean) => void,
+ selectedComment: Comment,
+ ) => {
+ try {
+ const data = await commentAPI.updateComment(selectedComment.id, selectedComment.body);
+
+ const updatedComments = {
+ ...comments,
+ [data.postId]: comments[data.postId].map((comment) =>
+ comment.id === data.id ? data : comment,
+ ),
+ };
+
+ setComments(updatedComments);
+ setShowEditCommentDialog(false);
+ } catch (error) {
+ console.error('댓글 업데이트 오류:', error);
+ }
+ };
+
+ // 댓글 삭제
+ const deleteCommentWithState = async (
+ setComments: (comments: Record) => void,
+ comments: Record,
+ id: number,
+ postId: number,
+ ) => {
+ try {
+ await commentAPI.deleteComment(id);
+
+ const updatedComments = {
+ ...comments,
+ [postId]: comments[postId].filter((comment) => comment.id !== id),
+ };
+
+ setComments(updatedComments);
+ } catch (error) {
+ console.error('댓글 삭제 오류:', error);
+ }
+ };
+
+ // 댓글 좋아요
+ const likeCommentWithState = async (
+ setComments: (comments: Record) => void,
+ comments: Record,
+ id: number,
+ postId: number,
+ ) => {
+ try {
+ const currentComment = comments[postId]?.find((c) => c.id === id);
+ if (!currentComment) return;
+
+ const data = await commentAPI.likeComment(id, currentComment.likes);
+
+ const updatedComments = {
+ ...comments,
+ [postId]: comments[postId].map((comment) =>
+ comment.id === data.id ? { ...data, likes: comment.likes + 1 } : comment,
+ ),
+ };
+
+ setComments(updatedComments);
+ } catch (error) {
+ console.error('댓글 좋아요 오류:', error);
+ }
+ };
+
+ return {
+ fetchCommentsWithState,
+ addCommentWithState,
+ updateCommentWithState,
+ deleteCommentWithState,
+ likeCommentWithState,
+ };
+};
diff --git a/src/features/comment/hooks/useCommentFeature.tsx b/src/features/comment/hooks/useCommentFeature.tsx
new file mode 100644
index 000000000..3ee4a7245
--- /dev/null
+++ b/src/features/comment/hooks/useCommentFeature.tsx
@@ -0,0 +1,126 @@
+import { useCommentStore } from '../store/index';
+import {
+ useAddComment,
+ useUpdateComment,
+ useDeleteComment,
+ useLikeComment,
+} from './useCommentQueries';
+
+export const useCommentFeature = () => {
+ // 클라이언트 상태 (UI 상태) - Zustand 사용
+ const {
+ selectedComment,
+ newComment,
+ showAddCommentDialog,
+ showEditCommentDialog,
+ setSelectedComment,
+ setNewComment,
+ setShowAddCommentDialog,
+ setShowEditCommentDialog,
+ clearNewComment,
+ clearSelectedComment,
+ } = useCommentStore();
+
+ // TanStack Query 훅들 사용 (최상위에서 호출)
+ const addCommentMutation = useAddComment();
+ const updateCommentMutation = useUpdateComment();
+ const deleteCommentMutation = useDeleteComment();
+ const likeCommentMutation = useLikeComment();
+
+ // 댓글 추가
+ const handleAddComment = async (commentData?: any) => {
+ const commentToAdd = commentData || newComment;
+
+ if (!commentToAdd.body || !commentToAdd.postId) {
+ console.error('댓글 추가 실패: body 또는 postId가 없습니다.', commentToAdd);
+ return;
+ }
+
+ try {
+ addCommentMutation.mutate(commentToAdd, {
+ onSuccess: (data) => {
+ console.log('댓글 추가 성공!', data);
+ setShowAddCommentDialog(false);
+ setNewComment({ body: '', postId: null, userId: 1 });
+ },
+ onError: (error) => {
+ console.error('댓글 추가 오류:', error);
+ },
+ });
+ } catch (error) {
+ console.error('mutation 실행 중 오류:', error);
+ }
+ };
+
+ // 댓글 수정
+ const handleUpdateComment = async () => {
+ if (!selectedComment || !selectedComment.body) return;
+
+ try {
+ updateCommentMutation.mutate(
+ { id: selectedComment.id, body: selectedComment.body },
+ {
+ onSuccess: () => {
+ setShowEditCommentDialog(false);
+ setSelectedComment(null);
+ },
+ onError: (error) => {
+ console.error('댓글 수정 오류:', error);
+ },
+ },
+ );
+ } catch (error) {
+ console.error('mutation 실행 중 오류:', error);
+ }
+ };
+
+ // 댓글 삭제
+ const handleDeleteComment = async (id: number) => {
+ try {
+ deleteCommentMutation.mutate(id, {
+ onError: (error) => {
+ console.error('댓글 삭제 오류:', error);
+ },
+ });
+ } catch (error) {
+ console.error('mutation 실행 중 오류:', error);
+ }
+ };
+
+ // 댓글 좋아요
+ const handleLikeComment = async (id: number) => {
+ try {
+ likeCommentMutation.mutate(
+ { id, likes: 1 }, // 기본값 1로 설정
+ {
+ onError: (error) => {
+ console.error('댓글 좋아요 오류:', error);
+ },
+ },
+ );
+ } catch (error) {
+ console.error('mutation 실행 중 오류:', error);
+ }
+ };
+
+ return {
+ // 상태 (클라이언트 상태만 반환)
+ selectedComment,
+ newComment,
+ showAddCommentDialog,
+ showEditCommentDialog,
+ // 상태 설정자
+ setSelectedComment,
+ setNewComment,
+ setShowAddCommentDialog,
+ setShowEditCommentDialog,
+ // 함수들
+ handleAddComment,
+ handleUpdateComment,
+ handleDeleteComment,
+ handleLikeComment,
+ // 유틸리티 함수들
+ clearNewComment,
+ clearSelectedComment,
+ };
+};
diff --git a/src/features/comment/hooks/useCommentQueries.ts b/src/features/comment/hooks/useCommentQueries.ts
new file mode 100644
index 000000000..69f212d75
--- /dev/null
+++ b/src/features/comment/hooks/useCommentQueries.ts
@@ -0,0 +1,175 @@
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { NewComment } from '../../../entities/comment';
+import * as commentAPI from '../../../entities/comment/api';
+
+// 댓글 목록 조회
+export const useComments = (postId: number) => {
+ return useQuery({
+ queryKey: ['comments', postId],
+ queryFn: async () => {
+ // 실제 API 호출하여 댓글 데이터 가져오기
+ return await commentAPI.fetchComments(postId);
+ },
+ enabled: !!postId,
+ });
+};
+
+// 댓글 추가
+export const useAddComment = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (comment: NewComment) => {
+ return await commentAPI.addComment(comment);
+ },
+ onSuccess: (data: any, variables: NewComment) => {
+ const postId = data.postId || variables.postId;
+
+ if (postId) {
+ const existingData = queryClient.getQueryData(['comments', postId]);
+ if (existingData && (existingData as any).comments) {
+ const updatedComments = [...(existingData as any).comments, data];
+ queryClient.setQueryData(['comments', postId], {
+ ...existingData,
+ comments: updatedComments,
+ });
+ } else {
+ queryClient.invalidateQueries({ queryKey: ['comments', postId] });
+ }
+ }
+ },
+ });
+};
+
+// 댓글 수정
+export const useUpdateComment = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ id, body }: { id: number; body: string }) => {
+ return { id, body, postId: null }; // 가짜 성공 응답
+ },
+ onSuccess: (data: any, variables: { id: number; body: string }) => {
+ // data.postId가 있으면 해당 게시글의 댓글만 업데이트
+ if (data && data.postId) {
+ const existingData = queryClient.getQueryData(['comments', data.postId]);
+ if (existingData && (existingData as any).comments) {
+ const updatedComments = (existingData as any).comments.map((comment: any) =>
+ comment.id === data.id ? data : comment,
+ );
+
+ queryClient.setQueryData(['comments', data.postId], {
+ ...existingData,
+ comments: updatedComments,
+ });
+ }
+ } else {
+ // postId가 없으면 모든 comments 쿼리에서 해당 댓글을 찾아서 업데이트
+ const queries = queryClient.getQueriesData({ queryKey: ['comments'], exact: false });
+ queries.forEach(([queryKey, queryData]) => {
+ if (queryData && (queryData as any).comments) {
+ const updatedComments = (queryData as any).comments.map((comment: any) =>
+ comment.id === variables.id ? { ...comment, body: variables.body } : comment,
+ );
+
+ queryClient.setQueryData(queryKey, {
+ ...queryData,
+ comments: updatedComments,
+ });
+ }
+ });
+ }
+ },
+ });
+};
+
+// 댓글 삭제
+export const useDeleteComment = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (id: number) => {
+ return { id, postId: null }; // 가짜 성공 응답
+ },
+ onSuccess: (data: any, variables: number) => {
+ // data.postId가 있으면 해당 게시글의 댓글만 삭제
+ if (data && data.postId) {
+ const existingData = queryClient.getQueryData(['comments', data.postId]);
+ if (existingData && (existingData as any).comments) {
+ const filteredComments = (existingData as any).comments.filter(
+ (comment: any) => comment.id !== data.id,
+ );
+
+ queryClient.setQueryData(['comments', data.postId], {
+ ...existingData,
+ comments: filteredComments,
+ });
+ }
+ } else {
+ // postId가 없으면 모든 comments 쿼리에서 해당 댓글을 찾아서 삭제
+ const queries = queryClient.getQueriesData({ queryKey: ['comments'], exact: false });
+ queries.forEach(([queryKey, queryData]) => {
+ if (queryData && (queryData as any).comments) {
+ const filteredComments = (queryData as any).comments.filter(
+ (comment: any) => comment.id !== variables,
+ );
+
+ queryClient.setQueryData(queryKey, {
+ ...queryData,
+ comments: filteredComments,
+ });
+ }
+ });
+ }
+ },
+ });
+};
+
+// 댓글 좋아요
+export const useLikeComment = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ id, likes }: { id: number; likes: number }) => {
+ return { id, likes: likes + 1, postId: null }; // 가짜 성공 응답
+ },
+ onSuccess: (data: any, variables: { id: number; likes: number }) => {
+ // data.postId가 있으면 해당 게시글의 댓글만 업데이트
+ if (data && data.postId) {
+ const existingData = queryClient.getQueryData(['comments', data.postId]);
+ if (existingData && (existingData as any).comments) {
+ const updatedComments = (existingData as any).comments.map((comment: any) =>
+ comment.id === data.id ? { ...comment, likes: comment.likes + 1 } : comment,
+ );
+
+ queryClient.setQueryData(['comments', data.postId], {
+ ...existingData,
+ comments: updatedComments,
+ });
+ }
+ } else {
+ // postId가 없으면 모든 comments 쿼리에서 해당 댓글을 찾아서 업데이트
+ const queries = queryClient.getQueriesData({ queryKey: ['comments'], exact: false });
+ queries.forEach(([queryKey, queryData]) => {
+ if (queryData && (queryData as any).comments) {
+ const updatedComments = (queryData as any).comments.map((comment: any) => {
+ if (comment.id === variables.id) {
+ // 현재 likes 값이 유효하지 않으면 1로 설정, 유효하면 +1
+ const currentLikes = comment.likes;
+ const newLikes =
+ currentLikes && !isNaN(currentLikes) && currentLikes >= 0 ? currentLikes + 1 : 1;
+ return { ...comment, likes: newLikes };
+ }
+ return comment;
+ });
+
+ queryClient.setQueryData(queryKey, {
+ ...queryData,
+ comments: updatedComments,
+ });
+ }
+ });
+ }
+ },
+ });
+};
diff --git a/src/features/comment/index.ts b/src/features/comment/index.ts
new file mode 100644
index 000000000..80a2c4ee7
--- /dev/null
+++ b/src/features/comment/index.ts
@@ -0,0 +1,2 @@
+export * from './hooks/useCommentFeature';
+export * from './store';
diff --git a/src/features/comment/store/commentStore.ts b/src/features/comment/store/commentStore.ts
new file mode 100644
index 000000000..19e7ea6ce
--- /dev/null
+++ b/src/features/comment/store/commentStore.ts
@@ -0,0 +1,54 @@
+import { create } from 'zustand';
+import { Comment, NewComment } from '../../../entities/comment';
+
+interface CommentStore {
+ // Phase 1: 기본 데이터 상태만 Zustand로 (기존 API 함수들과 호환)
+ comments: Record;
+ selectedComment: Comment | null;
+ newComment: NewComment;
+
+ // Phase 2: UI 상태 Zustand로
+ showAddCommentDialog: boolean;
+ showEditCommentDialog: boolean;
+
+ // 상태 설정자들 (기존 API 함수들과 호환)
+ setComments: (comments: Record) => void;
+ setSelectedComment: (comment: Comment | null) => void;
+ setNewComment: (comment: NewComment) => void;
+ setShowAddCommentDialog: (show: boolean) => void;
+ setShowEditCommentDialog: (show: boolean) => void;
+
+ // 유틸리티 함수들
+ clearNewComment: () => void;
+ clearSelectedComment: () => void;
+}
+
+export const useCommentStore = create((set, _get) => {
+ return {
+ // Phase 1: 기본 데이터 상태 (기존과 동일한 초기값)
+ comments: {},
+ selectedComment: null,
+ newComment: { body: '', postId: null, userId: 1 }, // userId를 1로 명시적 설정
+
+ // Phase 2: UI 상태 (기존과 동일한 초기값)
+ showAddCommentDialog: false,
+ showEditCommentDialog: false,
+
+ // 상태 설정자들 (기존과 동일한 기능)
+ setComments: (comments) => {
+ if (typeof comments === 'function') {
+ console.error('setComments에 함수가 전달됨!');
+ return;
+ }
+ set({ comments });
+ },
+ setSelectedComment: (comment) => set({ selectedComment: comment }),
+ setNewComment: (comment) => set({ newComment: comment }),
+ setShowAddCommentDialog: (show) => set({ showAddCommentDialog: show }),
+ setShowEditCommentDialog: (show) => set({ showEditCommentDialog: show }),
+
+ // 유틸리티 함수들 (기존과 동일한 기능)
+ clearNewComment: () => set({ newComment: { body: '', postId: null, userId: 1 } }), // userId를 1로 명시적 설정
+ clearSelectedComment: () => set({ selectedComment: null }),
+ };
+});
diff --git a/src/features/comment/store/index.ts b/src/features/comment/store/index.ts
new file mode 100644
index 000000000..37d6ef9be
--- /dev/null
+++ b/src/features/comment/store/index.ts
@@ -0,0 +1 @@
+export * from './commentStore';
diff --git a/src/features/comment/ui/CommentForm.tsx b/src/features/comment/ui/CommentForm.tsx
new file mode 100644
index 000000000..09835d88a
--- /dev/null
+++ b/src/features/comment/ui/CommentForm.tsx
@@ -0,0 +1,41 @@
+import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, Textarea } from '../../../shared/ui';
+
+import { NewComment } from '../../../entities/comment';
+
+interface CommentFormProps {
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+ title: string;
+ comment: Partial;
+ onCommentChange: (comment: Partial) => void;
+ onSubmit: () => void;
+ submitText: string;
+}
+
+export const CommentForm = ({
+ isOpen,
+ onOpenChange,
+ title,
+ comment,
+ onCommentChange,
+ onSubmit,
+ submitText,
+}: CommentFormProps) => {
+ return (
+
+ );
+};
diff --git a/src/features/comment/ui/CommentList.tsx b/src/features/comment/ui/CommentList.tsx
new file mode 100644
index 000000000..3fc7bea3a
--- /dev/null
+++ b/src/features/comment/ui/CommentList.tsx
@@ -0,0 +1,103 @@
+import { Button } from '../../../shared/ui';
+import { Edit2, Plus, ThumbsUp, Trash2 } from 'lucide-react';
+import { Comment } from '../../../entities/comment';
+import { highlightText } from '../../../shared/utils';
+import { useComments } from '../hooks/useCommentQueries';
+
+interface CommentListProps {
+ postId: number;
+ searchQuery: string;
+ onAddComment: () => void;
+ onEditComment: (comment: Comment) => void;
+ onLikeComment: (id: number, postId: number) => void;
+ onDeleteComment: (id: number, postId: number) => void;
+}
+
+export const CommentList = ({
+ postId,
+ searchQuery,
+ onAddComment,
+ onEditComment,
+ onLikeComment,
+ onDeleteComment,
+}: CommentListProps) => {
+ // TanStack Query로 댓글 데이터 가져오기
+ const { data: commentsData, isLoading, error } = useComments(postId);
+
+ const comments = commentsData?.comments || [];
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
+
댓글을 불러오는 중 오류가 발생했습니다.
+
+ );
+ }
+
+ return (
+
+
+
+ {comments.length === 0 ? (
+
댓글이 없습니다.
+ ) : (
+ comments.map((comment: Comment) => (
+
+
+ {comment.user.username}:
+ {highlightText(comment.body, searchQuery)}
+
+
+
+
+
+
+
+ ))
+ )}
+
+
+ );
+};
diff --git a/src/features/comment/ui/index.ts b/src/features/comment/ui/index.ts
new file mode 100644
index 000000000..0bebc0008
--- /dev/null
+++ b/src/features/comment/ui/index.ts
@@ -0,0 +1,2 @@
+export * from './CommentForm';
+export * from './CommentList';
diff --git a/src/features/index.ts b/src/features/index.ts
new file mode 100644
index 000000000..77a5935d7
--- /dev/null
+++ b/src/features/index.ts
@@ -0,0 +1,3 @@
+export * from './post';
+export * from './comment';
+export * from './user';
diff --git a/src/features/post/api/index.ts b/src/features/post/api/index.ts
new file mode 100644
index 000000000..0f08e30cc
--- /dev/null
+++ b/src/features/post/api/index.ts
@@ -0,0 +1,167 @@
+import { Post, NewPost } from '../../../entities/post';
+import { User } from '../../../entities/user';
+import * as postAPI from '../../../entities/post/api';
+import * as userAPI from '../../../entities/user/api';
+
+// 상태 관리와 결합된 API 로직
+export const usePostAPI = () => {
+ // 게시물 가져오기 (사용자 정보 포함)
+ const fetchPostsWithUsers = async (
+ setLoading: (loading: boolean) => void,
+ setPosts: (posts: Post[]) => void,
+ setTotal: (total: number) => void,
+ limit: number,
+ skip: number,
+ ) => {
+ setLoading(true);
+ try {
+ const [postsData, usersData] = await Promise.all([
+ postAPI.fetchPosts(limit, skip),
+ userAPI.fetchUsers(),
+ ]);
+
+ const postsWithUsers = postsData.posts.map((post: Post) => ({
+ ...post,
+ author: usersData.users.find((user: User) => user.id === post.userId),
+ }));
+
+ setPosts(postsWithUsers);
+ setTotal(postsData.total);
+ } catch (error) {
+ console.error('게시물 가져오기 오류:', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // 게시물 검색 (사용자 정보 포함)
+ const searchPostsWithUsers = async (
+ setLoading: (loading: boolean) => void,
+ setPosts: (posts: Post[]) => void,
+ setTotal: (total: number) => void,
+ searchQuery: string,
+ fetchPosts: () => void,
+ ) => {
+ if (!searchQuery) {
+ fetchPosts();
+ return;
+ }
+ setLoading(true);
+ try {
+ const [postsData, usersData] = await Promise.all([
+ postAPI.searchPosts(searchQuery),
+ userAPI.fetchUsers(),
+ ]);
+
+ const postsWithUsers = postsData.posts.map((post: Post) => ({
+ ...post,
+ author: usersData.users.find((user: User) => user.id === post.userId),
+ }));
+
+ setPosts(postsWithUsers);
+ setTotal(postsData.total);
+ } catch (error) {
+ console.error('게시물 검색 오류:', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // 태그별 게시물 가져오기 (사용자 정보 포함)
+ const fetchPostsByTagWithUsers = async (
+ setLoading: (loading: boolean) => void,
+ setPosts: (posts: Post[]) => void,
+ setTotal: (total: number) => void,
+ tag: string,
+ fetchPosts: () => void,
+ ) => {
+ if (!tag || tag === 'all') {
+ fetchPosts();
+ return;
+ }
+ setLoading(true);
+ try {
+ const [postsData, usersData] = await Promise.all([
+ postAPI.fetchPostsByTag(tag),
+ userAPI.fetchUsers(),
+ ]);
+
+ const postsWithUsers = postsData.posts.map((post: Post) => ({
+ ...post,
+ author: usersData.users.find((user: User) => user.id === post.userId),
+ }));
+
+ setPosts(postsWithUsers);
+ setTotal(postsData.total);
+ } catch (error) {
+ console.error('태그별 게시물 가져오기 오류:', error);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // 게시물 추가 (사용자 정보 포함)
+ const addPostWithUser = async (
+ setPosts: (posts: Post[]) => void,
+ posts: Post[],
+ setShowAddDialog: (show: boolean) => void,
+ setNewPost: (post: NewPost) => void,
+ newPost: NewPost,
+ ) => {
+ try {
+ const data = await postAPI.addPost(newPost);
+
+ // 새 게시글에 author 정보 추가
+ const usersData = await userAPI.fetchUsers();
+ const newPostWithAuthor = {
+ ...data,
+ author: usersData.users.find((user: User) => user.id === data.userId),
+ };
+
+ setPosts([newPostWithAuthor, ...posts]);
+ setShowAddDialog(false);
+ setNewPost({ title: '', body: '', userId: 1 });
+ } catch (error) {
+ console.error('게시물 추가 오류:', error);
+ }
+ };
+
+ // 게시물 업데이트
+ const updatePostWithState = async (
+ setPosts: (posts: Post[]) => void,
+ posts: Post[],
+ setShowEditDialog: (show: boolean) => void,
+ selectedPost: Post,
+ ) => {
+ try {
+ const data = await postAPI.updatePost(selectedPost.id, selectedPost);
+ setPosts(posts.map((post) => (post.id === data.id ? data : post)));
+ setShowEditDialog(false);
+ } catch (error) {
+ console.error('게시물 업데이트 오류:', error);
+ }
+ };
+
+ // 게시물 삭제
+ const deletePostWithState = async (
+ setPosts: (posts: Post[]) => void,
+ posts: Post[],
+ id: number,
+ ) => {
+ try {
+ await postAPI.deletePost(id);
+ setPosts(posts.filter((post) => post.id !== id));
+ } catch (error) {
+ console.error('게시물 삭제 오류:', error);
+ }
+ };
+
+ return {
+ fetchPostsWithUsers,
+ searchPostsWithUsers,
+ fetchPostsByTagWithUsers,
+ addPostWithUser,
+ updatePostWithState,
+ deletePostWithState,
+ };
+};
diff --git a/src/features/post/hooks/index.ts b/src/features/post/hooks/index.ts
new file mode 100644
index 000000000..925320d81
--- /dev/null
+++ b/src/features/post/hooks/index.ts
@@ -0,0 +1 @@
+export * from './usePostFeature';
diff --git a/src/features/post/hooks/usePostFeature.tsx b/src/features/post/hooks/usePostFeature.tsx
new file mode 100644
index 000000000..92b44db91
--- /dev/null
+++ b/src/features/post/hooks/usePostFeature.tsx
@@ -0,0 +1,186 @@
+// Post 상태 관리 훅 (TanStack Query 적용)
+
+import { useState, useEffect } from 'react';
+import { useLocation, useNavigate } from 'react-router-dom';
+import { Post } from '../../../entities/post';
+import { Tag } from '../../../entities/tag';
+import { fetchTags } from '../../../entities/tag';
+import { usePostStore } from '../store';
+import { updateURL as updateURLUtil } from '../../../shared/utils';
+import {
+ usePosts,
+ useSearchPosts,
+ usePostsByTag,
+ useAddPost,
+ useUpdatePost,
+ useDeletePost,
+} from './usePostQueries';
+
+export const usePostFeature = () => {
+ const navigate = useNavigate();
+ const location = useLocation();
+ const queryParams = new URLSearchParams(location.search);
+
+ // 클라이언트 상태 (UI 상태) - Zustand 사용
+ const { selectedPost, newPost, setSelectedPost, setNewPost } = usePostStore();
+
+ // 클라이언트 상태 (UI 상태) - useState 사용
+ const [skip, setSkip] = useState(parseInt(queryParams.get('skip') || '0'));
+ const [limit, setLimit] = useState(parseInt(queryParams.get('limit') || '10'));
+ const [searchQuery, setSearchQuery] = useState(queryParams.get('search') || '');
+ const [sortBy, setSortBy] = useState(queryParams.get('sortBy') || 'sortBy');
+ const [sortOrder, setSortOrder] = useState(queryParams.get('sortOrder') || 'asc');
+ const [showAddDialog, setShowAddDialog] = useState(false);
+ const [showEditDialog, setShowEditDialog] = useState(false);
+ const [tags, setTags] = useState([]);
+ const [selectedTag, setSelectedTag] = useState(queryParams.get('tag') || '');
+ const [showPostDetailDialog, setShowPostDetailDialog] = useState(false);
+
+ // URL 업데이트 함수
+ const updateURL = () => {
+ updateURLUtil(navigate, { skip, limit, searchQuery, sortBy, sortOrder, selectedTag });
+ };
+
+ // 태그 가져오기
+ const handleFetchTags = async () => {
+ await fetchTags(setTags);
+ };
+
+ // 게시물 상세 보기
+ const openPostDetail = (post: Post) => {
+ setSelectedPost(post);
+ setShowPostDetailDialog(true);
+ };
+
+ // TanStack Query 훅들 사용
+ const { data: postsData, isLoading: postsLoading } = usePosts(limit, skip);
+ const { data: searchData, isLoading: searchLoading } = useSearchPosts(searchQuery);
+ const { data: tagData, isLoading: tagLoading } = usePostsByTag(selectedTag);
+
+ const addPostMutation = useAddPost();
+ const updatePostMutation = useUpdatePost();
+ const deletePostMutation = useDeletePost();
+
+ // 현재 표시할 데이터 결정
+ const getCurrentPostsData = () => {
+ if (searchQuery && searchData) {
+ return searchData;
+ }
+ if (selectedTag && selectedTag !== 'all' && tagData) {
+ return tagData;
+ }
+ return postsData || { posts: [], total: 0 };
+ };
+
+ const currentData = getCurrentPostsData();
+ const posts = currentData.posts;
+ const total = currentData.total;
+ const loading = postsLoading || searchLoading || tagLoading;
+
+ // 게시글 추가
+ const handleAddPost = async () => {
+ if (!newPost.title || !newPost.body) return;
+
+ addPostMutation.mutate(newPost, {
+ onSuccess: () => {
+ setShowAddDialog(false);
+ setNewPost({ title: '', body: '', userId: 1 });
+ },
+ onError: (error) => {
+ console.error('게시글 추가 오류:', error);
+ },
+ });
+ };
+
+ // 게시글 수정
+ const handleUpdatePost = async () => {
+ if (!selectedPost) return;
+
+ updatePostMutation.mutate(
+ { id: selectedPost.id, post: selectedPost },
+ {
+ onSuccess: () => {
+ setShowEditDialog(false);
+ setSelectedPost(null);
+ },
+ onError: (error) => {
+ console.error('게시글 수정 오류:', error);
+ },
+ },
+ );
+ };
+
+ // 게시글 삭제
+ const handleDeletePost = async (id: number) => {
+ deletePostMutation.mutate(id, {
+ onError: (error) => {
+ console.error('게시글 삭제 오류:', error);
+ },
+ });
+ };
+
+ // 게시글 검색
+ const handleSearchPosts = () => {
+ // TanStack Query가 자동으로 검색을 처리함
+ updateURL();
+ };
+
+ // useEffect들 (PostsManagerPage.tsx에서 그대로 복사)
+ useEffect(() => {
+ handleFetchTags();
+ }, []);
+
+ useEffect(() => {
+ 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]);
+
+ return {
+ // 상태
+ posts,
+ total,
+ skip,
+ limit,
+ searchQuery,
+ selectedPost,
+ sortBy,
+ sortOrder,
+ showAddDialog,
+ showEditDialog,
+ newPost,
+ loading,
+ tags,
+ selectedTag,
+ showPostDetailDialog,
+ // 상태 설정자
+ setSkip,
+ setLimit,
+ setSearchQuery,
+ setSelectedPost,
+ setSortBy,
+ setSortOrder,
+ setShowAddDialog,
+ setShowEditDialog,
+ setNewPost,
+ setTags,
+ setSelectedTag,
+ setShowPostDetailDialog,
+ // 함수들
+ updateURL,
+ handleFetchTags,
+ openPostDetail,
+ handleSearchPosts,
+ handleAddPost,
+ handleUpdatePost,
+ handleDeletePost,
+ };
+};
diff --git a/src/features/post/hooks/usePostQueries.ts b/src/features/post/hooks/usePostQueries.ts
new file mode 100644
index 000000000..804e66a94
--- /dev/null
+++ b/src/features/post/hooks/usePostQueries.ts
@@ -0,0 +1,164 @@
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
+import { Post, NewPost } from '../../../entities/post';
+import { User } from '../../../entities/user';
+import * as postAPI from '../../../entities/post/api';
+import * as userAPI from '../../../entities/user/api';
+
+// 게시글 목록 조회 (사용자 정보 포함)
+export const usePosts = (limit: number, skip: number) => {
+ return useQuery({
+ queryKey: ['posts', limit, skip],
+ queryFn: async () => {
+ const [postsData, usersData] = await Promise.all([
+ postAPI.fetchPosts(limit, skip),
+ userAPI.fetchUsers(),
+ ]);
+
+ const postsWithUsers = postsData.posts.map((post: Post) => ({
+ ...post,
+ author: usersData.users.find((user: User) => user.id === post.userId),
+ }));
+
+ return { posts: postsWithUsers, total: postsData.total };
+ },
+ });
+};
+
+// 게시글 검색 (사용자 정보 포함)
+export const useSearchPosts = (query: string) => {
+ return useQuery({
+ queryKey: ['posts', 'search', query],
+ queryFn: async () => {
+ if (!query) return { posts: [], total: 0 };
+
+ const [postsData, usersData] = await Promise.all([
+ postAPI.searchPosts(query),
+ userAPI.fetchUsers(),
+ ]);
+
+ const postsWithUsers = postsData.posts.map((post: Post) => ({
+ ...post,
+ author: usersData.users.find((user: User) => user.id === post.userId),
+ }));
+
+ return { posts: postsWithUsers, total: postsData.total };
+ },
+ enabled: !!query,
+ });
+};
+
+// 태그별 게시글 조회 (사용자 정보 포함)
+export const usePostsByTag = (tag: string) => {
+ return useQuery({
+ queryKey: ['posts', 'tag', tag],
+ queryFn: async () => {
+ if (!tag || tag === 'all') return { posts: [], total: 0 };
+
+ const [postsData, usersData] = await Promise.all([
+ postAPI.fetchPostsByTag(tag),
+ userAPI.fetchUsers(),
+ ]);
+
+ const postsWithUsers = postsData.posts.map((post: Post) => ({
+ ...post,
+ author: usersData.users.find((user: User) => user.id === post.userId),
+ }));
+
+ return { posts: postsWithUsers, total: postsData.total };
+ },
+ enabled: !!tag && tag !== 'all',
+ });
+};
+
+// 게시글 추가
+export const useAddPost = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (newPost: NewPost) => {
+ const data = await postAPI.addPost(newPost);
+
+ // API 응답에서 ID 확인 및 처리
+ let postWithId = data;
+ if (!data.id) {
+ // ID가 없으면 임시 ID 생성 (서버에서 생성된 ID로 대체될 예정)
+ postWithId = {
+ ...data,
+ id: Date.now(),
+ };
+ }
+
+ // 새 게시글에 author 정보 추가
+ const usersData = await userAPI.fetchUsers();
+ const newPostWithAuthor = {
+ ...postWithId,
+ author: usersData.users.find((user: User) => user.id === postWithId.userId),
+ };
+ return newPostWithAuthor;
+ },
+ onSuccess: (newPost) => {
+ // 모든 posts 관련 쿼리에 낙관적 업데이트 적용
+ const queries = queryClient.getQueriesData({ queryKey: ['posts'], exact: false });
+ queries.forEach(([queryKey, data]) => {
+ if (data && (data as any).posts) {
+ queryClient.setQueryData(queryKey, {
+ ...data,
+ posts: [newPost, ...(data as any).posts],
+ total: (data as any).total + 1,
+ });
+ }
+ });
+ },
+ });
+};
+
+// 게시글 수정
+export const useUpdatePost = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ id, post }: { id: number; post: Partial }) => {
+ return await postAPI.updatePost(id, post);
+ },
+ onSuccess: (updatedPost) => {
+ // 모든 posts 관련 쿼리에 낙관적 업데이트 적용
+ const queries = queryClient.getQueriesData({ queryKey: ['posts'], exact: false });
+ queries.forEach(([queryKey, data]) => {
+ if (data && (data as any).posts) {
+ const updatedPosts = (data as any).posts.map((p: Post) =>
+ p.id === updatedPost.id ? { ...p, ...updatedPost } : p,
+ );
+ queryClient.setQueryData(queryKey, {
+ ...data,
+ posts: updatedPosts,
+ });
+ }
+ });
+ },
+ });
+};
+
+// 게시글 삭제
+export const useDeletePost = () => {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async (id: number) => {
+ return await postAPI.deletePost(id);
+ },
+ onSuccess: (_, deletedId) => {
+ // 모든 posts 관련 쿼리에 낙관적 업데이트 적용
+ const queries = queryClient.getQueriesData({ queryKey: ['posts'], exact: false });
+ queries.forEach(([queryKey, data]) => {
+ if (data && (data as any).posts) {
+ const filteredPosts = (data as any).posts.filter((p: Post) => p.id !== deletedId);
+ queryClient.setQueryData(queryKey, {
+ ...data,
+ posts: filteredPosts,
+ total: (data as any).total - 1,
+ });
+ }
+ });
+ },
+ });
+};
diff --git a/src/features/post/index.ts b/src/features/post/index.ts
new file mode 100644
index 000000000..5f84b2ace
--- /dev/null
+++ b/src/features/post/index.ts
@@ -0,0 +1 @@
+export * from './hooks/usePostFeature';
diff --git a/src/features/post/store/index.ts b/src/features/post/store/index.ts
new file mode 100644
index 000000000..a9b8dd937
--- /dev/null
+++ b/src/features/post/store/index.ts
@@ -0,0 +1 @@
+export * from './postStore';
diff --git a/src/features/post/store/postStore.ts b/src/features/post/store/postStore.ts
new file mode 100644
index 000000000..ea659d0c7
--- /dev/null
+++ b/src/features/post/store/postStore.ts
@@ -0,0 +1,34 @@
+import { create } from 'zustand';
+import { Post, NewPost } from '../../../entities/post';
+
+interface PostStore {
+ // Phase 1: 기본 데이터 상태 (기존 API 함수들과 호환)
+ posts: Post[];
+ selectedPost: Post | null;
+ newPost: NewPost;
+
+ // Phase 1: 기본 setState 함수들 (기존 API 함수들과 호환)
+ setPosts: (posts: Post[]) => void;
+ setSelectedPost: (post: Post | null) => void;
+ setNewPost: (post: NewPost) => void;
+
+ // Phase 1: 기본 초기화 함수들
+ clearNewPost: () => void;
+ clearSelectedPost: () => void;
+}
+
+export const usePostStore = create((set) => ({
+ // Phase 1: 기본 초기값 (기존과 동일)
+ posts: [],
+ selectedPost: null,
+ newPost: { title: '', body: '', userId: 1 },
+
+ // Phase 1: 기본 setState 함수들 (기존 API 함수들과 호환)
+ setPosts: (posts) => set({ posts }),
+ setSelectedPost: (post) => set({ selectedPost: post }),
+ setNewPost: (post) => set({ newPost: post }),
+
+ // Phase 1: 기본 초기화 함수들
+ clearNewPost: () => set({ newPost: { title: '', body: '', userId: 1 } }),
+ clearSelectedPost: () => set({ selectedPost: null }),
+}));
diff --git a/src/features/post/ui/PostDetail.tsx b/src/features/post/ui/PostDetail.tsx
new file mode 100644
index 000000000..2c8fde713
--- /dev/null
+++ b/src/features/post/ui/PostDetail.tsx
@@ -0,0 +1,34 @@
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../../shared/ui';
+import { Post } from '../../../entities/post';
+import { highlightText } from '../../../shared/utils';
+import { ReactNode } from 'react';
+
+interface PostDetailProps {
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+ post: Post | null;
+ searchQuery: string;
+ children?: ReactNode;
+}
+
+export const PostDetail = ({ isOpen, onOpenChange, post, searchQuery, children }: PostDetailProps) => {
+ if (!post) return null;
+
+ return (
+
+ );
+};
diff --git a/src/features/post/ui/PostForm.tsx b/src/features/post/ui/PostForm.tsx
new file mode 100644
index 000000000..5d4a4f40f
--- /dev/null
+++ b/src/features/post/ui/PostForm.tsx
@@ -0,0 +1,56 @@
+import { Button, Dialog, DialogContent, DialogHeader, DialogTitle, Input, Textarea } from '../../../shared/ui';
+import { Post, NewPost } from '../../../entities/post';
+
+interface PostFormProps {
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+ title: string;
+ post: Post | NewPost;
+ onPostChange: (post: Post | NewPost) => void;
+ onSubmit: () => void;
+ submitText: string;
+ isEdit?: boolean;
+}
+
+export const PostForm = ({
+ isOpen,
+ onOpenChange,
+ title,
+ post,
+ onPostChange,
+ onSubmit,
+ submitText,
+ isEdit = false,
+}: PostFormProps) => {
+ return (
+
+ );
+};
diff --git a/src/features/post/ui/PostTable.tsx b/src/features/post/ui/PostTable.tsx
new file mode 100644
index 000000000..9bb6bba32
--- /dev/null
+++ b/src/features/post/ui/PostTable.tsx
@@ -0,0 +1,112 @@
+// Post 테이블 UI 컴포넌트
+
+import { Edit2, MessageSquare, ThumbsDown, ThumbsUp, Trash2 } from 'lucide-react';
+import {
+ Button,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from '../../../shared/ui';
+import { highlightText } from '../../../shared/utils';
+
+export const PostTable = ({
+ posts,
+ searchQuery,
+ selectedTag,
+ setSelectedTag,
+ updateURL,
+ openUserModal,
+ openPostDetail,
+ setSelectedPost,
+ setShowEditDialog,
+ handleDeletePost,
+}: any) => {
+ return (
+
+
+
+ ID
+ 제목
+ 작성자
+ 반응
+ 작업
+
+
+
+ {posts.map((post: any) => (
+
+ {post.id}
+
+
+
{highlightText(post.title, searchQuery)}
+
+
+ {post.tags?.map((tag: any) => (
+ {
+ setSelectedTag(tag);
+ updateURL();
+ }}
+ >
+ {tag}
+
+ ))}
+
+
+
+
+ openUserModal(post.author)}
+ >
+

+
{post.author?.username}
+
+
+
+
+
+ {post.reactions?.likes || 0}
+
+ {post.reactions?.dislikes || 0}
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ );
+};
diff --git a/src/features/post/ui/index.ts b/src/features/post/ui/index.ts
new file mode 100644
index 000000000..a0d0ae2c1
--- /dev/null
+++ b/src/features/post/ui/index.ts
@@ -0,0 +1,3 @@
+export * from './PostTable';
+export * from './PostForm';
+export * from './PostDetail';
diff --git a/src/features/user/api/index.ts b/src/features/user/api/index.ts
new file mode 100644
index 000000000..f405ff445
--- /dev/null
+++ b/src/features/user/api/index.ts
@@ -0,0 +1,24 @@
+import { User } from '../../../entities/user';
+import * as userAPI from '../../../entities/user/api';
+
+// 상태 관리와 결합된 API 로직
+export const useUserAPI = () => {
+ // 사용자 정보 가져오기
+ const fetchUserWithState = async (
+ userId: number,
+ setUser: (user: User) => void,
+ setShowUserModal: (show: boolean) => void,
+ ) => {
+ try {
+ const userData = await userAPI.fetchUser(userId);
+ setUser(userData);
+ setShowUserModal(true);
+ } catch (error) {
+ console.error('사용자 정보 가져오기 오류:', error);
+ }
+ };
+
+ return {
+ fetchUserWithState,
+ };
+};
diff --git a/src/features/user/hooks/useUserFeature.tsx b/src/features/user/hooks/useUserFeature.tsx
new file mode 100644
index 000000000..6d6553fd5
--- /dev/null
+++ b/src/features/user/hooks/useUserFeature.tsx
@@ -0,0 +1,42 @@
+import { useUserStore } from '../store';
+import { User } from '../../../entities/user';
+import { useUser } from './useUserQueries';
+
+export const useUserFeature = () => {
+ // Zustand 스토어 사용 (클라이언트 상태)
+ const { user, setUser, clearUser, showUserModal, setShowUserModal } = useUserStore();
+
+ // TanStack Query 훅 사용
+ const { data: userData, isLoading, error } = useUser(user?.id || 0);
+
+ const openUserModal = async (user: User | undefined) => {
+ if (!user || !user.id) {
+ console.warn('사용자 정보가 없습니다:', user);
+ return;
+ }
+
+ // TanStack Query가 자동으로 사용자 데이터를 가져옴
+ // useUser(user.id) 훅이 자동으로 작동
+ setUser(user);
+ setShowUserModal(true);
+ };
+
+ // TanStack Query 데이터를 우선적으로 사용
+ const currentUser = userData || user;
+
+ // 기존 반환값과 동일 (변경 없음)
+ return {
+ // 상태 (TanStack Query 데이터 우선)
+ showUserModal,
+ user: currentUser,
+ // TanStack Query 상태 추가
+ isLoading,
+ error,
+ // 상태 설정자
+ setShowUserModal,
+ setUser,
+ clearUser,
+ // 함수들
+ openUserModal,
+ };
+};
diff --git a/src/features/user/hooks/useUserQueries.ts b/src/features/user/hooks/useUserQueries.ts
new file mode 100644
index 000000000..38368357a
--- /dev/null
+++ b/src/features/user/hooks/useUserQueries.ts
@@ -0,0 +1,25 @@
+import { useQuery } from '@tanstack/react-query';
+import * as userAPI from '../../../entities/user/api';
+
+// 단일 사용자 조회
+export const useUser = (userId: number) => {
+ return useQuery({
+ queryKey: ['user', userId],
+ queryFn: async () => {
+ const data = await userAPI.fetchUser(userId);
+ return data;
+ },
+ enabled: !!userId,
+ });
+};
+
+// 전체 사용자 목록 조회
+export const useUsers = () => {
+ return useQuery({
+ queryKey: ['users'],
+ queryFn: async () => {
+ const data = await userAPI.fetchUsers();
+ return data;
+ },
+ });
+};
diff --git a/src/features/user/index.ts b/src/features/user/index.ts
new file mode 100644
index 000000000..4188f66be
--- /dev/null
+++ b/src/features/user/index.ts
@@ -0,0 +1,2 @@
+export * from './hooks/useUserFeature';
+export * from './store';
diff --git a/src/features/user/store/index.ts b/src/features/user/store/index.ts
new file mode 100644
index 000000000..bafb64922
--- /dev/null
+++ b/src/features/user/store/index.ts
@@ -0,0 +1 @@
+export { useUserStore } from './userStore';
diff --git a/src/features/user/store/userStore.ts b/src/features/user/store/userStore.ts
new file mode 100644
index 000000000..76e1e8dd7
--- /dev/null
+++ b/src/features/user/store/userStore.ts
@@ -0,0 +1,24 @@
+import { create } from 'zustand';
+import { User } from '../../../shared/types/common';
+
+interface UserStore {
+ // 기존 useState로 관리되던 상태들
+ user: User | null;
+ showUserModal: boolean;
+
+ // 기존 setState 함수들
+ setUser: (user: User) => void;
+ clearUser: () => void;
+ setShowUserModal: (show: boolean) => void;
+}
+
+export const useUserStore = create((set) => ({
+ // 기존 초기값과 동일
+ user: null,
+ showUserModal: false,
+
+ // 기존 setState 함수들과 동일한 동작
+ setUser: (user) => set({ user }),
+ clearUser: () => set({ user: null }),
+ setShowUserModal: (show) => set({ showUserModal: show }),
+}));
diff --git a/src/features/user/ui/UserProfile.tsx b/src/features/user/ui/UserProfile.tsx
new file mode 100644
index 000000000..15a15f58f
--- /dev/null
+++ b/src/features/user/ui/UserProfile.tsx
@@ -0,0 +1,88 @@
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../../shared/ui';
+import { User } from '../../../entities/user';
+import { useUser } from '../hooks/useUserQueries';
+
+interface UserProfileProps {
+ isOpen: boolean;
+ onOpenChange: (open: boolean) => void;
+ user: User | null;
+}
+
+export const UserProfile = ({ isOpen, onOpenChange, user }: UserProfileProps) => {
+ // TanStack Query로 사용자 데이터 가져오기 (props의 user가 기본값)
+ const { data: userData, isLoading, error } = useUser(user?.id || 0);
+
+ // props의 user 또는 TanStack Query 데이터 사용
+ const displayUser = userData || user;
+
+ if (!displayUser) return null;
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+};
diff --git a/src/features/user/ui/index.ts b/src/features/user/ui/index.ts
new file mode 100644
index 000000000..884472841
--- /dev/null
+++ b/src/features/user/ui/index.ts
@@ -0,0 +1 @@
+export * from './UserProfile';
diff --git a/src/main.tsx b/src/main.tsx
index bef5202a3..2239905c1 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -1,10 +1,10 @@
-import { StrictMode } from 'react'
-import { createRoot } from 'react-dom/client'
-import './index.css'
-import App from './App.tsx'
+import { StrictMode } from 'react';
+import { createRoot } from 'react-dom/client';
+import './index.css';
+import App from './App.tsx';
createRoot(document.getElementById('root')!).render(
,
-)
+);
diff --git a/src/pages/PostsManagerPage.tsx b/src/pages/PostsManagerPage.tsx
index f80eb91ef..294031772 100644
--- a/src/pages/PostsManagerPage.tsx
+++ b/src/pages/PostsManagerPage.tsx
@@ -1,708 +1,222 @@
-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 { usePostFeature } from '../features/post';
+import { useCommentFeature } from '../features/comment';
+import { useUserFeature } from '../features/user';
+import { PostManager } from '../widgets/PostManager';
+import { PostForm, PostDetail } from '../features/post/ui';
+import { CommentForm, CommentList } from '../features/comment/ui';
+import { UserProfile } from '../features/user/ui';
+import { openPostDetailWithComments as openPostDetailWithCommentsUtil } from '../entities/post/model';
+import { SearchBar, FilterBar, PostTable, Pagination } from '../widgets';
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)
+ // Post Feature 사용
+ const {
+ posts,
+ total,
+ skip,
+ limit,
+ searchQuery,
+ selectedPost,
+ sortBy,
+ sortOrder,
+ showAddDialog,
+ showEditDialog,
+ newPost,
+ loading,
+ tags,
+ selectedTag,
+ showPostDetailDialog,
+ setSkip,
+ setLimit,
+ setSearchQuery,
+ setSelectedPost,
+ setSortBy,
+ setSortOrder,
+ setShowAddDialog,
+ setShowEditDialog,
+ setNewPost,
+ setSelectedTag,
+ setShowPostDetailDialog,
+ updateURL,
+ handleFetchTags,
+ openPostDetail,
+ handleSearchPosts,
+ handleAddPost,
+ handleUpdatePost,
+ handleDeletePost,
+ } = usePostFeature();
+
+ // User Feature 사용
+ const { showUserModal, user, setShowUserModal, openUserModal } = useUserFeature();
+
+ // Comment Feature 사용 (컴포넌트 최상위에서 호출)
+ const {
+ selectedComment,
+ newComment,
+ showAddCommentDialog,
+ showEditCommentDialog,
+ setSelectedComment,
+ setNewComment,
+ setShowAddCommentDialog,
+ setShowEditCommentDialog,
+ handleUpdateComment,
+ handleDeleteComment,
+ handleLikeComment,
+ handleAddComment,
+ } = useCommentFeature();
+
+ // 게시물 상세 보기 (댓글도 함께 가져오기) - entities 함수 사용
+ const openPostDetailWithComments = (post: any) => {
+ openPostDetailWithCommentsUtil(post, openPostDetail);
+ };
+
+ // 댓글 추가 시 postId 설정
+ const handleAddCommentWithPostId = () => {
+ if (selectedPost && newComment.body) {
+ const commentWithPostId = {
+ ...newComment,
+ postId: selectedPost.id,
+ userId: newComment.userId || 1,
+ };
+
+ // TanStack Query를 사용하여 댓글 추가 (데이터 직접 전달)
+ handleAddComment(commentWithPostId);
} 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}
+ console.error('selectedPost가 없거나 댓글 내용이 없습니다!');
}
- const regex = new RegExp(`(${highlight})`, "gi")
- const parts = text.split(regex)
- return (
-
- {parts.map((part, i) => (regex.test(part) ? {part} : {part}))}
-
- )
- }
-
- // 게시물 테이블 렌더링
- const renderPostTable = () => (
-
-
-
- ID
- 제목
- 작성자
- 반응
- 작업
-
-
-
- {posts.map((post) => (
-
- {post.id}
-
-
-
{highlightText(post.title, searchQuery)}
-
-
- {post.tags?.map((tag) => (
- {
- setSelectedTag(tag)
- updateURL()
- }}
- >
- {tag}
-
- ))}
-
-
-
-
- openUserModal(post.author)}>
-

-
{post.author?.username}
-
-
-
-
-
- {post.reactions?.likes || 0}
-
- {post.reactions?.dislikes || 0}
-
-
-
-
-
-
-
-
-
-
- ))}
-
-
- )
-
- // 댓글 렌더링
- const renderComments = (postId) => (
-
-
-
댓글
-
-
-
- {comments[postId]?.map((comment) => (
-
-
- {comment.user.username}:
- {highlightText(comment.body, searchQuery)}
-
-
-
-
-
-
-
- ))}
-
-
- )
+ };
return (
-
-
-
- 게시물 관리자
-
-
-
-
-
- {/* 검색 및 필터 컨트롤 */}
-
-
-
-
- setSearchQuery(e.target.value)}
- onKeyPress={(e) => e.key === "Enter" && searchPosts()}
- />
-
-
-
-
-
-
-
- {/* 게시물 테이블 */}
- {loading ?
로딩 중...
: renderPostTable()}
-
- {/* 페이지네이션 */}
-
-
- 표시
-
- 항목
-
-
-
-
-
-
+ <>
+
setShowAddDialog(true)}>
+ {/* 검색 및 필터 컨트롤 */}
+
+
+
-
+
+ {/* 게시물 테이블 */}
+ {loading ? (
+ 로딩 중...
+ ) : (
+
+ )}
+
+ {/* 페이지네이션 */}
+
+
{/* 게시물 추가 대화상자 */}
-
+
{/* 게시물 수정 대화상자 */}
-
+
{
+ if (selectedPost) {
+ setSelectedPost({ ...selectedPost, ...post });
+ }
+ }}
+ onSubmit={handleUpdatePost}
+ submitText='게시물 업데이트'
+ isEdit={true}
+ />
{/* 댓글 추가 대화상자 */}
-
+ setNewComment({ ...newComment, ...comment })}
+ onSubmit={handleAddCommentWithPostId}
+ submitText='댓글 추가'
+ />
{/* 댓글 수정 대화상자 */}
-
+ {
+ if (selectedComment) {
+ setSelectedComment({ ...selectedComment, ...comment });
+ }
+ }}
+ onSubmit={handleUpdateComment}
+ submitText='댓글 업데이트'
+ />
{/* 게시물 상세 보기 대화상자 */}
-
+
+ {selectedPost && (
+ setShowAddCommentDialog(true)}
+ onEditComment={(comment) => {
+ setSelectedComment(comment);
+ setShowEditCommentDialog(true);
+ }}
+ onLikeComment={handleLikeComment}
+ onDeleteComment={handleDeleteComment}
+ />
+ )}
+
{/* 사용자 모달 */}
-
-
- )
-}
+
+ >
+ );
+};
-export default PostsManager
+export default PostsManager;
diff --git a/src/shared/config/api.ts b/src/shared/config/api.ts
new file mode 100644
index 000000000..d2dee6743
--- /dev/null
+++ b/src/shared/config/api.ts
@@ -0,0 +1,24 @@
+// 환경별 API 설정
+export const API_CONFIG = {
+ // 개발 환경: 로컬 API 사용
+ development: {
+ baseURL: '/api',
+ useMock: false,
+ },
+ // 프로덕션 환경: Mock 데이터 사용
+ production: {
+ baseURL: '/api',
+ useMock: true,
+ },
+};
+
+// 현재 환경에 따른 설정
+export const getApiConfig = () => {
+ const env = import.meta.env.MODE || 'development';
+ return API_CONFIG[env as keyof typeof API_CONFIG] || API_CONFIG.development;
+};
+
+// Mock 데이터 사용 여부
+export const shouldUseMock = () => {
+ return getApiConfig().useMock;
+};
diff --git a/src/shared/config/httpClient.ts b/src/shared/config/httpClient.ts
new file mode 100644
index 000000000..8aa4450fb
--- /dev/null
+++ b/src/shared/config/httpClient.ts
@@ -0,0 +1,94 @@
+// HTTP 클라이언트 - 환경별 API 분기 처리
+class HttpClient {
+ private baseUrl: string;
+ private isDev: boolean;
+
+ constructor() {
+ // GitHub Pages 도메인 체크로 환경 감지
+ const isGitHubPages = window.location.hostname === 'adds9810.github.io';
+ this.isDev = !isGitHubPages;
+
+ // 개발: 로컬 API, 프로덕션: 외부 API
+ this.baseUrl = this.isDev ? '' : 'https://dummyjson.com';
+ }
+
+ // URL을 환경에 맞게 변환
+ private buildUrl(path: string): string {
+ if (path.startsWith('/api')) {
+ if (this.isDev) {
+ // 개발 환경: Vite proxy가 처리
+ return path;
+ } else {
+ // 프로덕션 환경: 외부 API로 변환
+ const apiPath = path.replace(/^\/api/, '');
+ return `${this.baseUrl}${apiPath}`;
+ }
+ }
+ return path;
+ }
+
+ // GET 요청
+ async get(url: string, options?: RequestInit): Promise {
+ const fullUrl = this.buildUrl(url);
+ return fetch(fullUrl, {
+ method: 'GET',
+ headers: options?.headers,
+ ...options,
+ });
+ }
+
+ // POST 요청
+ async post(url: string, data?: any, options?: RequestInit): Promise {
+ const fullUrl = this.buildUrl(url);
+ return fetch(fullUrl, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options?.headers,
+ },
+ body: data ? JSON.stringify(data) : undefined,
+ ...options,
+ });
+ }
+
+ // PUT 요청
+ async put(url: string, data?: any, options?: RequestInit): Promise {
+ const fullUrl = this.buildUrl(url);
+ return fetch(fullUrl, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options?.headers,
+ },
+ body: data ? JSON.stringify(data) : undefined,
+ ...options,
+ });
+ }
+
+ // PATCH 요청
+ async patch(url: string, data?: any, options?: RequestInit): Promise {
+ const fullUrl = this.buildUrl(url);
+ return fetch(fullUrl, {
+ method: 'PATCH',
+ headers: {
+ 'Content-Type': 'application/json',
+ ...options?.headers,
+ },
+ body: data ? JSON.stringify(data) : undefined,
+ ...options,
+ });
+ }
+
+ // DELETE 요청
+ async delete(url: string, options?: RequestInit): Promise {
+ const fullUrl = this.buildUrl(url);
+ return fetch(fullUrl, {
+ method: 'DELETE',
+ headers: options?.headers,
+ ...options,
+ });
+ }
+}
+
+// HTTP 클라이언트 인스턴스
+export const httpClient = new HttpClient();
diff --git a/src/shared/index.ts b/src/shared/index.ts
new file mode 100644
index 000000000..080f7b87b
--- /dev/null
+++ b/src/shared/index.ts
@@ -0,0 +1,2 @@
+export * from './ui';
+export * from './utils';
diff --git a/src/shared/types/common.ts b/src/shared/types/common.ts
new file mode 100644
index 000000000..684e0ca3d
--- /dev/null
+++ b/src/shared/types/common.ts
@@ -0,0 +1,46 @@
+export interface BaseProps {
+ className?: string;
+}
+
+export interface BasePropsWithChildren extends BaseProps {
+ children: React.ReactNode;
+}
+
+export interface User {
+ id: number;
+ username: string;
+ image: string;
+ firstName?: string;
+ lastName?: string;
+ age?: number;
+ email?: string;
+ phone?: string;
+ address?: {
+ address: string;
+ city: string;
+ state: string;
+ };
+ company?: {
+ name: string;
+ title: string;
+ };
+}
+
+export interface ApiResponse {
+ data: T;
+ message?: string;
+ success: boolean;
+}
+
+export interface PaginationParams {
+ page: number;
+ limit: number;
+ skip: number;
+}
+
+export interface PaginatedResponse {
+ data: T[];
+ total: number;
+ skip: number;
+ limit: number;
+}
diff --git a/src/shared/ui/Button/Button.tsx b/src/shared/ui/Button/Button.tsx
new file mode 100644
index 000000000..32c375cdf
--- /dev/null
+++ b/src/shared/ui/Button/Button.tsx
@@ -0,0 +1,28 @@
+import { forwardRef } from 'react';
+import { BaseProps } from '../../types/common';
+import { VariantProps } from 'class-variance-authority';
+import { buttonVariants } from './buttonVariants';
+
+interface ButtonProps extends BaseProps, VariantProps {
+ children: React.ReactNode;
+ onClick?: () => void;
+ disabled?: boolean;
+}
+
+export const Button = forwardRef(
+ ({ children, disabled, onClick, className, variant, size, ...props }, ref) => {
+ return (
+
+ );
+ },
+);
+
+Button.displayName = 'Button';
diff --git a/src/shared/ui/Button/buttonVariants.ts b/src/shared/ui/Button/buttonVariants.ts
new file mode 100644
index 000000000..6efe6576d
--- /dev/null
+++ b/src/shared/ui/Button/buttonVariants.ts
@@ -0,0 +1,27 @@
+import { cva } from 'class-variance-authority';
+
+export 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',
+ },
+ },
+);
diff --git a/src/shared/ui/Button/index.ts b/src/shared/ui/Button/index.ts
new file mode 100644
index 000000000..fe9c53c51
--- /dev/null
+++ b/src/shared/ui/Button/index.ts
@@ -0,0 +1 @@
+export { Button } from './Button';
diff --git a/src/shared/ui/Card/Card.tsx b/src/shared/ui/Card/Card.tsx
new file mode 100644
index 000000000..bd4f837fd
--- /dev/null
+++ b/src/shared/ui/Card/Card.tsx
@@ -0,0 +1,13 @@
+import { forwardRef } from 'react';
+import { BaseProps } from '../../types/common';
+
+interface CardProps extends BaseProps, React.HTMLAttributes {}
+
+export const Card = forwardRef(({ className, ...props }, ref) => (
+
+));
+Card.displayName = 'Card';
diff --git a/src/shared/ui/Card/CardContent.tsx b/src/shared/ui/Card/CardContent.tsx
new file mode 100644
index 000000000..aa9f33950
--- /dev/null
+++ b/src/shared/ui/Card/CardContent.tsx
@@ -0,0 +1,11 @@
+import { forwardRef } from 'react';
+import { BaseProps } from '../../types/common';
+
+interface CardContentProps extends BaseProps, React.HTMLAttributes {}
+
+export const CardContent = forwardRef(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+CardContent.displayName = 'CardContent';
diff --git a/src/shared/ui/Card/CardHeader.tsx b/src/shared/ui/Card/CardHeader.tsx
new file mode 100644
index 000000000..c3610aa2f
--- /dev/null
+++ b/src/shared/ui/Card/CardHeader.tsx
@@ -0,0 +1,12 @@
+import { forwardRef } from 'react';
+import { BaseProps } from '../../types/common';
+
+interface CardHeaderProps extends BaseProps, React.HTMLAttributes {}
+
+export const CardHeader = forwardRef(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+
+CardHeader.displayName = 'CardHeader';
diff --git a/src/shared/ui/Card/CardTitle.tsx b/src/shared/ui/Card/CardTitle.tsx
new file mode 100644
index 000000000..0418581c4
--- /dev/null
+++ b/src/shared/ui/Card/CardTitle.tsx
@@ -0,0 +1,15 @@
+import { forwardRef } from 'react';
+import { BaseProps } from '../../types/common';
+
+interface CardTitleProps extends BaseProps, React.HTMLAttributes {}
+
+export const CardTitle = forwardRef(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+CardTitle.displayName = 'CardTitle';
diff --git a/src/shared/ui/Card/index.ts b/src/shared/ui/Card/index.ts
new file mode 100644
index 000000000..12f6c1ede
--- /dev/null
+++ b/src/shared/ui/Card/index.ts
@@ -0,0 +1,4 @@
+export { Card } from './Card';
+export { CardHeader } from './CardHeader';
+export { CardTitle } from './CardTitle';
+export { CardContent } from './CardContent';
diff --git a/src/shared/ui/Dialog/Dialog.tsx b/src/shared/ui/Dialog/Dialog.tsx
new file mode 100644
index 000000000..dab04f419
--- /dev/null
+++ b/src/shared/ui/Dialog/Dialog.tsx
@@ -0,0 +1,7 @@
+import * as DialogPrimitive from '@radix-ui/react-dialog';
+
+export const Dialog = DialogPrimitive.Root;
+export const DialogTrigger = DialogPrimitive.Trigger;
+// 기존에는 있었으나 재사용할거 같지 않아서 컨텐츠에 풀어 사용
+// export const DialogPortal = DialogPrimitive.Portal;
+// export const DialogOverlay = DialogPrimitive.Overlay;
diff --git a/src/shared/ui/Dialog/DialogContent.tsx b/src/shared/ui/Dialog/DialogContent.tsx
new file mode 100644
index 000000000..71349e47d
--- /dev/null
+++ b/src/shared/ui/Dialog/DialogContent.tsx
@@ -0,0 +1,24 @@
+import { X } from 'lucide-react';
+import * as DialogPrimitive from '@radix-ui/react-dialog';
+import { forwardRef } from 'react';
+import { BasePropsWithChildren } from '../../types/common';
+
+export const DialogContent = forwardRef(
+ ({ className, children, ...props }, ref) => (
+
+
+
+ {children}
+
+
+ 닫기
+
+
+
+ ),
+);
+DialogContent.displayName = DialogPrimitive.Content.displayName;
diff --git a/src/shared/ui/Dialog/DialogHeader.tsx b/src/shared/ui/Dialog/DialogHeader.tsx
new file mode 100644
index 000000000..f4edb629e
--- /dev/null
+++ b/src/shared/ui/Dialog/DialogHeader.tsx
@@ -0,0 +1,9 @@
+import { BasePropsWithChildren } from '../../types/common';
+
+export const DialogHeader = ({ children, className, ...props }: BasePropsWithChildren) => (
+
+ {children}
+
+);
+
+DialogHeader.displayName = 'DialogHeader';
diff --git a/src/shared/ui/Dialog/DialogTitle.tsx b/src/shared/ui/Dialog/DialogTitle.tsx
new file mode 100644
index 000000000..149ac8991
--- /dev/null
+++ b/src/shared/ui/Dialog/DialogTitle.tsx
@@ -0,0 +1,17 @@
+import { forwardRef } from 'react';
+import * as DialogPrimitive from '@radix-ui/react-dialog';
+import { BasePropsWithChildren } from '../../types/common';
+
+export const DialogTitle = forwardRef(
+ ({ className, children, ...props }, ref) => (
+
+ {children}
+
+ ),
+);
+
+DialogTitle.displayName = DialogPrimitive.Title.displayName;
diff --git a/src/shared/ui/Dialog/index.ts b/src/shared/ui/Dialog/index.ts
new file mode 100644
index 000000000..48585e200
--- /dev/null
+++ b/src/shared/ui/Dialog/index.ts
@@ -0,0 +1,4 @@
+export { Dialog, DialogTrigger } from './Dialog';
+export { DialogContent } from './DialogContent';
+export { DialogHeader } from './DialogHeader';
+export { DialogTitle } from './DialogTitle';
diff --git a/src/shared/ui/Input/Input.tsx b/src/shared/ui/Input/Input.tsx
new file mode 100644
index 000000000..2e72e6c0d
--- /dev/null
+++ b/src/shared/ui/Input/Input.tsx
@@ -0,0 +1,19 @@
+import { forwardRef } from 'react';
+import { BaseProps } from '../../types/common';
+
+interface InputProps extends BaseProps, React.InputHTMLAttributes {}
+
+export const Input = forwardRef(
+ ({ className, type, ...props }, ref) => {
+ return (
+
+ );
+ },
+);
+
+Input.displayName = 'Input';
diff --git a/src/shared/ui/Input/index.ts b/src/shared/ui/Input/index.ts
new file mode 100644
index 000000000..6322cf324
--- /dev/null
+++ b/src/shared/ui/Input/index.ts
@@ -0,0 +1 @@
+export { Input } from './Input';
diff --git a/src/shared/ui/Select/Select.tsx b/src/shared/ui/Select/Select.tsx
new file mode 100644
index 000000000..75c35b9be
--- /dev/null
+++ b/src/shared/ui/Select/Select.tsx
@@ -0,0 +1,5 @@
+import * as SelectPrimitive from '@radix-ui/react-select';
+
+export const Select = SelectPrimitive.Root;
+export const SelectGroup = SelectPrimitive.Group;
+export const SelectValue = SelectPrimitive.Value;
diff --git a/src/shared/ui/Select/SelectContent.tsx b/src/shared/ui/Select/SelectContent.tsx
new file mode 100644
index 000000000..12c77a936
--- /dev/null
+++ b/src/shared/ui/Select/SelectContent.tsx
@@ -0,0 +1,23 @@
+import { forwardRef } from 'react';
+import * as SelectPrimitive from '@radix-ui/react-select';
+import { BasePropsWithChildren } from '../../types/common';
+
+interface SelectContentProps extends BasePropsWithChildren {
+ position?: 'popper' | 'item-aligned';
+}
+
+export const SelectContent = forwardRef(
+ ({ className, children, position = 'popper', ...props }, ref) => (
+
+
+ {children}
+
+
+ ),
+);
+SelectContent.displayName = SelectPrimitive.Content.displayName;
diff --git a/src/shared/ui/Select/SelectItem.tsx b/src/shared/ui/Select/SelectItem.tsx
new file mode 100644
index 000000000..1bcb37d2a
--- /dev/null
+++ b/src/shared/ui/Select/SelectItem.tsx
@@ -0,0 +1,27 @@
+import { forwardRef } from 'react';
+import * as SelectPrimitive from '@radix-ui/react-select';
+import { Check } from 'lucide-react';
+import { BasePropsWithChildren } from '../../types/common';
+
+interface SelectItemProps extends BasePropsWithChildren {
+ value: string;
+ disabled?: boolean;
+}
+
+export const SelectItem = forwardRef(
+ ({ className, children, ...props }, ref) => (
+
+
+
+
+
+
+ {children}
+
+ ),
+);
+SelectItem.displayName = SelectPrimitive.Item.displayName;
diff --git a/src/shared/ui/Select/SelectTrigger.tsx b/src/shared/ui/Select/SelectTrigger.tsx
new file mode 100644
index 000000000..3a3c3b776
--- /dev/null
+++ b/src/shared/ui/Select/SelectTrigger.tsx
@@ -0,0 +1,22 @@
+import { forwardRef } from 'react';
+import * as SelectPrimitive from '@radix-ui/react-select';
+import { ChevronDown } from 'lucide-react';
+import { BasePropsWithChildren } from '../../types/common';
+
+interface SelectTriggerProps extends BasePropsWithChildren {
+ disabled?: boolean;
+}
+
+export const SelectTrigger = forwardRef(
+ ({ className, children, ...props }, ref) => (
+
+ {children}
+
+
+ ),
+);
+SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
diff --git a/src/shared/ui/Select/index.ts b/src/shared/ui/Select/index.ts
new file mode 100644
index 000000000..99e2f53dd
--- /dev/null
+++ b/src/shared/ui/Select/index.ts
@@ -0,0 +1,4 @@
+export { Select, SelectGroup, SelectValue } from './Select';
+export { SelectTrigger } from './SelectTrigger';
+export { SelectContent } from './SelectContent';
+export { SelectItem } from './SelectItem';
diff --git a/src/shared/ui/Table/Table.tsx b/src/shared/ui/Table/Table.tsx
new file mode 100644
index 000000000..b32c6c41f
--- /dev/null
+++ b/src/shared/ui/Table/Table.tsx
@@ -0,0 +1,14 @@
+import { forwardRef } from 'react';
+import { BaseProps } from '../../types/common';
+
+interface TableProps extends BaseProps, React.HTMLAttributes {}
+
+export const Table = forwardRef(({ className, ...props }, ref) => (
+
+));
diff --git a/src/shared/ui/Table/TableBody.tsx b/src/shared/ui/Table/TableBody.tsx
new file mode 100644
index 000000000..1020c391c
--- /dev/null
+++ b/src/shared/ui/Table/TableBody.tsx
@@ -0,0 +1,11 @@
+import { forwardRef } from 'react';
+import { BaseProps } from '../../types/common';
+
+interface TableBodyProps extends BaseProps, React.HTMLAttributes {}
+
+export const TableBody = forwardRef(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+TableBody.displayName = 'TableBody';
diff --git a/src/shared/ui/Table/TableCell.tsx b/src/shared/ui/Table/TableCell.tsx
new file mode 100644
index 000000000..e0fc9cfcd
--- /dev/null
+++ b/src/shared/ui/Table/TableCell.tsx
@@ -0,0 +1,15 @@
+import { forwardRef } from 'react';
+import { BaseProps } from '../../types/common';
+
+interface TableCellBodyProps extends BaseProps, React.HTMLAttributes {}
+
+export const TableCell = forwardRef(
+ ({ className, ...props }, ref) => (
+ |
+ ),
+);
+TableCell.displayName = 'TableCell';
diff --git a/src/shared/ui/Table/TableHead.tsx b/src/shared/ui/Table/TableHead.tsx
new file mode 100644
index 000000000..f84253963
--- /dev/null
+++ b/src/shared/ui/Table/TableHead.tsx
@@ -0,0 +1,15 @@
+import { forwardRef } from 'react';
+import { BaseProps } from '../../types/common';
+
+interface TableHeadBodyProps extends BaseProps, React.HTMLAttributes {}
+
+export const TableHead = forwardRef(
+ ({ className, ...props }, ref) => (
+ |
+ ),
+);
+TableHead.displayName = 'TableHead';
diff --git a/src/shared/ui/Table/TableHeader.tsx b/src/shared/ui/Table/TableHeader.tsx
new file mode 100644
index 000000000..6350eb694
--- /dev/null
+++ b/src/shared/ui/Table/TableHeader.tsx
@@ -0,0 +1,11 @@
+import { forwardRef } from 'react';
+import { BaseProps } from '../../types/common';
+
+interface TableHeaderProps extends BaseProps, React.HTMLAttributes {}
+
+export const TableHeader = forwardRef(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+TableHeader.displayName = 'TableHeader';
diff --git a/src/shared/ui/Table/TableRow.tsx b/src/shared/ui/Table/TableRow.tsx
new file mode 100644
index 000000000..24cc7b02d
--- /dev/null
+++ b/src/shared/ui/Table/TableRow.tsx
@@ -0,0 +1,15 @@
+import { forwardRef } from 'react';
+import { BaseProps } from '../../types/common';
+
+interface TableRowProps extends BaseProps, React.HTMLAttributes {}
+
+export const TableRow = forwardRef(
+ ({ className, ...props }, ref) => (
+
+ ),
+);
+TableRow.displayName = 'TableRow';
diff --git a/src/shared/ui/Table/index.ts b/src/shared/ui/Table/index.ts
new file mode 100644
index 000000000..04aebad65
--- /dev/null
+++ b/src/shared/ui/Table/index.ts
@@ -0,0 +1,6 @@
+export { Table } from './Table';
+export { TableHeader } from './TableHeader';
+export { TableBody } from './TableBody';
+export { TableRow } from './TableRow';
+export { TableHead } from './TableHead';
+export { TableCell } from './TableCell';
diff --git a/src/shared/ui/Textarea/Textarea.tsx b/src/shared/ui/Textarea/Textarea.tsx
new file mode 100644
index 000000000..e6a5bd0b5
--- /dev/null
+++ b/src/shared/ui/Textarea/Textarea.tsx
@@ -0,0 +1,17 @@
+import { forwardRef } from 'react';
+import { BaseProps } from '../../types/common';
+
+interface TextareaProps extends BaseProps, React.TextareaHTMLAttributes {}
+
+export const Textarea = forwardRef(
+ ({ className, ...props }, ref) => {
+ return (
+
+ );
+ },
+);
+Textarea.displayName = 'Textarea';
diff --git a/src/shared/ui/Textarea/index.ts b/src/shared/ui/Textarea/index.ts
new file mode 100644
index 000000000..f4212c043
--- /dev/null
+++ b/src/shared/ui/Textarea/index.ts
@@ -0,0 +1 @@
+export { Textarea } from './Textarea';
diff --git a/src/shared/ui/index.tsx b/src/shared/ui/index.tsx
new file mode 100644
index 000000000..8bea16782
--- /dev/null
+++ b/src/shared/ui/index.tsx
@@ -0,0 +1,14 @@
+export { Button } from './Button';
+export { Input } from './Input';
+export { Card, CardHeader, CardTitle, CardContent } from './Card';
+export { Textarea } from './Textarea';
+export {
+ Select,
+ SelectGroup,
+ SelectValue,
+ SelectTrigger,
+ SelectContent,
+ SelectItem,
+} from './Select';
+export { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle } from './Dialog';
+export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from './Table';
diff --git a/src/shared/utils/highlightText.tsx b/src/shared/utils/highlightText.tsx
new file mode 100644
index 000000000..1ec3fb58d
--- /dev/null
+++ b/src/shared/utils/highlightText.tsx
@@ -0,0 +1,16 @@
+// PostsManagerPage.tsx에서 이동한 텍스트 하이라이트 유틸리티 함수
+export const highlightText = (text: string, highlight: string) => {
+ if (!text || !text.trim()) return 내용 없음;
+ 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/utils/index.ts b/src/shared/utils/index.ts
new file mode 100644
index 000000000..a559f1b49
--- /dev/null
+++ b/src/shared/utils/index.ts
@@ -0,0 +1,2 @@
+export * from './highlightText';
+export * from './urlUtils';
diff --git a/src/shared/utils/urlUtils.ts b/src/shared/utils/urlUtils.ts
new file mode 100644
index 000000000..8f1255193
--- /dev/null
+++ b/src/shared/utils/urlUtils.ts
@@ -0,0 +1,21 @@
+// URL 관련 유틸리티 함수들
+export const updateURL = (
+ navigate: (path: string) => void,
+ params: {
+ skip?: number;
+ limit?: number;
+ searchQuery?: string;
+ sortBy?: string;
+ sortOrder?: string;
+ selectedTag?: string;
+ }
+) => {
+ const urlParams = new URLSearchParams();
+ if (params.skip) urlParams.set('skip', params.skip.toString());
+ if (params.limit) urlParams.set('limit', params.limit.toString());
+ if (params.searchQuery) urlParams.set('search', params.searchQuery);
+ if (params.sortBy) urlParams.set('sortBy', params.sortBy);
+ if (params.sortOrder) urlParams.set('sortOrder', params.sortOrder);
+ if (params.selectedTag) urlParams.set('tag', params.selectedTag);
+ navigate(`?${urlParams.toString()}`);
+};
diff --git a/src/widgets/Comments/Comments.tsx b/src/widgets/Comments/Comments.tsx
new file mode 100644
index 000000000..5e6cb7268
--- /dev/null
+++ b/src/widgets/Comments/Comments.tsx
@@ -0,0 +1,83 @@
+import { Edit2, Plus, ThumbsUp, Trash2 } from 'lucide-react';
+import { Button } from '../../shared/ui';
+import { Comment } from '../../entities/comment';
+import { highlightText } from '../../shared/utils';
+
+interface CommentsProps {
+ postId: number;
+ comments: Record;
+ searchQuery: string;
+ setNewComment: (comment: any) => void;
+ setShowAddCommentDialog: (show: boolean) => void;
+ setSelectedComment: (comment: Comment) => void;
+ setShowEditCommentDialog: (show: boolean) => void;
+ handleLikeComment: (id: number, postId: number) => void;
+ handleDeleteComment: (id: number, postId: number) => void;
+}
+
+export const Comments = ({
+ postId,
+ comments,
+ searchQuery,
+ setNewComment,
+ setShowAddCommentDialog,
+ setSelectedComment,
+ setShowEditCommentDialog,
+ handleLikeComment,
+ handleDeleteComment,
+}: CommentsProps) => {
+ return (
+
+
+
댓글
+
+
+
+ {comments[postId]?.map((comment) => (
+
+
+ {comment.user.username}:
+ {highlightText(comment.body, searchQuery)}
+
+
+
+
+
+
+
+ ))}
+
+
+ );
+};
diff --git a/src/widgets/Comments/index.ts b/src/widgets/Comments/index.ts
new file mode 100644
index 000000000..ff3845b7a
--- /dev/null
+++ b/src/widgets/Comments/index.ts
@@ -0,0 +1 @@
+export * from './Comments';
diff --git a/src/widgets/FilterBar/FilterBar.tsx b/src/widgets/FilterBar/FilterBar.tsx
new file mode 100644
index 000000000..fb4822974
--- /dev/null
+++ b/src/widgets/FilterBar/FilterBar.tsx
@@ -0,0 +1,73 @@
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../shared/ui';
+import { Tag } from '../../entities/tag';
+
+interface FilterBarProps {
+ selectedTag: string;
+ sortBy: string;
+ sortOrder: string;
+ tags: Tag[];
+ onTagChange: (tag: string) => void;
+ onSortByChange: (sortBy: string) => void;
+ onSortOrderChange: (sortOrder: string) => void;
+ onFetchPostsByTag: (tag: string) => void;
+ updateURL: () => void;
+}
+
+export const FilterBar = ({
+ selectedTag,
+ sortBy,
+ sortOrder,
+ tags,
+ onTagChange,
+ onSortByChange,
+ onSortOrderChange,
+ onFetchPostsByTag,
+ updateURL,
+}: FilterBarProps) => {
+ return (
+ <>
+
+
+
+
+
+ >
+ );
+};
diff --git a/src/widgets/FilterBar/index.ts b/src/widgets/FilterBar/index.ts
new file mode 100644
index 000000000..e9e38d556
--- /dev/null
+++ b/src/widgets/FilterBar/index.ts
@@ -0,0 +1 @@
+export { FilterBar } from './FilterBar';
diff --git a/src/components/Footer.tsx b/src/widgets/Footer/Footer.tsx
similarity index 100%
rename from src/components/Footer.tsx
rename to src/widgets/Footer/Footer.tsx
diff --git a/src/widgets/Footer/index.ts b/src/widgets/Footer/index.ts
new file mode 100644
index 000000000..da94c2936
--- /dev/null
+++ b/src/widgets/Footer/index.ts
@@ -0,0 +1 @@
+export { default as Footer } from './Footer';
diff --git a/src/components/Header.tsx b/src/widgets/Header/Header.tsx
similarity index 100%
rename from src/components/Header.tsx
rename to src/widgets/Header/Header.tsx
diff --git a/src/widgets/Header/index.ts b/src/widgets/Header/index.ts
new file mode 100644
index 000000000..5653319de
--- /dev/null
+++ b/src/widgets/Header/index.ts
@@ -0,0 +1 @@
+export { default as Header } from './Header';
diff --git a/src/widgets/Pagination/Pagination.tsx b/src/widgets/Pagination/Pagination.tsx
new file mode 100644
index 000000000..90033ddc9
--- /dev/null
+++ b/src/widgets/Pagination/Pagination.tsx
@@ -0,0 +1,38 @@
+import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../shared/ui';
+
+interface PaginationProps {
+ skip: number;
+ limit: number;
+ total: number;
+ onSkipChange: (skip: number) => void;
+ onLimitChange: (limit: number) => void;
+}
+
+export const Pagination = ({ skip, limit, total, onSkipChange, onLimitChange }: PaginationProps) => {
+ return (
+
+
+ 표시
+
+ 항목
+
+
+
+
+
+
+ );
+};
diff --git a/src/widgets/Pagination/index.ts b/src/widgets/Pagination/index.ts
new file mode 100644
index 000000000..0a1fd4dad
--- /dev/null
+++ b/src/widgets/Pagination/index.ts
@@ -0,0 +1 @@
+export { Pagination } from './Pagination';
diff --git a/src/widgets/PostManager/PostManager.tsx b/src/widgets/PostManager/PostManager.tsx
new file mode 100644
index 000000000..aac1ea98e
--- /dev/null
+++ b/src/widgets/PostManager/PostManager.tsx
@@ -0,0 +1,27 @@
+import { Plus } from 'lucide-react';
+import { Button, Card, CardContent, CardHeader, CardTitle } from '../../shared/ui';
+import { ReactNode } from 'react';
+
+interface PostManagerProps {
+ onAddPost: () => void;
+ children: ReactNode;
+}
+
+export const PostManager = ({ onAddPost, children }: PostManagerProps) => {
+ return (
+
+
+
+ 게시물 관리자
+
+
+
+
+ {children}
+
+
+ );
+};
diff --git a/src/widgets/PostManager/index.ts b/src/widgets/PostManager/index.ts
new file mode 100644
index 000000000..154c60ef5
--- /dev/null
+++ b/src/widgets/PostManager/index.ts
@@ -0,0 +1 @@
+export * from './PostManager';
diff --git a/src/widgets/PostTable/PostTable.tsx b/src/widgets/PostTable/PostTable.tsx
new file mode 100644
index 000000000..0da6696ca
--- /dev/null
+++ b/src/widgets/PostTable/PostTable.tsx
@@ -0,0 +1,116 @@
+import { Edit2, MessageSquare, ThumbsDown, ThumbsUp, Trash2 } from 'lucide-react';
+import { Button, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../shared/ui';
+import { Post } from '../../entities/post';
+import { highlightText } from '../../shared/utils';
+
+interface PostTableProps {
+ posts: Post[];
+ searchQuery: string;
+ selectedTag: string;
+ setSelectedTag: (tag: string) => void;
+ updateURL: () => void;
+ openUserModal: (user: any) => void;
+ openPostDetail: (post: Post) => void;
+ setSelectedPost: (post: Post) => void;
+ setShowEditDialog: (show: boolean) => void;
+ handleDeletePost: (id: number) => void;
+}
+
+export const PostTable = ({
+ posts,
+ searchQuery,
+ selectedTag,
+ setSelectedTag,
+ updateURL,
+ openUserModal,
+ openPostDetail,
+ setSelectedPost,
+ setShowEditDialog,
+ handleDeletePost,
+}: PostTableProps) => {
+ return (
+
+
+
+ ID
+ 제목
+ 작성자
+ 반응
+ 작업
+
+
+
+ {posts.map((post) => (
+
+ {post.id}
+
+
+
{highlightText(post.title, searchQuery)}
+
+
+ {post.tags?.map((tag) => (
+ {
+ setSelectedTag(tag);
+ updateURL();
+ }}
+ >
+ {tag}
+
+ ))}
+
+
+
+
+ post.author && openUserModal(post.author)}
+ >
+

+
{post.author?.username || '알 수 없음'}
+
+
+
+
+
+ {post.reactions?.likes || 0}
+
+ {post.reactions?.dislikes || 0}
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ );
+};
diff --git a/src/widgets/PostTable/index.ts b/src/widgets/PostTable/index.ts
new file mode 100644
index 000000000..b1a165cb9
--- /dev/null
+++ b/src/widgets/PostTable/index.ts
@@ -0,0 +1 @@
+export * from './PostTable';
diff --git a/src/widgets/SearchBar/SearchBar.tsx b/src/widgets/SearchBar/SearchBar.tsx
new file mode 100644
index 000000000..a9e075b6c
--- /dev/null
+++ b/src/widgets/SearchBar/SearchBar.tsx
@@ -0,0 +1,25 @@
+import { Search } from 'lucide-react';
+import { Input } from '../../shared/ui';
+
+interface SearchBarProps {
+ searchQuery: string;
+ onSearchChange: (query: string) => void;
+ onSearch: () => void;
+}
+
+export const SearchBar = ({ searchQuery, onSearchChange, onSearch }: SearchBarProps) => {
+ return (
+
+
+
+ onSearchChange(e.target.value)}
+ onKeyPress={(e) => e.key === 'Enter' && onSearch()}
+ />
+
+
+ );
+};
diff --git a/src/widgets/SearchBar/index.ts b/src/widgets/SearchBar/index.ts
new file mode 100644
index 000000000..8c1933163
--- /dev/null
+++ b/src/widgets/SearchBar/index.ts
@@ -0,0 +1 @@
+export { SearchBar } from './SearchBar';
diff --git a/src/widgets/index.ts b/src/widgets/index.ts
new file mode 100644
index 000000000..1808bfbc8
--- /dev/null
+++ b/src/widgets/index.ts
@@ -0,0 +1,8 @@
+export { Header } from './Header';
+export { Footer } from './Footer';
+export { PostManager } from './PostManager';
+export { PostTable } from './PostTable';
+export { Comments } from './Comments';
+export { SearchBar } from './SearchBar';
+export { FilterBar } from './FilterBar';
+export { Pagination } from './Pagination';
diff --git a/tsconfig.app.json b/tsconfig.app.json
index 5a2def4b7..1381ace91 100644
--- a/tsconfig.app.json
+++ b/tsconfig.app.json
@@ -21,5 +21,5 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
- "include": ["src"]
+ "include": ["src", "vite-env.d.ts"]
}
diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo
new file mode 100644
index 000000000..25db3b935
--- /dev/null
+++ b/tsconfig.app.tsbuildinfo
@@ -0,0 +1 @@
+{"root":["./src/app.tsx","./src/index.tsx","./src/main.tsx","./src/entities/index.ts","./src/entities/comment/index.ts","./src/entities/comment/api/index.ts","./src/entities/comment/model/commentutils.ts","./src/entities/comment/model/index.ts","./src/entities/comment/types/index.ts","./src/entities/post/index.ts","./src/entities/post/api/index.ts","./src/entities/post/model/index.ts","./src/entities/post/model/postutils.ts","./src/entities/post/types/index.ts","./src/entities/tag/index.ts","./src/entities/tag/api/index.ts","./src/entities/tag/types/index.ts","./src/entities/user/index.ts","./src/entities/user/api/index.ts","./src/entities/user/model/index.ts","./src/entities/user/types/index.ts","./src/features/index.ts","./src/features/comment/index.ts","./src/features/comment/api/index.ts","./src/features/comment/hooks/usecommentfeature.tsx","./src/features/comment/hooks/usecommentqueries.ts","./src/features/comment/store/commentstore.ts","./src/features/comment/store/index.ts","./src/features/comment/ui/commentform.tsx","./src/features/comment/ui/commentlist.tsx","./src/features/comment/ui/index.ts","./src/features/post/index.ts","./src/features/post/api/index.ts","./src/features/post/hooks/index.ts","./src/features/post/hooks/usepostfeature.tsx","./src/features/post/hooks/usepostqueries.ts","./src/features/post/store/index.ts","./src/features/post/store/poststore.ts","./src/features/post/ui/postdetail.tsx","./src/features/post/ui/postform.tsx","./src/features/post/ui/posttable.tsx","./src/features/post/ui/index.ts","./src/features/user/index.ts","./src/features/user/api/index.ts","./src/features/user/hooks/useuserfeature.tsx","./src/features/user/hooks/useuserqueries.ts","./src/features/user/store/index.ts","./src/features/user/store/userstore.ts","./src/features/user/ui/userprofile.tsx","./src/features/user/ui/index.ts","./src/pages/postsmanagerpage.tsx","./src/shared/index.ts","./src/shared/config/api.ts","./src/shared/config/httpclient.ts","./src/shared/types/common.ts","./src/shared/ui/index.tsx","./src/shared/ui/button/button.tsx","./src/shared/ui/button/buttonvariants.ts","./src/shared/ui/button/index.ts","./src/shared/ui/card/card.tsx","./src/shared/ui/card/cardcontent.tsx","./src/shared/ui/card/cardheader.tsx","./src/shared/ui/card/cardtitle.tsx","./src/shared/ui/card/index.ts","./src/shared/ui/dialog/dialog.tsx","./src/shared/ui/dialog/dialogcontent.tsx","./src/shared/ui/dialog/dialogheader.tsx","./src/shared/ui/dialog/dialogtitle.tsx","./src/shared/ui/dialog/index.ts","./src/shared/ui/input/input.tsx","./src/shared/ui/input/index.ts","./src/shared/ui/select/select.tsx","./src/shared/ui/select/selectcontent.tsx","./src/shared/ui/select/selectitem.tsx","./src/shared/ui/select/selecttrigger.tsx","./src/shared/ui/select/index.ts","./src/shared/ui/table/table.tsx","./src/shared/ui/table/tablebody.tsx","./src/shared/ui/table/tablecell.tsx","./src/shared/ui/table/tablehead.tsx","./src/shared/ui/table/tableheader.tsx","./src/shared/ui/table/tablerow.tsx","./src/shared/ui/table/index.ts","./src/shared/ui/textarea/textarea.tsx","./src/shared/ui/textarea/index.ts","./src/shared/utils/highlighttext.tsx","./src/shared/utils/index.ts","./src/shared/utils/urlutils.ts","./src/widgets/index.ts","./src/widgets/comments/comments.tsx","./src/widgets/comments/index.ts","./src/widgets/filterbar/filterbar.tsx","./src/widgets/filterbar/index.ts","./src/widgets/footer/footer.tsx","./src/widgets/footer/index.ts","./src/widgets/header/header.tsx","./src/widgets/header/index.ts","./src/widgets/pagination/pagination.tsx","./src/widgets/pagination/index.ts","./src/widgets/postmanager/postmanager.tsx","./src/widgets/postmanager/index.ts","./src/widgets/posttable/posttable.tsx","./src/widgets/posttable/index.ts","./src/widgets/searchbar/searchbar.tsx","./src/widgets/searchbar/index.ts","./vite-env.d.ts"],"version":"5.9.2"}
\ No newline at end of file
diff --git a/tsconfig.node.tsbuildinfo b/tsconfig.node.tsbuildinfo
new file mode 100644
index 000000000..240b89231
--- /dev/null
+++ b/tsconfig.node.tsbuildinfo
@@ -0,0 +1 @@
+{"root":["./vite.config.ts"],"version":"5.9.2"}
\ No newline at end of file
diff --git a/vite-env.d.ts b/vite-env.d.ts
index 11f02fe2a..92b376fa0 100644
--- a/vite-env.d.ts
+++ b/vite-env.d.ts
@@ -1 +1,6 @@
///
+
+declare module '*.css' {
+ const content: string;
+ export default content;
+}
diff --git a/vite.config.ts b/vite.config.ts
index be7b7a3d4..3fda0a4de 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,17 +1,39 @@
-import { defineConfig } from "vite"
-import react from "@vitejs/plugin-react"
+import { defineConfig } from 'vite';
+import react from '@vitejs/plugin-react';
+import path from 'path';
-// https://vite.dev/config/
-export default defineConfig({
- plugins: [react()],
- server: {
- proxy: {
- "/api": {
- // target: 'https://jsonplaceholder.typicode.com',
- target: "https://dummyjson.com",
- changeOrigin: true,
- rewrite: (path) => path.replace(/^\/api/, ""),
+// https://vitejs.dev/config/
+export default defineConfig(({ mode }) => {
+ // GitHub Pages 배포 시에만 base 경로 설정
+ const base =
+ mode === 'production' && process.env.GITHUB_PAGES === 'true' ? '/front_6th_chapter2-3/' : './';
+
+ return {
+ plugins: [react()],
+ base: base,
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, 'src'), // 별칭 적용 안했으면 빼도됨
},
},
- },
-})
+ build: {
+ outDir: 'dist',
+ assetsDir: 'assets',
+ sourcemap: false, // 프로덕션에서는 소스맵 비활성화
+ minify: 'esbuild', // terser 대신 esbuild 사용
+ },
+ server: {
+ proxy: {
+ '/api': {
+ target: 'https://dummyjson.com',
+ changeOrigin: true,
+ rewrite: (path) => path.replace(/^\/api/, ''),
+ },
+ },
+ },
+ // 배포 환경에서도 API 호출이 작동하도록 define 설정
+ define: {
+ 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV),
+ },
+ };
+});