From 534ada1ddedebc0735797c470818426e71220d0e Mon Sep 17 00:00:00 2001 From: JiHoon-0330 Date: Sat, 9 Aug 2025 15:00:52 +0900 Subject: [PATCH 01/15] =?UTF-8?q?=EB=B9=88=20=EC=BB=A4=EB=B0=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From f7b2f9be2b546ef1b229067cafaa788bf7ccedba Mon Sep 17 00:00:00 2001 From: JiHoon-0330 Date: Sun, 10 Aug 2025 19:24:12 +0900 Subject: [PATCH 02/15] =?UTF-8?q?=ED=8E=98=EC=96=B4=EC=BD=94=EB=94=A9=201?= =?UTF-8?q?=EC=9D=BC=EC=B0=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .prettierrc | 28 +- convention.md | 6 + eslint.config.js | 42 ++- global.d.ts | 2 + package.json | 3 + pnpm-lock.yaml | 80 ++++- src/App.tsx | 38 ++- src/entities/comments/api.ts | 0 src/entities/comments/types.ts | 44 +++ src/entities/posts/api.ts | 7 + src/entities/posts/api/fetch.ts | 1 + src/entities/posts/api/query.ts | 7 + src/entities/posts/types.ts | 73 +++++ src/entities/users/api/fetch.ts | 11 + src/entities/users/api/query.ts | 7 + src/entities/users/model.ts | 0 src/entities/users/types.ts | 107 ++++++ src/index.tsx | 14 +- src/main.tsx | 11 +- src/pages/PostsManagerPage.tsx | 556 +++++++++++++++++++------------- tsconfig.app.json | 10 +- vite.config.ts | 18 +- 22 files changed, 781 insertions(+), 284 deletions(-) create mode 100644 convention.md create mode 100644 global.d.ts create mode 100644 src/entities/comments/api.ts create mode 100644 src/entities/comments/types.ts create mode 100644 src/entities/posts/api.ts create mode 100644 src/entities/posts/api/fetch.ts create mode 100644 src/entities/posts/api/query.ts create mode 100644 src/entities/posts/types.ts create mode 100644 src/entities/users/api/fetch.ts create mode 100644 src/entities/users/api/query.ts create mode 100644 src/entities/users/model.ts create mode 100644 src/entities/users/types.ts diff --git a/.prettierrc b/.prettierrc index d9ae6b1fb..be63a50ef 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,9 +1,27 @@ { - "semi": false, - "printWidth": 120, + "semi": true, "tabWidth": 2, - "singleQuote": false, "quoteProps": "consistent", "trailingComma": "all", - "singleAttributePerLine": false -} \ No newline at end of file + "singleAttributePerLine": false, + "singleQuote": true, + "plugins": ["@trivago/prettier-plugin-sort-imports"], + "importOrder": [ + "^react$", + "^react/(.*)$", + "^react-router-dom$", + "^react-router-dom/(.*)$", + "^@@tanstack/react-query$", + "^@tanstack/react-query/(.*)$", + "", + "^@app/(.*)$", + "^@pages/(.*)$", + "^@widgets/(.*)$", + "^@features/(.*)$", + "^@entities/(.*)$", + "^@shared/(.*)$", + "^[./]" + ], + "importOrderSeparation": true, + "importOrderSortSpecifiers": true +} diff --git a/convention.md b/convention.md new file mode 100644 index 000000000..8017ebb10 --- /dev/null +++ b/convention.md @@ -0,0 +1,6 @@ +### naminge 컨벤션 + +- CRUD Naming 채 + +### function 키워드 쓰기 ? 화살표 함수 쓰기? +화살표 함수 사용하기 \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index 092408a9f..43adb13ba 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,14 +1,15 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import tseslint from 'typescript-eslint' +import js from '@eslint/js'; +import fsdPlugin from 'eslint-plugin-fsd-lint'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import globals from 'globals'; +import tseslint from 'typescript-eslint'; export default tseslint.config( { ignores: ['dist'] }, { extends: [js.configs.recommended, ...tseslint.configs.recommended], - files: ['**/*.{ts,tsx}'], + files: ['**/*.{ts,tsx,css}'], languageOptions: { ecmaVersion: 2020, globals: globals.browser, @@ -16,6 +17,7 @@ export default tseslint.config( plugins: { 'react-hooks': reactHooks, 'react-refresh': reactRefresh, + 'fsd': fsdPlugin, }, rules: { ...reactHooks.configs.recommended.rules, @@ -23,6 +25,32 @@ export default tseslint.config( 'warn', { allowConstantExport: true }, ], + // Enforces FSD layer import rules (e.g., features cannot import pages) + 'fsd/forbidden-imports': 'error', + + // Disallows relative imports between slices/layers, use aliases (@) + // Allows relative imports within the same slice by default (configurable) + 'fsd/no-relative-imports': [ + 'error', + { + allowSameSlice: true, + }, + ], + + // Enforces importing only via public API (index files) + 'fsd/no-public-api-sidestep': 'error', + + // Prevents direct imports between slices in the same layer + 'fsd/no-cross-slice-dependency': 'error', + + // Prevents UI imports in business logic layers (e.g., entities) + 'fsd/no-ui-in-business-logic': 'error', + + // Forbids direct import of the global store + 'fsd/no-global-store-imports': 'error', + + // Enforces import order based on FSD layers + 'fsd/ordered-imports': 'warn', }, }, -) +); diff --git a/global.d.ts b/global.d.ts new file mode 100644 index 000000000..577d508da --- /dev/null +++ b/global.d.ts @@ -0,0 +1,2 @@ +declare module '*.css'; +declare module '*.module.css'; diff --git a/package.json b/package.json index e014c5272..a168c827d 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "coverage": "vitest run --coverage" }, "dependencies": { + "@tanstack/react-query": "^5.84.2", "react": "^19.1.1", "react-dom": "^19.1.1" }, @@ -22,12 +23,14 @@ "@testing-library/jest-dom": "^6.6.4", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", + "@trivago/prettier-plugin-sort-imports": "^5.2.2", "@types/react": "^19.1.9", "@types/react-dom": "^19.1.7", "@vitejs/plugin-react": "^5.0.0", "axios": "^1.11.0", "class-variance-authority": "^0.7.1", "eslint": "^9.33.0", + "eslint-plugin-fsd-lint": "^1.0.9", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "globals": "^16.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6b2a40d18..98e0e9941 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@tanstack/react-query': + specifier: ^5.84.2 + version: 5.84.2(react@19.1.1) react: specifier: ^19.1.1 version: 19.1.1 @@ -33,6 +36,9 @@ importers: '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.0) + '@trivago/prettier-plugin-sort-imports': + specifier: ^5.2.2 + version: 5.2.2(prettier@3.6.2) '@types/react': specifier: ^19.1.9 version: 19.1.9 @@ -51,6 +57,9 @@ importers: eslint: specifier: ^9.33.0 version: 9.33.0 + eslint-plugin-fsd-lint: + specifier: ^1.0.9 + version: 1.0.9(eslint@9.33.0) eslint-plugin-react-hooks: specifier: ^5.2.0 version: 5.2.0(eslint@9.33.0) @@ -103,10 +112,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 +935,14 @@ packages: cpu: [x64] os: [win32] + '@tanstack/query-core@5.83.1': + resolution: {integrity: sha512-OG69LQgT7jSp+5pPuCfzltq/+7l2xoweggjme9vlbCPa/d7D7zaqv5vN/S82SzSYZ4EDLTxNO1PWrv49RAS64Q==} + + '@tanstack/react-query@5.84.2': + resolution: {integrity: sha512-cZadySzROlD2+o8zIfbD978p0IphuQzRWiiH3I2ugnTmz4jbjc0+TdibpwqxlzynEen8OulgAg+rzdNF37s7XQ==} + peerDependencies: + react: ^18 || ^19 + '@testing-library/dom@10.4.0': resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} engines: {node: '>=18'} @@ -959,6 +972,22 @@ packages: peerDependencies: '@testing-library/dom': '>=7.21.4' + '@trivago/prettier-plugin-sort-imports@5.2.2': + resolution: {integrity: sha512-fYDQA9e6yTNmA13TLVSA+WMQRc5Bn/c0EUBditUHNfMMxN7M82c38b1kEggVE3pLpZ0FwkwJkUEKMiOi52JXFA==} + engines: {node: '>18.12'} + peerDependencies: + '@vue/compiler-sfc': 3.x + prettier: 2.x - 3.x + prettier-plugin-svelte: 3.x + svelte: 4.x || 5.x + peerDependenciesMeta: + '@vue/compiler-sfc': + optional: true + prettier-plugin-svelte: + optional: true + svelte: + optional: true + '@types/aria-query@5.0.4': resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} @@ -1383,6 +1412,11 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + eslint-plugin-fsd-lint@1.0.9: + resolution: {integrity: sha512-D5Rh40tX9oqD63uD4RfYK6ZfbsMOGEstTC/nbRWmE5S4AK+WB2dibISyw9LQ5BIoP77yew/SmJ7fZ1iIH5IneA==} + peerDependencies: + eslint: '>=9.0.0' + eslint-plugin-react-hooks@5.2.0: resolution: {integrity: sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==} engines: {node: '>=10'} @@ -1634,6 +1668,9 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + javascript-natural-sort@0.7.1: + resolution: {integrity: sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -2362,12 +2399,6 @@ snapshots: '@csstools/css-tokenizer': 3.0.3 lru-cache: 10.4.3 - '@babel/code-frame@7.26.2': - dependencies: - '@babel/helper-validator-identifier': 7.25.9 - js-tokens: 4.0.0 - picocolors: 1.1.1 - '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.27.1 @@ -3081,9 +3112,16 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.46.2': optional: true + '@tanstack/query-core@5.83.1': {} + + '@tanstack/react-query@5.84.2(react@19.1.1)': + dependencies: + '@tanstack/query-core': 5.83.1 + react: 19.1.1 + '@testing-library/dom@10.4.0': dependencies: - '@babel/code-frame': 7.26.2 + '@babel/code-frame': 7.27.1 '@babel/runtime': 7.26.0 '@types/aria-query': 5.0.4 aria-query: 5.3.0 @@ -3116,6 +3154,18 @@ snapshots: dependencies: '@testing-library/dom': 10.4.0 + '@trivago/prettier-plugin-sort-imports@5.2.2(prettier@3.6.2)': + dependencies: + '@babel/generator': 7.28.0 + '@babel/parser': 7.28.0 + '@babel/traverse': 7.28.0 + '@babel/types': 7.28.2 + javascript-natural-sort: 0.7.1 + lodash: 4.17.21 + prettier: 3.6.2 + transitivePeerDependencies: + - supports-color + '@types/aria-query@5.0.4': {} '@types/babel__core@7.20.5': @@ -3347,7 +3397,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': @@ -3590,6 +3640,10 @@ snapshots: escape-string-regexp@4.0.0: {} + eslint-plugin-fsd-lint@1.0.9(eslint@9.33.0): + dependencies: + eslint: 9.33.0 + eslint-plugin-react-hooks@5.2.0(eslint@9.33.0): dependencies: eslint: 9.33.0 @@ -3835,6 +3889,8 @@ snapshots: isexe@2.0.0: {} + javascript-natural-sort@0.7.1: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} diff --git a/src/App.tsx b/src/App.tsx index 0c0032aab..0165024a5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,20 +1,26 @@ -import { BrowserRouter as Router } from "react-router-dom" -import Header from "./components/Header.tsx" -import Footer from "./components/Footer.tsx" -import PostsManagerPage from "./pages/PostsManagerPage.tsx" +import { BrowserRouter as Router } from 'react-router-dom'; + +import Footer from '@/components/Footer'; +import Header from '@/components/Header'; +import PostsManagerPage from '@/pages/PostsManagerPage'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; + +const queryClient = new QueryClient(); const App = () => { return ( - -
-
-
- -
-
-
-
- ) -} + + +
+
+
+ +
+
+
+
+
+ ); +}; -export default App +export default App; diff --git a/src/entities/comments/api.ts b/src/entities/comments/api.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/entities/comments/types.ts b/src/entities/comments/types.ts new file mode 100644 index 000000000..545e0ed72 --- /dev/null +++ b/src/entities/comments/types.ts @@ -0,0 +1,44 @@ +//http://localhost:5173/api/comments/post +interface CommentsUser { + fullName: string; + id: number; + username: string; +} +interface Comments { + id: number; + body: string; + postId: number; + likes: number; + user: CommentsUser; +} + +export interface CommentsResponse { + comments: Comments; + limit: number; + skip: number; + total: number; +} +//POST http://localhost:5173/api/comments/add +export interface PostAddComment { + body: string; + postId: number; + userId: number; +} +//DELETE http://localhost:5173/api/comments/84 +export interface DeleteComment { + deleteOn: string; + isDeleted: true; + id: number; + body: string; + likes: number; + postId: number; + user: CommentsUser; +} + +// PATCH http://localhost:5173/api/comments/196 +export type PatchCommentsDetailRequest = Pick; +export type PatchCommentsDetailResponse = Comments; + +// PUT http://localhost:5173/api/comments/196 +export type PutCommentsDetail = Pick; +export type PutCommentsDetailResponse = Comments; diff --git a/src/entities/posts/api.ts b/src/entities/posts/api.ts new file mode 100644 index 000000000..fd3da3d68 --- /dev/null +++ b/src/entities/posts/api.ts @@ -0,0 +1,7 @@ +import { useQuery } from '@tanstack/react-query'; + +export const usePosts = () => { + useQuery({ + queryKey: ['posts'], + }); +}; diff --git a/src/entities/posts/api/fetch.ts b/src/entities/posts/api/fetch.ts new file mode 100644 index 000000000..e9bffcf4b --- /dev/null +++ b/src/entities/posts/api/fetch.ts @@ -0,0 +1 @@ +export const getPosts = () => {}; diff --git a/src/entities/posts/api/query.ts b/src/entities/posts/api/query.ts new file mode 100644 index 000000000..694223e30 --- /dev/null +++ b/src/entities/posts/api/query.ts @@ -0,0 +1,7 @@ +import { useQuery } from '@tanstack/react-query'; + +export const usePosts = () => { + useQuery({ + queryKey: [''], + }); +}; diff --git a/src/entities/posts/types.ts b/src/entities/posts/types.ts new file mode 100644 index 000000000..0a75fe666 --- /dev/null +++ b/src/entities/posts/types.ts @@ -0,0 +1,73 @@ +// http://localhost:5173/api/posts?limit=10&skip=0 +export interface PostsResponse { + posts: Post[]; + total: number; + skip: number; + limit: number; +} + +// http://localhost:5173/api/posts/search?q=all +export interface PostsSearchResponse { + posts: Post[]; + total: number; + skip: number; + limit: number; +} + +// http://localhost:5173/api/posts/tag/mystery +export interface PostsTagDetailResponse { + posts: Post[]; + total: number; + skip: number; + limit: number; +} + +// http://localhost:5173/api/posts/tags +export type PostsTagsResponse = PostTag[]; + +// POST http://localhost:5173/api/posts/add +type RequiredPostsAddKey = 'title' | 'body' | 'userId'; +export type PostsAddRequest = Pick & + Partial>; + +// PUT http://localhost:5173/api/posts/252 +type RequiredPutPostsDetailRequestKey = 'title' | 'body' | 'userId' | 'id'; +export type PutPostsDetailRequest< + T extends Pick & + Partial>, +> = T; + +// DELETE http://localhost:5173/api/posts/252 +export type DeletePostsDetailResponse = Pick; + +export type PostsAddResponse = Pick; + +export interface PostTag { + name: string; + slug: string; + url: string; +} + +export interface Post { + // 게시글 아이디 + id: number; + + // 게시글 내용 + title: string; + body: string; + + // 게시글 태그 + tags: PostTag['slug'][]; + + // 게시글 반응 + reactions: { + likes: number; + dislikes: number; + }; + + // 조회수 + views: number; + + // 작성자 유저 아이디 + userId: number; +} diff --git a/src/entities/users/api/fetch.ts b/src/entities/users/api/fetch.ts new file mode 100644 index 000000000..e2e93d2cf --- /dev/null +++ b/src/entities/users/api/fetch.ts @@ -0,0 +1,11 @@ +import { UserResponse } from '../types.ts'; + +export const getUsers = async (user: UserResponse) => { + try { + const response = await fetch(`/api/users/${user.id}`); + const userData = await response.json(); + return userData; + } catch (error) { + console.error('사용자 정보 가져오기 오류:', error); + } +}; diff --git a/src/entities/users/api/query.ts b/src/entities/users/api/query.ts new file mode 100644 index 000000000..0c327db24 --- /dev/null +++ b/src/entities/users/api/query.ts @@ -0,0 +1,7 @@ +import { useQuery } from '@tanstack/react-query'; + +export const useGetUser = (userId: number) => { + const { data, isLoading, error } = useQuery({ + queryKey: ['user', userId], + }); +}; diff --git a/src/entities/users/model.ts b/src/entities/users/model.ts new file mode 100644 index 000000000..e69de29bb diff --git a/src/entities/users/types.ts b/src/entities/users/types.ts new file mode 100644 index 000000000..99a8f865c --- /dev/null +++ b/src/entities/users/types.ts @@ -0,0 +1,107 @@ +type Gender = 'male' | 'femail'; + +interface Hair { + color: string; + type: string; +} + +// 주소 타입 +interface Address { + address: string; + city: string; + state: string; + stateCode: string; + postalCode: string; + coordinates: { + lat: number; + lng: number; + }; + country: string; +} + +interface Bank { + cardExpire: string; + cardNumber: string; + cardType: string; + iban: string; +} +interface Company { + address: Address; + department: string; + name: string; + title: string; +} +interface Crypto { + coin: string; + wallet: string; + network: string; +} +// ============================================ +// 메인 사용자 인터페이스 +// ============================================ + +// http://localhost:5173/api/users/${user.id} +export interface UserResponse { + // ============================================ + // 기본 식별 정보 + // ============================================ + id: number; + firstName: string; + lastName: string; + maidenName: string; + username: string; + email: string; + phone: string; + + // ============================================ + // 개인 정보 + // ============================================ + age: number; + gender: Gender; + birthDate: string; + bloodGroup: string; + + // ============================================ + // 신체 정보 + // ============================================ + height: number; + weight: number; + eyeColor: string; + hair: Hair; + image: string; + + // ============================================ + // 주소 정보 + // ============================================ + address: Address; + + // ============================================ + // 보안/시스템 정보 + // ============================================ + password: string; + role: string; + ip: string; + macAddress: string; + userAgent: string; + + // ============================================ + // 금융 정보 + // ============================================ + bank: Bank; + crypto: Crypto; + + // ============================================ + // 기타 정보 + // ============================================ + university: string; + company: Company; + ein: string; + ssn: string; +} + +//http://localhost:5173/api/users +export interface UserInComment { + id: number; + image: string; + username: string; +} diff --git a/src/index.tsx b/src/index.tsx index 369e197bb..317f510f5 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,12 +1,14 @@ -import React from "react" -import ReactDOM from "react-dom/client" -import { BrowserRouter as Router } from "react-router-dom" -import App from "./App" +import React from 'react'; -ReactDOM.createRoot(document.getElementById("root")!).render( +import { BrowserRouter as Router } from 'react-router-dom'; + +import App from '@/App'; +import ReactDOM from 'react-dom/client'; + +ReactDOM.createRoot(document.getElementById('root')!).render( , -) +); diff --git a/src/main.tsx b/src/main.tsx index bef5202a3..450482f0b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,10 +1,11 @@ -import { StrictMode } from 'react' -import { createRoot } from 'react-dom/client' -import './index.css' -import App from './App.tsx' +import { StrictMode } from 'react'; + +import App from '@/App.tsx'; +import '@/index.css'; +import { createRoot } from 'react-dom/client'; createRoot(document.getElementById('root')!).render( , -) +); diff --git a/src/pages/PostsManagerPage.tsx b/src/pages/PostsManagerPage.tsx index f80eb91ef..afd30404c 100644 --- a/src/pages/PostsManagerPage.tsx +++ b/src/pages/PostsManagerPage.tsx @@ -1,6 +1,5 @@ -import { useEffect, useState } from "react" -import { Edit2, MessageSquare, Plus, Search, ThumbsDown, ThumbsUp, Trash2 } from "lucide-react" -import { useLocation, useNavigate } from "react-router-dom" +import { useEffect, useState } from 'react'; + import { Button, Card, @@ -24,322 +23,355 @@ import { TableHeader, TableRow, Textarea, -} from "../components" +} from '@/components'; +import { + Edit2, + MessageSquare, + Plus, + Search, + ThumbsDown, + ThumbsUp, + Trash2, +} from 'lucide-react'; +import { useLocation, useNavigate } from 'react-router-dom'; const PostsManager = () => { - const navigate = useNavigate() - const location = useLocation() - const queryParams = new URLSearchParams(location.search) + 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) + 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 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 + 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") + postsData = data; + return fetch('/api/users?limit=0&select=username,image'); }) .then((response) => response.json()) .then((users) => { - usersData = users.users + usersData = users.users; const postsWithUsers = postsData.posts.map((post) => ({ ...post, author: usersData.find((user) => user.id === post.userId), - })) - setPosts(postsWithUsers) - setTotal(postsData.total) + })); + setPosts(postsWithUsers); + setTotal(postsData.total); }) .catch((error) => { - console.error("게시물 가져오기 오류:", error) + console.error('게시물 가져오기 오류:', error); }) .finally(() => { - setLoading(false) - }) - } + setLoading(false); + }); + }; // 태그 가져오기 const fetchTags = async () => { try { - const response = await fetch("/api/posts/tags") - const data = await response.json() - setTags(data) + const response = await fetch('/api/posts/tags'); + const data = await response.json(); + setTags(data); } catch (error) { - console.error("태그 가져오기 오류:", error) + console.error('태그 가져오기 오류:', error); } - } + }; // 게시물 검색 const searchPosts = async () => { if (!searchQuery) { - fetchPosts() - return + fetchPosts(); + return; } - setLoading(true) + setLoading(true); try { - const response = await fetch(`/api/posts/search?q=${searchQuery}`) - const data = await response.json() - setPosts(data.posts) - setTotal(data.total) + 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) + console.error('게시물 검색 오류:', error); } - setLoading(false) - } + setLoading(false); + }; // 태그별 게시물 가져오기 const fetchPostsByTag = async (tag) => { - if (!tag || tag === "all") { - fetchPosts() - return + if (!tag || tag === 'all') { + fetchPosts(); + return; } - setLoading(true) + 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() + 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) + setPosts(postsWithUsers); + setTotal(postsData.total); } catch (error) { - console.error("태그별 게시물 가져오기 오류:", error) + console.error('태그별 게시물 가져오기 오류:', error); } - setLoading(false) - } + setLoading(false); + }; // 게시물 추가 const addPost = async () => { try { - const response = await fetch("/api/posts/add", { - method: "POST", - headers: { "Content-Type": "application/json" }, + 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 }) + }); + const data = await response.json(); + setPosts([data, ...posts]); + setShowAddDialog(false); + setNewPost({ title: '', body: '', userId: 1 }); } catch (error) { - console.error("게시물 추가 오류:", error) + console.error('게시물 추가 오류:', error); } - } + }; // 게시물 업데이트 const updatePost = async () => { try { const response = await fetch(`/api/posts/${selectedPost.id}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, + 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) + }); + const data = await response.json(); + setPosts(posts.map((post) => (post.id === data.id ? data : post))); + setShowEditDialog(false); } catch (error) { - console.error("게시물 업데이트 오류:", error) + console.error('게시물 업데이트 오류:', error); } - } + }; // 게시물 삭제 const deletePost = async (id) => { try { await fetch(`/api/posts/${id}`, { - method: "DELETE", - }) - setPosts(posts.filter((post) => post.id !== id)) + method: 'DELETE', + }); + setPosts(posts.filter((post) => post.id !== id)); } catch (error) { - console.error("게시물 삭제 오류:", error) + console.error('게시물 삭제 오류:', error); } - } + }; // 댓글 가져오기 const fetchComments = async (postId) => { - if (comments[postId]) return // 이미 불러온 댓글이 있으면 다시 불러오지 않음 + if (comments[postId]) return; // 이미 불러온 댓글이 있으면 다시 불러오지 않음 try { - const response = await fetch(`/api/comments/post/${postId}`) - const data = await response.json() - setComments((prev) => ({ ...prev, [postId]: data.comments })) + const response = await fetch(`/api/comments/post/${postId}`); + const data = await response.json(); + setComments((prev) => ({ ...prev, [postId]: data.comments })); } catch (error) { - console.error("댓글 가져오기 오류:", error) + console.error('댓글 가져오기 오류:', error); } - } + }; // 댓글 추가 const addComment = async () => { try { - const response = await fetch("/api/comments/add", { - method: "POST", - headers: { "Content-Type": "application/json" }, + const response = await fetch('/api/comments/add', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newComment), - }) - const data = await response.json() + }); + const data = await response.json(); setComments((prev) => ({ ...prev, [data.postId]: [...(prev[data.postId] || []), data], - })) - setShowAddCommentDialog(false) - setNewComment({ body: "", postId: null, userId: 1 }) + })); + setShowAddCommentDialog(false); + setNewComment({ body: '', postId: null, userId: 1 }); } catch (error) { - console.error("댓글 추가 오류:", error) + console.error('댓글 추가 오류:', error); } - } + }; // 댓글 업데이트 const updateComment = async () => { try { const response = await fetch(`/api/comments/${selectedComment.id}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ body: selectedComment.body }), - }) - const data = await response.json() + }); + const data = await response.json(); setComments((prev) => ({ ...prev, - [data.postId]: prev[data.postId].map((comment) => (comment.id === data.id ? data : comment)), - })) - setShowEditCommentDialog(false) + [data.postId]: prev[data.postId].map((comment) => + comment.id === data.id ? data : comment, + ), + })); + setShowEditCommentDialog(false); } catch (error) { - console.error("댓글 업데이트 오류:", error) + console.error('댓글 업데이트 오류:', error); } - } + }; // 댓글 삭제 const deleteComment = async (id, postId) => { try { await fetch(`/api/comments/${id}`, { - method: "DELETE", - }) + method: 'DELETE', + }); setComments((prev) => ({ ...prev, [postId]: prev[postId].filter((comment) => comment.id !== id), - })) + })); } catch (error) { - console.error("댓글 삭제 오류:", 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() + 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)), - })) + [postId]: prev[postId].map((comment) => + comment.id === data.id + ? { ...data, likes: comment.likes + 1 } + : comment, + ), + })); } catch (error) { - console.error("댓글 좋아요 오류:", error) + console.error('댓글 좋아요 오류:', error); } - } + }; // 게시물 상세 보기 const openPostDetail = (post) => { - setSelectedPost(post) - fetchComments(post.id) - setShowPostDetailDialog(true) - } + 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) + const response = await fetch(`/api/users/${user.id}`); + const userData = await response.json(); + setSelectedUser(userData); + setShowUserModal(true); } catch (error) { - console.error("사용자 정보 가져오기 오류:", error) + console.error('사용자 정보 가져오기 오류:', error); } - } + }; useEffect(() => { - fetchTags() - }, []) + fetchTags(); + }, []); useEffect(() => { if (selectedTag) { - fetchPostsByTag(selectedTag) + fetchPostsByTag(selectedTag); } else { - fetchPosts() + fetchPosts(); } - updateURL() - }, [skip, limit, sortBy, sortOrder, selectedTag]) + 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 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 (!text) return null; if (!highlight.trim()) { - return {text} + return {text}; } - const regex = new RegExp(`(${highlight})`, "gi") - const parts = text.split(regex) + const regex = new RegExp(`(${highlight})`, 'gi'); + const parts = text.split(regex); return ( - {parts.map((part, i) => (regex.test(part) ? {part} : {part}))} + {parts.map((part, i) => + regex.test(part) ? ( + {part} + ) : ( + {part} + ), + )} - ) - } + ); + }; // 게시물 테이블 렌더링 const renderPostTable = () => ( @@ -367,12 +399,12 @@ const PostsManager = () => { key={tag} className={`px-1 text-[9px] font-semibold rounded-[4px] cursor-pointer ${ selectedTag === tag - ? "text-white bg-blue-500 hover:bg-blue-600" - : "text-blue-800 bg-blue-100 hover:bg-blue-200" + ? 'text-white bg-blue-500 hover:bg-blue-600' + : 'text-blue-800 bg-blue-100 hover:bg-blue-200' }`} onClick={() => { - setSelectedTag(tag) - updateURL() + setSelectedTag(tag); + updateURL(); }} > {tag} @@ -382,8 +414,15 @@ const PostsManager = () => { -
openUserModal(post.author)}> - {post.author?.username} +
openUserModal(post.author)} + > + {post.author?.username} {post.author?.username}
@@ -397,20 +436,28 @@ const PostsManager = () => {
- -
@@ -419,7 +466,7 @@ const PostsManager = () => { ))} - ) + ); // 댓글 렌더링 const renderComments = (postId) => ( @@ -429,8 +476,8 @@ const PostsManager = () => {
{comments[postId]?.map((comment) => ( -
+
- {comment.user.username}: - {highlightText(comment.body, searchQuery)} + + {comment.user.username}: + + + {highlightText(comment.body, searchQuery)} +
- @@ -453,13 +511,17 @@ const PostsManager = () => { variant="ghost" size="sm" onClick={() => { - setSelectedComment(comment) - setShowEditCommentDialog(true) + setSelectedComment(comment); + setShowEditCommentDialog(true); }} > -
@@ -467,7 +529,7 @@ const PostsManager = () => { ))}
- ) + ); return ( @@ -492,16 +554,16 @@ const PostsManager = () => { className="pl-8" value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} - onKeyPress={(e) => e.key === "Enter" && searchPosts()} + onKeyPress={(e) => e.key === 'Enter' && searchPosts()} />
setLimit(Number(value))}> + setNewPost({ ...newPost, title: e.target.value })} + onChange={(e) => + setNewPost({ ...newPost, title: e.target.value }) + } />