From 1aca2c160a19905161e67a98b9a3d61912cc1903 Mon Sep 17 00:00:00 2001 From: zero0205 Date: Thu, 13 Feb 2025 23:24:55 +0900 Subject: [PATCH 01/15] =?UTF-8?q?:wrench:=20[Chore]:=20tanstack=20Query=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B4=80=EB=A0=A8=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 상태 관리를 위한 @tanstack/react-query와 devtools 설치 - Query용 ESLint 플러그인 추가 - 무한 스크롤 구현을 위한 react-intersection-observer 설치 --- apps/client/package.json | 4 ++ pnpm-lock.yaml | 150 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) diff --git a/apps/client/package.json b/apps/client/package.json index 7c4e3e1..a9dd54d 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -16,6 +16,8 @@ "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-toast": "^1.2.2", + "@tanstack/react-query": "^5.66.0", + "@tanstack/react-query-devtools": "^5.66.0", "axios": "^1.7.7", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", @@ -24,6 +26,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.53.2", + "react-intersection-observer": "^9.15.1", "react-player": "^2.16.0", "react-router-dom": "^6.27.0", "socket.io-client": "^4.8.1", @@ -31,6 +34,7 @@ "tailwindcss-animate": "^1.0.7" }, "devDependencies": { + "@tanstack/eslint-plugin-query": "^5.66.1", "@types/node": "^20.3.1", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ad0663..aa8ec94 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -299,6 +299,12 @@ importers: '@radix-ui/react-toast': specifier: ^1.2.2 version: 1.2.2(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tanstack/react-query': + specifier: ^5.66.0 + version: 5.66.0(react@18.3.1) + '@tanstack/react-query-devtools': + specifier: ^5.66.0 + version: 5.66.0(@tanstack/react-query@5.66.0(react@18.3.1))(react@18.3.1) axios: specifier: ^1.7.7 version: 1.7.7 @@ -323,6 +329,9 @@ importers: react-hook-form: specifier: ^7.53.2 version: 7.53.2(react@18.3.1) + react-intersection-observer: + specifier: ^9.15.1 + version: 9.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-player: specifier: ^2.16.0 version: 2.16.0(react@18.3.1) @@ -339,6 +348,9 @@ importers: specifier: ^1.0.7 version: 1.0.7(tailwindcss@3.4.14(ts-node@10.9.2(@swc/core@1.8.0)(@types/node@20.17.6)(typescript@5.6.3))) devDependencies: + '@tanstack/eslint-plugin-query': + specifier: ^5.66.1 + version: 5.66.1(eslint@8.57.1)(typescript@5.6.3) '@types/node': specifier: ^20.3.1 version: 20.17.6 @@ -2684,6 +2696,28 @@ packages: '@swc/types@0.1.14': resolution: {integrity: sha512-PbSmTiYCN+GMrvfjrMo9bdY+f2COnwbdnoMw7rqU/PI5jXpKjxOGZ0qqZCImxnT81NkNsKnmEpvu+hRXLBeCJg==} + '@tanstack/eslint-plugin-query@5.66.1': + resolution: {integrity: sha512-pYMVTGgJ7yPk9Rm6UWEmbY6TX0EmMmxJqYkthgeDCwEznToy2m+W928nUODFirtZBZlhBsqHy33LO0kyTlgf0w==} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + + '@tanstack/query-core@5.66.0': + resolution: {integrity: sha512-J+JeBtthiKxrpzUu7rfIPDzhscXF2p5zE/hVdrqkACBP8Yu0M96mwJ5m/8cPPYQE9aRNvXztXHlNwIh4FEeMZw==} + + '@tanstack/query-devtools@5.65.0': + resolution: {integrity: sha512-g5y7zc07U9D3esMdqUfTEVu9kMHoIaVBsD0+M3LPdAdD710RpTcLiNvJY1JkYXqkq9+NV+CQoemVNpQPBXVsJg==} + + '@tanstack/react-query-devtools@5.66.0': + resolution: {integrity: sha512-uB57wA2YZaQ2fPcFW0E9O1zAGDGSbRKRx84uMk/86VyU9jWVxvJ3Uzp+zNm+nZJYsuekCIo2opTdgNuvM3cKgA==} + peerDependencies: + '@tanstack/react-query': ^5.66.0 + react: ^18 || ^19 + + '@tanstack/react-query@5.66.0': + resolution: {integrity: sha512-z3sYixFQJe8hndFnXgWu7C79ctL+pI0KAelYyW+khaNJ1m22lWrhJU2QrsTcRKMuVPtoZvfBYrTStIdKo+x0Xw==} + peerDependencies: + react: ^18 || ^19 + '@tsconfig/node10@1.0.11': resolution: {integrity: sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==} @@ -2926,6 +2960,10 @@ packages: resolution: {integrity: sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==} engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/scope-manager@8.24.0': + resolution: {integrity: sha512-HZIX0UByphEtdVBKaQBgTDdn9z16l4aTUz8e8zPQnyxwHBtf5vtl1L+OhH+m1FGV9DrRmoDuYKqzVrvWDcDozw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/type-utils@6.21.0': resolution: {integrity: sha512-rZQI7wHfao8qMX3Rd3xqeYSMCL3SoiSQLBATSiVKARdFGCYSRvmViieZjqc58jKgs8Y8i9YvVVhRbHSTA4VBag==} engines: {node: ^16.0.0 || >=18.0.0} @@ -2954,6 +2992,10 @@ packages: resolution: {integrity: sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==} engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/types@8.24.0': + resolution: {integrity: sha512-VacJCBTyje7HGAw7xp11q439A+zeGG0p0/p2zsZwpnMzjPB5WteaWqt4g2iysgGFafrqvyLWqq6ZPZAOCoefCw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@6.21.0': resolution: {integrity: sha512-6npJTkZcO+y2/kr+z0hc4HwNfrrP4kNYh57ek7yCNlrBjWQ1Y0OS7jiZTkgumrvkX5HkEKXFZkkdFNkaW2wmUQ==} engines: {node: ^16.0.0 || >=18.0.0} @@ -2972,6 +3014,12 @@ packages: typescript: optional: true + '@typescript-eslint/typescript-estree@8.24.0': + resolution: {integrity: sha512-ITjYcP0+8kbsvT9bysygfIfb+hBj6koDsu37JZG7xrCiy3fPJyNmfVtaGsgTUSEuTzcvME5YI5uyL5LD1EV5ZQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <5.8.0' + '@typescript-eslint/utils@6.21.0': resolution: {integrity: sha512-NfWVaC8HP9T8cbKQxHcsJBY5YE1O33+jpMwN45qzWWaPDZgLIbo12toGMWnmhvCpd3sIxkpDw3Wv1B3dYrbDQQ==} engines: {node: ^16.0.0 || >=18.0.0} @@ -2984,6 +3032,13 @@ packages: peerDependencies: eslint: ^8.56.0 + '@typescript-eslint/utils@8.24.0': + resolution: {integrity: sha512-07rLuUBElvvEb1ICnafYWr4hk8/U7X9RDCOqd9JcAMtjh/9oRmcfN4yGzbPVirgMR0+HLVHehmu19CWeh7fsmQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 + typescript: '>=4.8.4 <5.8.0' + '@typescript-eslint/visitor-keys@6.21.0': resolution: {integrity: sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==} engines: {node: ^16.0.0 || >=18.0.0} @@ -2992,6 +3047,10 @@ packages: resolution: {integrity: sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==} engines: {node: ^18.18.0 || >=20.0.0} + '@typescript-eslint/visitor-keys@8.24.0': + resolution: {integrity: sha512-kArLq83QxGLbuHrTMoOEWO+l2MwsNS2TGISEdx8xgqpkbytB07XmlQyQdNDrCc1ecSqx0cnmhGvpX+VBwqqSkg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ungap/structured-clone@1.2.0': resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} @@ -4096,6 +4155,10 @@ packages: resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + eslint-visitor-keys@4.2.0: + resolution: {integrity: sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + eslint@8.57.1: resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -5781,6 +5844,15 @@ packages: peerDependencies: react: ^16.8.0 || ^17 || ^18 || ^19 + react-intersection-observer@9.15.1: + resolution: {integrity: sha512-vGrqYEVWXfH+AGu241uzfUpNK4HAdhCkSAyFdkMb9VWWXs6mxzBLpWCxEy9YcnDNY2g9eO6z7qUtTBdA9hc8pA==} + peerDependencies: + react: ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + react-dom: + optional: true + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -6428,6 +6500,12 @@ packages: peerDependencies: typescript: '>=4.2.0' + ts-api-utils@2.0.1: + resolution: {integrity: sha512-dnlgjFSVetynI8nzgJ+qF62efpglpWRk8isUEWZGWlJYySCTD6aKvbUDu+zbPeDakk3bg5H4XpitHukgfL1m9w==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} @@ -9720,6 +9798,29 @@ snapshots: dependencies: '@swc/counter': 0.1.3 + '@tanstack/eslint-plugin-query@5.66.1(eslint@8.57.1)(typescript@5.6.3)': + dependencies: + '@typescript-eslint/utils': 8.24.0(eslint@8.57.1)(typescript@5.6.3) + eslint: 8.57.1 + transitivePeerDependencies: + - supports-color + - typescript + + '@tanstack/query-core@5.66.0': {} + + '@tanstack/query-devtools@5.65.0': {} + + '@tanstack/react-query-devtools@5.66.0(@tanstack/react-query@5.66.0(react@18.3.1))(react@18.3.1)': + dependencies: + '@tanstack/query-devtools': 5.65.0 + '@tanstack/react-query': 5.66.0(react@18.3.1) + react: 18.3.1 + + '@tanstack/react-query@5.66.0(react@18.3.1)': + dependencies: + '@tanstack/query-core': 5.66.0 + react: 18.3.1 + '@tsconfig/node10@1.0.11': {} '@tsconfig/node12@1.0.11': {} @@ -10034,6 +10135,11 @@ snapshots: '@typescript-eslint/types': 7.18.0 '@typescript-eslint/visitor-keys': 7.18.0 + '@typescript-eslint/scope-manager@8.24.0': + dependencies: + '@typescript-eslint/types': 8.24.0 + '@typescript-eslint/visitor-keys': 8.24.0 + '@typescript-eslint/type-utils@6.21.0(eslint@8.57.1)(typescript@5.3.3)': dependencies: '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.3.3) @@ -10062,6 +10168,8 @@ snapshots: '@typescript-eslint/types@7.18.0': {} + '@typescript-eslint/types@8.24.0': {} + '@typescript-eslint/typescript-estree@6.21.0(typescript@5.3.3)': dependencies: '@typescript-eslint/types': 6.21.0 @@ -10092,6 +10200,20 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/typescript-estree@8.24.0(typescript@5.6.3)': + dependencies: + '@typescript-eslint/types': 8.24.0 + '@typescript-eslint/visitor-keys': 8.24.0 + debug: 4.3.7(supports-color@5.5.0) + fast-glob: 3.3.2 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.6.3 + ts-api-utils: 2.0.1(typescript@5.6.3) + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@6.21.0(eslint@8.57.1)(typescript@5.3.3)': dependencies: '@eslint-community/eslint-utils': 4.4.1(eslint@8.57.1) @@ -10117,6 +10239,17 @@ snapshots: - supports-color - typescript + '@typescript-eslint/utils@8.24.0(eslint@8.57.1)(typescript@5.6.3)': + dependencies: + '@eslint-community/eslint-utils': 4.4.1(eslint@8.57.1) + '@typescript-eslint/scope-manager': 8.24.0 + '@typescript-eslint/types': 8.24.0 + '@typescript-eslint/typescript-estree': 8.24.0(typescript@5.6.3) + eslint: 8.57.1 + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/visitor-keys@6.21.0': dependencies: '@typescript-eslint/types': 6.21.0 @@ -10127,6 +10260,11 @@ snapshots: '@typescript-eslint/types': 7.18.0 eslint-visitor-keys: 3.4.3 + '@typescript-eslint/visitor-keys@8.24.0': + dependencies: + '@typescript-eslint/types': 8.24.0 + eslint-visitor-keys: 4.2.0 + '@ungap/structured-clone@1.2.0': {} '@vitejs/plugin-react-swc@3.7.1(vite@5.4.10(@types/node@20.17.6)(terser@5.36.0))': @@ -11440,6 +11578,8 @@ snapshots: eslint-visitor-keys@3.4.3: {} + eslint-visitor-keys@4.2.0: {} + eslint@8.57.1: dependencies: '@eslint-community/eslint-utils': 4.4.1(eslint@8.57.1) @@ -13458,6 +13598,12 @@ snapshots: dependencies: react: 18.3.1 + react-intersection-observer@9.15.1(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + optionalDependencies: + react-dom: 18.3.1(react@18.3.1) + react-is@16.13.1: {} react-is@18.3.1: {} @@ -14251,6 +14397,10 @@ snapshots: dependencies: typescript: 5.6.3 + ts-api-utils@2.0.1(typescript@5.6.3): + dependencies: + typescript: 5.6.3 + ts-interface-checker@0.1.13: {} ts-jest@29.2.5(@babel/core@7.26.0)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.26.0))(jest@29.7.0(@types/node@20.17.6)(ts-node@10.9.2(@swc/core@1.8.0)(@types/node@20.17.6)(typescript@5.3.3)))(typescript@5.3.3): From 1a69f8b202c573e1772f1641cb4c936c053d9673 Mon Sep 17 00:00:00 2001 From: zero0205 Date: Fri, 14 Feb 2025 16:46:45 +0900 Subject: [PATCH 02/15] =?UTF-8?q?:wrench:=20[Chore]:=20Tanstack=20Query=20?= =?UTF-8?q?ESLint=20=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/client/.eslintrc | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/client/.eslintrc b/apps/client/.eslintrc index 684ff1e..864b689 100644 --- a/apps/client/.eslintrc +++ b/apps/client/.eslintrc @@ -15,7 +15,13 @@ "es2021": true }, - "extends": ["airbnb", "airbnb/hooks", "plugin:@typescript-eslint/recommended", "prettier"], + "extends": [ + "airbnb", + "airbnb/hooks", + "plugin:@typescript-eslint/recommended", + "plugin:@tanstack/query/recommended", + "prettier" + ], "settings": { "react": { @@ -23,8 +29,6 @@ } }, - "plugins": ["prettier"], - "rules": { // React 관련 규칙 "react/react-in-jsx-scope": "off", @@ -59,6 +63,10 @@ // 접근성 관련 규칙 "jsx-a11y/media-has-caption": "off", + // tanstack query 관련 규칙 + "@tanstack/query/exhaustive-deps": "error", + "@tanstack/query/stable-query-client": "error", + // 기타 규칙 "no-param-reassign": [ "warn", From b09504d234aa1fcab5004e08f415f8508ff2ebcc Mon Sep 17 00:00:00 2001 From: zero0205 Date: Sun, 16 Feb 2025 23:46:30 +0900 Subject: [PATCH 03/15] =?UTF-8?q?:wrench:=20[Chore]:=20Tanstack=20Query=20?= =?UTF-8?q?=EC=B4=88=EA=B8=B0=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/client/.eslintrc | 1 + apps/client/src/app/App.tsx | 15 +++++++++++++++ apps/client/src/app/index.ts | 2 ++ apps/client/src/main.tsx | 5 ++--- 4 files changed, 20 insertions(+), 3 deletions(-) create mode 100644 apps/client/src/app/App.tsx create mode 100644 apps/client/src/app/index.ts diff --git a/apps/client/.eslintrc b/apps/client/.eslintrc index 864b689..9a6add9 100644 --- a/apps/client/.eslintrc +++ b/apps/client/.eslintrc @@ -3,6 +3,7 @@ "parserOptions": { "project": ["./tsconfig.json"], + "tsconfigRootDir": "./apps/client", "ecmaVersion": 12, "sourceType": "module", "ecmaFeatures": { diff --git a/apps/client/src/app/App.tsx b/apps/client/src/app/App.tsx new file mode 100644 index 0000000..3c874ad --- /dev/null +++ b/apps/client/src/app/App.tsx @@ -0,0 +1,15 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { RouterProvider } from 'react-router-dom'; +import { router } from './routes'; + +const queryClient = new QueryClient(); + +export function App() { + return ( + + + + + ); +} diff --git a/apps/client/src/app/index.ts b/apps/client/src/app/index.ts new file mode 100644 index 0000000..a56e30d --- /dev/null +++ b/apps/client/src/app/index.ts @@ -0,0 +1,2 @@ +export { App } from './App'; +export { Providers } from './providers'; diff --git a/apps/client/src/main.tsx b/apps/client/src/main.tsx index 07116fa..84a22b6 100644 --- a/apps/client/src/main.tsx +++ b/apps/client/src/main.tsx @@ -1,6 +1,5 @@ import { createRoot } from 'react-dom/client'; import './index.css'; -import { RouterProvider } from 'react-router-dom'; -import { router } from '@/app/routes'; +import { App } from './app'; -createRoot(document.getElementById('root')!).render(); +createRoot(document.getElementById('root')!).render(); From 1c1d9ce66274ae1cfd680e29b4caa9519b16dd22 Mon Sep 17 00:00:00 2001 From: zero0205 Date: Mon, 17 Feb 2025 13:46:36 +0900 Subject: [PATCH 04/15] =?UTF-8?q?:recycle:=20[Refactor]:=20Attendance=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=ED=8E=98=EC=B9=AD=20=EB=A1=9C=EC=A7=81=20useQuery?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/pages/Profile/ui/Attendance.tsx | 57 +++++++++---------- 1 file changed, 28 insertions(+), 29 deletions(-) diff --git a/apps/client/src/pages/Profile/ui/Attendance.tsx b/apps/client/src/pages/Profile/ui/Attendance.tsx index 7ebc9b4..f1942a0 100644 --- a/apps/client/src/pages/Profile/ui/Attendance.tsx +++ b/apps/client/src/pages/Profile/ui/Attendance.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; import { ErrorCharacter, LoadingCharacter } from '@/shared/ui'; import { PlayIcon } from '@/shared/ui/Icons'; import { axiosInstance } from '@/shared/api'; @@ -12,43 +12,42 @@ type AttendanceData = { isAttendance: boolean; }; -export function Attendance() { - const [attendanceList, setAttendanceList] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [showLoading, setShowLoading] = useState(false); - const [error, setError] = useState(null); - - const navigate = useNavigate(); +type AttendanceResponse = { + success: boolean; + status: string; + message: string; + data: { + memberId: number; + attendances: AttendanceData[]; + }; +}; - useEffect(() => { - axiosInstance - .get('/v1/members/attendance') - .then(response => { - if (response.data.success) { - setAttendanceList(response.data.data.attendances); - } else { - setError(new Error(response.data.message)); - } - }) - .catch(err => setError(err instanceof Error ? err : new Error(err))) - .finally(() => { - setIsLoading(false); - }); - }, [setAttendanceList, setError]); +const fetchAttendance = async (): Promise => { + const { data } = await axiosInstance.get('/v1/members/attendance'); + if (!data.success) { + throw new Error(data.message || '출석부 조회에 실패했습니다.'); + } + return data.data.attendances; +}; - useEffect(() => { - const timer = setTimeout(() => { - setShowLoading(true); - }, 250); +export function Attendance() { + const navigate = useNavigate(); - return () => clearTimeout(timer); + const { + data: attendanceList, + error, + isLoading, + } = useQuery({ + queryKey: ['attendance'], + queryFn: fetchAttendance, + staleTime: 1000 * 60, }); const handlePlayRecord = (attendanceId: number) => { navigate(`/record/${attendanceId}`); }; - if (showLoading && isLoading) { + if (isLoading) { return (
From 6f4868006004d821371ab10138ea50eb1238c0ac Mon Sep 17 00:00:00 2001 From: zero0205 Date: Mon, 17 Feb 2025 19:55:30 +0900 Subject: [PATCH 05/15] =?UTF-8?q?:recycle:=20[Refactor]:=20Attendance=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=ED=8E=98?= =?UTF-8?q?=EC=B9=AD=20=EB=A1=9C=EC=A7=81=20entities=EB=A1=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entities/attendance/api/attendanceApi.ts | 10 +++++ apps/client/src/entities/attendance/index.ts | 3 ++ .../src/entities/attendance/model/queries.ts | 21 ++++++++++ .../src/entities/attendance/model/types.ts | 17 ++++++++ .../src/pages/Profile/ui/Attendance.tsx | 42 ++----------------- 5 files changed, 55 insertions(+), 38 deletions(-) create mode 100644 apps/client/src/entities/attendance/api/attendanceApi.ts create mode 100644 apps/client/src/entities/attendance/index.ts create mode 100644 apps/client/src/entities/attendance/model/queries.ts create mode 100644 apps/client/src/entities/attendance/model/types.ts diff --git a/apps/client/src/entities/attendance/api/attendanceApi.ts b/apps/client/src/entities/attendance/api/attendanceApi.ts new file mode 100644 index 0000000..c8ede9e --- /dev/null +++ b/apps/client/src/entities/attendance/api/attendanceApi.ts @@ -0,0 +1,10 @@ +import { axiosInstance } from '@/shared/api'; +import { AttendanceData, AttendanceResponse } from '../model/types'; + +export const fetchAttendance = async (): Promise => { + const { data } = await axiosInstance.get('/v1/members/attendance'); + if (!data.success) { + throw new Error(data.message || '출석부 조회에 실패했습니다.'); + } + return data.data.attendances; +}; diff --git a/apps/client/src/entities/attendance/index.ts b/apps/client/src/entities/attendance/index.ts new file mode 100644 index 0000000..7ea2a72 --- /dev/null +++ b/apps/client/src/entities/attendance/index.ts @@ -0,0 +1,3 @@ +export type { AttendanceData, AttendanceResponse } from './model/types'; +export { useAttendanceList } from './model/queries'; +export { fetchAttendance } from './api/attendanceApi'; diff --git a/apps/client/src/entities/attendance/model/queries.ts b/apps/client/src/entities/attendance/model/queries.ts new file mode 100644 index 0000000..284953a --- /dev/null +++ b/apps/client/src/entities/attendance/model/queries.ts @@ -0,0 +1,21 @@ +import { useQuery } from '@tanstack/react-query'; +import { fetchAttendance } from '@/entities/attendance/api/attendanceApi'; +import { AttendanceData } from './types'; + +export const useAttendanceList = () => { + const { + data: attendanceList, + error, + isLoading, + } = useQuery({ + queryKey: ['attendance'], + queryFn: fetchAttendance, + staleTime: 1000 * 60, + }); + + return { + attendanceList, + error, + isLoading, + }; +}; diff --git a/apps/client/src/entities/attendance/model/types.ts b/apps/client/src/entities/attendance/model/types.ts new file mode 100644 index 0000000..0a75f68 --- /dev/null +++ b/apps/client/src/entities/attendance/model/types.ts @@ -0,0 +1,17 @@ +export type AttendanceData = { + attendanceId: number; + date: string; + startTime: string; + endTime: string; + isAttendance: boolean; +}; + +export type AttendanceResponse = { + success: boolean; + status: string; + message: string; + data: { + memberId: number; + attendances: AttendanceData[]; + }; +}; diff --git a/apps/client/src/pages/Profile/ui/Attendance.tsx b/apps/client/src/pages/Profile/ui/Attendance.tsx index f1942a0..575df21 100644 --- a/apps/client/src/pages/Profile/ui/Attendance.tsx +++ b/apps/client/src/pages/Profile/ui/Attendance.tsx @@ -1,48 +1,14 @@ import { useNavigate } from 'react-router-dom'; -import { useQuery } from '@tanstack/react-query'; import { ErrorCharacter, LoadingCharacter } from '@/shared/ui'; import { PlayIcon } from '@/shared/ui/Icons'; -import { axiosInstance } from '@/shared/api'; +import { useAttendanceList } from '@/entities/attendance'; -type AttendanceData = { - attendanceId: number; - date: string; - startTime: string; - endTime: string; - isAttendance: boolean; -}; - -type AttendanceResponse = { - success: boolean; - status: string; - message: string; - data: { - memberId: number; - attendances: AttendanceData[]; - }; -}; - -const fetchAttendance = async (): Promise => { - const { data } = await axiosInstance.get('/v1/members/attendance'); - if (!data.success) { - throw new Error(data.message || '출석부 조회에 실패했습니다.'); - } - return data.data.attendances; -}; +const ATTENDANCE_TABLE_HEADER = ['학습일', '시작 시간', '종료 시간', '출석 여부']; export function Attendance() { + const { attendanceList, error, isLoading } = useAttendanceList(); const navigate = useNavigate(); - const { - data: attendanceList, - error, - isLoading, - } = useQuery({ - queryKey: ['attendance'], - queryFn: fetchAttendance, - staleTime: 1000 * 60, - }); - const handlePlayRecord = (attendanceId: number) => { navigate(`/record/${attendanceId}`); }; @@ -67,7 +33,7 @@ export function Attendance() {
- {['학습일', '시작 시간', '종료 시간', '출석 여부'].map((data: string) => ( + {ATTENDANCE_TABLE_HEADER.map((data: string) => (
{data}
From 414a7b5e6fe6525a830090cb5e58ee6ed78e3244 Mon Sep 17 00:00:00 2001 From: zero0205 Date: Tue, 18 Feb 2025 17:41:42 +0900 Subject: [PATCH 06/15] =?UTF-8?q?:recycle:=20[Refactor]:=20Profile=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=9C=A0=EC=A0=80=20?= =?UTF-8?q?=EB=8D=B0=EC=9D=B4=ED=84=B0=20=ED=8E=98=EC=B9=AD=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20useQuery=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/client/src/pages/Profile/ProfilePage.tsx | 39 ++++++------------- 1 file changed, 12 insertions(+), 27 deletions(-) diff --git a/apps/client/src/pages/Profile/ProfilePage.tsx b/apps/client/src/pages/Profile/ProfilePage.tsx index f48d75f..d6feee2 100644 --- a/apps/client/src/pages/Profile/ProfilePage.tsx +++ b/apps/client/src/pages/Profile/ProfilePage.tsx @@ -1,30 +1,23 @@ import { useEffect, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { Attendance, UserInfo } from './ui'; import { UserData } from './model'; import { EditUserInfo } from '@/features/editProfile'; import { axiosInstance } from '@/shared/api'; import { ErrorCharacter, LoadingCharacter } from '@/shared/ui'; +const getUserInfo = async (): Promise => { + const response = await axiosInstance.get('/v1/members/info'); + if (!response.data.success) { + throw new Error(response.data.message); + } + return response.data.data; +}; + export function ProfilePage() { - const [userData, setUserData] = useState(null); - const [isLoading, setIsLoading] = useState(false); - const [error, setError] = useState(null); const [isEditing, setIsEditing] = useState(false); - const [showLoading, setShowLoading] = useState(true); - useEffect(() => { - axiosInstance - .get('/v1/members/info') - .then(response => { - if (response.data.success) { - setUserData(response.data.data); - } else { - setError(new Error(response.data.message)); - } - }) - .catch(err => setError(err instanceof Error ? err : new Error(err))) - .finally(() => setIsLoading(false)); - }, [isEditing]); + const { data: userData, isLoading, error } = useQuery({ queryKey: ['userData'], queryFn: getUserInfo }); useEffect(() => { if (!userData) return; @@ -33,19 +26,11 @@ export function ProfilePage() { } }, [userData, isEditing]); - useEffect(() => { - const timeoutId = setTimeout(() => { - setShowLoading(false); - }, 250); - - return () => clearTimeout(timeoutId); - }, []); - const toggleEditing = () => { setIsEditing(prev => !prev); }; - if (showLoading && isLoading) { + if (isLoading) { return (
@@ -53,7 +38,7 @@ export function ProfilePage() { ); } - if (error || !userData) { + if (error) { return (
From 1b8baedc5832dafdb56fb265c2b319f184a0b50a Mon Sep 17 00:00:00 2001 From: zero0205 Date: Tue, 18 Feb 2025 18:22:10 +0900 Subject: [PATCH 07/15] =?UTF-8?q?:recycle:=20[Refactor]:=20profile?= =?UTF-8?q?=EA=B3=BC=20profileImage=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20?= =?UTF-8?q?=ED=8E=98=EC=B9=AD=20=EB=A1=9C=EC=A7=81=20entities=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/client/src/entities/user/api/userApi.ts | 19 +++++++++++++++ apps/client/src/entities/user/index.ts | 3 +++ .../client/src/entities/user/model/queries.ts | 24 +++++++++++++++++++ .../src/entities/user/model/queryFactory.ts | 5 ++++ .../Profile => entities/user}/model/types.ts | 0 apps/client/src/pages/Profile/ProfilePage.tsx | 14 ++--------- apps/client/src/pages/Profile/index.ts | 1 - apps/client/src/pages/Profile/model/index.ts | 1 - apps/client/src/pages/Profile/ui/UserInfo.tsx | 2 +- apps/client/src/widgets/Header/Header.tsx | 14 +++-------- 10 files changed, 57 insertions(+), 26 deletions(-) create mode 100644 apps/client/src/entities/user/api/userApi.ts create mode 100644 apps/client/src/entities/user/index.ts create mode 100644 apps/client/src/entities/user/model/queries.ts create mode 100644 apps/client/src/entities/user/model/queryFactory.ts rename apps/client/src/{pages/Profile => entities/user}/model/types.ts (100%) delete mode 100644 apps/client/src/pages/Profile/model/index.ts diff --git a/apps/client/src/entities/user/api/userApi.ts b/apps/client/src/entities/user/api/userApi.ts new file mode 100644 index 0000000..20012fb --- /dev/null +++ b/apps/client/src/entities/user/api/userApi.ts @@ -0,0 +1,19 @@ +import { UserData } from '@/entities/user'; +import { axiosInstance } from '@/shared/api'; + +export const getUserInfo = async (): Promise => { + const response = await axiosInstance.get('/v1/members/info'); + if (!response.data.success) { + throw new Error(response.data.message); + } + return response.data.data; +}; + +export const getUserProfileImage = async (): Promise => { + const response = await axiosInstance.get('/v1/members/profile-image'); + if (!response.data.success) { + throw new Error(response.data.message); + } + + return response.data.data.profileImage; +}; diff --git a/apps/client/src/entities/user/index.ts b/apps/client/src/entities/user/index.ts new file mode 100644 index 0000000..6da6663 --- /dev/null +++ b/apps/client/src/entities/user/index.ts @@ -0,0 +1,3 @@ +export type { UserData } from './model/types'; +export { getUserInfo, getUserProfileImage } from './api/userApi'; +export { useUserData } from './model/queries'; diff --git a/apps/client/src/entities/user/model/queries.ts b/apps/client/src/entities/user/model/queries.ts new file mode 100644 index 0000000..1f7bcd5 --- /dev/null +++ b/apps/client/src/entities/user/model/queries.ts @@ -0,0 +1,24 @@ +import { useQuery } from '@tanstack/react-query'; +import { getUserInfo, getUserProfileImage } from '@/entities/user'; +import { userKeys } from './queryFactory'; + +export const useUserData = () => { + const { + data: userData, + isLoading, + error, + } = useQuery({ queryKey: userKeys.profile(), queryFn: getUserInfo, staleTime: 1000 * 60 * 10 }); + + return { userData, isLoading, error }; +}; + +export const useProfileImage = (isLoggedIn: boolean) => { + const { data, isLoading, error } = useQuery({ + queryKey: userKeys.profileImage(), + queryFn: getUserProfileImage, + enabled: isLoggedIn, + staleTime: 1000 * 60 * 10, + }); + + return { profileImgUrl: data, isLoading, error }; +}; diff --git a/apps/client/src/entities/user/model/queryFactory.ts b/apps/client/src/entities/user/model/queryFactory.ts new file mode 100644 index 0000000..438e769 --- /dev/null +++ b/apps/client/src/entities/user/model/queryFactory.ts @@ -0,0 +1,5 @@ +export const userKeys = { + all: ['user'] as const, + profile: () => [...userKeys.all, 'profile'] as const, + profileImage: () => [...userKeys.all, 'profile-image'] as const, +}; diff --git a/apps/client/src/pages/Profile/model/types.ts b/apps/client/src/entities/user/model/types.ts similarity index 100% rename from apps/client/src/pages/Profile/model/types.ts rename to apps/client/src/entities/user/model/types.ts diff --git a/apps/client/src/pages/Profile/ProfilePage.tsx b/apps/client/src/pages/Profile/ProfilePage.tsx index d6feee2..00b46fe 100644 --- a/apps/client/src/pages/Profile/ProfilePage.tsx +++ b/apps/client/src/pages/Profile/ProfilePage.tsx @@ -1,23 +1,13 @@ import { useEffect, useState } from 'react'; -import { useQuery } from '@tanstack/react-query'; import { Attendance, UserInfo } from './ui'; -import { UserData } from './model'; import { EditUserInfo } from '@/features/editProfile'; -import { axiosInstance } from '@/shared/api'; import { ErrorCharacter, LoadingCharacter } from '@/shared/ui'; - -const getUserInfo = async (): Promise => { - const response = await axiosInstance.get('/v1/members/info'); - if (!response.data.success) { - throw new Error(response.data.message); - } - return response.data.data; -}; +import { useUserData } from '@/entities/user'; export function ProfilePage() { const [isEditing, setIsEditing] = useState(false); - const { data: userData, isLoading, error } = useQuery({ queryKey: ['userData'], queryFn: getUserInfo }); + const { userData, isLoading, error } = useUserData(); useEffect(() => { if (!userData) return; diff --git a/apps/client/src/pages/Profile/index.ts b/apps/client/src/pages/Profile/index.ts index ce68016..a2ab4b8 100644 --- a/apps/client/src/pages/Profile/index.ts +++ b/apps/client/src/pages/Profile/index.ts @@ -1,2 +1 @@ export { ProfilePage as default } from './ProfilePage'; -export type { UserData } from './model'; diff --git a/apps/client/src/pages/Profile/model/index.ts b/apps/client/src/pages/Profile/model/index.ts deleted file mode 100644 index 66de0f4..0000000 --- a/apps/client/src/pages/Profile/model/index.ts +++ /dev/null @@ -1 +0,0 @@ -export type { UserData } from './types'; diff --git a/apps/client/src/pages/Profile/ui/UserInfo.tsx b/apps/client/src/pages/Profile/ui/UserInfo.tsx index 437d980..910c7e1 100644 --- a/apps/client/src/pages/Profile/ui/UserInfo.tsx +++ b/apps/client/src/pages/Profile/ui/UserInfo.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { ErrorCharacter, LoadingCharacter, BlogIcon, EditIcon, GithubIcon, LinkedInIcon, MailIcon } from '@/shared/ui'; import { Avatar, AvatarFallback, AvatarImage } from '@/shared/ui/shadcn/avatar'; -import { UserData } from '../model'; +import { UserData } from '@/entities/user'; type UserInfoProps = Readonly<{ userData: UserData | undefined; diff --git a/apps/client/src/widgets/Header/Header.tsx b/apps/client/src/widgets/Header/Header.tsx index 3e5ea73..59df4d7 100644 --- a/apps/client/src/widgets/Header/Header.tsx +++ b/apps/client/src/widgets/Header/Header.tsx @@ -1,20 +1,20 @@ -import { useContext, useEffect, useRef, useState } from 'react'; +import { useContext, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Avatar, AvatarFallback, AvatarImage } from '@/shared/ui/shadcn/avatar'; import { cn } from '@/shared/lib'; import { AuthContext, useAuth } from '@/features/auth'; -import { axiosInstance } from '@/shared/api'; import { Button } from '@/shared/ui/shadcn/button'; import { LogoButton } from './LogoButton'; import { LogInButton } from './LogInButton'; +import { useProfileImage } from '@/entities/user/model/queries'; export function Header() { const [isCheckedIn, setIsCheckedIn] = useState(false); - const [profileImgUrl, setProfileImgUrl] = useState(''); const broadcastRef = useRef(null); const { isLoggedIn } = useContext(AuthContext); const { logout } = useAuth(); const navigate = useNavigate(); + const { profileImgUrl } = useProfileImage(isLoggedIn); const handleCheckInClick = () => { if (broadcastRef.current && !broadcastRef.current.closed) { @@ -54,14 +54,6 @@ export function Header() { navigate('/'); }; - useEffect(() => { - if (!isLoggedIn) return; - axiosInstance.get('/v1/members/profile-image').then(response => { - if (!response.data.success) return; - setProfileImgUrl(response.data.data.profileImage); - }); - }, [isLoggedIn]); - return (
From f68820ed2d4111307712f261b01aa23e9df21a20 Mon Sep 17 00:00:00 2001 From: zero0205 Date: Tue, 18 Feb 2025 18:33:51 +0900 Subject: [PATCH 08/15] =?UTF-8?q?:recycle:=20[Refactor]:=20bookmark?= =?UTF-8?q?=EB=A5=BC=20features=20=EB=A0=88=EC=9D=B4=EC=96=B4=EB=A1=9C=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/client/src/features/bookmark/index.ts | 2 ++ .../src/{widgets/Banner => features/bookmark/model}/types.ts | 0 .../src/{widgets/Banner => features/bookmark/ui}/Bookmark.tsx | 2 +- apps/client/src/widgets/Banner/Banner.tsx | 2 +- 4 files changed, 4 insertions(+), 2 deletions(-) create mode 100644 apps/client/src/features/bookmark/index.ts rename apps/client/src/{widgets/Banner => features/bookmark/model}/types.ts (100%) rename apps/client/src/{widgets/Banner => features/bookmark/ui}/Bookmark.tsx (99%) diff --git a/apps/client/src/features/bookmark/index.ts b/apps/client/src/features/bookmark/index.ts new file mode 100644 index 0000000..8ae33f3 --- /dev/null +++ b/apps/client/src/features/bookmark/index.ts @@ -0,0 +1,2 @@ +export { Bookmark } from './ui/Bookmark'; +export type { BookmarkData } from './model/types'; diff --git a/apps/client/src/widgets/Banner/types.ts b/apps/client/src/features/bookmark/model/types.ts similarity index 100% rename from apps/client/src/widgets/Banner/types.ts rename to apps/client/src/features/bookmark/model/types.ts diff --git a/apps/client/src/widgets/Banner/Bookmark.tsx b/apps/client/src/features/bookmark/ui/Bookmark.tsx similarity index 99% rename from apps/client/src/widgets/Banner/Bookmark.tsx rename to apps/client/src/features/bookmark/ui/Bookmark.tsx index f9a8084..7baee8b 100644 --- a/apps/client/src/widgets/Banner/Bookmark.tsx +++ b/apps/client/src/features/bookmark/ui/Bookmark.tsx @@ -6,7 +6,7 @@ import { Button } from '@/shared/ui/shadcn/button'; import { useToast } from '@/shared/lib'; import { AuthContext } from '@/shared/contexts'; import { axiosInstance } from '@/shared/api'; -import { BookmarkData } from './types'; +import { BookmarkData } from '@/features/bookmark'; export function Bookmark() { const { isLoggedIn } = useContext(AuthContext); diff --git a/apps/client/src/widgets/Banner/Banner.tsx b/apps/client/src/widgets/Banner/Banner.tsx index 34b5330..17f1866 100644 --- a/apps/client/src/widgets/Banner/Banner.tsx +++ b/apps/client/src/widgets/Banner/Banner.tsx @@ -1,5 +1,5 @@ import { MoveCharacter } from '@/shared/ui'; -import { Bookmark } from './Bookmark'; +import { Bookmark } from '@/features/bookmark'; export function Banner() { return ( From 3f2ffbc9b0147ac12de788e9e61cfaab36010b07 Mon Sep 17 00:00:00 2001 From: zero0205 Date: Tue, 18 Feb 2025 18:44:02 +0900 Subject: [PATCH 09/15] =?UTF-8?q?:sparkles:=20[Feat]:=20axios=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EC=97=90=EB=9F=AC=20=ED=95=B8=EB=93=A4=EB=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/client/src/shared/api/axios.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/apps/client/src/shared/api/axios.ts b/apps/client/src/shared/api/axios.ts index ed2abf6..85adcfa 100644 --- a/apps/client/src/shared/api/axios.ts +++ b/apps/client/src/shared/api/axios.ts @@ -19,3 +19,18 @@ axiosInstance.interceptors.request.use( }, error => Promise.reject(error instanceof Error ? error : new Error(error)), ); + +axiosInstance.interceptors.response.use( + response => { + if (!response.data.success) { + throw new Error(response.data.message); + } + return response; + }, + error => { + if (axios.isAxiosError(error) && error.response?.data) { + throw new Error(error.response.data.message); + } + throw new Error('서버와 통신 중 오류가 발생했습니다.'); + }, +); From 5f69e73357144667c7198acf591658e15714056c Mon Sep 17 00:00:00 2001 From: zero0205 Date: Tue, 18 Feb 2025 19:16:34 +0900 Subject: [PATCH 10/15] =?UTF-8?q?:recycle:=20[Refactor]:=20bookmark=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C,=20=EC=83=9D=EC=84=B1,=20=EC=82=AD=EC=A0=9C?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20useQuery=EC=99=80=20useMutation?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/bookmark/api/bookmarkApi.ts | 10 ++++ apps/client/src/features/bookmark/index.ts | 2 + .../src/features/bookmark/model/queries.ts | 44 ++++++++++++++ .../src/features/bookmark/ui/Bookmark.tsx | 59 ++++++------------- 4 files changed, 75 insertions(+), 40 deletions(-) create mode 100644 apps/client/src/features/bookmark/api/bookmarkApi.ts create mode 100644 apps/client/src/features/bookmark/model/queries.ts diff --git a/apps/client/src/features/bookmark/api/bookmarkApi.ts b/apps/client/src/features/bookmark/api/bookmarkApi.ts new file mode 100644 index 0000000..0d272bc --- /dev/null +++ b/apps/client/src/features/bookmark/api/bookmarkApi.ts @@ -0,0 +1,10 @@ +import { axiosInstance } from '@/shared/api'; +import { BookmarkData } from '@/features/bookmark'; + +export const getBookmarks = () => axiosInstance.get('/v1/bookmarks').then(res => res.data.data.bookmarks); + +export const addBookmark = (newBookmark: BookmarkData) => + axiosInstance.post('/v1/bookmarks', newBookmark).then(res => res.data.data); + +export const deleteBookmark = (bookmarkId: number) => + axiosInstance.delete(`/v1/bookmarks/${bookmarkId}`).then(res => res.data); diff --git a/apps/client/src/features/bookmark/index.ts b/apps/client/src/features/bookmark/index.ts index 8ae33f3..1bf47fe 100644 --- a/apps/client/src/features/bookmark/index.ts +++ b/apps/client/src/features/bookmark/index.ts @@ -1,2 +1,4 @@ export { Bookmark } from './ui/Bookmark'; export type { BookmarkData } from './model/types'; +export { useBookmarkMutation } from './model/queries'; +export { getBookmarks, addBookmark, deleteBookmark } from './api/bookmarkApi'; diff --git a/apps/client/src/features/bookmark/model/queries.ts b/apps/client/src/features/bookmark/model/queries.ts new file mode 100644 index 0000000..b020f19 --- /dev/null +++ b/apps/client/src/features/bookmark/model/queries.ts @@ -0,0 +1,44 @@ +// src/features/bookmark/model/useBookmarkMutation.ts +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useToast } from '@/shared/lib'; +import { addBookmark, deleteBookmark } from '@/features/bookmark/api/bookmarkApi'; +import { BookmarkData } from './types'; + +export const useBookmarkMutation = ({ onAddSuccess }: { onAddSuccess?: () => void }) => { + const queryClient = useQueryClient(); + const { toast } = useToast(); + + const { mutate: mutateAdd } = useMutation({ + mutationFn: addBookmark, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['bookmarks'] }); + onAddSuccess?.(); + }, + onError: (error: Error) => { + toast({ + variant: 'destructive', + title: '북마크 생성 실패', + description: error.message, + }); + }, + }); + + const { mutate: mutateDelete } = useMutation({ + mutationFn: deleteBookmark, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['bookmarks'] }); + }, + onError: (error: Error) => { + toast({ + variant: 'destructive', + title: '북마크 삭제 실패', + description: error.message, + }); + }, + }); + + return { + mutateAdd, + mutateDelete, + }; +}; diff --git a/apps/client/src/features/bookmark/ui/Bookmark.tsx b/apps/client/src/features/bookmark/ui/Bookmark.tsx index 7baee8b..d3851a0 100644 --- a/apps/client/src/features/bookmark/ui/Bookmark.tsx +++ b/apps/client/src/features/bookmark/ui/Bookmark.tsx @@ -1,16 +1,14 @@ import { createPortal } from 'react-dom'; import { useForm } from 'react-hook-form'; -import { useContext, useEffect, useState } from 'react'; +import { useContext, useState } from 'react'; +import { useQuery } from '@tanstack/react-query'; import { Modal, CloseIcon } from '@/shared/ui'; import { Button } from '@/shared/ui/shadcn/button'; -import { useToast } from '@/shared/lib'; import { AuthContext } from '@/shared/contexts'; -import { axiosInstance } from '@/shared/api'; -import { BookmarkData } from '@/features/bookmark'; +import { BookmarkData, getBookmarks, useBookmarkMutation } from '@/features/bookmark'; export function Bookmark() { const { isLoggedIn } = useContext(AuthContext); - const [bookmarkList, setBookmarkList] = useState([]); const [showModal, setShowModal] = useState(false); const { register, @@ -18,7 +16,20 @@ export function Bookmark() { formState: { errors }, reset, } = useForm(); - const { toast } = useToast(); + + const { data: bookmarkList = [] } = useQuery({ + queryKey: ['bookmarks'], + queryFn: getBookmarks, + enabled: isLoggedIn, + staleTime: 1000 * 50 * 5, + }); + + const { mutateAdd, mutateDelete } = useBookmarkMutation({ + onAddSuccess: () => { + reset(); + setShowModal(false); + }, + }); const handleClickBookmarkButton = (url: string) => { window.open(url); @@ -26,47 +37,15 @@ export function Bookmark() { const handleAddBookmark = (newBookmark: BookmarkData) => { if (!isLoggedIn) return; - axiosInstance - .post('/v1/bookmarks', newBookmark) - .then(response => { - if (response.data.success) { - const addedBookmark = { ...newBookmark, bookmarkId: response.data.data.bookmarkId }; - const newBookmarkList = [...bookmarkList, addedBookmark]; - setBookmarkList(newBookmarkList); - } else { - toast({ variant: 'destructive', title: '북마크 생성 실패' }); - } - }) - .finally(() => { - reset(); - setShowModal(false); - }); + mutateAdd(newBookmark); }; const handleDeleteBookmark = (e: React.MouseEvent, bookmarkId: number) => { e.stopPropagation(); - if (!isLoggedIn) return; - axiosInstance.delete(`/v1/bookmarks/${bookmarkId}`).then(response => { - if (response.data.success) { - const newBookmarkList = bookmarkList.filter((data, _) => data.bookmarkId !== bookmarkId); - setBookmarkList(newBookmarkList); - } else { - toast({ variant: 'destructive', title: '북마크 삭제 실패' }); - } - }); + mutateDelete(bookmarkId); }; - useEffect(() => { - axiosInstance.get('/v1/bookmarks').then(response => { - if (response.data.success) { - setBookmarkList(response.data.data.bookmarks); - } else { - toast({ variant: 'destructive', title: '북마크 조회 실패' }); - } - }); - }, [toast]); - return ( <> {isLoggedIn && ( From efd9de46ca763c93d24398c852f48f31688ab780 Mon Sep 17 00:00:00 2001 From: zero0205 Date: Thu, 27 Feb 2025 11:53:08 +0900 Subject: [PATCH 11/15] =?UTF-8?q?:recycle:=20[Refactor]:=20=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EB=B8=8C=20=EC=8B=9C=EC=B2=AD=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EC=97=90=EC=84=9C=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=ED=8E=98=EC=B9=AD=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?useQuery=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useAPI 훅 삭제 --- apps/client/src/entities/user/index.ts | 2 +- apps/client/src/features/index.ts | 1 + apps/client/src/features/watching/index.ts | 2 +- .../src/features/watching/model/index.ts | 1 + .../watching/model/liveCamperInfoApi.ts | 10 +++++ .../src/features/watching/model/queries.ts | 13 ++++++ .../src/features/watching/model/types.ts | 6 +++ .../ui/LiveCamperInfo/LiveCamperInfo.tsx | 9 ++-- apps/client/src/shared/api/index.ts | 1 - apps/client/src/shared/api/useAPI.ts | 45 ------------------- apps/client/src/shared/types/index.ts | 2 + apps/client/src/widgets/Header/Header.tsx | 6 +-- 12 files changed, 42 insertions(+), 56 deletions(-) create mode 100644 apps/client/src/features/index.ts create mode 100644 apps/client/src/features/watching/model/liveCamperInfoApi.ts create mode 100644 apps/client/src/features/watching/model/queries.ts create mode 100644 apps/client/src/features/watching/model/types.ts delete mode 100644 apps/client/src/shared/api/useAPI.ts diff --git a/apps/client/src/entities/user/index.ts b/apps/client/src/entities/user/index.ts index 6da6663..ddfbff2 100644 --- a/apps/client/src/entities/user/index.ts +++ b/apps/client/src/entities/user/index.ts @@ -1,3 +1,3 @@ export type { UserData } from './model/types'; export { getUserInfo, getUserProfileImage } from './api/userApi'; -export { useUserData } from './model/queries'; +export { useUserData, useProfileImage } from './model/queries'; diff --git a/apps/client/src/features/index.ts b/apps/client/src/features/index.ts new file mode 100644 index 0000000..8c732e6 --- /dev/null +++ b/apps/client/src/features/index.ts @@ -0,0 +1 @@ +export { useAuth, AuthContext } from './auth'; diff --git a/apps/client/src/features/watching/index.ts b/apps/client/src/features/watching/index.ts index 3ba4257..f791aef 100644 --- a/apps/client/src/features/watching/index.ts +++ b/apps/client/src/features/watching/index.ts @@ -1,2 +1,2 @@ export { LiveCamperInfo, LivePlayer } from './ui'; -export { useConsume } from './model'; +export { useConsume, useLiveInfo } from './model'; diff --git a/apps/client/src/features/watching/model/index.ts b/apps/client/src/features/watching/model/index.ts index e5bfd95..b1fea80 100644 --- a/apps/client/src/features/watching/model/index.ts +++ b/apps/client/src/features/watching/model/index.ts @@ -1 +1,2 @@ export { useConsume } from './useConsume'; +export { useLiveInfo } from './queries'; diff --git a/apps/client/src/features/watching/model/liveCamperInfoApi.ts b/apps/client/src/features/watching/model/liveCamperInfoApi.ts new file mode 100644 index 0000000..907eb4d --- /dev/null +++ b/apps/client/src/features/watching/model/liveCamperInfoApi.ts @@ -0,0 +1,10 @@ +import { axiosInstance } from '@/shared/api'; +import { LiveInfo } from './types'; + +export const getLiveCamperInfo = async (liveId: string): Promise => { + const response = await axiosInstance.get(`v1/broadcasts/${liveId}/info`); + if (!response.data.success) { + throw new Error(response.data.message); + } + return response.data.data; +}; diff --git a/apps/client/src/features/watching/model/queries.ts b/apps/client/src/features/watching/model/queries.ts new file mode 100644 index 0000000..8d53bd0 --- /dev/null +++ b/apps/client/src/features/watching/model/queries.ts @@ -0,0 +1,13 @@ +import { useQuery } from '@tanstack/react-query'; +import { getLiveCamperInfo } from './liveCamperInfoApi'; + +export const useLiveInfo = (liveId: string) => { + const { data, isLoading, isError } = useQuery({ + queryKey: ['liveInfo', liveId], + queryFn: () => getLiveCamperInfo(liveId), + staleTime: 1000 * 60, + refetchInterval: 1000 * 30, + }); + + return { data, isLoading, isError }; +}; diff --git a/apps/client/src/features/watching/model/types.ts b/apps/client/src/features/watching/model/types.ts new file mode 100644 index 0000000..050021d --- /dev/null +++ b/apps/client/src/features/watching/model/types.ts @@ -0,0 +1,6 @@ +import { UserData } from '@/entities/user'; + +export type LiveInfo = { + title: string; + viewers: number; +} & UserData; diff --git a/apps/client/src/features/watching/ui/LiveCamperInfo/LiveCamperInfo.tsx b/apps/client/src/features/watching/ui/LiveCamperInfo/LiveCamperInfo.tsx index 8242e00..4cb39a4 100644 --- a/apps/client/src/features/watching/ui/LiveCamperInfo/LiveCamperInfo.tsx +++ b/apps/client/src/features/watching/ui/LiveCamperInfo/LiveCamperInfo.tsx @@ -1,6 +1,5 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/shared/ui/shadcn/avatar'; import { Badge } from '@/shared/ui/shadcn/badge'; -import { useAPI } from '@/shared/api'; import { LoadingCharacter, ErrorCharacter, @@ -10,12 +9,12 @@ import { BlogIcon, LinkedInIcon, } from '@/shared/ui'; -import { LiveInfo } from './types'; +import { useLiveInfo } from '@/features/watching'; export function LiveCamperInfo({ liveId }: Readonly<{ liveId: string }>) { - const { data, isLoading, error } = useAPI(`v1/broadcasts/${liveId}/info`); + const { data, isLoading, isError } = useLiveInfo(liveId); - if (error || !data) { + if (isError || !data) { return (
@@ -79,7 +78,7 @@ export function LiveCamperInfo({ liveId }: Readonly<{ liveId: string }>) { - window.open(data.contacts.linkedin, '_blank')}> + window.open(data.contacts.linkedIn, '_blank')}>
diff --git a/apps/client/src/shared/api/index.ts b/apps/client/src/shared/api/index.ts index a2123fd..c3b123d 100644 --- a/apps/client/src/shared/api/index.ts +++ b/apps/client/src/shared/api/index.ts @@ -1,2 +1 @@ export { axiosInstance } from './axios'; -export { useAPI } from './useAPI'; diff --git a/apps/client/src/shared/api/useAPI.ts b/apps/client/src/shared/api/useAPI.ts deleted file mode 100644 index 32fe424..0000000 --- a/apps/client/src/shared/api/useAPI.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { useCallback, useEffect, useState } from 'react'; -import { axiosInstance } from '@/shared/api'; - -type APIOptions = { - method?: 'GET' | 'POST' | 'DELETE' | 'PUT' | 'PATCH'; - params?: Record; - data?: unknown; -}; - -type APIQueryState = { - data: T | null; - fetchData: () => Promise; - isLoading: boolean; - error: Error | null; -}; - -export const useAPI = (endpoint: string, options: APIOptions = {}): APIQueryState => { - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const [data, setData] = useState(null); - - const fetchData = useCallback(async () => { - setIsLoading(true); - try { - const result = await axiosInstance.request({ - url: endpoint, - method: options.method ?? 'GET', - params: options.params, - data: options.data, - }); - setData(result.data.data); - setError(null); - } catch (err) { - setError(err instanceof Error ? err : new Error('Failed to fetch data.')); - } finally { - setIsLoading(false); - } - }, [endpoint, options.method, options.params, options.data]); - - useEffect(() => { - fetchData(); - }, [fetchData]); - - return { data, fetchData, isLoading, error }; -}; diff --git a/apps/client/src/shared/types/index.ts b/apps/client/src/shared/types/index.ts index e69de29..cea55c0 100644 --- a/apps/client/src/shared/types/index.ts +++ b/apps/client/src/shared/types/index.ts @@ -0,0 +1,2 @@ +export type { TransportInfo, ConnectTransportResponse } from './mediasoupTypes'; +export type { Field } from './sharedTypes'; diff --git a/apps/client/src/widgets/Header/Header.tsx b/apps/client/src/widgets/Header/Header.tsx index 59df4d7..b4241e7 100644 --- a/apps/client/src/widgets/Header/Header.tsx +++ b/apps/client/src/widgets/Header/Header.tsx @@ -1,12 +1,12 @@ import { useContext, useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Avatar, AvatarFallback, AvatarImage } from '@/shared/ui/shadcn/avatar'; -import { cn } from '@/shared/lib'; -import { AuthContext, useAuth } from '@/features/auth'; import { Button } from '@/shared/ui/shadcn/button'; +import { cn } from '@/shared/lib'; +import { useProfileImage } from '@/entities/user'; +import { AuthContext, useAuth } from '@/features'; import { LogoButton } from './LogoButton'; import { LogInButton } from './LogInButton'; -import { useProfileImage } from '@/entities/user/model/queries'; export function Header() { const [isCheckedIn, setIsCheckedIn] = useState(false); From ef225edb4282777fd0eae4c4689a4e8d839619c8 Mon Sep 17 00:00:00 2001 From: zero0205 Date: Thu, 27 Feb 2025 12:19:54 +0900 Subject: [PATCH 12/15] =?UTF-8?q?:recycle:=20[Refactor]:=20=EB=85=B9?= =?UTF-8?q?=ED=99=94=20=EB=AA=A9=EB=A1=9D=20=ED=8E=98=EC=B9=AD=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20useQuery=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/entities/record/api/recordApi.ts | 14 ++++++++ apps/client/src/entities/record/index.ts | 2 ++ .../client/src/entities/record/model/types.ts | 6 ++++ .../entities/record/model/useRecordList.ts | 9 ++++++ apps/client/src/pages/Record/RecordPage.tsx | 8 +---- .../client/src/pages/Record/ui/RecordList.tsx | 32 +++++++++++-------- 6 files changed, 51 insertions(+), 20 deletions(-) create mode 100644 apps/client/src/entities/record/api/recordApi.ts create mode 100644 apps/client/src/entities/record/index.ts create mode 100644 apps/client/src/entities/record/model/types.ts create mode 100644 apps/client/src/entities/record/model/useRecordList.ts diff --git a/apps/client/src/entities/record/api/recordApi.ts b/apps/client/src/entities/record/api/recordApi.ts new file mode 100644 index 0000000..da9e5bf --- /dev/null +++ b/apps/client/src/entities/record/api/recordApi.ts @@ -0,0 +1,14 @@ +import { axiosInstance } from '@/shared/api'; + +export const getRecordList = async (attendanceId: string | undefined) => { + if (!attendanceId) { + throw new Error('attendanceId가 없습니다.'); + } + + const response = await axiosInstance.get(`/v1/records/${attendanceId}`); + + if (!response.data.success) { + throw new Error(response.data.message); + } + return response.data.data; +}; diff --git a/apps/client/src/entities/record/index.ts b/apps/client/src/entities/record/index.ts new file mode 100644 index 0000000..391a3b1 --- /dev/null +++ b/apps/client/src/entities/record/index.ts @@ -0,0 +1,2 @@ +export type { RecordData } from './model/types'; +export { useRecordList } from './model/useRecordList'; diff --git a/apps/client/src/entities/record/model/types.ts b/apps/client/src/entities/record/model/types.ts new file mode 100644 index 0000000..5a6ae61 --- /dev/null +++ b/apps/client/src/entities/record/model/types.ts @@ -0,0 +1,6 @@ +export type RecordData = { + recordId: number; + title: string; + video: string; + date: string; +}; diff --git a/apps/client/src/entities/record/model/useRecordList.ts b/apps/client/src/entities/record/model/useRecordList.ts new file mode 100644 index 0000000..63a5cf6 --- /dev/null +++ b/apps/client/src/entities/record/model/useRecordList.ts @@ -0,0 +1,9 @@ +import { useQuery } from '@tanstack/react-query'; +import { getRecordList } from '../api/recordApi'; + +export const useRecordList = (attendanceId: string | undefined) => useQuery({ + queryKey: ['record-list', attendanceId], + queryFn: () => getRecordList(attendanceId), + staleTime: 1000 * 60 * 60, + enabled: !!attendanceId, + }); diff --git a/apps/client/src/pages/Record/RecordPage.tsx b/apps/client/src/pages/Record/RecordPage.tsx index add84e2..5d2aa58 100644 --- a/apps/client/src/pages/Record/RecordPage.tsx +++ b/apps/client/src/pages/Record/RecordPage.tsx @@ -1,12 +1,6 @@ import { useState } from 'react'; import { RecordInfo, RecordList, RecordPlayer } from './ui'; - -export type RecordData = { - recordId: number; - title: string; - video: string; - date: string; -}; +import { RecordData } from '@/entities/record'; export function RecordPage() { const [nowPlaying, setNowPlaying] = useState({ recordId: 0, title: '', video: '', date: '' }); diff --git a/apps/client/src/pages/Record/ui/RecordList.tsx b/apps/client/src/pages/Record/ui/RecordList.tsx index 9590d40..79dac64 100644 --- a/apps/client/src/pages/Record/ui/RecordList.tsx +++ b/apps/client/src/pages/Record/ui/RecordList.tsx @@ -1,28 +1,34 @@ -import { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; -import { PlayIcon, ErrorCharacter } from '@/shared/ui'; -import { RecordData } from '../RecordPage'; -import { axiosInstance } from '@/shared/api'; +import { PlayIcon, ErrorCharacter, LoadingCharacter } from '@/shared/ui'; +import { RecordData, useRecordList } from '@/entities/record'; type RecordListProps = Readonly<{ onClickList: (data: RecordData) => void; }>; export function RecordList({ onClickList }: RecordListProps) { - const [recordList, setRecordList] = useState([]); const { attendanceId } = useParams<{ attendanceId: string }>(); - const [error, setError] = useState(''); + const { data: recordList, isLoading, isError } = useRecordList(attendanceId); - useEffect(() => { - axiosInstance.get(`/v1/records/${attendanceId}`).then(response => { - if (response.data.success) setRecordList(response.data.data.records); - else setError(response.data.message); - }); - }, [attendanceId]); + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError) { + return ( +
+ +
+ ); + } return (
- {error ? ( + {isError ? (
From e2b40c9681bbe4e9208e09419f6c5ddb64ae825c Mon Sep 17 00:00:00 2001 From: zero0205 Date: Thu, 27 Feb 2025 23:55:02 +0900 Subject: [PATCH 13/15] =?UTF-8?q?:recycle:=20[Refactor]:=20=EB=B0=A9?= =?UTF-8?q?=EC=86=A1=20=EB=AA=A9=EB=A1=9D=20=ED=8E=98=EC=B9=AD=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=EC=9D=84=20useInfiniteQuery=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - features/liveList 추가 - widgets에 있던 라이브카드, 검색, 필터를 features/liveList로 이동 - useIntersect 훅 shared 레이어로 이동 - IconButton props 변경 --- apps/client/package.json | 1 - .../client/src/features/liveList/api/index.ts | 1 + .../liveList/api/livePreviewListApi.ts | 21 +++++ apps/client/src/features/liveList/index.ts | 2 + .../src/features/liveList/model/index.ts | 1 + .../src/features/liveList/model/queries.ts | 21 +++++ .../liveList/model/types.ts} | 4 +- .../liveList/ui}/FieldFilter.tsx | 0 .../liveList/ui/LivePreviewCard.tsx} | 2 +- .../liveList/ui}/Search.tsx | 2 +- apps/client/src/features/liveList/ui/index.ts | 3 + apps/client/src/pages/Home/index.ts | 2 +- apps/client/src/pages/Home/model/index.ts | 2 - .../src/pages/Home/{ => ui}/HomePage.tsx | 0 apps/client/src/pages/Home/ui/index.ts | 1 + apps/client/src/shared/lib/index.ts | 1 + .../Home/model => shared/lib}/useIntersect.ts | 10 ++- apps/client/src/shared/ui/IconButton.tsx | 8 +- apps/client/src/widgets/LiveList/LiveList.tsx | 86 +++++++++---------- pnpm-lock.yaml | 38 +++----- 20 files changed, 119 insertions(+), 87 deletions(-) create mode 100644 apps/client/src/features/liveList/api/index.ts create mode 100644 apps/client/src/features/liveList/api/livePreviewListApi.ts create mode 100644 apps/client/src/features/liveList/index.ts create mode 100644 apps/client/src/features/liveList/model/index.ts create mode 100644 apps/client/src/features/liveList/model/queries.ts rename apps/client/src/{pages/Home/model/homeTypes.ts => features/liveList/model/types.ts} (83%) rename apps/client/src/{widgets/LiveList => features/liveList/ui}/FieldFilter.tsx (100%) rename apps/client/src/{widgets/LiveList/LiveCard.tsx => features/liveList/ui/LivePreviewCard.tsx} (94%) rename apps/client/src/{widgets/LiveList => features/liveList/ui}/Search.tsx (96%) create mode 100644 apps/client/src/features/liveList/ui/index.ts delete mode 100644 apps/client/src/pages/Home/model/index.ts rename apps/client/src/pages/Home/{ => ui}/HomePage.tsx (100%) create mode 100644 apps/client/src/pages/Home/ui/index.ts rename apps/client/src/{pages/Home/model => shared/lib}/useIntersect.ts (74%) diff --git a/apps/client/package.json b/apps/client/package.json index a9dd54d..f8629e1 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -26,7 +26,6 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-hook-form": "^7.53.2", - "react-intersection-observer": "^9.15.1", "react-player": "^2.16.0", "react-router-dom": "^6.27.0", "socket.io-client": "^4.8.1", diff --git a/apps/client/src/features/liveList/api/index.ts b/apps/client/src/features/liveList/api/index.ts new file mode 100644 index 0000000..0858fac --- /dev/null +++ b/apps/client/src/features/liveList/api/index.ts @@ -0,0 +1 @@ +export { getLivePreviewList, searchLivePreviewList } from './livePreviewListApi'; diff --git a/apps/client/src/features/liveList/api/livePreviewListApi.ts b/apps/client/src/features/liveList/api/livePreviewListApi.ts new file mode 100644 index 0000000..20574cc --- /dev/null +++ b/apps/client/src/features/liveList/api/livePreviewListApi.ts @@ -0,0 +1,21 @@ +import { axiosInstance } from '@/shared/api'; +import { Field } from '@/shared/types'; +import { Cursor, LivePreviewInfo, LivePreviewListInfo } from '../model'; + +const LIMIT = 12; + +export const getLivePreviewList = async (field: Field, cursor: Cursor): Promise => { + const response = await axiosInstance.get('/v1/broadcasts', { params: { field, cursor, limit: LIMIT } }); + if (!response.data.success) { + throw new Error('방송 목록 조회에 실패했습니다.'); + } + return response.data.data; +}; + +export const searchLivePreviewList = async (keyword: string): Promise => { + const response = await axiosInstance.get('/v1/broadcasts/search', { params: { keyword: keyword.trim() } }); + if (!response.data.success) { + throw new Error('방송 목록 검색에 실패했습니다.'); + } + return response.data.data; +}; diff --git a/apps/client/src/features/liveList/index.ts b/apps/client/src/features/liveList/index.ts new file mode 100644 index 0000000..b2ada99 --- /dev/null +++ b/apps/client/src/features/liveList/index.ts @@ -0,0 +1,2 @@ +export type { LivePreviewInfo, LivePreviewListInfo } from './model'; +export { FieldFilter, LivePreviewCard, Search } from './ui'; diff --git a/apps/client/src/features/liveList/model/index.ts b/apps/client/src/features/liveList/model/index.ts new file mode 100644 index 0000000..65aeeaf --- /dev/null +++ b/apps/client/src/features/liveList/model/index.ts @@ -0,0 +1 @@ +export type { LivePreviewInfo, LivePreviewListInfo, Cursor } from './types'; diff --git a/apps/client/src/features/liveList/model/queries.ts b/apps/client/src/features/liveList/model/queries.ts new file mode 100644 index 0000000..e44cecd --- /dev/null +++ b/apps/client/src/features/liveList/model/queries.ts @@ -0,0 +1,21 @@ +import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; +import { getLivePreviewList, searchLivePreviewList } from '../api'; +import { Field } from '@/shared/types'; +import { Cursor } from './types'; + +export const useLivePreviewList = (field: Field) => + useInfiniteQuery({ + queryKey: ['live-preview-list', field], + queryFn: ({ pageParam }) => getLivePreviewList(field, pageParam), + initialPageParam: null as Cursor, + getNextPageParam: lastPage => lastPage.nextCursor, + refetchOnWindowFocus: true, + }); + +export const useSearchLivePreviewList = (keyword: string) => + useQuery({ + queryKey: ['live-preview-search', keyword], + queryFn: () => searchLivePreviewList(keyword), + enabled: !!keyword && keyword.trim().length > 0, + refetchOnWindowFocus: false, + }); diff --git a/apps/client/src/pages/Home/model/homeTypes.ts b/apps/client/src/features/liveList/model/types.ts similarity index 83% rename from apps/client/src/pages/Home/model/homeTypes.ts rename to apps/client/src/features/liveList/model/types.ts index 20b5047..1a7207a 100644 --- a/apps/client/src/pages/Home/model/homeTypes.ts +++ b/apps/client/src/features/liveList/model/types.ts @@ -9,7 +9,9 @@ export type LivePreviewInfo = { field: Field; }; +export type Cursor = string | null; + export type LivePreviewListInfo = { broadcasts: LivePreviewInfo[]; - nextCursor: string | null; + nextCursor: Cursor; }; diff --git a/apps/client/src/widgets/LiveList/FieldFilter.tsx b/apps/client/src/features/liveList/ui/FieldFilter.tsx similarity index 100% rename from apps/client/src/widgets/LiveList/FieldFilter.tsx rename to apps/client/src/features/liveList/ui/FieldFilter.tsx diff --git a/apps/client/src/widgets/LiveList/LiveCard.tsx b/apps/client/src/features/liveList/ui/LivePreviewCard.tsx similarity index 94% rename from apps/client/src/widgets/LiveList/LiveCard.tsx rename to apps/client/src/features/liveList/ui/LivePreviewCard.tsx index 3354470..9b18690 100644 --- a/apps/client/src/widgets/LiveList/LiveCard.tsx +++ b/apps/client/src/features/liveList/ui/LivePreviewCard.tsx @@ -8,7 +8,7 @@ type LiveCardProps = Readonly<{ thumbnailUrl: string; }>; -export function LiveCard({ liveId, title, userId, profileUrl, thumbnailUrl }: LiveCardProps) { +export function LivePreviewCard({ liveId, title, userId, profileUrl, thumbnailUrl }: LiveCardProps) { const navigate = useNavigate(); const handleClick = () => { diff --git a/apps/client/src/widgets/LiveList/Search.tsx b/apps/client/src/features/liveList/ui/Search.tsx similarity index 96% rename from apps/client/src/widgets/LiveList/Search.tsx rename to apps/client/src/features/liveList/ui/Search.tsx index d3fa755..b8e329f 100644 --- a/apps/client/src/widgets/LiveList/Search.tsx +++ b/apps/client/src/features/liveList/ui/Search.tsx @@ -27,7 +27,7 @@ export function Search({ onSearch }: SearchProps) { className="flex-1 bg-transparent focus-visible:outline-none" placeholder="검색할 방송 제목을 입력해주세요" /> - + diff --git a/apps/client/src/features/liveList/ui/index.ts b/apps/client/src/features/liveList/ui/index.ts new file mode 100644 index 0000000..3e6579c --- /dev/null +++ b/apps/client/src/features/liveList/ui/index.ts @@ -0,0 +1,3 @@ +export { FieldFilter } from './FieldFilter'; +export { LivePreviewCard } from './LivePreviewCard'; +export { Search } from './Search'; diff --git a/apps/client/src/pages/Home/index.ts b/apps/client/src/pages/Home/index.ts index 0799f47..9d44c0c 100644 --- a/apps/client/src/pages/Home/index.ts +++ b/apps/client/src/pages/Home/index.ts @@ -1 +1 @@ -export { HomePage } from './HomePage'; +export { HomePage } from './ui'; diff --git a/apps/client/src/pages/Home/model/index.ts b/apps/client/src/pages/Home/model/index.ts deleted file mode 100644 index e62cd6e..0000000 --- a/apps/client/src/pages/Home/model/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { useIntersect } from './useIntersect'; -export type { LivePreviewInfo, LivePreviewListInfo } from './homeTypes'; diff --git a/apps/client/src/pages/Home/HomePage.tsx b/apps/client/src/pages/Home/ui/HomePage.tsx similarity index 100% rename from apps/client/src/pages/Home/HomePage.tsx rename to apps/client/src/pages/Home/ui/HomePage.tsx diff --git a/apps/client/src/pages/Home/ui/index.ts b/apps/client/src/pages/Home/ui/index.ts new file mode 100644 index 0000000..0799f47 --- /dev/null +++ b/apps/client/src/pages/Home/ui/index.ts @@ -0,0 +1 @@ +export { HomePage } from './HomePage'; diff --git a/apps/client/src/shared/lib/index.ts b/apps/client/src/shared/lib/index.ts index 1f4c153..6a71a2e 100644 --- a/apps/client/src/shared/lib/index.ts +++ b/apps/client/src/shared/lib/index.ts @@ -3,3 +3,4 @@ export { useTheme } from './useTheme'; export { useToast } from './useToast'; export { getRtpCapabilities, createDevice, connectTransport } from './mediasoupHelpers'; export { cn, checkDependencies } from './utils'; +export { useIntersect } from './useIntersect'; diff --git a/apps/client/src/pages/Home/model/useIntersect.ts b/apps/client/src/shared/lib/useIntersect.ts similarity index 74% rename from apps/client/src/pages/Home/model/useIntersect.ts rename to apps/client/src/shared/lib/useIntersect.ts index 361be51..dcd6f57 100644 --- a/apps/client/src/pages/Home/model/useIntersect.ts +++ b/apps/client/src/shared/lib/useIntersect.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; type IntersectHandler = (entry: IntersectionObserverEntry, observer: IntersectionObserver) => void; @@ -8,11 +8,15 @@ type UseIntersectProps = { }; export const useIntersect = ({ onIntersect, options }: UseIntersectProps) => { + const [inView, setInView] = useState(false); const ref = useRef(null); const callback = useCallback( (entries: IntersectionObserverEntry[], observer: IntersectionObserver) => { entries.forEach(entry => { - if (entry.isIntersecting) onIntersect(entry, observer); + setInView(entry.isIntersecting); + if (entry.isIntersecting) { + onIntersect(entry, observer); + } }); }, [onIntersect], @@ -27,5 +31,5 @@ export const useIntersect = ({ onIntersect, options }: UseIntersectProps) => { }; }, [ref, options, callback]); - return ref; + return { ref, inView }; }; diff --git a/apps/client/src/shared/ui/IconButton.tsx b/apps/client/src/shared/ui/IconButton.tsx index 62fe9d4..9562b1c 100644 --- a/apps/client/src/shared/ui/IconButton.tsx +++ b/apps/client/src/shared/ui/IconButton.tsx @@ -1,3 +1,5 @@ +import { ButtonHTMLAttributes } from 'react'; + type IconButtonProps = Readonly<{ children: React.ReactNode; title?: string; @@ -5,9 +7,10 @@ type IconButtonProps = Readonly<{ onClick?: () => void; disabled?: boolean; className?: string; -}>; +}> & + ButtonHTMLAttributes; -export function IconButton({ children, title, ariaLabel, onClick, disabled, className }: IconButtonProps) { +export function IconButton({ children, title, ariaLabel, onClick, disabled, className, ...props }: IconButtonProps) { return ( diff --git a/apps/client/src/widgets/LiveList/LiveList.tsx b/apps/client/src/widgets/LiveList/LiveList.tsx index e3cd189..4d5a4d8 100644 --- a/apps/client/src/widgets/LiveList/LiveList.tsx +++ b/apps/client/src/widgets/LiveList/LiveList.tsx @@ -1,70 +1,62 @@ -import { useCallback, useEffect, useState } from 'react'; -import { axiosInstance } from '@/shared/api'; -import { FieldFilter } from './FieldFilter'; -import { LiveCard } from './LiveCard'; -import { LivePreviewInfo } from '@/pages/Home/model/homeTypes'; -import { Search } from './Search'; +import { useEffect, useState } from 'react'; +import { FieldFilter, LivePreviewCard, Search, LivePreviewInfo } from '@/features/liveList'; import { Field } from '@/shared/types/sharedTypes'; -import { useIntersect } from '@/pages/Home/model'; - -const LIMIT = 12; +import { useIntersect } from '@/shared/lib'; +import { useLivePreviewList, useSearchLivePreviewList } from '@/features/liveList/model/queries'; export function LiveList() { - const [liveList, setLiveList] = useState([]); - const [hasNext, setHasNext] = useState(true); - const [cursor, setCursor] = useState(null); const [field, setField] = useState(''); + const [searchKeyword, setSearchKeyword] = useState(''); + const [isSearching, setIsSearching] = useState(false); + const [liveList, setLiveList] = useState([]); - const getLiveList = useCallback(() => { - axiosInstance.get('/v1/broadcasts', { params: { field, cursor, limit: LIMIT } }).then(response => { - if (response.data.success) { - const { broadcasts, nextCursor } = response.data.data; - setLiveList(prev => [...prev, ...broadcasts]); - setCursor(nextCursor); - if (!nextCursor) setHasNext(false); - } - }); - }, [field, cursor]); + const { data: infiniteData, fetchNextPage, hasNextPage, isFetching } = useLivePreviewList(field); - const ref = useIntersect({ + const { data: searchData } = useSearchLivePreviewList(searchKeyword); + + const { ref } = useIntersect({ onIntersect: (entry, observer) => { observer.unobserve(entry.target); - if (hasNext && cursor) getLiveList(); + if (hasNextPage && !isFetching && !isSearching) { + fetchNextPage(); + } }, options: { threshold: 0.3 }, }); - const hanldeFilterField = (selectedField: Field) => { - axiosInstance - .get('/v1/broadcasts', { params: { field: selectedField, cursor: null, limit: LIMIT } }) - .then(response => { - if (response.data.success) { - const { broadcasts, nextCursor } = response.data.data; - setLiveList(broadcasts); - setCursor(nextCursor); - setHasNext(!!nextCursor); - } - }); + useEffect(() => { + if (!isSearching && infiniteData) { + const newList = infiniteData.pages.flatMap(page => page.broadcasts); + setLiveList(newList); + } + }, [infiniteData, isSearching]); + + useEffect(() => { + if (isSearching && searchData) { + setLiveList(searchData); + } + }, [searchData, isSearching]); + + const handleFilterField = (selectedField: Field) => { + setField(selectedField); + setIsSearching(false); + setSearchKeyword(''); }; const handleSearch = (keyword: string) => { + if (keyword.trim() === '') { + setIsSearching(false); + setSearchKeyword(''); + } + setSearchKeyword(keyword); setField(''); - setCursor(null); - axiosInstance.get('/v1/broadcasts/search', { params: { keyword: keyword.trim() } }).then(response => { - if (response.data.success) { - setLiveList(response.data.data); - } - }); + setIsSearching(true); }; - useEffect(() => { - getLiveList(); - }, [getLiveList]); - return (
- +
@@ -74,7 +66,7 @@ export function LiveList() { const { broadcastId, broadcastTitle, camperId, profileImage, thumbnail } = data; return (
- Date: Sun, 2 Mar 2025 15:17:06 +0900 Subject: [PATCH 14/15] =?UTF-8?q?:recycle:=20[Refactor]:=20liveList?= =?UTF-8?q?=EB=A5=BC=20=EB=AA=A8=EB=91=90=20livePreviewList=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/{liveList => livePreview}/api/index.ts | 0 .../{liveList => livePreview}/api/livePreviewListApi.ts | 0 .../src/features/{liveList => livePreview}/index.ts | 0 .../src/features/{liveList => livePreview}/model/index.ts | 0 .../features/{liveList => livePreview}/model/queries.ts | 0 .../src/features/{liveList => livePreview}/model/types.ts | 0 .../features/{liveList => livePreview}/ui/FieldFilter.tsx | 0 .../{liveList => livePreview}/ui/LivePreviewCard.tsx | 0 .../src/features/{liveList => livePreview}/ui/Search.tsx | 0 .../src/features/{liveList => livePreview}/ui/index.ts | 0 apps/client/src/pages/Home/ui/HomePage.tsx | 4 ++-- apps/client/src/widgets/LiveList/index.ts | 1 - .../LiveList.tsx => LivePreviewList/LivePreviewList.tsx} | 8 ++++---- apps/client/src/widgets/LivePreviewList/index.ts | 1 + apps/client/src/widgets/index.ts | 2 +- 15 files changed, 8 insertions(+), 8 deletions(-) rename apps/client/src/features/{liveList => livePreview}/api/index.ts (100%) rename apps/client/src/features/{liveList => livePreview}/api/livePreviewListApi.ts (100%) rename apps/client/src/features/{liveList => livePreview}/index.ts (100%) rename apps/client/src/features/{liveList => livePreview}/model/index.ts (100%) rename apps/client/src/features/{liveList => livePreview}/model/queries.ts (100%) rename apps/client/src/features/{liveList => livePreview}/model/types.ts (100%) rename apps/client/src/features/{liveList => livePreview}/ui/FieldFilter.tsx (100%) rename apps/client/src/features/{liveList => livePreview}/ui/LivePreviewCard.tsx (100%) rename apps/client/src/features/{liveList => livePreview}/ui/Search.tsx (100%) rename apps/client/src/features/{liveList => livePreview}/ui/index.ts (100%) delete mode 100644 apps/client/src/widgets/LiveList/index.ts rename apps/client/src/widgets/{LiveList/LiveList.tsx => LivePreviewList/LivePreviewList.tsx} (95%) create mode 100644 apps/client/src/widgets/LivePreviewList/index.ts diff --git a/apps/client/src/features/liveList/api/index.ts b/apps/client/src/features/livePreview/api/index.ts similarity index 100% rename from apps/client/src/features/liveList/api/index.ts rename to apps/client/src/features/livePreview/api/index.ts diff --git a/apps/client/src/features/liveList/api/livePreviewListApi.ts b/apps/client/src/features/livePreview/api/livePreviewListApi.ts similarity index 100% rename from apps/client/src/features/liveList/api/livePreviewListApi.ts rename to apps/client/src/features/livePreview/api/livePreviewListApi.ts diff --git a/apps/client/src/features/liveList/index.ts b/apps/client/src/features/livePreview/index.ts similarity index 100% rename from apps/client/src/features/liveList/index.ts rename to apps/client/src/features/livePreview/index.ts diff --git a/apps/client/src/features/liveList/model/index.ts b/apps/client/src/features/livePreview/model/index.ts similarity index 100% rename from apps/client/src/features/liveList/model/index.ts rename to apps/client/src/features/livePreview/model/index.ts diff --git a/apps/client/src/features/liveList/model/queries.ts b/apps/client/src/features/livePreview/model/queries.ts similarity index 100% rename from apps/client/src/features/liveList/model/queries.ts rename to apps/client/src/features/livePreview/model/queries.ts diff --git a/apps/client/src/features/liveList/model/types.ts b/apps/client/src/features/livePreview/model/types.ts similarity index 100% rename from apps/client/src/features/liveList/model/types.ts rename to apps/client/src/features/livePreview/model/types.ts diff --git a/apps/client/src/features/liveList/ui/FieldFilter.tsx b/apps/client/src/features/livePreview/ui/FieldFilter.tsx similarity index 100% rename from apps/client/src/features/liveList/ui/FieldFilter.tsx rename to apps/client/src/features/livePreview/ui/FieldFilter.tsx diff --git a/apps/client/src/features/liveList/ui/LivePreviewCard.tsx b/apps/client/src/features/livePreview/ui/LivePreviewCard.tsx similarity index 100% rename from apps/client/src/features/liveList/ui/LivePreviewCard.tsx rename to apps/client/src/features/livePreview/ui/LivePreviewCard.tsx diff --git a/apps/client/src/features/liveList/ui/Search.tsx b/apps/client/src/features/livePreview/ui/Search.tsx similarity index 100% rename from apps/client/src/features/liveList/ui/Search.tsx rename to apps/client/src/features/livePreview/ui/Search.tsx diff --git a/apps/client/src/features/liveList/ui/index.ts b/apps/client/src/features/livePreview/ui/index.ts similarity index 100% rename from apps/client/src/features/liveList/ui/index.ts rename to apps/client/src/features/livePreview/ui/index.ts diff --git a/apps/client/src/pages/Home/ui/HomePage.tsx b/apps/client/src/pages/Home/ui/HomePage.tsx index abc49be..8033883 100644 --- a/apps/client/src/pages/Home/ui/HomePage.tsx +++ b/apps/client/src/pages/Home/ui/HomePage.tsx @@ -1,10 +1,10 @@ -import { Banner, LiveList } from '@/widgets'; +import { Banner, LivePreviewList } from '@/widgets'; export function HomePage() { return (
- +
); } diff --git a/apps/client/src/widgets/LiveList/index.ts b/apps/client/src/widgets/LiveList/index.ts deleted file mode 100644 index e761f10..0000000 --- a/apps/client/src/widgets/LiveList/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { LiveList } from './LiveList'; diff --git a/apps/client/src/widgets/LiveList/LiveList.tsx b/apps/client/src/widgets/LivePreviewList/LivePreviewList.tsx similarity index 95% rename from apps/client/src/widgets/LiveList/LiveList.tsx rename to apps/client/src/widgets/LivePreviewList/LivePreviewList.tsx index 4d5a4d8..2d70d95 100644 --- a/apps/client/src/widgets/LiveList/LiveList.tsx +++ b/apps/client/src/widgets/LivePreviewList/LivePreviewList.tsx @@ -1,10 +1,10 @@ import { useEffect, useState } from 'react'; -import { FieldFilter, LivePreviewCard, Search, LivePreviewInfo } from '@/features/liveList'; -import { Field } from '@/shared/types/sharedTypes'; +import { FieldFilter, LivePreviewCard, Search, LivePreviewInfo } from '@/features/livePreview'; +import { Field } from '@/shared/types'; import { useIntersect } from '@/shared/lib'; -import { useLivePreviewList, useSearchLivePreviewList } from '@/features/liveList/model/queries'; +import { useLivePreviewList, useSearchLivePreviewList } from '@/features/livePreview/model/queries'; -export function LiveList() { +export function LivePreviewList() { const [field, setField] = useState(''); const [searchKeyword, setSearchKeyword] = useState(''); const [isSearching, setIsSearching] = useState(false); diff --git a/apps/client/src/widgets/LivePreviewList/index.ts b/apps/client/src/widgets/LivePreviewList/index.ts new file mode 100644 index 0000000..06d02ea --- /dev/null +++ b/apps/client/src/widgets/LivePreviewList/index.ts @@ -0,0 +1 @@ +export { LivePreviewList } from './LivePreviewList'; diff --git a/apps/client/src/widgets/index.ts b/apps/client/src/widgets/index.ts index 635bafa..92b6db0 100644 --- a/apps/client/src/widgets/index.ts +++ b/apps/client/src/widgets/index.ts @@ -1,3 +1,3 @@ export { Banner } from './Banner'; -export { LiveList } from './LiveList'; +export { LivePreviewList } from './LivePreviewList'; export { Header } from './Header'; From a480e91f19a096bddb92f4a3f8cb9497bedc4ab2 Mon Sep 17 00:00:00 2001 From: zero0205 Date: Sun, 2 Mar 2025 17:13:42 +0900 Subject: [PATCH 15/15] =?UTF-8?q?:recycle:=20[Refactor]:=20=EC=9C=A0?= =?UTF-8?q?=EC=A0=80=20=EC=A0=95=EB=B3=B4=20=EC=88=98=EC=A0=95=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20useMutation=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/client/src/entities/user/api/userApi.ts | 11 +++++- apps/client/src/entities/user/index.ts | 6 +-- .../client/src/entities/user/model/queries.ts | 35 ++++++++++++++++- apps/client/src/entities/user/model/types.ts | 9 +++++ .../src/features/editProfile/lib/index.ts | 2 + .../src/features/editProfile/lib/types.ts | 11 ++++++ .../src/features/editProfile/lib/utils.ts | 15 +++++++ .../features/editProfile/ui/EditUserInfo.tsx | 39 +++---------------- 8 files changed, 89 insertions(+), 39 deletions(-) create mode 100644 apps/client/src/features/editProfile/lib/index.ts create mode 100644 apps/client/src/features/editProfile/lib/types.ts create mode 100644 apps/client/src/features/editProfile/lib/utils.ts diff --git a/apps/client/src/entities/user/api/userApi.ts b/apps/client/src/entities/user/api/userApi.ts index 20012fb..4411535 100644 --- a/apps/client/src/entities/user/api/userApi.ts +++ b/apps/client/src/entities/user/api/userApi.ts @@ -1,4 +1,4 @@ -import { UserData } from '@/entities/user'; +import { UserData, MutationUserData } from '@/entities/user'; import { axiosInstance } from '@/shared/api'; export const getUserInfo = async (): Promise => { @@ -17,3 +17,12 @@ export const getUserProfileImage = async (): Promise => { return response.data.data.profileImage; }; + +export const patchUserInfo = async (formData: MutationUserData) => { + const response = await axiosInstance.patch('/v1/members/info', formData); + if (!response.data.success) { + throw new Error(response.data.message); + } + + return response.data; +}; diff --git a/apps/client/src/entities/user/index.ts b/apps/client/src/entities/user/index.ts index ddfbff2..f6765a8 100644 --- a/apps/client/src/entities/user/index.ts +++ b/apps/client/src/entities/user/index.ts @@ -1,3 +1,3 @@ -export type { UserData } from './model/types'; -export { getUserInfo, getUserProfileImage } from './api/userApi'; -export { useUserData, useProfileImage } from './model/queries'; +export type { UserData, MutationUserData } from './model/types'; +export { getUserInfo, getUserProfileImage, patchUserInfo } from './api/userApi'; +export { useUserData, useProfileImage, useUserDataMutation } from './model/queries'; diff --git a/apps/client/src/entities/user/model/queries.ts b/apps/client/src/entities/user/model/queries.ts index 1f7bcd5..0cba428 100644 --- a/apps/client/src/entities/user/model/queries.ts +++ b/apps/client/src/entities/user/model/queries.ts @@ -1,6 +1,8 @@ -import { useQuery } from '@tanstack/react-query'; -import { getUserInfo, getUserProfileImage } from '@/entities/user'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { getUserInfo, getUserProfileImage, patchUserInfo } from '@/entities/user'; import { userKeys } from './queryFactory'; +import { useToast } from '@/shared/lib'; +import { MutationUserData } from './types'; export const useUserData = () => { const { @@ -22,3 +24,32 @@ export const useProfileImage = (isLoggedIn: boolean) => { return { profileImgUrl: data, isLoading, error }; }; + +export const useUserDataMutation = (onSuccessCallback: () => void) => { + const queryClient = useQueryClient(); + const { toast } = useToast(); + + return useMutation({ + mutationFn: (formData: MutationUserData) => patchUserInfo(formData), + onSuccess: async data => { + if (data.success) { + toast({ title: '프로필 업데이트 성공', variant: 'default' }); + await queryClient.invalidateQueries({ queryKey: userKeys.profile(), refetchType: 'active' }); + onSuccessCallback(); + } else { + toast({ + title: '프로필 업데이트 실패', + description: data.message || '알 수 없는 오류가 발생했습니다', + variant: 'destructive', + }); + } + }, + onError: error => { + toast({ + title: '프로필 업데이트 실패', + description: error instanceof Error ? error.message : '네트워크 오류가 발생했습니다', + variant: 'destructive', + }); + }, + }); +}; diff --git a/apps/client/src/entities/user/model/types.ts b/apps/client/src/entities/user/model/types.ts index 5b54fd7..19d0c8c 100644 --- a/apps/client/src/entities/user/model/types.ts +++ b/apps/client/src/entities/user/model/types.ts @@ -15,3 +15,12 @@ export type UserData = { contacts: Contacts; profileImage: string; }; + +export type MutationUserData = { + contacts: { + email: string; + github: string; + blog: string; + linkedin: string; + }; +} & Pick; diff --git a/apps/client/src/features/editProfile/lib/index.ts b/apps/client/src/features/editProfile/lib/index.ts new file mode 100644 index 0000000..a7b45ac --- /dev/null +++ b/apps/client/src/features/editProfile/lib/index.ts @@ -0,0 +1,2 @@ +export type { FormInput } from './types'; +export { transformFormToApiData } from './utils'; diff --git a/apps/client/src/features/editProfile/lib/types.ts b/apps/client/src/features/editProfile/lib/types.ts new file mode 100644 index 0000000..6486267 --- /dev/null +++ b/apps/client/src/features/editProfile/lib/types.ts @@ -0,0 +1,11 @@ +import { Field } from '@/shared/types'; + +export type FormInput = { + camperId: string | undefined; + name: string | undefined; + field: Field | undefined; + email: string | undefined; + github: string | undefined; + blog: string | undefined; + linkedIn: string | undefined; +}; diff --git a/apps/client/src/features/editProfile/lib/utils.ts b/apps/client/src/features/editProfile/lib/utils.ts new file mode 100644 index 0000000..e12784f --- /dev/null +++ b/apps/client/src/features/editProfile/lib/utils.ts @@ -0,0 +1,15 @@ +import { Field } from '@/shared/types'; +import { FormInput } from './types'; +import { MutationUserData } from '@/entities/user'; + +export const transformFormToApiData = (data: FormInput, selectedField: Field | undefined): MutationUserData => ({ + name: data.name!, + camperId: data.camperId!, + field: selectedField!, + contacts: { + email: data.email ? data.email : '', + github: data.github ? data.github : '', + blog: data.blog ? data.blog : '', + linkedin: data.linkedIn ? data.linkedIn : '', + }, + }); diff --git a/apps/client/src/features/editProfile/ui/EditUserInfo.tsx b/apps/client/src/features/editProfile/ui/EditUserInfo.tsx index 6cffb6c..ff50419 100644 --- a/apps/client/src/features/editProfile/ui/EditUserInfo.tsx +++ b/apps/client/src/features/editProfile/ui/EditUserInfo.tsx @@ -1,27 +1,16 @@ import { useForm } from 'react-hook-form'; import { useState } from 'react'; import { Avatar, AvatarFallback, AvatarImage } from '@/shared/ui/shadcn/avatar'; -import { UserData } from '@/pages/Profile'; -import { Field } from '@/shared/types/sharedTypes'; +import { UserData, useUserDataMutation } from '@/entities/user'; +import { Field } from '@/shared/types'; import { Button } from '@/shared/ui/shadcn/button'; -import { axiosInstance } from '@/shared/api'; -import { useToast } from '@/shared/lib'; +import { FormInput, transformFormToApiData } from '../lib'; type EditUserInfoProps = Readonly<{ userData: UserData | undefined; toggleEditing: () => void; }>; -export type FormInput = { - camperId: string | undefined; - name: string | undefined; - field: Field | undefined; - email: string | undefined; - github: string | undefined; - blog: string | undefined; - linkedIn: string | undefined; -}; - export function EditUserInfo({ userData, toggleEditing }: EditUserInfoProps) { const [selectedField, setSelectedField] = useState(userData?.field); const { @@ -39,34 +28,18 @@ export function EditUserInfo({ userData, toggleEditing }: EditUserInfoProps) { linkedIn: userData?.contacts.linkedIn, }, }); - const { toast } = useToast(); + const { mutateAsync } = useUserDataMutation(toggleEditing); const handleSelectField = (field: Field) => { setSelectedField(selectedField === field ? '' : field); }; const handlePatchUserInfo = (data: FormInput) => { - const formData = { - name: data.name, - camperId: data.camperId, - field: selectedField, - contacts: { - email: data.email ? data.email : '', - github: data.github ? data.github : '', - blog: data.blog ? data.blog : '', - linkedin: data.linkedIn ? data.linkedIn : '', - }, - }; + const formData = transformFormToApiData(data, selectedField); if (!formData.field) return; - axiosInstance.patch('/v1/members/info', formData).then(response => { - if (response.data.success) { - toggleEditing(); - } else { - toast({ variant: 'destructive', title: '유저 정보 수정 실패' }); - } - }); + mutateAsync(formData); }; return (