From d24ecd3b8952a16690c1bfe4b9dc9be54ec533e0 Mon Sep 17 00:00:00 2001 From: JeonYumin <40783675+JeonYumin94@users.noreply.github.com> Date: Mon, 22 Jul 2024 17:26:47 +0900 Subject: [PATCH 01/19] =?UTF-8?q?FE-34=20:sparkles:=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20(#53)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * FE-34 :lipstick: 마이페이지 UI 초기작업 * FE-34 :sparkles: 내 정보 조회 API 연동 * FE-34 :lipstick: shadcn/ui Dialog 설치 * FE-34 :sparkles: 프로필 수정 API 연동 * FE-34 :sparkles: 이미지 실패 토스트 추가 --- next.config.mjs | 17 +++ package-lock.json | 119 +++++++++++++++- package.json | 3 + src/apis/index.ts | 26 +++- src/apis/user.ts | 10 +- src/components/ui/dialog.tsx | 65 +++++++++ src/hooks/userQueryHooks.ts | 16 ++- src/pageLayout/MypageLayout/MyPageLayout.tsx | 47 +++++++ src/pages/_app.tsx | 2 + src/pages/mypage/index.tsx | 5 + src/schema/user.ts | 15 ++ src/types/user.ts | 13 ++ src/user/ui-profile/Profile.tsx | 41 ++++++ src/user/ui-profile/ProfileEdit.tsx | 140 +++++++++++++++++++ src/user/utill/constants.ts | 3 + src/user/utill/fileNameChange.ts | 8 ++ tailwind.config.js | 3 + 17 files changed, 521 insertions(+), 12 deletions(-) create mode 100644 src/components/ui/dialog.tsx create mode 100644 src/pageLayout/MypageLayout/MyPageLayout.tsx create mode 100644 src/pages/mypage/index.tsx create mode 100644 src/types/user.ts create mode 100644 src/user/ui-profile/Profile.tsx create mode 100644 src/user/ui-profile/ProfileEdit.tsx create mode 100644 src/user/utill/constants.ts create mode 100644 src/user/utill/fileNameChange.ts diff --git a/next.config.mjs b/next.config.mjs index d5456a15..49ba09f8 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,6 +1,23 @@ /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, + images: { + domains: ['sprint-fe-project.s3.ap-northeast-2.amazonaws.com', 'localhost'], + remotePatterns: [ + { + protocol: 'https', + hostname: 'via.placeholder.com', + port: '', + pathname: '/**', + }, + ], + }, + rewrites: async () => [ + { + source: '/api/proxy/:path*', + destination: 'https://sprint-fe-project.s3.ap-northeast-2.amazonaws.com/:path*', + }, + ], }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 561da0f2..d3bddea1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@lukemorales/query-key-factory": "^1.3.4", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-avatar": "^1.1.0", + "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-radio-group": "^1.2.0", @@ -23,6 +24,7 @@ "axios": "^1.7.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "formik": "^2.4.6", "lucide-react": "^0.402.0", "next": "14.2.4", "qs": "^6.12.2", @@ -33,6 +35,7 @@ "sharp": "^0.33.4", "tailwind-merge": "^2.4.0", "tailwindcss-animate": "^1.0.7", + "yup": "^1.4.0", "zod": "^3.23.8" }, "devDependencies": { @@ -1917,6 +1920,15 @@ "react": "^18 || ^19" } }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.5.tgz", + "integrity": "sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==", + "dependencies": { + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0" + } + }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", @@ -1935,8 +1947,7 @@ "node_modules/@types/prop-types": { "version": "15.7.12", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", - "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==", - "devOptional": true + "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==" }, "node_modules/@types/qs": { "version": "6.9.15", @@ -1948,7 +1959,6 @@ "version": "18.3.3", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz", "integrity": "sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw==", - "devOptional": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -2841,8 +2851,7 @@ "node_modules/csstype": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, "node_modules/damerau-levenshtein": { "version": "1.0.8", @@ -2956,6 +2965,14 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/deepmerge": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-2.2.1.tgz", + "integrity": "sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -4166,6 +4183,30 @@ "node": ">= 6" } }, + "node_modules/formik": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/formik/-/formik-2.4.6.tgz", + "integrity": "sha512-A+2EI7U7aG296q2TLGvNapDNTZp1khVt5Vk0Q/fyfSROss0V/V6+txt2aJnwEos44IxTCW/LYAi/zgWzlevj+g==", + "funding": [ + { + "type": "individual", + "url": "https://opencollective.com/formik" + } + ], + "dependencies": { + "@types/hoist-non-react-statics": "^3.3.1", + "deepmerge": "^2.1.1", + "hoist-non-react-statics": "^3.3.0", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "react-fast-compare": "^2.0.1", + "tiny-warning": "^1.0.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -4491,6 +4532,19 @@ "node": ">= 0.4" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, "node_modules/husky": { "version": "9.0.11", "resolved": "https://registry.npmjs.org/husky/-/husky-9.0.11.tgz", @@ -5155,8 +5209,12 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" }, "node_modules/lodash.merge": { "version": "4.6.2", @@ -6173,6 +6231,11 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "dev": true }, + "node_modules/property-expr": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", + "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==" + }, "node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -6243,6 +6306,11 @@ "react": "^18.3.1" } }, + "node_modules/react-fast-compare": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-2.0.4.tgz", + "integrity": "sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==" + }, "node_modules/react-hook-form": { "version": "7.52.1", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.52.1.tgz", @@ -7105,6 +7173,16 @@ "node": ">=0.8" } }, + "node_modules/tiny-case": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-case/-/tiny-case-1.0.3.tgz", + "integrity": "sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==" + }, + "node_modules/tiny-warning": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -7116,6 +7194,11 @@ "node": ">=8.0" } }, + "node_modules/toposort": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", + "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==" + }, "node_modules/ts-api-utils": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz", @@ -7608,6 +7691,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yup": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/yup/-/yup-1.4.0.tgz", + "integrity": "sha512-wPbgkJRCqIf+OHyiTBQoJiP5PFuAXaWiJK6AmYkzQAh5/c2K9hzSApBZG5wV9KoKSePF7sAxmNSvh/13YHkFDg==", + "dependencies": { + "property-expr": "^2.0.5", + "tiny-case": "^1.0.3", + "toposort": "^2.0.2", + "type-fest": "^2.19.0" + } + }, + "node_modules/yup/node_modules/type-fest": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "3.23.8", "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", diff --git a/package.json b/package.json index 66aa1a4e..e1fa6298 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@lukemorales/query-key-factory": "^1.3.4", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-avatar": "^1.1.0", + "@radix-ui/react-dialog": "^1.1.1", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-radio-group": "^1.2.0", @@ -28,6 +29,7 @@ "axios": "^1.7.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "formik": "^2.4.6", "lucide-react": "^0.402.0", "next": "14.2.4", "qs": "^6.12.2", @@ -38,6 +40,7 @@ "sharp": "^0.33.4", "tailwind-merge": "^2.4.0", "tailwindcss-animate": "^1.0.7", + "yup": "^1.4.0", "zod": "^3.23.8" }, "devDependencies": { diff --git a/src/apis/index.ts b/src/apis/index.ts index 29949fc2..2748fca1 100644 --- a/src/apis/index.ts +++ b/src/apis/index.ts @@ -1,10 +1,34 @@ import axios from 'axios'; import qs from 'qs'; +// NOTE: 토큰 가져오는 함수 +const getToken = () => + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MjQsInRlYW1JZCI6IjUtOSIsInNjb3BlIjoicmVmcmVzaCIsImlhdCI6MTcyMTYxNTYxNSwiZXhwIjoxNzIyMjIwNDE1LCJpc3MiOiJzcC1lcGlncmFtIn0.sCNFSgOQcYGbXWTxWablo9bOmbsw1EI6dTWt8n0xmDQ'; + +// NOTE: axios 선언 const httpClient = axios.create({ baseURL: process.env.NEXT_PUBLIC_BASE_URL, - headers: { 'Content-Type': 'application/json' }, paramsSerializer: (parameters) => qs.stringify(parameters, { arrayFormat: 'repeat', encode: false }), }); +// NOTE: 요청 인터셉터 추가 +httpClient.interceptors.request.use( + (config) => { + const newConfig = { ...config }; + const token = getToken(); + if (token) { + newConfig.headers.Authorization = `Bearer ${token}`; + } + + if (newConfig.data instanceof FormData) { + newConfig.headers['Content-Type'] = 'multipart/form-data'; + } else { + newConfig.headers['Content-Type'] = 'application/json'; + } + + return newConfig; + }, + (error) => Promise.reject(error), +); + export default httpClient; diff --git a/src/apis/user.ts b/src/apis/user.ts index 395b0167..551dad17 100644 --- a/src/apis/user.ts +++ b/src/apis/user.ts @@ -1,4 +1,5 @@ -import type { GetUserReponseType, GetUserRequestType, PatchMeRequestType } from '@/schema/user'; +import type { GetUserReponseType, GetUserRequestType, PatchMeRequestType, PostPresignedUrlRequestType, PostPresignedUrlResponseType } from '@/schema/user'; + import httpClient from '.'; export const getMe = async (): Promise => { @@ -16,3 +17,10 @@ export const updateMe = async (request: PatchMeRequestType): Promise => { + const formData = new FormData(); + formData.append('image', request.image); + const response = await httpClient.post('/images/upload', formData); + return response.data; +}; diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx new file mode 100644 index 00000000..f44152fd --- /dev/null +++ b/src/components/ui/dialog.tsx @@ -0,0 +1,65 @@ +import * as React from 'react'; +import * as DialogPrimitive from '@radix-ui/react-dialog'; +import { X } from 'lucide-react'; + +import cn from '@/lib/utils'; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +function DialogHeader({ className, ...props }: React.HTMLAttributes) { + return
; +} +DialogHeader.displayName = 'DialogHeader'; + +function DialogFooter({ className, ...props }: React.HTMLAttributes) { + return
; +} +DialogFooter.displayName = 'DialogFooter'; + +const DialogTitle = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef, React.ComponentPropsWithoutRef>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { Dialog, DialogPortal, DialogOverlay, DialogClose, DialogTrigger, DialogContent, DialogHeader, DialogFooter, DialogTitle, DialogDescription }; diff --git a/src/hooks/userQueryHooks.ts b/src/hooks/userQueryHooks.ts index 7c28fe75..f5043b15 100644 --- a/src/hooks/userQueryHooks.ts +++ b/src/hooks/userQueryHooks.ts @@ -1,6 +1,6 @@ import quries from '@/apis/queries'; -import { updateMe } from '@/apis/user'; -import { GetUserReponseType, GetUserRequestType, PatchMeRequestType } from '@/schema/user'; +import { updateMe, createPresignedUrl } from '@/apis/user'; +import { GetUserRequestType, PatchMeRequestType, PostPresignedUrlRequestType, PostPresignedUrlResponseType } from '@/schema/user'; import { MutationOptions } from '@/types/query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; @@ -8,7 +8,7 @@ export const useMeQuery = () => useQuery(quries.user.getMe()); export const useUserQuery = (requset: GetUserRequestType) => useQuery(quries.user.getUser(requset)); -export const useUpdateMe = (options: MutationOptions) => { +export const useUpdateMe = (options: MutationOptions) => { const queryClient = useQueryClient(); return useMutation({ mutationFn: (request: PatchMeRequestType) => updateMe(request), @@ -21,3 +21,13 @@ export const useUpdateMe = (options: MutationOptions) => { }, }); }; + +// presignedUrl 생성 +export const useCreatePresignedUrl = (options?: MutationOptions) => + useMutation({ + mutationFn: (request: PostPresignedUrlRequestType) => createPresignedUrl(request), + ...options, + onSuccess: (data: PostPresignedUrlResponseType) => + // 이미지 URL 반환 + data.url, + }); diff --git a/src/pageLayout/MypageLayout/MyPageLayout.tsx b/src/pageLayout/MypageLayout/MyPageLayout.tsx new file mode 100644 index 00000000..0046edaa --- /dev/null +++ b/src/pageLayout/MypageLayout/MyPageLayout.tsx @@ -0,0 +1,47 @@ +import Header from '@/components/Header/Header'; +import { useMeQuery } from '@/hooks/userQueryHooks'; +import UserInfo from '@/types/user'; +import Profile from '@/user/ui-profile/Profile'; +import { useRouter } from 'next/navigation'; + +export default function MyPageLayout() { + const { data, isLoading, isError }: { data: UserInfo | undefined; isLoading: boolean; isError: boolean } = useMeQuery(); + + const router = useRouter(); + + if (isError) { + return
error
; + } + + if (isLoading) { + return
loading
; + } + + // NOTE: 회원정보가 확인되지 않는다면 로그인 페이지로 이동 + if (!data) { + router.push('/login'); + return false; + } + + return ( +
+
{}} /> +
+
+ +
오늘의 감정
+
캘린더
+
감정차트
+
+
+
+
+

