Skip to content

Commit 07ca91e

Browse files
committed
fix: 충돌 수정
2 parents 3b08c9b + 326751c commit 07ca91e

File tree

12 files changed

+299
-47
lines changed

12 files changed

+299
-47
lines changed

src/api/image/image.api.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import axios from 'axios';
2+
import api from '@/api/api';
3+
import type { ApiResponse } from '@/types/api-response';
4+
import type { PresignedUrlInfo } from '@/types/image';
5+
6+
/**
7+
* presigned URL 발급
8+
* @param fileNames 업로드할 파일 이름 배열
9+
* @returns presigned URL 문자열 배열
10+
*/
11+
const getPresignedUrls = async (fileNames: string[]): Promise<PresignedUrlInfo[]> => {
12+
const res = await api.get<ApiResponse<PresignedUrlInfo[]>>('/images/upload-url', {
13+
params: { file: fileNames },
14+
paramsSerializer: { indexes: null },
15+
});
16+
17+
const urls = res.data.data;
18+
19+
if (!urls || urls.length === 0) {
20+
throw new Error('Presigned URL 발급에 실패했습니다.');
21+
}
22+
23+
return urls;
24+
};
25+
26+
/**
27+
* S3에 파일 업로드
28+
* @param url presigned URL
29+
* @param file 업로드할 File 객체
30+
*/
31+
const uploadToS3 = async (url: string, file: File): Promise<void> => {
32+
await axios.put(url, file, {
33+
headers: {
34+
'Content-Type': file.type,
35+
},
36+
});
37+
};
38+
39+
export { getPresignedUrls, uploadToS3 };

src/assets/icons/file_select.svg

Lines changed: 5 additions & 0 deletions
Loading

src/assets/index.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import NaverIcon from '@/assets/icons/naver_icon.svg?react';
3939
import SoundIcon from '@/assets/icons/sound.svg?react';
4040
import EnvironmentIcon from '@/assets/icons/environment.svg?react';
4141
import CompanionIcon from '@/assets/icons/companion.svg?react';
42+
import GalleryProfileIcon from '@/assets/icons/file_select.svg?react';
4243

4344
import TicketAlt from '@/assets/gif/ticket_alt.gif';
4445

@@ -85,4 +86,5 @@ export {
8586
EnvironmentIcon,
8687
CompanionIcon,
8788
TicketAlt,
88-
};
89+
GalleryProfileIcon,
90+
};
Lines changed: 39 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,50 @@
1-
// src/components/common/ImagePreviewItem.tsx
2-
31
import { CloseIcon } from '@/assets';
42
import { Image } from '@/components';
3+
import type { ButtonRounded } from '@/components/common/Button';
54

65
interface ImagePreviewItemProps {
7-
image: File;
6+
previewUrl: string;
87
index: number;
9-
onRemove: (index: number) => void;
8+
rounded?: ButtonRounded;
9+
size?: number;
10+
closeButton?: boolean;
11+
onRemove?: (index: number) => void;
12+
onClick?: () => void;
1013
}
1114

