diff --git a/src/api/image/image.api.ts b/src/api/image/image.api.ts new file mode 100644 index 0000000..7e21695 --- /dev/null +++ b/src/api/image/image.api.ts @@ -0,0 +1,39 @@ +import axios from 'axios'; +import api from '@/api/api'; +import type { ApiResponse } from '@/types/api-response'; +import type { PresignedUrlInfo } from '@/types/image'; + +/** + * presigned URL 발급 + * @param fileNames 업로드할 파일 이름 배열 + * @returns presigned URL 문자열 배열 + */ +const getPresignedUrls = async (fileNames: string[]): Promise => { + const res = await api.get>('/images/upload-url', { + params: { file: fileNames }, + paramsSerializer: { indexes: null }, + }); + + const urls = res.data.data; + + if (!urls || urls.length === 0) { + throw new Error('Presigned URL 발급에 실패했습니다.'); + } + + return urls; +}; + +/** + * S3에 파일 업로드 + * @param url presigned URL + * @param file 업로드할 File 객체 + */ +const uploadToS3 = async (url: string, file: File): Promise => { + await axios.put(url, file, { + headers: { + 'Content-Type': file.type, + }, + }); +}; + +export { getPresignedUrls, uploadToS3 }; diff --git a/src/assets/icons/file_select.svg b/src/assets/icons/file_select.svg new file mode 100644 index 0000000..35bd9ed --- /dev/null +++ b/src/assets/icons/file_select.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/src/assets/index.tsx b/src/assets/index.tsx index 590db39..8265aa2 100644 --- a/src/assets/index.tsx +++ b/src/assets/index.tsx @@ -39,6 +39,7 @@ import NaverIcon from '@/assets/icons/naver_icon.svg?react'; import SoundIcon from '@/assets/icons/sound.svg?react'; import EnvironmentIcon from '@/assets/icons/environment.svg?react'; import CompanionIcon from '@/assets/icons/companion.svg?react'; +import GalleryProfileIcon from '@/assets/icons/file_select.svg?react'; import TicketAlt from '@/assets/gif/ticket_alt.gif'; @@ -85,4 +86,5 @@ export { EnvironmentIcon, CompanionIcon, TicketAlt, -}; \ No newline at end of file + GalleryProfileIcon, +}; diff --git a/src/components/common/ImagePreview/ImagePreviewItem.tsx b/src/components/common/ImagePreview/ImagePreviewItem.tsx index e0d75a0..6798ef7 100644 --- a/src/components/common/ImagePreview/ImagePreviewItem.tsx +++ b/src/components/common/ImagePreview/ImagePreviewItem.tsx @@ -1,32 +1,50 @@ -// src/components/common/ImagePreviewItem.tsx - import { CloseIcon } from '@/assets'; import { Image } from '@/components'; +import type { ButtonRounded } from '@/components/common/Button'; interface ImagePreviewItemProps { - image: File; + previewUrl: string; index: number; - onRemove: (index: number) => void; + rounded?: ButtonRounded; + size?: number; + closeButton?: boolean; + onRemove?: (index: number) => void; + onClick?: () => void; } -export default function ImagePreviewItem({ image, index, onRemove }: ImagePreviewItemProps) { +export default function ImagePreviewItem({ + previewUrl, + index, + rounded = 'md', + size = 84, + onClick, + closeButton = true, + onRemove, +}: ImagePreviewItemProps) { + const roundedClass = rounded === 'full' ? 'rounded-full' : 'rounded-md'; + const cursorClass = onClick ? 'cursor-pointer' : ''; + const dimensionClass = `w-[${size}px] h-[${size}px]`; + return ( -
-
- {`preview-${index}`} -
- +
+ {`preview-${index}`} + {closeButton && onRemove && ( + + )}
); } diff --git a/src/hooks/mutations/usePresignedUrlMutation.ts b/src/hooks/mutations/usePresignedUrlMutation.ts new file mode 100644 index 0000000..d7a2ead --- /dev/null +++ b/src/hooks/mutations/usePresignedUrlMutation.ts @@ -0,0 +1,10 @@ +import { useMutation } from '@tanstack/react-query'; +import { getPresignedUrls } from '@/api/image/image.api'; +import type { ApiError } from '@/types/api-response'; +import type { PresignedUrlInfo } from '@/types/image'; + +export const usePresignedUrlMutation = () => { + return useMutation({ + mutationFn: ({ fileNames }) => getPresignedUrls(fileNames), + }); +}; diff --git a/src/hooks/mutations/useUploadToS3Mutation.ts b/src/hooks/mutations/useUploadToS3Mutation.ts new file mode 100644 index 0000000..99d1913 --- /dev/null +++ b/src/hooks/mutations/useUploadToS3Mutation.ts @@ -0,0 +1,14 @@ +import { useMutation } from '@tanstack/react-query'; +import { uploadToS3 } from '@/api/image/image.api'; +import type { ApiError } from '@/types/api-response'; + +interface UploadParams { + url: string; + file: File; +} + +export const useUploadToS3Mutation = () => { + return useMutation({ + mutationFn: ({ url, file }) => uploadToS3(url, file), + }); +}; diff --git a/src/hooks/useImageUpload.ts b/src/hooks/useImageUpload.ts index 59dd616..c07400f 100644 --- a/src/hooks/useImageUpload.ts +++ b/src/hooks/useImageUpload.ts @@ -2,28 +2,53 @@ import { useState } from 'react'; export function useImgUpload(maxCount: number = 5) { const [images, setImages] = useState([]); + const [previewUrls, setPreviewUrls] = useState([]); const addImages = (files: FileList | null) => { if (!files) return; - const selected = Array.from(files).slice(0, maxCount - images.length); - setImages((prev) => [...prev, ...selected]); + const selected = Array.from(files).slice(0, maxCount); + + // 기존 이미지가 1장 제한일 경우 덮어쓰기 + if (maxCount === 1) { + // 기존 preview 해제 + previewUrls.forEach((url) => URL.revokeObjectURL(url)); + + const newPreviewUrls = selected.map((file) => URL.createObjectURL(file)); + setImages(selected); + setPreviewUrls(newPreviewUrls); + } else { + const selectableCount = maxCount - images.length; + const sliced = selected.slice(0, selectableCount); + const newPreviewUrls = sliced.map((file) => URL.createObjectURL(file)); + + setImages((prev) => [...prev, ...sliced]); + setPreviewUrls((prev) => [...prev, ...newPreviewUrls]); + } }; const removeImage = (index: number) => { + // 미리보기 URL 해제 + URL.revokeObjectURL(previewUrls[index]); + setImages((prev) => prev.filter((_, i) => i !== index)); + setPreviewUrls((prev) => prev.filter((_, i) => i !== index)); }; const resetImages = () => { + previewUrls.forEach((url) => URL.revokeObjectURL(url)); setImages([]); + setPreviewUrls([]); }; const isMax = images.length >= maxCount; return { - images, + images, // File[] + previewUrls, // string[] addImages, removeImage, resetImages, isMax, + selectedFiles: images, }; } diff --git a/src/hooks/useS3UploadFlow.ts b/src/hooks/useS3UploadFlow.ts new file mode 100644 index 0000000..d130906 --- /dev/null +++ b/src/hooks/useS3UploadFlow.ts @@ -0,0 +1,71 @@ +import { getPresignedUrls, uploadToS3 } from '@/api/image/image.api'; + +export const useS3UploadFlow = async ( + files: File[], +): Promise<{ fileName: string; url: string }[]> => { + if (files.length === 0) return []; + + const fileNames = files.map((file) => file.name); + + const response = await getPresignedUrls(fileNames); + const presignedInfos = Array.isArray(response) ? response : [response]; + + if (!presignedInfos || presignedInfos.length !== files.length) { + throw new Error('Presigned URL을 일부 가져오지 못했습니다.'); + } + + const uploadResults = await Promise.all( + files.map(async (file) => { + const matched = presignedInfos.find((p) => p.fileName === file.name); + if (!matched) throw new Error(`${file.name}에 대한 presigned URL이 없습니다.`); + + await uploadToS3(matched.preSignedUrl, file); + + return { + fileName: file.name, + url: matched.preSignedUrl, + }; + }), + ); + + return uploadResults; +}; + +// import { usePresignedUrlMutation } from '@/hooks/mutations/usePresignedUrlMutation'; +// import { useUploadToS3Mutation } from '@/hooks/mutations/useUploadToS3Mutation'; + +// export const useS3UploadFlow = () => { +// const { mutateAsync: getUrls } = usePresignedUrlMutation(); +// const { mutateAsync: upload } = useUploadToS3Mutation(); + +// const uploadImagesToS3 = async (files: File[]): Promise<{ fileName: string; url: string }[]> => { +// if (files.length === 0) return []; + +// const fileNames = files.map((file) => file.name); + +// const presignedInfos = await getUrls({ fileNames }); + +// if (!presignedInfos || presignedInfos.length !== files.length) { +// throw new Error('Presigned URL을 일부 가져오지 못했습니다.'); +// } + +// // 파일명 기준으로 presignedUrl 매칭 +// const uploadResults = await Promise.all( +// files.map(async (file) => { +// const info = presignedInfos.find((p) => p.fileName === file.name); +// if (!info) throw new Error(`${file.name}에 대한 presigned URL이 없습니다.`); + +// await upload({ url: info.preSignedUrl, file }); + +// return { +// fileName: info.fileName, +// url: info.preSignedUrl.split('?')[0], // S3 public URL +// }; +// }), +// ); + +// return uploadResults; +// }; + +// return { uploadImagesToS3 }; +// }; diff --git a/src/pages/onboarding/OnboardingNicknamePage.tsx b/src/pages/onboarding/OnboardingNicknamePage.tsx index d16929e..4ebc802 100644 --- a/src/pages/onboarding/OnboardingNicknamePage.tsx +++ b/src/pages/onboarding/OnboardingNicknamePage.tsx @@ -1,9 +1,12 @@ -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Button, Header } from '@/components'; +import { Button, Header, ImagePreviewItem } from '@/components'; import Input from '@/components/common/Input/Input'; import ProgressBar from '@/components/common/ProgressBar/ProgressBar'; import { useOnboardingStore } from '@/store/useOnboardingStore'; +import { GalleryProfileIcon } from '@/assets'; +import { useImgUpload } from '@/hooks'; +import { useS3UploadFlow } from '@/hooks/useS3UploadFlow'; const OnboardingNicknamePage = () => { const [input, setInput] = useState(''); @@ -11,10 +14,48 @@ const OnboardingNicknamePage = () => { const setNickname = useOnboardingStore((state) => state.setNickname); - const handleNext = () => { + const { addImages, previewUrls, selectedFiles } = useImgUpload(1); + const setProfileImageFileName = useOnboardingStore((state) => state.setProfileImageFilename); + + const fileInputRef = useRef(null); + + const handleClickGallery = () => { + if (fileInputRef.current) { + fileInputRef.current.value = ''; + fileInputRef.current.click(); + } + }; + + const handleChangeFile = (e: React.ChangeEvent) => { + const files = e.target.files; + addImages(e.target.files); + + if (files && files.length > 0) { + const file = files[0]; + setProfileImageFileName(file.name); + console.log('저장된 이미지 파일: ', file.name); + } + }; + + const handleNext = async () => { setNickname(input); console.log('닉네임: ', input); - navigate('/onboarding/genre'); + if (selectedFiles.length > 0) { + try { + const result = await useS3UploadFlow(selectedFiles); + const imageUrl = result[0]?.url; + + if (imageUrl) { + setProfileImageFileName(imageUrl); + console.log('프로필 이미지 URL:', imageUrl); + navigate('/onboarding/genre'); + } else { + console.error('이미지 업로드 실패'); + } + } catch (e) { + console.error('이미지 업로드 중 에러 발생', e); + } + } }; return ( @@ -29,22 +70,34 @@ const OnboardingNicknamePage = () => { {/* 타이틀 */}

프로필을 만들어주세요

- {/* 프로필 이미지 (예시용 박스) */}
-
- 갤러리 아이콘 -
+ {/* 파일 input */} + + + {previewUrls.length === 0 ? ( +
+ +
+ ) : ( + + )}
{/* 닉네임 입력 */}
- +

10자 이내로 작성해주세요.

diff --git a/src/pages/review/ReviewContent.tsx b/src/pages/review/ReviewContent.tsx index b483f82..116344f 100644 --- a/src/pages/review/ReviewContent.tsx +++ b/src/pages/review/ReviewContent.tsx @@ -16,7 +16,7 @@ const MIN_TEXT_LENGTH = 30; export default function ReviewTextForm() { const { text, setText, reviewTitle, setReviewTitle, isInitialized } = useReviewStore(); - const { images, addImages, removeImage } = useImgUpload(5); + const { images, addImages, removeImage, previewUrls } = useImgUpload(5); const navigate = useNavigate(); const isValid = reviewTitle.trim().length > 0 && text.trim().length >= MIN_TEXT_LENGTH; @@ -62,8 +62,13 @@ export default function ReviewTextForm() { /> {/* 이미지 미리보기? */} - {images.map((image, index) => ( - + {images.map((_, index) => ( + ))}
diff --git a/src/store/useOnboardingStore.ts b/src/store/useOnboardingStore.ts index c5ec1ff..927d3e3 100644 --- a/src/store/useOnboardingStore.ts +++ b/src/store/useOnboardingStore.ts @@ -3,16 +3,17 @@ import { genreMap, type GenreEnType, type GenreType } from '@/types/movieGenre'; import type { CinemaType, CinemaFormat } from '@/types/onboarding'; interface OnboardingState { + profileImageFilename: string; nickname: string; + genre: GenreType[]; selectedGenres: GenreEnType[]; - // selectedCinemas: CinemaType[]; selectedCinemas: string[]; cinemaFormat: CinemaFormat; setNickname: (name: string) => void; toggleGenre: (genre: GenreType) => void; toggleCinema: (cinema: CinemaType) => void; setCinemaFormat: (format: CinemaFormat) => void; - genre: GenreType[]; + setProfileImageFilename: (filename: string) => void; setGenre: (genres: GenreType[]) => void; setSelectedGenres: (genres: GenreEnType[]) => void; setSelectedCinemas: (cinemas: CinemaType[]) => void; @@ -27,6 +28,9 @@ export const useOnboardingStore = create((set, get) => ({ selectedCinemas: [], cinemaFormat: 'IMAX', + profileImageFilename: '', + setProfileImageFilename: (filename) => set({ profileImageFilename: filename }), + setNickname: (name) => set({ nickname: name }), toggleGenre: (genre) => { @@ -56,5 +60,5 @@ export const useOnboardingStore = create((set, get) => ({ setCinemaFormat: (format) => set({ cinemaFormat: format }), - setSelectedCinemas: (cinemas) => set({ selectedCinemas: cinemas }), // ✅ 추가 + setSelectedCinemas: (cinemas) => set({ selectedCinemas: cinemas }), })); diff --git a/src/types/image.ts b/src/types/image.ts index 2acbb3c..20382cb 100644 --- a/src/types/image.ts +++ b/src/types/image.ts @@ -2,3 +2,8 @@ export interface ReviewImage { imageUrl: string; order: number; } + +export interface PresignedUrlInfo { + fileName: string; + preSignedUrl: string; +}