내 에피그램(19)

+

내 댓글(110)

+
+
댓글 컴포넌트
+
+
+
+ ); +} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 37d2f8d3..107acf01 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -3,6 +3,7 @@ import '@/styles/globals.css'; import type { AppProps } from 'next/app'; import { HydrationBoundary, QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import Toaster from '@/components/ui/toaster'; export default function App({ Component, pageProps }: AppProps) { const [queryClient] = React.useState(() => new QueryClient()); @@ -10,6 +11,7 @@ export default function App({ Component, pageProps }: AppProps) { + diff --git a/src/pages/mypage/index.tsx b/src/pages/mypage/index.tsx new file mode 100644 index 00000000..69b8c83e --- /dev/null +++ b/src/pages/mypage/index.tsx @@ -0,0 +1,5 @@ +import MyPageLayout from '@/pageLayout/MypageLayout/MyPageLayout'; + +export default function mypage() { + return ; +} diff --git a/src/schema/user.ts b/src/schema/user.ts index 83d1f8d8..753011d7 100644 --- a/src/schema/user.ts +++ b/src/schema/user.ts @@ -1,4 +1,5 @@ import * as z from 'zod'; +import { MAX_FILE_SIZE, ACCEPTED_IMAGE_TYPES } from '@/user/utill/constants'; export const PatchMeRequest = z.object({ image: z.string().url(), @@ -18,6 +19,20 @@ export const GetUserReponse = z.object({ id: z.number(), }); +const PostPresignedUrlRequest = z.object({ + image: z + .instanceof(File) + .refine((file) => file.size <= MAX_FILE_SIZE, `업로드 파일의 용량은 최대 ${MAX_FILE_SIZE / (1024 * 1024)}MB 입니다.`) + .refine((file) => ACCEPTED_IMAGE_TYPES.includes(file.type), '.jpg, .jpeg, .png 확장자만 업로드 가능합니다.'), +}); + +export const PostPresignedUrlResponse = z.object({ + url: z.string().url(), +}); + export type GetUserReponseType = z.infer; export type GetUserRequestType = z.infer; export type PatchMeRequestType = z.infer; + +export type PostPresignedUrlRequestType = z.infer; +export type PostPresignedUrlResponseType = z.infer; diff --git a/src/types/user.ts b/src/types/user.ts new file mode 100644 index 00000000..d821ed31 --- /dev/null +++ b/src/types/user.ts @@ -0,0 +1,13 @@ +export default interface UserInfo { + nickname: string; + image: string; + id: number; + updatedAt: Date; + createdAt: Date; + teamId: string; +} + +export interface UserProfileProps { + image: string; + nickname: string; +} diff --git a/src/user/ui-profile/Profile.tsx b/src/user/ui-profile/Profile.tsx new file mode 100644 index 00000000..42fd5f74 --- /dev/null +++ b/src/user/ui-profile/Profile.tsx @@ -0,0 +1,41 @@ +import Image from 'next/image'; +import { UserProfileProps } from '@/types/user'; +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Dialog, DialogTrigger, DialogContent } from '@/components/ui/dialog'; +import { sampleImage } from '../utill/constants'; +import ProfileEdit from './ProfileEdit'; + +export default function Profile({ image, nickname }: UserProfileProps) { + const [isModalOpen, setIsModalOpen] = useState(false); + + const handleProfileEditClose = () => { + setIsModalOpen(false); + }; + + // TODO: 여러개의 샘플 이미지 랜덤하게 뜨도록 추가 할 예정 + const profileImage = image || sampleImage; + + return ( +
+
+
+ 유저 프로필 +
+
+

{nickname}

+
+ + + + + + + + +
+
+ ); +} diff --git a/src/user/ui-profile/ProfileEdit.tsx b/src/user/ui-profile/ProfileEdit.tsx new file mode 100644 index 00000000..218f2c7a --- /dev/null +++ b/src/user/ui-profile/ProfileEdit.tsx @@ -0,0 +1,140 @@ +import Image from 'next/image'; +import { UserProfileProps } from '@/types/user'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import Label from '@/components/ui/label'; +import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { useToast } from '@/components/ui/use-toast'; +import { useEffect, useRef } from 'react'; +import { Form, Formik, useFormik } from 'formik'; +import { useCreatePresignedUrl, useUpdateMe } from '@/hooks/userQueryHooks'; +import * as Yup from 'yup'; +import { AxiosError } from 'axios'; +import fileNameChange from '../utill/fileNameChange'; + +interface UserProfileEditProps { + initialValues: { + image: string; + nickname: string; + }; + onModalClose: () => void; +} + +const validationSchema = Yup.object().shape({ + nickname: Yup.string().min(1, '닉네임은 1자 이상 30자 이하여야 합니다.').max(30, '닉네임은 1자 이상 30자 이하여야 합니다.').required('닉네임은 필수 항목입니다.'), +}); + +export default function ProfileEdit({ initialValues, onModalClose }: UserProfileEditProps) { + const createPresignedUrl = useCreatePresignedUrl(); + const fileInputRef = useRef(null); + + const { toast } = useToast(); + + const handleSubmit = async () => { + await formik.submitForm(); // Formik의 submitForm 함수 호출 + }; + + const { mutate: updateMe } = useUpdateMe({ + onSuccess: () => { + onModalClose(); + toast({ + description: '프로필 수정이 완료되었습니다.', + }); + }, + onError: () => { + toast({ + description: '프로필 수정 실패', + }); + }, + }); + + const formik = useFormik({ + initialValues: { + image: '', + nickname: '', + }, + validationSchema, + onSubmit: async (values, { setSubmitting }) => { + try { + // 프로필 업데이트 + await updateProfile(values); + setSubmitting(false); + } catch (error) { + // 에러 처리 + } finally { + setSubmitting(false); + } + }, + }); + + const updateProfile = (values: UserProfileProps) => { + updateMe(values); + }; + + // 프로필 사진 변경 클릭 + const handleImageEditClick = () => { + if (fileInputRef.current) { + fileInputRef.current.click(); + } + }; + + // 이미지 변경 시 + async function handleImageChange(e: React.ChangeEvent): Promise { + const { files } = e.currentTarget; + if (files && files.length > 0) { + const file = files[0]; + + try { + // 중복된 파일명 및 한글파일이 저장되지 않도록 파일이름 포멧 변경 + const newFileName = fileNameChange(); + const newFile = new File([file], `${newFileName}.${file.name.split('.').pop()}`, { type: file.type }); + + // presignedUrl 구하는 함수 (s3 업로드까지 같이) + const { url } = await createPresignedUrl.mutateAsync({ image: newFile }); + formik.setFieldValue('image', url); + } catch (error) { + // 에러 처리: 실패 시 토스트 메시지 + const axiosError = error as AxiosError; + + onModalClose(); + const errorMessage = `(error: ${axiosError.response?.status}) 잘못 된 요청입니다. 관리자에게 문의해주세요`; + + toast({ + description: errorMessage, + className: 'bg-red-400 text-white', + }); + } + } + } + + useEffect(() => { + formik.setValues(initialValues); + }, [initialValues]); + + return ( + + {({ isSubmitting }) => ( +
+ + 프로필 수정 +
+
+ 유저 프로필 + handleImageChange(e)} className='hidden' ref={fileInputRef} /> +
+
+ + +
+
+ + + +
+
+ )} +
+ ); +} diff --git a/src/user/utill/constants.ts b/src/user/utill/constants.ts new file mode 100644 index 00000000..e8d8b154 --- /dev/null +++ b/src/user/utill/constants.ts @@ -0,0 +1,3 @@ +export const MAX_FILE_SIZE = 1024 * 1024 * 5; // 5MB +export const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png']; +export const sampleImage = '/ProfileTestImage.jpg'; diff --git a/src/user/utill/fileNameChange.ts b/src/user/utill/fileNameChange.ts new file mode 100644 index 00000000..73007d0c --- /dev/null +++ b/src/user/utill/fileNameChange.ts @@ -0,0 +1,8 @@ +function fileNameChange() { + const now = new Date(); + const formattedFileName = `profile${now.getHours()}${now.getMinutes()}${now.getSeconds()}${now.getDate()}${now.getMonth() + 1}${now.getFullYear()}`; + + return formattedFileName; +} + +export default fileNameChange; diff --git a/tailwind.config.js b/tailwind.config.js index 25a4545d..a8b08d2c 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -50,6 +50,9 @@ module.exports = { 'sub-gray_2': '#E3E9F1', 'sub-gray_3': '#EFF3F8', }, + boxShadow: { + '3xl': '0px 0px 36px 0px rgba(0, 0, 0, 0.05)', + }, screens: { sm: '640px', md: '768px', From 6d5e64b121097fa455e3a25a0211fc56173fefa5 Mon Sep 17 00:00:00 2001 From: JeonYumin <40783675+JeonYumin94@users.noreply.github.com> Date: Tue, 23 Jul 2024 12:02:00 +0900 Subject: [PATCH 02/19] =?UTF-8?q?FE-36=20:sparkles:=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=BA=98=EB=A6=B0=EB=8D=94=20?= =?UTF-8?q?=EC=B6=9C=EB=A0=A5=20=ED=95=A8=EC=88=98=20(#58)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * FE-36 :lipstick: 감정달력 UI * FE-36 :sparkles: 캘린더 함수 생성 --- package-lock.json | 10 +++ package.json | 1 + public/icon/arrow-bottom-icon.svg | 5 ++ public/icon/arrow-right-icon.svg | 5 ++ src/hooks/useCalendar.ts | 74 +++++++++++++++++++ src/pageLayout/MypageLayout/MyPageLayout.tsx | 3 +- src/user/ui-profile/Calendar.tsx | 76 ++++++++++++++++++++ src/user/utill/constants.ts | 14 +++- 8 files changed, 184 insertions(+), 4 deletions(-) create mode 100644 public/icon/arrow-bottom-icon.svg create mode 100644 public/icon/arrow-right-icon.svg create mode 100644 src/hooks/useCalendar.ts create mode 100644 src/user/ui-profile/Calendar.tsx diff --git a/package-lock.json b/package-lock.json index d3bddea1..983823d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,6 +24,7 @@ "axios": "^1.7.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "date-fns": "^3.6.0", "formik": "^2.4.6", "lucide-react": "^0.402.0", "next": "14.2.4", @@ -2910,6 +2911,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.3.5", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", diff --git a/package.json b/package.json index e1fa6298..348c8937 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "axios": "^1.7.2", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "date-fns": "^3.6.0", "formik": "^2.4.6", "lucide-react": "^0.402.0", "next": "14.2.4", diff --git a/public/icon/arrow-bottom-icon.svg b/public/icon/arrow-bottom-icon.svg new file mode 100644 index 00000000..8e0e4d20 --- /dev/null +++ b/public/icon/arrow-bottom-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/icon/arrow-right-icon.svg b/public/icon/arrow-right-icon.svg new file mode 100644 index 00000000..dc5959d5 --- /dev/null +++ b/public/icon/arrow-right-icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/hooks/useCalendar.ts b/src/hooks/useCalendar.ts new file mode 100644 index 00000000..1687f326 --- /dev/null +++ b/src/hooks/useCalendar.ts @@ -0,0 +1,74 @@ +import { getDaysInMonth } from 'date-fns'; +import React, { useState } from 'react'; +import { CALENDAR_LENGTH, DAY_OF_WEEK } from '../user/utill/constants'; + +interface CalendarData { + weekCalendarList: number[][]; // 주별 날짜 리스트 + currentDate: Date; // 현재 날짜 + setCurrentDate: React.Dispatch>; // 현재 날짜를 설정하는 함수 +} + +// 이전 달의 날짜를 계산하는 함수 +const getPreviousDays = (firstDayOfCurrentMonth: Date, totalPrevMonthDays: number): number[] => + // 현재 월의 첫 번째 날의 요일을 기준으로 이전 달의 날짜를 배열로 반환 + Array.from({ length: firstDayOfCurrentMonth.getDay() }, (_, index) => totalPrevMonthDays - firstDayOfCurrentMonth.getDay() + index + 1); +// 현재 월의 날짜를 배열로 반환하는 함수 +const getCurrentDays = (totalMonthDays: number): number[] => Array.from({ length: totalMonthDays }, (_, i) => i + 1); // 1부터 totalMonthDays까지의 배열 생성 +// 다음 달의 날짜를 계산하는 함수 +const getNextDays = (currentDayList: number[], prevDayList: number[]): number[] => { + // 다음 달의 날짜 수를 계산하여 배열로 반환 + const nextDayCount = CALENDAR_LENGTH - currentDayList.length - prevDayList.length; + return Array.from({ length: Math.max(nextDayCount, 0) }, (_, index) => index + 1); +}; + +const useCalendar = (): CalendarData => { + const [currentDate, setCurrentDate] = useState(new Date()); // 현재 날짜를 상태로 관리 + + // 현재 월의 총 날짜 수를 가져옴 + const totalMonthDays = getDaysInMonth(currentDate); + + // 이전 달의 마지막 날짜를 계산 + const prevMonthLastDate = new Date(currentDate.getFullYear(), currentDate.getMonth(), 0); + // 이전 달의 총 날짜 수를 가져옴 + const totalPrevMonthDays = getDaysInMonth(prevMonthLastDate); + + // 현재 월의 첫 번째 날짜를 계산 + const firstDayOfCurrentMonth = new Date(currentDate.getFullYear(), currentDate.getMonth(), 1); + // 이전 달의 날짜 리스트 + const prevDayList = getPreviousDays(firstDayOfCurrentMonth, totalPrevMonthDays); + // 현재 월의 날짜 리스트 + const currentDayList = getCurrentDays(totalMonthDays); + // 다음 달의 날짜 리스트 + const nextDayList = getNextDays(currentDayList, prevDayList); + + // 전체 날짜 리스트 (이전 / 현재 / 다음 달 날짜 포함) + const currentCalendarList = [...prevDayList, ...currentDayList, ...nextDayList]; + + // 주별로 날짜 리스트를 분할 + const weekCalendarList: number[][] = []; + currentCalendarList.forEach((currDate, index) => { + const chunkIndex = Math.floor(index / DAY_OF_WEEK); + if (!weekCalendarList[chunkIndex]) { + weekCalendarList[chunkIndex] = []; // 주 배열이 없으면 초기화 + } + weekCalendarList[chunkIndex].push(currDate); // 누적값 반환 + }); + + // NOTE: 한 달이 5주 일 수도, 6주 일 수도 있을 때 5주인 경우 해당 달에 필요없는 다음 달의 날짜가 출력되기 때문에 (CALENDAR_LENGTH를 최대치인 42로 잡아서) 마지막 주의 첫 번째 숫자가 10이하의 날짜로 시작한다면 해당 배열을 삭제하도록 추가. + // TODO: 추후 다른 방법이 있다면 변경 할 예정 + if (weekCalendarList.length > 0) { + const lastWeek = weekCalendarList[weekCalendarList.length - 1]; + if (lastWeek[0] <= 10) { + weekCalendarList.pop(); + } + } + + // 캘린더 정보를 반환 + return { + weekCalendarList, // 주별 날짜 리스트 + currentDate, + setCurrentDate, + }; +}; + +export default useCalendar; diff --git a/src/pageLayout/MypageLayout/MyPageLayout.tsx b/src/pageLayout/MypageLayout/MyPageLayout.tsx index 0046edaa..7eebb5a9 100644 --- a/src/pageLayout/MypageLayout/MyPageLayout.tsx +++ b/src/pageLayout/MypageLayout/MyPageLayout.tsx @@ -1,6 +1,7 @@ import Header from '@/components/Header/Header'; import { useMeQuery } from '@/hooks/userQueryHooks'; import UserInfo from '@/types/user'; +import Calendar from '@/user/ui-profile/Calendar'; import Profile from '@/user/ui-profile/Profile'; import { useRouter } from 'next/navigation'; @@ -30,7 +31,7 @@ export default function MyPageLayout() {
오늘의 감정
-
캘린더
+
감정차트
diff --git a/src/user/ui-profile/Calendar.tsx b/src/user/ui-profile/Calendar.tsx new file mode 100644 index 00000000..69ffbe35 --- /dev/null +++ b/src/user/ui-profile/Calendar.tsx @@ -0,0 +1,76 @@ +import React, { useState } from 'react'; +import Image from 'next/image'; +import { subMonths } from 'date-fns'; +import { Button } from '@/components/ui/button'; +import { DropdownMenu, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import ARROW_BOTTOM_ICON from '../../../public/icon/arrow-bottom-icon.svg'; +import ARROW_RIGHT_ICON from '../../../public/icon/arrow-right-icon.svg'; +import ARROW_LEFT_ICON from '../../../public/icon/arrow-left-icon.svg'; +import useCalendar from '../../hooks/useCalendar'; +import { DAY_LIST, DATE_MONTH_FIXER, DEFAULT_TRASH_VALUE } from '../utill/constants'; + +export default function Calendar() { + const [position, setPosition] = useState('bottom'); + const { weekCalendarList, currentDate, setCurrentDate } = useCalendar(); + + // 이전 달 클릭 + const handlePrevMonth = () => setCurrentDate((prevDate) => subMonths(prevDate, DATE_MONTH_FIXER)); + // 다음 달 클릭 + const handleNextMonth = () => setCurrentDate((prevDate) => subMonths(prevDate, -DATE_MONTH_FIXER)); + + return ( +
+ {/* 캘린더 헤더 */} +
+
+
{`${currentDate.getFullYear()}년 ${currentDate.getMonth() + 1}월`}
+ + + + + + + + EmotionSelector 추가 예정 + + + +
+
+ + +
+
+ {/* 캘린더 */} +
+
+ {DAY_LIST.map((day) => ( +
+ {day} +
+ ))} +
+ {weekCalendarList.map((week, weekIndex) => ( + // eslint-disable-next-line react/no-array-index-key +
+ {week.map((day, dayIndex) => ( + // eslint-disable-next-line react/no-array-index-key +
+ {day === DEFAULT_TRASH_VALUE ? '' : day} +
+ ))} +
+ ))} +
+
+ ); +} diff --git a/src/user/utill/constants.ts b/src/user/utill/constants.ts index e8d8b154..ca63a32d 100644 --- a/src/user/utill/constants.ts +++ b/src/user/utill/constants.ts @@ -1,3 +1,11 @@ -export const MAX_FILE_SIZE = 1024 * 1024 * 5; // 5MB -export const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png']; -export const sampleImage = '/ProfileTestImage.jpg'; +// 파일 업로드 관련 +export const MAX_FILE_SIZE = 1024 * 1024 * 5; // 파일 업로드 최대 용량 5MB +export const ACCEPTED_IMAGE_TYPES = ['image/jpeg', 'image/jpg', 'image/png']; // 허용 가능 확장자 +export const sampleImage = '/ProfileTestImage.jpg'; // 초기프로필 이미지 + +// 캘린더 관련 상수 +export const DAY_LIST = ['일', '월', '화', '수', '목', '금', '토']; // 요일 +export const DATE_MONTH_FIXER = 1; // 날짜 조정 상수 (현재 사용되지 않음, 필요에 따라 활용 가능) +export const CALENDAR_LENGTH = 42; // 6주에 맞추어 캘린더의 총 길이를 42로 설정 +export const DAY_OF_WEEK = 7; // 한 주의 날 수 (일~토) +export const DEFAULT_TRASH_VALUE = -1; // 기본값 설정 (필요에 따라 사용 가능) From 74a85a346a8b43db5f589e4e568a65432eee473a Mon Sep 17 00:00:00 2001 From: JeonYumin <40783675+JeonYumin94@users.noreply.github.com> Date: Wed, 24 Jul 2024 13:01:15 +0900 Subject: [PATCH 03/19] =?UTF-8?q?FE-36=20:sparkles:=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EA=B0=90=EC=A0=95=EB=8B=AC?= =?UTF-8?q?=EB=A0=A5=20(#65)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * FE-36 :lipstick: 감정달력 UI * FE-36 :sparkles: 캘린더 함수 생성 * FE-36 :sparkles: 마이페이지 감정달력: 오늘 날짜 표시 * FE-36 :sparkles: 마이페이지 월 별 감정로그 조회 API 연동 * FE-36 :sparkles: 월별 감정 로그 해당 날짜에 출력 * FE-36 :lipstick: 달력 css 수정 * FE-36 :art: 마이페이지 캘린더 헤더 컴포넌트 분리 * FE-36 :sparkles: 마이페이지 감정달력 필터기능 --- src/apis/emotion.ts | 12 ++ src/apis/index.ts | 2 +- src/apis/queries.ts | 8 ++ src/hooks/useGetEmotion.ts | 7 + src/pageLayout/MypageLayout/MyPageLayout.tsx | 2 +- src/schema/emotion.ts | 22 ++++ src/types/emotion.ts | 8 ++ src/user/ui-profile/Calendar.tsx | 128 ++++++++++++------- src/user/ui-profile/CalendarHeader.tsx | 58 +++++++++ src/user/utill/constants.ts | 9 ++ 10 files changed, 209 insertions(+), 47 deletions(-) create mode 100644 src/apis/emotion.ts create mode 100644 src/hooks/useGetEmotion.ts create mode 100644 src/schema/emotion.ts create mode 100644 src/types/emotion.ts create mode 100644 src/user/ui-profile/CalendarHeader.tsx diff --git a/src/apis/emotion.ts b/src/apis/emotion.ts new file mode 100644 index 00000000..8158e5a1 --- /dev/null +++ b/src/apis/emotion.ts @@ -0,0 +1,12 @@ +import { GetMonthlyEmotionLogsRequestType, GetMonthlyEmotionLogsResponseType } from '@/schema/emotion'; + +import httpClient from '.'; + +const getMonthlyEmotionLogs = async (request: GetMonthlyEmotionLogsRequestType): Promise => { + const response = await httpClient.get(`/emotionLogs/monthly`, { + params: request, + }); + return response.data; +}; + +export default getMonthlyEmotionLogs; diff --git a/src/apis/index.ts b/src/apis/index.ts index 2748fca1..970d3ff9 100644 --- a/src/apis/index.ts +++ b/src/apis/index.ts @@ -3,7 +3,7 @@ import qs from 'qs'; // NOTE: 토큰 가져오는 함수 const getToken = () => - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MjQsInRlYW1JZCI6IjUtOSIsInNjb3BlIjoicmVmcmVzaCIsImlhdCI6MTcyMTYxNTYxNSwiZXhwIjoxNzIyMjIwNDE1LCJpc3MiOiJzcC1lcGlncmFtIn0.sCNFSgOQcYGbXWTxWablo9bOmbsw1EI6dTWt8n0xmDQ'; + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MjQsInRlYW1JZCI6IjUtOSIsInNjb3BlIjoicmVmcmVzaCIsImlhdCI6MTcyMTc1MzA3OSwiZXhwIjoxNzIyMzU3ODc5LCJpc3MiOiJzcC1lcGlncmFtIn0.76ZQCcyle7jM8hu5PQauhQ5zvehW1Cm8KulYAdN1Les'; // NOTE: axios 선언 const httpClient = axios.create({ diff --git a/src/apis/queries.ts b/src/apis/queries.ts index 1f84081c..0010c375 100644 --- a/src/apis/queries.ts +++ b/src/apis/queries.ts @@ -1,6 +1,8 @@ import { createQueryKeyStore } from '@lukemorales/query-key-factory'; import { GetUserRequestType } from '@/schema/user'; +import { GetMonthlyEmotionLogsRequestType } from '@/schema/emotion'; import { getMe, getUser } from './user'; +import getMonthlyEmotionLogs from './emotion'; const quries = createQueryKeyStore({ user: { @@ -13,6 +15,12 @@ const quries = createQueryKeyStore({ queryFn: () => getUser(request), }), }, + emotion: { + getMonthlyEmotionLogs: (request: GetMonthlyEmotionLogsRequestType) => ({ + queryKey: ['getMonthlyEmotionLogs', request], + queryFn: () => getMonthlyEmotionLogs(request), + }), + }, }); export default quries; diff --git a/src/hooks/useGetEmotion.ts b/src/hooks/useGetEmotion.ts new file mode 100644 index 00000000..46fe745e --- /dev/null +++ b/src/hooks/useGetEmotion.ts @@ -0,0 +1,7 @@ +import quries from '@/apis/queries'; +import { GetMonthlyEmotionLogsRequestType } from '@/schema/emotion'; +import { useQuery } from '@tanstack/react-query'; + +const useMonthlyEmotionLogs = (requset: GetMonthlyEmotionLogsRequestType) => useQuery(quries.emotion.getMonthlyEmotionLogs(requset)); + +export default useMonthlyEmotionLogs; diff --git a/src/pageLayout/MypageLayout/MyPageLayout.tsx b/src/pageLayout/MypageLayout/MyPageLayout.tsx index 7eebb5a9..cac3bea6 100644 --- a/src/pageLayout/MypageLayout/MyPageLayout.tsx +++ b/src/pageLayout/MypageLayout/MyPageLayout.tsx @@ -31,7 +31,7 @@ export default function MyPageLayout() {
오늘의 감정
- +
감정차트
diff --git a/src/schema/emotion.ts b/src/schema/emotion.ts new file mode 100644 index 00000000..4fa234a9 --- /dev/null +++ b/src/schema/emotion.ts @@ -0,0 +1,22 @@ +import * as z from 'zod'; + +/** **************** 감정 달력 ***************** */ +export const GetMonthlyEmotionLogsRequest = z.object({ + userId: z.number(), + year: z.number(), + month: z.number(), +}); + +// 감정 로그 항목의 스키마 정의 +const EmotionSchema = z.object({ + id: z.number(), + userId: z.number(), + emotion: z.enum(['MOVED', 'HAPPY', 'WORRIED', 'SAD', 'ANGRY']), + createdAt: z.coerce.date(), +}); + +// 감정 로그 배열 정의 +export const GetMonthlyEmotionLogsResponse = z.array(EmotionSchema); + +export type GetMonthlyEmotionLogsRequestType = z.infer; +export type GetMonthlyEmotionLogsResponseType = z.infer; diff --git a/src/types/emotion.ts b/src/types/emotion.ts new file mode 100644 index 00000000..4bdcbd07 --- /dev/null +++ b/src/types/emotion.ts @@ -0,0 +1,8 @@ +export interface Emotion { + userId: number; + year: number; + month: number; +} + +// 감정 로그 타입 지정 +export type EmotionType = 'MOVED' | 'HAPPY' | 'WORRIED' | 'SAD' | 'ANGRY'; diff --git a/src/user/ui-profile/Calendar.tsx b/src/user/ui-profile/Calendar.tsx index 69ffbe35..e9f972b5 100644 --- a/src/user/ui-profile/Calendar.tsx +++ b/src/user/ui-profile/Calendar.tsx @@ -1,73 +1,111 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import Image from 'next/image'; import { subMonths } from 'date-fns'; -import { Button } from '@/components/ui/button'; -import { DropdownMenu, DropdownMenuContent, DropdownMenuRadioGroup, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; -import ARROW_BOTTOM_ICON from '../../../public/icon/arrow-bottom-icon.svg'; -import ARROW_RIGHT_ICON from '../../../public/icon/arrow-right-icon.svg'; -import ARROW_LEFT_ICON from '../../../public/icon/arrow-left-icon.svg'; +import useMonthlyEmotionLogs from '@/hooks/useGetEmotion'; +import { Emotion, EmotionType } from '@/types/emotion'; import useCalendar from '../../hooks/useCalendar'; -import { DAY_LIST, DATE_MONTH_FIXER, DEFAULT_TRASH_VALUE } from '../utill/constants'; +import { DAY_LIST, DATE_MONTH_FIXER, iconPaths } from '../utill/constants'; +import CalendarHeader from './CalendarHeader'; -export default function Calendar() { - const [position, setPosition] = useState('bottom'); +interface CalendarProps { + userId: number; +} + +export default function Calendar({ userId }: CalendarProps) { + // 캘린더 함수 호출 const { weekCalendarList, currentDate, setCurrentDate } = useCalendar(); + // 감정 필터 + const [selectedEmotion, setSelectedEmotion] = useState(null); + + // 감정 달력 객체 상태 추가 + const [emotionRequest, setEmotionRequest] = useState({ + userId, + year: currentDate.getFullYear(), + month: currentDate.getMonth() + 1, + }); + + // 월별 감정 로그 조회 + const { data: monthlyEmotionLogs = [] } = useMonthlyEmotionLogs(emotionRequest); + + // 달력에 출력할 수 있게 매핑 + const emotionMap: Record = Array.isArray(monthlyEmotionLogs) + ? monthlyEmotionLogs.reduce>((acc, log) => { + const date = new Date(log.createdAt); + const dateString = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + acc[dateString] = log.emotion as EmotionType; + return acc; + }, {}) + : {}; + + // '월'이 변경될 때마다 request 업데이트 + useEffect(() => { + setEmotionRequest({ + userId, + year: currentDate.getFullYear(), + month: currentDate.getMonth() + 1, + }); + }, [currentDate]); // 이전 달 클릭 const handlePrevMonth = () => setCurrentDate((prevDate) => subMonths(prevDate, DATE_MONTH_FIXER)); // 다음 달 클릭 const handleNextMonth = () => setCurrentDate((prevDate) => subMonths(prevDate, -DATE_MONTH_FIXER)); + // 감정 필터 + const handleEmotionSelect = (emotion: EmotionType) => { + // 현재 선택된 감정과 같으면 초기화 + if (selectedEmotion === emotion) { + setSelectedEmotion(null); + } else { + setSelectedEmotion(emotion); + } + }; + + // 필터링된 감정 맵 생성 + const filteredEmotionMap = selectedEmotion ? Object.fromEntries(Object.entries(emotionMap).filter(([, value]) => value === selectedEmotion)) : emotionMap; + return (
{/* 캘린더 헤더 */} -
-
-
{`${currentDate.getFullYear()}년 ${currentDate.getMonth() + 1}월`}
- - - - - - - - EmotionSelector 추가 예정 - - - -
-
- - -
-
+ {/* 캘린더 */}
{DAY_LIST.map((day) => ( -
+
{day}
))}
{weekCalendarList.map((week, weekIndex) => ( + // TODO: index 값 Lint error. 임시로 주석 사용. 추후 수정 예정 // eslint-disable-next-line react/no-array-index-key
- {week.map((day, dayIndex) => ( - // eslint-disable-next-line react/no-array-index-key -
- {day === DEFAULT_TRASH_VALUE ? '' : day} -
- ))} + {week.map((day, dayIndex) => { + // 현재 날짜와 비교 + const isToday = day === currentDate.getDate() && currentDate.getMonth() === new Date().getMonth() && currentDate.getFullYear() === new Date().getFullYear(); + const dateString = `${currentDate.getFullYear()}-${String(currentDate.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`; + const emotion: EmotionType = filteredEmotionMap[dateString]; // 날짜에 해당하는 감정 가져오기 + const iconPath = iconPaths[emotion]; // 해당 감정 아이콘 출력 + + return ( +
+ {emotion ? ( +
+

{day}

+ 감정 +
+ ) : ( +

{day}

+ )} +
+ ); + })}
))}
diff --git a/src/user/ui-profile/CalendarHeader.tsx b/src/user/ui-profile/CalendarHeader.tsx new file mode 100644 index 00000000..b3af1862 --- /dev/null +++ b/src/user/ui-profile/CalendarHeader.tsx @@ -0,0 +1,58 @@ +import { DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenuGroup, DropdownMenu } from '@/components/ui/dropdown-menu'; +import { Button } from '@/components/ui/button'; +import Image from 'next/image'; +import { EmotionType } from '@/types/emotion'; +import ARROW_BOTTOM_ICON from '../../../public/icon/arrow-bottom-icon.svg'; +import ARROW_RIGHT_ICON from '../../../public/icon/arrow-right-icon.svg'; +import ARROW_LEFT_ICON from '../../../public/icon/arrow-left-icon.svg'; +import { iconPaths } from '../utill/constants'; + +interface CalendarHeaderProps { + currentDate: Date; + onPrevMonth: () => void; + onNextMonth: () => void; + onEmotionSelect: (emotion: EmotionType) => void; + selectEmotion: EmotionType | null; +} + +export default function CalendarHeader({ currentDate, onPrevMonth, onNextMonth, onEmotionSelect, selectEmotion }: CalendarHeaderProps) { + return ( +
+
+
{`${currentDate.getFullYear()}년 ${currentDate.getMonth() + 1}월`}
+ + + + + + + {Object.entries(iconPaths).map(([emotionKey, iconPath]) => ( + + + + ))} + + + +
+
+ + +
+
+ ); +} diff --git a/src/user/utill/constants.ts b/src/user/utill/constants.ts index ca63a32d..30981ea0 100644 --- a/src/user/utill/constants.ts +++ b/src/user/utill/constants.ts @@ -9,3 +9,12 @@ export const DATE_MONTH_FIXER = 1; // 날짜 조정 상수 (현재 사용되지 export const CALENDAR_LENGTH = 42; // 6주에 맞추어 캘린더의 총 길이를 42로 설정 export const DAY_OF_WEEK = 7; // 한 주의 날 수 (일~토) export const DEFAULT_TRASH_VALUE = -1; // 기본값 설정 (필요에 따라 사용 가능) + +// 아이콘 파일 경로 매핑 +export const iconPaths = { + MOVED: '/icon/Color/HeartFaceColorIcon.svg', + HAPPY: '/icon/Color/SmileFaceColorIcon.svg', + WORRIED: '/icon/Color/ThinkFaceColorIcon.svg', + SAD: '/icon/Color/SadFaceColorIcon.svg', + ANGRY: '/icon/Color/AngryFaceColorIcon.svg', +}; From 4d3cc05c2a86b7a2da3be3daf33f236dadc76300 Mon Sep 17 00:00:00 2001 From: JeonYumin <40783675+JeonYumin94@users.noreply.github.com> Date: Sun, 28 Jul 2024 00:54:49 +0900 Subject: [PATCH 04/19] =?UTF-8?q?FE-27=20:twisted=5Frightwards=5Farrows:?= =?UTF-8?q?=20=EC=97=90=ED=94=BD=20=EB=B8=8C=EB=9E=9C=EC=B9=98=20=EC=B5=9C?= =?UTF-8?q?=EC=8B=A0=ED=99=94=20(#81)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * FE-29 :twisted_rightwards_arrows: 로그인 페이지 머지 요청 (#39) * :heavy_plus_sign: 이미지 파일 추가 * :lipstick: 로그인 페이지 레이아웃 생성 * :lipstick: 로그인 페이지 UI 생성 및 반응형 디자인 구현 * FE-60 :sparkles: react hook form, zod 추가 * FE-60 :lipstick: 로그인 폼 스타일 수정 - 텍스트 인풋 테두리 - 로그인 버튼 * FE-60 :recycle: 로그인 스키마 분리 * :sparkles: 로그인 응답 데이터 스키마 정의 * :sparkles: 로그인 api 생성 * :sparkles: 요청과 응답에 관한 인터셉터 추가 * :sparkles: useSignin mutation hook 생성 * :zap: useSignin hook 로그인 폼에 적용 * :fire: AuthLayout 삭제 * :art: onSubmit 함수 인라인으로 정의 * :recycle: 응답 인터셉터의 에러 처리 및 토큰 갱신 로직 개선 * :recycle: postSignin api 에러처리 로직 삭제 * :fire: useSignin hook 삭제 * :truck: useSigninMutation hook으로 이름 변경 및 파일 이동 * :sparkles: Toaster 컴포넌트 추가 * :sparkles: toast로 에러메시지 띄우기 * FE-71 🔀 에피그램 작성 페이지 (#71) * FE-64💄 글작성 페이지 UI추가 (#44) * FE-72 ✨ 에피그램 등록 api연동 (#52) * FE-72✨ 글작성페이지 스키마 추가 * FE-72✨ form태그 Form컴포넌트로 변경 * FE-72✨ 태그 저장기능 추가 * FE-72✨ 에피그램 등록 api연동 * FE-72✨ 에피그램 등록시 해당 에피그램 페이지로 이동 기능 추가 * FE-72✨ 등록 중일때의 로직추가 * FE-72✨ toast-> alert-dailog로 변경 * FE-72📝 TODO주석 추가 --------- Co-authored-by: 우지석 * FE-73✨ 유효성검사 추가 (#66) * FE-73♻️ Tag관리 함수 훅으로 분리 * FE-73✨ RadioGroup 로직 수정 * FE-73✨ 유효성검사 추가 * FE-73♻️ 저자 본인 선택시의 로직 변경 * FE-73✨ 중복 태그 검사 로직 추가 * FE-73♻️ 출처 유효성(optional)검사 수정 * FE-73✨ 필수항목 입력했을때 버튼 활성화 * FE-73🐛 태그를 입력했다가 지웠을때 버튼 활성화되있는 버그 수정 * FE-73🐛 useEffect 의존성배열 lint problem 해결 * FE-73🐛 url유효성검사 에러 메세지 안뜨는 버그 수정 --------- Co-authored-by: 우지석 * FE-71♻️ epic브랜치 코드리뷰 반영 (#76) * FE-71♻️ token,interceptor 로직 수정 * FE-71♻️ AddEpigram 코드리뷰 반영 * FE-71🔥 테스트용 상세페이지 삭제 * FE-71♻️ onKeyDown -> onKeyUp 수정 --------- Co-authored-by: 우지석 --------- Co-authored-by: MOON <50370479+jangmoonwon@users.noreply.github.com> Co-authored-by: Jiseok Woo <115205098+jisurk@users.noreply.github.com> Co-authored-by: 우지석 --- public/lg.svg | 5 + public/logo-google.svg | 15 ++ public/logo-kakao.svg | 13 ++ public/logo-naver.svg | 5 + src/apis/add.ts | 9 + src/apis/auth.ts | 9 + src/apis/index.ts | 56 +++-- src/hooks/epigramQueryHook.ts | 24 ++ src/hooks/useSignInMutation.ts | 22 ++ src/hooks/useTagManagementHook.ts | 47 ++++ src/pageLayout/Epigram/AddEpigram.tsx | 316 ++++++++++++++++++++++++++ src/pages/addEpigram.tsx | 7 + src/pages/auth/SignIn.tsx | 100 ++++++++ src/schema/addEpigram.ts | 44 ++++ src/schema/auth.ts | 25 ++ 15 files changed, 678 insertions(+), 19 deletions(-) create mode 100644 public/lg.svg create mode 100644 public/logo-google.svg create mode 100644 public/logo-kakao.svg create mode 100644 public/logo-naver.svg create mode 100644 src/apis/add.ts create mode 100644 src/apis/auth.ts create mode 100644 src/hooks/epigramQueryHook.ts create mode 100644 src/hooks/useSignInMutation.ts create mode 100644 src/hooks/useTagManagementHook.ts create mode 100644 src/pageLayout/Epigram/AddEpigram.tsx create mode 100644 src/pages/addEpigram.tsx create mode 100644 src/pages/auth/SignIn.tsx create mode 100644 src/schema/addEpigram.ts create mode 100644 src/schema/auth.ts diff --git a/public/lg.svg b/public/lg.svg new file mode 100644 index 00000000..a4d3364f --- /dev/null +++ b/public/lg.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/logo-google.svg b/public/logo-google.svg new file mode 100644 index 00000000..5b169484 --- /dev/null +++ b/public/logo-google.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/public/logo-kakao.svg b/public/logo-kakao.svg new file mode 100644 index 00000000..f546e64d --- /dev/null +++ b/public/logo-kakao.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/public/logo-naver.svg b/public/logo-naver.svg new file mode 100644 index 00000000..dbec93dd --- /dev/null +++ b/public/logo-naver.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/apis/add.ts b/src/apis/add.ts new file mode 100644 index 00000000..66a6b010 --- /dev/null +++ b/src/apis/add.ts @@ -0,0 +1,9 @@ +import { AddEpigramRequestType, AddEpigramResponseType } from '@/schema/addEpigram'; +import httpClient from '.'; + +const postEpigram = async (request: AddEpigramRequestType): Promise => { + const response = await httpClient.post('/epigrams', request); + return response.data; +}; + +export default postEpigram; diff --git a/src/apis/auth.ts b/src/apis/auth.ts new file mode 100644 index 00000000..8244a9b4 --- /dev/null +++ b/src/apis/auth.ts @@ -0,0 +1,9 @@ +import type { PostSigninRequestType, PostSigninResponseType } from '@/schema/auth'; +import httpClient from '.'; + +const postSignin = async (request: PostSigninRequestType): Promise => { + const response = await httpClient.post('/auth/signIn', request); + return response.data; +}; + +export default postSignin; diff --git a/src/apis/index.ts b/src/apis/index.ts index 970d3ff9..c9b7cd80 100644 --- a/src/apis/index.ts +++ b/src/apis/index.ts @@ -1,34 +1,52 @@ import axios from 'axios'; import qs from 'qs'; -// NOTE: 토큰 가져오는 함수 -const getToken = () => - 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MjQsInRlYW1JZCI6IjUtOSIsInNjb3BlIjoicmVmcmVzaCIsImlhdCI6MTcyMTc1MzA3OSwiZXhwIjoxNzIyMzU3ODc5LCJpc3MiOiJzcC1lcGlncmFtIn0.76ZQCcyle7jM8hu5PQauhQ5zvehW1Cm8KulYAdN1Les'; - // NOTE: axios 선언 const httpClient = axios.create({ baseURL: process.env.NEXT_PUBLIC_BASE_URL, paramsSerializer: (parameters) => qs.stringify(parameters, { arrayFormat: 'repeat', encode: false }), }); -// NOTE: 요청 인터셉터 추가 -httpClient.interceptors.request.use( - (config) => { - const newConfig = { ...config }; - const token = getToken(); - if (token) { - newConfig.headers.Authorization = `Bearer ${token}`; - } +// NOTE: eslint-disable no-param-reassign 미해결로 인한 설정 +httpClient.interceptors.request.use((config) => { + const accessToken = localStorage.getItem('accessToken'); + /* eslint-disable no-param-reassign */ + if (accessToken) config.headers.Authorization = `Bearer ${accessToken}`; + /* eslint-enable no-param-reassign */ + return config; +}); - if (newConfig.data instanceof FormData) { - newConfig.headers['Content-Type'] = 'multipart/form-data'; - } else { - newConfig.headers['Content-Type'] = 'application/json'; - } +httpClient.interceptors.response.use( + (response) => response, - return newConfig; + (error) => { + if (error.response && error.response.status === 401) { + const refreshToken = localStorage.getItem('refreshToken'); + + if (!refreshToken) { + window.location.href = '/auth/SignIn'; + return Promise.reject(error); + } + + return httpClient + .post('/auth/refresh-token', null, { + headers: { Authorization: `Bearer ${refreshToken}` }, + }) + .then((response) => { + const { accessToken, refreshToken: newRefreshToken } = response.data; + localStorage.setItem('accessToken', accessToken); + localStorage.setItem('refreshToken', newRefreshToken); + + const originalRequest = error.config; + return httpClient(originalRequest); + }) + .catch(() => { + window.location.href = '/auth/SignIn'; + return Promise.reject(error); + }); + } + return Promise.reject(error); }, - (error) => Promise.reject(error), ); export default httpClient; diff --git a/src/hooks/epigramQueryHook.ts b/src/hooks/epigramQueryHook.ts new file mode 100644 index 00000000..e2ca6679 --- /dev/null +++ b/src/hooks/epigramQueryHook.ts @@ -0,0 +1,24 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { AddEpigramFormType, AddEpigramResponseType } from '@/schema/addEpigram'; +import { MutationOptions } from '@/types/query'; +import postEpigram from '@/apis/add'; +import { AxiosError } from 'axios'; + +// TODO: 에피그램 수정과 삭제에도 사용 가능하게 훅 수정 예정 + +const useAddEpigram = (options?: MutationOptions) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (newEpigram: AddEpigramFormType) => postEpigram(newEpigram), + ...options, + onSuccess: (...args) => { + queryClient.invalidateQueries({ queryKey: ['epigrams'] }); + if (options?.onSuccess) { + options.onSuccess(...args); + } + }, + }); +}; + +export default useAddEpigram; diff --git a/src/hooks/useSignInMutation.ts b/src/hooks/useSignInMutation.ts new file mode 100644 index 00000000..eaf9fd76 --- /dev/null +++ b/src/hooks/useSignInMutation.ts @@ -0,0 +1,22 @@ +import postSignin from '@/apis/auth'; +import { toast } from '@/components/ui/use-toast'; +import { useMutation } from '@tanstack/react-query'; +import { useRouter } from 'next/router'; + +const useSigninMutation = () => { + const router = useRouter(); + + return useMutation({ + mutationFn: postSignin, + onSuccess: (data) => { + localStorage.setItem('accessToken', data.accessToken); + localStorage.setItem('refreshToken', data.refreshToken); + router.push('/'); + }, + onError: () => { + toast({ description: '이메일 혹은 비밀번호를 확인해주세요.', className: 'border-state-error text-state-error font-semibold' }); + }, + }); +}; + +export default useSigninMutation; diff --git a/src/hooks/useTagManagementHook.ts b/src/hooks/useTagManagementHook.ts new file mode 100644 index 00000000..dd0082de --- /dev/null +++ b/src/hooks/useTagManagementHook.ts @@ -0,0 +1,47 @@ +import { useState } from 'react'; +import { UseFormSetValue, UseFormGetValues, UseFormSetError } from 'react-hook-form'; +import { AddEpigramFormType } from '@/schema/addEpigram'; + +// NOTE: setError메서드로 FormField에 에러 설정 가능 +const useTagManagement = ({ + setValue, + getValues, + setError, +}: { + setValue: UseFormSetValue; + getValues: UseFormGetValues; + setError: UseFormSetError; +}) => { + const [currentTag, setCurrentTag] = useState(''); + + const handleAddTag = () => { + if (!currentTag || currentTag.length > 10) { + return; + } + const currentTags = getValues('tags') || []; + + if (currentTags.length >= 3) { + return; + } + if (currentTags.includes(currentTag)) { + setError('tags', { type: 'manual', message: '이미 저장된 태그입니다.' }); + return; + } + + setValue('tags', [...currentTags, currentTag]); + setCurrentTag(''); + setError('tags', { type: 'manual', message: '' }); + }; + + const handleRemoveTag = (tagToRemove: string) => { + const currentTags = getValues('tags') || []; + setValue( + 'tags', + currentTags.filter((tag) => tag !== tagToRemove), + ); + }; + + return { currentTag, setCurrentTag, handleAddTag, handleRemoveTag }; +}; + +export default useTagManagement; diff --git a/src/pageLayout/Epigram/AddEpigram.tsx b/src/pageLayout/Epigram/AddEpigram.tsx new file mode 100644 index 00000000..f314c730 --- /dev/null +++ b/src/pageLayout/Epigram/AddEpigram.tsx @@ -0,0 +1,316 @@ +import React, { KeyboardEvent, useCallback, useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import Header from '@/components/Header/Header'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; +import { Textarea } from '@/components/ui/textarea'; +import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from '@/components/ui/form'; +import { AddEpigramFormSchema, AddEpigramFormType } from '@/schema/addEpigram'; +import useAddEpigram from '@/hooks/epigramQueryHook'; +import { useRouter } from 'next/router'; +import { AlertDialog, AlertDialogAction, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from '@/components/ui/alert-dialog'; +import useTagManagement from '@/hooks/useTagManagementHook'; +import { useMeQuery } from '@/hooks/userQueryHooks'; + +function AddEpigram() { + const router = useRouter(); + const { data: userData, isPending, isError } = useMeQuery(); + const [isAlertOpen, setIsAlertOpen] = useState(false); + const [alertContent, setAlertContent] = useState({ title: '', description: '' }); + const [selectedAuthorOption, setSelectedAuthorOption] = useState('directly'); // 기본값을 'directly'로 설정 + const [isFormValid, setIsFormValid] = useState(false); + + const form = useForm({ + resolver: zodResolver(AddEpigramFormSchema), + defaultValues: { + content: '', + author: '', + referenceTitle: '', + referenceUrl: '', + tags: [], + }, + }); + + // NOTE: 필수항목들에 값이 들어있는지 확인 함수 + const checkFormEmpty = useCallback(() => { + const { content, author, tags } = form.getValues(); + return content.trim() !== '' && author.trim() !== '' && tags.length > 0; + }, [form]); + + // NOTE: form값이 변경될때 필수항목들이 들어있는지 확인 + const watchForm = useCallback(() => { + setIsFormValid(checkFormEmpty()); + }, [checkFormEmpty]); + + useEffect(() => { + const subscription = form.watch(watchForm); + return () => subscription.unsubscribe(); + }, [form, watchForm]); + + const { currentTag, setCurrentTag, handleAddTag, handleRemoveTag } = useTagManagement({ + setValue: form.setValue, + getValues: form.getValues, + setError: form.setError, + }); + const addEpigramMutation = useAddEpigram({ + onSuccess: () => { + setAlertContent({ + title: '등록 완료', + description: '등록이 완료되었습니다.', + }); + setIsAlertOpen(true); + form.reset(); + }, + onError: () => { + setAlertContent({ + title: '등록 실패', + description: '다시 시도해주세요.', + }); + setIsAlertOpen(true); + }, + }); + + const handleAlertClose = () => { + setIsAlertOpen(false); + if (alertContent.title === '등록 완료') { + router.push(`/epigram/${addEpigramMutation.data?.id}`); + } + }; + + const AUTHOR_OPTIONS = [ + { value: 'directly', label: '직접 입력' }, + { value: 'unknown', label: '알 수 없음' }, + { value: 'me', label: '본인' }, + ]; + + // NOTE: default를 직접 입력으로 설정 + // NOTE: 본인을 선택 시 유저의 nickname이 들어감 + const handleAuthorChange = async (value: string) => { + setSelectedAuthorOption(value); + let authorValue: string; + + switch (value) { + case 'unknown': + authorValue = '알 수 없음'; + break; + case 'me': + if (isPending) { + authorValue = '로딩 중...'; + } else if (userData) { + authorValue = userData.nickname; + } else { + authorValue = '본인 (정보 없음)'; + } + break; + default: + authorValue = ''; + } + form.setValue('author', authorValue); + }; + + if (isPending) { + return
사용자 정보를 불러오는 중...
; + } + + if (isError) { + return
사용자 정보를 불러오는 데 실패했습니다. 페이지를 새로고침 해주세요.
; + } + + // NOTE: 태그를 저장하려고 할때 enter키를 누르면 폼제출이 되는걸 방지 + const handleKeyUp = (e: KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleAddTag(); + } + }; + + // NOTE: url와title은 필수 항목이 아니라서 빈칸으로 제출할 때 항목에서 제외 + const handleSubmit = (data: AddEpigramFormType) => { + const submitData = { ...data }; + + if (!submitData.referenceUrl) { + delete submitData.referenceUrl; + } + + if (!submitData.referenceTitle) { + delete submitData.referenceTitle; + } + + addEpigramMutation.mutate(submitData); + }; + + return ( + <> +
{}} /> +
+
+ + ( + + + 내용 + * + + +