12-
export default function ImagePreviewItem({ image, index, onRemove }: ImagePreviewItemProps) {
15+
export default function ImagePreviewItem({
16+
previewUrl,
17+
index,
18+
rounded = 'md',
19+
size = 84,
20+
onClick,
21+
closeButton = true,
22+
onRemove,
23+
}: ImagePreviewItemProps) {
24+
const roundedClass = rounded === 'full' ? 'rounded-full' : 'rounded-md';
25+
const cursorClass = onClick ? 'cursor-pointer' : '';
26+
const dimensionClass = `w-[${size}px] h-[${size}px]`;
27+
1328
return (
14-
<div className="relative h-[84px] w-[84px] shrink-0 rounded-md">
15-
<div className="h-full w-full overflow-hidden">
16-
<Image
17-
src={URL.createObjectURL(image)}
18-
alt={`preview-${index}`}
19-
className="z-10 h-full w-full"
20-
aspectRatio=""
21-
rounded="rounded-md"
22-
/>
23-
</div>
24-
<button
25-
onClick={() => onRemove(index)}
26-
className="absolute -top-1.5 -right-1.5 z-10 flex h-4 w-4 items-center justify-center rounded-full bg-red-500"
27-
>
28-
<CloseIcon className="h-2 w-2" />
29-
</button>
29+
<div
30+
className={`relative shrink-0 ${roundedClass} ${cursorClass} ${dimensionClass}`}
31+
onClick={onClick}
32+
>
33+
<Image
34+
src={previewUrl}
35+
alt={`preview-${index}`}
36+
className="z-10 h-full w-full"
37+
aspectRatio=""
38+
rounded={roundedClass}
39+
/>
40+
{closeButton && onRemove && (
41+
<button
42+
onClick={() => onRemove(index)}
43+
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"
44+
>
45+
<CloseIcon className="h-2 w-2" />
46+
</button>
47+
)}
3048
</div>
3149
);
3250
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { useMutation } from '@tanstack/react-query';
2+
import { getPresignedUrls } from '@/api/image/image.api';
3+
import type { ApiError } from '@/types/api-response';
4+
import type { PresignedUrlInfo } from '@/types/image';
5+
6+
export const usePresignedUrlMutation = () => {
7+
return useMutation<PresignedUrlInfo[], ApiError, { fileNames: string[] }>({
8+
mutationFn: ({ fileNames }) => getPresignedUrls(fileNames),
9+
});
10+
};
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { useMutation } from '@tanstack/react-query';
2+
import { uploadToS3 } from '@/api/image/image.api';
3+
import type { ApiError } from '@/types/api-response';
4+
5+
interface UploadParams {
6+
url: string;
7+
file: File;
8+
}
9+
10+
export const useUploadToS3Mutation = () => {
11+
return useMutation<void, ApiError, UploadParams>({
12+
mutationFn: ({ url, file }) => uploadToS3(url, file),
13+
});
14+
};

src/hooks/useImageUpload.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,53 @@ import { useState } from 'react';
22

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

67
const addImages = (files: FileList | null) => {
78
if (!files) return;
8-
const selected = Array.from(files).slice(0, maxCount - images.length);
9-
setImages((prev) => [...prev, ...selected]);
9+
const selected = Array.from(files).slice(0, maxCount);
10+
11+
// 기존 이미지가 1장 제한일 경우 덮어쓰기
12+
if (maxCount === 1) {
13+
// 기존 preview 해제
14+
previewUrls.forEach((url) => URL.revokeObjectURL(url));
15+
16+
const newPreviewUrls = selected.map((file) => URL.createObjectURL(file));
17+
setImages(selected);
18+
setPreviewUrls(newPreviewUrls);
19+
} else {
20+
const selectableCount = maxCount - images.length;
21+
const sliced = selected.slice(0, selectableCount);
22+
const newPreviewUrls = sliced.map((file) => URL.createObjectURL(file));
23+
24+
setImages((prev) => [...prev, ...sliced]);
25+
setPreviewUrls((prev) => [...prev, ...newPreviewUrls]);
26+
}
1027
};
1128

1229
const removeImage = (index: number) => {
30+
// 미리보기 URL 해제
31+
URL.revokeObjectURL(previewUrls[index]);
32+
1333
setImages((prev) => prev.filter((_, i) => i !== index));
34+
setPreviewUrls((prev) => prev.filter((_, i) => i !== index));
1435
};
1536

1637
const resetImages = () => {
38+
previewUrls.forEach((url) => URL.revokeObjectURL(url));
1739
setImages([]);
40+
setPreviewUrls([]);
1841
};
1942

2043
const isMax = images.length >= maxCount;
2144

2245
return {
23-
images,
46+
images, // File[]
47+
previewUrls, // string[]
2448
addImages,
2549
removeImage,
2650
resetImages,
2751
isMax,
52+
selectedFiles: images,
2853
};
2954
}

src/hooks/useS3UploadFlow.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { getPresignedUrls, uploadToS3 } from '@/api/image/image.api';
2+
3+
export const useS3UploadFlow = async (
4+
files: File[],
5+
): Promise<{ fileName: string; url: string }[]> => {
6+
if (files.length === 0) return [];
7+
8+
const fileNames = files.map((file) => file.name);
9+
10+
const response = await getPresignedUrls(fileNames);
11+
const presignedInfos = Array.isArray(response) ? response : [response];
12+
13+
if (!presignedInfos || presignedInfos.length !== files.length) {
14+
throw new Error('Presigned URL을 일부 가져오지 못했습니다.');
15+
}
16+
17+
const uploadResults = await Promise.all(
18+
files.map(async (file) => {
19+
const matched = presignedInfos.find((p) => p.fileName === file.name);
20+
if (!matched) throw new Error(`${file.name}에 대한 presigned URL이 없습니다.`);
21+
22+
await uploadToS3(matched.preSignedUrl, file);
23+
24+
return {
25+
fileName: file.name,
26+
url: matched.preSignedUrl,
27+
};
28+
}),
29+
);
30+
31+
return uploadResults;
32+
};
33+
34+
// import { usePresignedUrlMutation } from '@/hooks/mutations/usePresignedUrlMutation';
35+
// import { useUploadToS3Mutation } from '@/hooks/mutations/useUploadToS3Mutation';
36+
37+
// export const useS3UploadFlow = () => {
38+
// const { mutateAsync: getUrls } = usePresignedUrlMutation();
39+
// const { mutateAsync: upload } = useUploadToS3Mutation();
40+
41+
// const uploadImagesToS3 = async (files: File[]): Promise<{ fileName: string; url: string }[]> => {
42+
// if (files.length === 0) return [];
43+
44+
// const fileNames = files.map((file) => file.name);
45+
46+
// const presignedInfos = await getUrls({ fileNames });
47+
48+
// if (!presignedInfos || presignedInfos.length !== files.length) {
49+
// throw new Error('Presigned URL을 일부 가져오지 못했습니다.');
50+
// }
51+
52+
// // 파일명 기준으로 presignedUrl 매칭
53+
// const uploadResults = await Promise.all(
54+
// files.map(async (file) => {
55+
// const info = presignedInfos.find((p) => p.fileName === file.name);
56+
// if (!info) throw new Error(`${file.name}에 대한 presigned URL이 없습니다.`);
57+
58+
// await upload({ url: info.preSignedUrl, file });
59+
60+
// return {
61+
// fileName: info.fileName,
62+
// url: info.preSignedUrl.split('?')[0], // S3 public URL
63+
// };
64+
// }),
65+
// );
66+
67+
// return uploadResults;
68+
// };
69+
70+
// return { uploadImagesToS3 };
71+
// };

0 commit comments

Comments
 (0)