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] =?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',