diff --git a/.github/workflows/Dev-CD.yml b/.github/workflows/Dev-CD.yml deleted file mode 100644 index 479af180..00000000 --- a/.github/workflows/Dev-CD.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: FrontEnd Dev CD - -on: - push: - branches: ['dev'] - workflow_dispatch: - -jobs: - deploy: - runs-on: ubuntu-latest - env: - AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} - AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} - - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20.11.0 - - - run: yarn install - - run: yarn build - - - name: S3 and CloudFront Deploy - uses: Reggionick/s3-deploy@v4 - with: - folder: .next - bucket: ${{ secrets.DEV_S3_BUCKET_NAME }} - bucket-region: ${{ secrets.AWS_DEFAULT_REGION }} - dist-id: ${{ secrets.DEV_CLOUDFRONT_ID }} - delete-removed: true diff --git a/package.json b/package.json index f62ef39d..9030b352 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@vanilla-extract/next-plugin": "^2.3.2", "@yaireo/tagify": "^4.19.0", "axios": "^1.6.5", + "browser-image-compression": "^2.0.2", "cheerio": "^1.0.0-rc.12", "copy-to-clipboard": "^3.3.3", "html-to-image": "^1.11.11", diff --git a/public/icons/camera.svg b/public/icons/camera.svg new file mode 100644 index 00000000..59689595 --- /dev/null +++ b/public/icons/camera.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/public/icons/error_x.svg b/public/icons/error_x.svg new file mode 100644 index 00000000..692a1b62 --- /dev/null +++ b/public/icons/error_x.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/app/_api/list/uploadItemImages.ts b/src/app/_api/list/uploadItemImages.ts index e8528767..31947fb9 100644 --- a/src/app/_api/list/uploadItemImages.ts +++ b/src/app/_api/list/uploadItemImages.ts @@ -2,13 +2,13 @@ import axiosInstance from '@/lib/axios/axiosInstance'; import { ItemImagesType, PresignedUrlListType } from '@/lib/types/listType'; import axios from 'axios'; -interface uploadItemImagesProps { +interface UploadItemImagesProps { listId: number; imageData: ItemImagesType; imageFileList: File[]; } -export const uploadItemImages = async ({ listId, imageData, imageFileList }: uploadItemImagesProps) => { +const uploadItemImages = async ({ listId, imageData, imageFileList }: UploadItemImagesProps) => { imageData.listId = listId; //PresignedUrl 생성 요청 @@ -29,3 +29,5 @@ export const uploadItemImages = async ({ listId, imageData, imageFileList }: upl await axiosInstance.post('/lists/upload-complete', imageData); } }; + +export default uploadItemImages; diff --git a/src/app/_api/user/checkNicknameDuplication.ts b/src/app/_api/user/checkNicknameDuplication.ts new file mode 100644 index 00000000..57b2e06a --- /dev/null +++ b/src/app/_api/user/checkNicknameDuplication.ts @@ -0,0 +1,9 @@ +import axiosInstance from '@/lib/axios/axiosInstance'; + +const checkNicknameDuplication = async (nickname: string) => { + const result = await axiosInstance.get(`/users/exists?nickname=${nickname}`); + + return result.data; //true:중복 false:미중복 +}; + +export default checkNicknameDuplication; diff --git a/src/app/_api/user/updateProfile.ts b/src/app/_api/user/updateProfile.ts new file mode 100644 index 00000000..b4d4c6c2 --- /dev/null +++ b/src/app/_api/user/updateProfile.ts @@ -0,0 +1,58 @@ +import axiosInstance from '@/lib/axios/axiosInstance'; +import axios from 'axios'; +import { UserProfileEditType } from '@/lib/types/userProfileType'; +import compressFile from '@/lib/utils/compressFile'; + +//프로필수정 이미지업로드 타입 +interface UserPresignedUrlsType { + ownerId: number; + backgroundPresignedUrl: string; + profilePresignedUrl: string; +} + +interface UpdateProfileParams { + userId: Number; + data: UserProfileEditType; +} + +const updateProfile = async ({ userId, data }: UpdateProfileParams) => { + const { nickname, description, backgroundImageUrl, profileImageUrl, newBackgroundFileList, newProfileFileList } = + data; + + //프로필 수정 + const result = await axiosInstance.patch(`/users/${userId}`, { + nickname, + description, + backgroundImageUrl, + profileImageUrl, + }); + + //이미지 수정 없는 경우 return + if (result.status !== 204 || (newBackgroundFileList === null && newProfileFileList === null)) return; + + //1. presignedUrl 생성요청 + const imageData = { + ownerId: userId, + backgroundExtension: newBackgroundFileList?.[0].type.split('/')[1], + profileExtension: newProfileFileList?.[0].type.split('/')[1], + }; + const response = await axiosInstance.post('/users/upload-url', imageData); + + //2. presignedUrl에 사진 업로드 + const { backgroundPresignedUrl, profilePresignedUrl } = response?.data; + + if (newBackgroundFileList !== null) { + const resultFile = await compressFile(newBackgroundFileList[0]); + await axios.put(backgroundPresignedUrl, resultFile); + } + + if (newProfileFileList !== null) { + const resultFile = await compressFile(newProfileFileList[0]); + await axios.put(profilePresignedUrl, resultFile); + } + + //3.서버에 성공 알림 + await axiosInstance.post('/users/upload-complete', imageData); +}; + +export default updateProfile; diff --git a/src/app/account/page.tsx b/src/app/account/page.tsx index f5fe4f17..5661549c 100644 --- a/src/app/account/page.tsx +++ b/src/app/account/page.tsx @@ -1,3 +1,13 @@ +'use client'; + +import useMoveToPage from '@/hooks/useMoveToPage'; + export default function AccountPage() { - return
마이페이지
; + const { onClickMoveToPage } = useMoveToPage(); + return ( + <> +
마이페이지
+ + + ); } diff --git a/src/app/account/profile/_components/ImagePreview.css.ts b/src/app/account/profile/_components/ImagePreview.css.ts new file mode 100644 index 00000000..efe2d838 --- /dev/null +++ b/src/app/account/profile/_components/ImagePreview.css.ts @@ -0,0 +1,48 @@ +import { style } from '@vanilla-extract/css'; + +export const backgroundImageContainer = style({ + maxWidth: 400, + width: '100%', + height: 230, + + display: 'flex', + alignItems: 'center', + + position: 'relative', + + borderRadius: '30px', + + overflow: 'hidden', +}); + +export const transparentBox = style({ + maxWidth: 400, + width: '100%', + height: 230, + padding: '0 23px', + + display: 'flex', + alignItems: 'center', + + position: 'absolute', + + borderRadius: '30px', +}); + +export const profileImageContainer = style({ + width: 90, + height: 90, + + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + + position: 'relative', + + backgroundColor: 'white', + + borderRadius: '50%', + border: '3px solid white', + + overflow: 'hidden', +}); diff --git a/src/app/account/profile/_components/ImagePreview.tsx b/src/app/account/profile/_components/ImagePreview.tsx new file mode 100644 index 00000000..d4659f1c --- /dev/null +++ b/src/app/account/profile/_components/ImagePreview.tsx @@ -0,0 +1,28 @@ +import Image from 'next/image'; +import * as styles from './ImagePreview.css'; + +interface ImagePreviewProps { + backgroundImageUrl: string; + profileImageUrl: string; +} + +/** TODO: 이미지 에러, 로딩 처리 + * - [ ] placeholder=blur처리 + * - [ ] ONERROR 처리 + */ +export default function ImagePreview({ backgroundImageUrl, profileImageUrl }: ImagePreviewProps) { + return ( +
+ {backgroundImageUrl && ( + <> + 배경이미지 +
+
+ 프로필이미지 +
+
+ + )} +
+ ); +} diff --git a/src/app/account/profile/_components/ProfileForm.css.ts b/src/app/account/profile/_components/ProfileForm.css.ts new file mode 100644 index 00000000..8c124d60 --- /dev/null +++ b/src/app/account/profile/_components/ProfileForm.css.ts @@ -0,0 +1,140 @@ +import { style, createVar } from '@vanilla-extract/css'; +import { vars } from '@/styles/theme.css'; +import { labelSmall, bodyMedium, caption } from '@/styles/font.css'; + +export const form = style({ + maxWidth: 400, + width: '100%', + + display: 'flex', + flexDirection: 'column', + gap: '12px', +}); + +const container = style({ + width: '100%', + + padding: '10px 12px', + + border: `1px solid ${vars.color.gray5}`, +}); + +export const inputContainer = style([ + container, + { + display: 'flex', + flexDirection: 'column', + gap: '8px', + }, +]); + +export const label = style([labelSmall, { color: vars.color.gray9 }]); + +export const inputText = style([bodyMedium]); + +export const textarea = style([ + bodyMedium, + { + border: 'none', + resize: 'none', + }, +]); + +export const textLength = style([ + bodyMedium, + { + color: vars.color.gray9, + textAlign: 'end', + }, +]); + +export const inputFile = style({ + display: 'none', +}); + +export const inputFileLabel = style({ + border: `1px solid ${vars.color.black}`, +}); + +const option = style({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + + backgroundColor: vars.color.gray3 /**TODO: white로 대체예정*/, + backgroundImage: 'none' /**TODO: backgroundImage로 대체예정*/, + + cursor: 'pointer', + + selectors: { + '&:hover': { + border: `1px solid ${vars.color.blue}`, + }, + }, +}); + +export const backgroundOptionContainer = style([ + container, + { + display: 'grid', + gridTemplateColumns: 'repeat(4, 1fr)', + gridTemplateRows: '1fr 1fr', + gridColumnGap: 8, + gridRowGap: 10, + }, +]); + +export const backgroundOption = style([ + option, + { + maxWidth: 85, + height: 47, + + borderRadius: 15, + }, +]); + +export const profileOptionContainer = style([ + container, + { + display: 'flex', + justifyContent: 'space-between', + gap: 14, + + position: 'relative', + }, +]); + +export const profileOption = style([ + option, + { + width: '100%', + minWidth: 30, + + borderRadius: '50%', + + selectors: { + '&::before': { + content: '', + display: 'block', + paddingBottom: '100%', + }, + }, + }, +]); + +export const error = style({ + marginTop: '0.6rem', + marginLeft: '0.9rem', + + display: 'flex', + alignItems: 'center', + gap: '0.45rem', +}); + +export const errorText = style([ + caption, + { + color: vars.color.red, + }, +]); diff --git a/src/app/account/profile/_components/ProfileForm.tsx b/src/app/account/profile/_components/ProfileForm.tsx new file mode 100644 index 00000000..b8b778f7 --- /dev/null +++ b/src/app/account/profile/_components/ProfileForm.tsx @@ -0,0 +1,179 @@ +import { FieldErrors, useForm, useFormContext, useWatch } from 'react-hook-form'; +import { useMutation } from '@tanstack/react-query'; + +import Camera from '/public/icons/camera.svg'; +import ErrorIcon from '/public/icons/error_x.svg'; + +import checkNicknameDuplication from '@/app/_api/user/checkNicknameDuplication'; + +import useDebounce from '@/hooks/useDebounce'; +import { profilePlaceholder } from '@/lib/constants/placeholder'; +import { + nicknameRules, + profileDescriptionRules, + nicknameDuplicateRules, +} from '@/lib/constants/formInputValidationRules'; +import { UserProfileEditType } from '@/lib/types/userProfileType'; +import toastMessage from '@/lib/constants/toastMessage'; +import toasting from '@/lib/utils/toasting'; + +import * as styles from './ProfileForm.css'; +import { ChangeEvent } from 'react'; + +type FormErrors = FieldErrors; + +const MockBackground = ['기본배경A', '기본배경B', '기본배경C', '기본배경D', '기본배경E', '기본배경F', '기본배경G']; +const MockProfile = ['A', 'B', 'C', 'D', 'E']; + +interface ProfileFormProps { + userNickname: string; + onProfileChange: (arg: File) => void; + onBackgroundChange: (arg: File) => void; +} + +export default function ProfileForm({ userNickname, onProfileChange, onBackgroundChange }: ProfileFormProps) { + const { + register, + control, + setError, + formState: { errors }, + } = useFormContext(); + + //닉네임 중복 검사 + const nicknameRegister = register('nickname', nicknameRules); + + const { mutate: checkNickname } = useMutation({ + mutationFn: checkNicknameDuplication, + onSuccess: (result) => { + if (result) { + setError('nickname', nicknameDuplicateRules); + } + }, + }); + + const debouncedOnNicknameChange = useDebounce(checkNickname, 500); + const handleNicknameChange = (e: ChangeEvent) => { + nicknameRegister.onChange(e); + if (e.target.value && e.target.value !== userNickname) { + debouncedOnNicknameChange(e.target.value); + } + }; + + //글자수세기 + const watchDescription = useWatch({ control, name: 'description' }); + + //이미지 미리보기 + const newBackgroundImageRegister = register('newBackgroundFileList'); + const newProfileImageRegister = register('newProfileFileList'); + + const MAX_IMAGE_INPUT_SIZE_MB = 50 * 1024 * 1024; //50MB + + const handleBackgroundChange = (e: ChangeEvent) => { + if (e.target.files) { + const targetFile = e.target.files[0]; + if (targetFile?.size > MAX_IMAGE_INPUT_SIZE_MB) { + toasting({ type: 'error', txt: toastMessage.ko.imageSizeError }); + } else { + newBackgroundImageRegister.onChange(e); + onBackgroundChange(e.target.files[0]); + } + } + }; + + const handleProfileChange = (e: ChangeEvent) => { + if (e.target.files) { + const targetFile = e.target.files[0]; + if (targetFile?.size > MAX_IMAGE_INPUT_SIZE_MB) { + toasting({ type: 'error', txt: toastMessage.ko.imageSizeError }); + } else { + newProfileImageRegister.onChange(e); + onProfileChange(e.target.files[0]); + } + } + }; + + return ( + <> +
+ {/* 닉네임 */} +
+
+ + { + handleNicknameChange(e); + }} + /> +
+ {errors.nickname && ( +
+ + {(errors as FormErrors)?.nickname?.message} +
+ )} +
+ +
+
+ +