Skip to content
39 changes: 39 additions & 0 deletions src/api/image/image.api.ts
Original file line number Diff line number Diff line change
@@ -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<PresignedUrlInfo[]> => {
const res = await api.get<ApiResponse<PresignedUrlInfo[]>>('/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<void> => {
await axios.put(url, file, {
headers: {
'Content-Type': file.type,
},
});
};

export { getPresignedUrls, uploadToS3 };
5 changes: 5 additions & 0 deletions src/assets/icons/file_select.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions src/assets/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import DefaultThumbnail from '@/assets/icons/default_thumbnail.svg?react';
import BarIcon from '@/assets/icons/bar_icon.svg?react';
import KakaoIcon from '@/assets/icons/kakao_icon.svg?react';
import NaverIcon from '@/assets/icons/naver_icon.svg?react';
import GalleryProfileIcon from '@/assets/icons/file_select.svg?react';

//gif
import TicketAlt from '@/assets/gif/ticket_alt.gif';
Expand Down Expand Up @@ -80,4 +81,5 @@ export {
TicketAlt,
KakaoIcon,
NaverIcon,
GalleryProfileIcon,
};
60 changes: 39 additions & 21 deletions src/components/common/ImagePreview/ImagePreviewItem.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="relative h-[84px] w-[84px] shrink-0 rounded-md">
<div className="h-full w-full overflow-hidden">
<Image
src={URL.createObjectURL(image)}
alt={`preview-${index}`}
className="z-10 h-full w-full"
aspectRatio=""
rounded="rounded-md"
/>
</div>
<button
onClick={() => onRemove(index)}
className="absolute -top-1.5 -right-1.5 z-10 flex h-4 w-4 items-center justify-center rounded-full bg-red-500"
>
<CloseIcon className="h-2 w-2" />
</button>
<div
className={`relative shrink-0 ${roundedClass} ${cursorClass} ${dimensionClass}`}
onClick={onClick}
>
<Image
src={previewUrl}
alt={`preview-${index}`}
className="z-10 h-full w-full"
aspectRatio=""
rounded={roundedClass}
/>
{closeButton && onRemove && (
<button
onClick={() => onRemove(index)}
className="absolute -top-1.5 -right-1.5 z-10 flex h-4 w-4 cursor-pointer items-center justify-center rounded-full bg-red-500"
>
<CloseIcon className="h-2 w-2" />
</button>
)}
</div>
);
}
10 changes: 10 additions & 0 deletions src/hooks/mutations/usePresignedUrlMutation.ts
Original file line number Diff line number Diff line change
@@ -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<PresignedUrlInfo[], ApiError, { fileNames: string[] }>({
mutationFn: ({ fileNames }) => getPresignedUrls(fileNames),
});
};
14 changes: 14 additions & 0 deletions src/hooks/mutations/useUploadToS3Mutation.ts
Original file line number Diff line number Diff line change
@@ -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<void, ApiError, UploadParams>({
mutationFn: ({ url, file }) => uploadToS3(url, file),
});
};
31 changes: 28 additions & 3 deletions src/hooks/useImageUpload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,53 @@ import { useState } from 'react';

export function useImgUpload(maxCount: number = 5) {
const [images, setImages] = useState<File[]>([]);
const [previewUrls, setPreviewUrls] = useState<string[]>([]);

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,
};
}
71 changes: 71 additions & 0 deletions src/hooks/useS3UploadFlow.ts
Original file line number Diff line number Diff line change
@@ -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 };
// };
83 changes: 68 additions & 15 deletions src/pages/onboarding/OnboardingNicknamePage.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,61 @@
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('');
const navigate = useNavigate();

const setNickname = useOnboardingStore((state) => state.setNickname);

const handleNext = () => {
const { addImages, previewUrls, selectedFiles } = useImgUpload(1);
const setProfileImageFileName = useOnboardingStore((state) => state.setProfileImageFilename);

const fileInputRef = useRef<HTMLInputElement | null>(null);

const handleClickGallery = () => {
if (fileInputRef.current) {
fileInputRef.current.value = '';
fileInputRef.current.click();
}
};

const handleChangeFile = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
Expand All @@ -29,22 +70,34 @@ const OnboardingNicknamePage = () => {
{/* 타이틀 */}
<h1 className="text-title-2 mb-10">프로필을 만들어주세요</h1>

{/* 프로필 이미지 (예시용 박스) */}
<div className="mb-10 flex justify-center">
<div className="flex h-36 w-36 items-center justify-center rounded-full bg-gray-100 text-sm text-black">
갤러리 아이콘
</div>
{/* 파일 input */}
<input
ref={fileInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleChangeFile}
/>

{previewUrls.length === 0 ? (
<div onClick={handleClickGallery}>
<GalleryProfileIcon className="cursor-pointer" />
</div>
) : (
<ImagePreviewItem
previewUrl={previewUrls[0]}
index={0}
rounded="full"
size={100}
onClick={handleClickGallery}
/>
)}
</div>

{/* 닉네임 입력 */}
<div className="mb-2">
<Input
label=""
value={input}
onChange={setInput}
placeholder="닉네임을 입력해주세요"
placeholderColorType="gray"
/>
<Input label="" value={input} onChange={setInput} placeholder="닉네임을 입력해주세요" />
</div>

<p className="text-caption-3 ml-1 text-gray-500">10자 이내로 작성해주세요.</p>
Expand Down
Loading