Skip to content

Commit fdcbad2

Browse files
committed
fix: controlled 방식으로 변경
1 parent f2cbe6f commit fdcbad2

File tree

3 files changed

+81
-42
lines changed

3 files changed

+81
-42
lines changed

src/shared/ui/MediaUpload/MediaUpload.stories.tsx

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { useState } from 'react';
12
import type { Meta, StoryObj } from '@storybook/react';
23

34
import { MediaUpload, Props } from './MediaUpload';
@@ -11,6 +12,25 @@ const meta = {
1112
export default meta;
1213
type Story = StoryObj<Props>;
1314

15+
function MediaUploadStoryWrapper(args: Props & { serverResponseUrl?: string[] }) {
16+
const [previewUrls, setPreviewUrls] = useState<string[]>(args.serverResponseUrl ?? []);
17+
const [previewFiles, setPreviewFiles] = useState<File[]>([]);
18+
19+
const handleFileChange = (files: File[] | null, urls: string[]) => {
20+
setPreviewFiles(files || []);
21+
setPreviewUrls(urls);
22+
};
23+
24+
return (
25+
<MediaUpload
26+
{...args}
27+
previewUrls={previewUrls}
28+
previewFiles={previewFiles}
29+
onFileChange={handleFileChange}
30+
/>
31+
);
32+
}
33+
1434
export const Basic: Story = {
1535
argTypes: {
1636
label: { control: { type: 'text' } },
@@ -19,7 +39,7 @@ export const Basic: Story = {
1939
maxSize: { control: { type: 'number', min: 1, step: 1 } },
2040
acceptedFormats: { control: 'text' },
2141
},
22-
render: (args) => <MediaUpload {...args} />,
42+
render: (args) => <MediaUploadStoryWrapper {...args} />,
2343
};
2444

2545
export const UploadFormats: Story = {
@@ -29,26 +49,30 @@ export const UploadFormats: Story = {
2949
maxSize: 3,
3050
acceptedFormats: ['image/*', 'video/*'],
3151
},
52+
render: (args) => <MediaUploadStoryWrapper {...args} />,
3253
};
3354

3455
export const WithTopAffix: Story = {
3556
args: {
3657
topAffix: '제목처럼 추가할 수 있어요',
3758
},
59+
render: (args) => <MediaUploadStoryWrapper {...args} />,
3860
};
3961

4062
export const MultipleMode: Story = {
4163
args: {
4264
multiple: true,
4365
},
66+
render: (args) => <MediaUploadStoryWrapper {...args} />,
4467
};
4568

4669
export const EditMode: Story = {
47-
args: {
48-
multiple: true,
49-
initialPreviewUrls: [
50-
'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?q=80&w=1200&auto=format&fit=crop',
51-
],
52-
initialFiles: [new File([], 'test.jpg')],
53-
},
70+
render: (args) => (
71+
<MediaUploadStoryWrapper
72+
{...args}
73+
serverResponseUrl={[
74+
'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?q=80&w=1200&auto=format&fit=crop',
75+
]}
76+
/>
77+
),
5478
};

src/shared/ui/MediaUpload/MediaUpload.tsx

Lines changed: 21 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ComponentProps, useId, useState } from 'react';
1+
import { ComponentProps, useId } from 'react';
22

33
import { MediaPreview } from './MediaUploadPreview';
44

@@ -46,44 +46,43 @@ export type Props = {
4646
*/
4747
multiple?: boolean;
4848
/**
49-
* initial preview urls for edit mode (e.g., existing uploaded image urls)
49+
* Array of preview URLs to display existing media
5050
*/
51-
initialPreviewUrls?: string[];
51+
previewUrls?: string[];
5252
/**
53-
* initial files for edit mode (if available).
53+
* Array of preview File objects for new uploads
5454
*/
55-
initialFiles?: File[];
55+
previewFiles?: File[] | null;
56+
/**
57+
* Callback function called when files are selected, removed, or reset
58+
*/
59+
onFileChange?: (files: File[] | null, previewUrls: string[]) => void;
5660
} & Omit<ComponentProps<'input'>, 'id'>;
5761

5862
const GB = 1024 * 1024 * 1024;
5963

6064
export function MediaUpload({
6165
topAffix,
62-
onFileUpload,
6366
id,
6467
label = '파일을 업로드해주세요. (jpg, jpeg, png)',
6568
description = '* 파일은 5GB까지 업로드 가능합니다.',
6669
maxSize = 5,
6770
acceptedFormats = ['image/*'],
6871
multiple = false,
69-
initialPreviewUrls,
70-
initialFiles,
72+
previewFiles = [],
73+
previewUrls = [],
74+
onFileChange,
7175
...props
7276
}: Props) {
7377
const generatedId = useId();
7478
const inputId = id || generatedId;
75-
76-
const [selectedFiles, setSelectedFiles] = useState<File[]>(initialFiles || []);
77-
const [previewUrls, setPreviewUrls] = useState<string[]>(initialPreviewUrls || []);
78-
const isSelected = selectedFiles.length > 0;
79+
const isSelected = previewUrls.length > 0;
7980

8081
const handleReset = () => {
8182
previewUrls.forEach((url) => {
8283
if (url.startsWith('blob:')) URL.revokeObjectURL(url);
8384
});
84-
setSelectedFiles([]);
85-
setPreviewUrls([]);
86-
onFileUpload?.(null);
85+
onFileChange?.(null, []);
8786
};
8887

8988
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
@@ -97,23 +96,18 @@ export function MediaUpload({
9796
}
9897

9998
const validatedFiles = multiple ? files : [files[0]];
100-
setSelectedFiles(validatedFiles);
10199
const urls = validatedFiles.map((file) => URL.createObjectURL(file));
102-
setPreviewUrls(urls);
103-
onFileUpload?.(validatedFiles);
100+
onFileChange?.(validatedFiles, urls);
104101
};
105102

106103
const handleRemoveFile = (index: number) => {
107104
const urlToRemove = previewUrls[index];
108-
if (urlToRemove?.startsWith('blob:')) {
109-
URL.revokeObjectURL(urlToRemove);
110-
}
111-
const newFiles = selectedFiles.filter((_, i) => i !== index);
105+
if (urlToRemove?.startsWith('blob:')) URL.revokeObjectURL(urlToRemove);
106+
107+
const newFiles = previewFiles?.filter((_, i) => i !== index) ?? null;
112108
const newUrls = previewUrls.filter((_, i) => i !== index);
113109

114-
setSelectedFiles(newFiles);
115-
setPreviewUrls(newUrls);
116-
onFileUpload?.(newFiles.length > 0 ? newFiles : null);
110+
onFileChange?.(newFiles, newUrls);
117111
};
118112

119113
return (
@@ -126,7 +120,7 @@ export function MediaUpload({
126120
<UploadBox id={inputId} label={label} description={description} />
127121
) : (
128122
<MediaPreview
129-
files={selectedFiles}
123+
files={previewFiles}
130124
previewUrls={previewUrls}
131125
onRemoveFile={handleRemoveFile}
132126
multiple={multiple}
@@ -178,7 +172,7 @@ function RefreshButton({ handleReset, isSelected }: RefreshButtonProp) {
178172
<Flex
179173
as="button"
180174
onClick={(e) => {
181-
e.stopPropagation();
175+
e.preventDefault();
182176
if (isSelected) handleReset();
183177
}}
184178
alignItems="center"

src/shared/ui/MediaUpload/MediaUploadPreview.tsx

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,24 @@
1+
import { useState, useEffect } from 'react';
2+
13
import { Flex } from '../Flex';
24
import { Icon } from '../Icon';
35

46
type Props = {
5-
files: File[];
7+
files: File[] | null;
68
previewUrls: string[];
79
onRemoveFile: (index: number) => void;
810
multiple: boolean;
911
};
1012

1113
export function MediaPreview({ files, previewUrls, onRemoveFile, multiple }: Props) {
1214
if (!multiple) {
13-
return <MediaPreviewItem file={files[0]} previewUrl={previewUrls[0]} />;
15+
return <MediaPreviewItem file={files?.[0]} previewUrl={previewUrls[0]} />;
1416
}
1517
return (
1618
<div className="grid grid-cols-2 gap-2 md:grid-cols-3 md:gap-4">
17-
{files?.map((file, index) => (
19+
{previewUrls?.map((previewUrl, index) => (
1820
<div key={index} className="relative aspect-square">
19-
<MediaPreviewItem file={file} previewUrl={previewUrls[index]} />
21+
<MediaPreviewItem file={files?.[index]} previewUrl={previewUrl} />
2022
<button
2123
type="button"
2224
onClick={() => onRemoveFile(index)}
@@ -31,17 +33,27 @@ export function MediaPreview({ files, previewUrls, onRemoveFile, multiple }: Pro
3133
}
3234

3335
type MediaPreviewItemProps = {
34-
file: File;
36+
file?: File;
3537
previewUrl: string;
3638
};
3739
function MediaPreviewItem({ file, previewUrl }: MediaPreviewItemProps) {
40+
const [isVideo, setIsVideo] = useState<boolean>(false);
41+
42+
useEffect(() => {
43+
if (file) return setIsVideo(file.type.startsWith('video/'));
44+
getMimeType(previewUrl).then((type) => {
45+
if (type) setIsVideo(type.startsWith('video/'));
46+
else setIsVideo(false);
47+
});
48+
}, [file, previewUrl]);
49+
3850
return (
3951
<Flex
4052
justifyContent="center"
4153
alignItems="center"
4254
className="relative h-full w-full rounded-xl border border-gray-200 bg-gray-50"
4355
>
44-
{file.type.startsWith('video/') ? (
56+
{isVideo ? (
4557
<video
4658
src={previewUrl}
4759
controls
@@ -50,10 +62,19 @@ function MediaPreviewItem({ file, previewUrl }: MediaPreviewItemProps) {
5062
) : (
5163
<img
5264
src={previewUrl}
53-
alt={`미리보기 ${file.name}`}
65+
alt={file ? `미리보기 ${file.name}` : '미리보기 이미지'}
5466
className="h-full max-h-[500px] w-full max-w-[500px] object-contain"
5567
/>
5668
)}
5769
</Flex>
5870
);
5971
}
72+
73+
async function getMimeType(url: string): Promise<string | null> {
74+
try {
75+
const res = await fetch(url, { method: 'HEAD' });
76+
return res.headers.get('Content-Type');
77+
} catch {
78+
return null;
79+
}
80+
}

0 commit comments

Comments
 (0)