Skip to content

Commit d4a8e2d

Browse files
authored
Merge pull request #82 from COW-dev/refactor/#81-image
[REFACTOR] MediaUpload, ImageGallery 컴포넌트 사용성개선
2 parents ef24e47 + b9d343f commit d4a8e2d

File tree

4 files changed

+90
-47
lines changed

4 files changed

+90
-47
lines changed

src/shared/ui/ImageGallery/ImageGallery.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ function ImageGalleryContent({ className }: { className?: string }) {
5252
</>
5353
)}
5454
</Flex>
55-
<Flex justifyContent="center" className="mt-2">
55+
<Flex justifyContent="center" className="mt-3">
5656
<ImageGalleryDots />
5757
</Flex>
5858
</Flex>
@@ -62,7 +62,7 @@ function ImageGalleryContent({ className }: { className?: string }) {
6262
function ImageGalleryDots() {
6363
const { images, current, goToIndex } = useImageGallery();
6464
return (
65-
<Flex alignItems="center" className="gap-2">
65+
<Flex alignItems="center" className="gap-2 md:gap-2.5">
6666
{images.map((_, index) => {
6767
const isActive = index === current;
6868
return (
@@ -71,7 +71,10 @@ function ImageGalleryDots() {
7171
type="button"
7272
aria-label={`Image ${index + 1}`}
7373
onClick={() => goToIndex(index)}
74-
className={cn('h-2 w-2 rounded-full', isActive ? 'bg-primary-300' : 'bg-gray-200')}
74+
className={cn(
75+
'h-2 w-2 cursor-pointer rounded-full md:h-2.5 md:w-2.5',
76+
isActive ? 'bg-primary-300' : 'bg-gray-200'
77+
)}
7578
/>
7679
);
7780
})}
@@ -93,7 +96,7 @@ function ImageGalleryArrow({ direction }: ImageGalleryArrowProps) {
9396
type="button"
9497
onClick={onClick}
9598
className={cn(
96-
'absolute top-1/2 z-10 -translate-y-1/2 rounded-full bg-white/75 p-1 shadow-sm transition-opacity hover:bg-white md:p-1.5',
99+
'absolute top-1/2 z-10 -translate-y-1/2 cursor-pointer rounded-full bg-white/75 p-1 shadow-sm transition-opacity hover:bg-white md:p-1.5',
97100
isPrev ? 'left-4' : 'right-4',
98101
isHidden && 'hidden'
99102
)}

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

Lines changed: 33 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,26 @@ const meta = {
1112
export default meta;
1213
type Story = StoryObj<Props>;
1314

15+
function MediaUploadStoryWrapper(args: Props & { serverResponseUrl?: string[] }) {
16+
const { serverResponseUrl } = args;
17+
const [previewUrls, setPreviewUrls] = useState<string[]>(serverResponseUrl ?? []);
18+
const [previewFiles, setPreviewFiles] = useState<File[]>([]);
19+
20+
const handleFileChange = (files: File[] | null, urls: string[]) => {
21+
setPreviewFiles(files || []);
22+
setPreviewUrls(urls);
23+
};
24+
25+
return (
26+
<MediaUpload
27+
{...args}
28+
previewUrls={previewUrls}
29+
previewFiles={previewFiles}
30+
onFileChange={handleFileChange}
31+
/>
32+
);
33+
}
34+
1435
export const Basic: Story = {
1536
argTypes: {
1637
label: { control: { type: 'text' } },
@@ -19,7 +40,7 @@ export const Basic: Story = {
1940
maxSize: { control: { type: 'number', min: 1, step: 1 } },
2041
acceptedFormats: { control: 'text' },
2142
},
22-
render: (args) => <MediaUpload {...args} />,
43+
render: (args) => <MediaUploadStoryWrapper {...args} />,
2344
};
2445

2546
export const UploadFormats: Story = {
@@ -29,26 +50,30 @@ export const UploadFormats: Story = {
2950
maxSize: 3,
3051
acceptedFormats: ['image/*', 'video/*'],
3152
},
53+
render: (args) => <MediaUploadStoryWrapper {...args} />,
3254
};
3355

3456
export const WithTopAffix: Story = {
3557
args: {
3658
topAffix: '제목처럼 추가할 수 있어요',
3759
},
60+
render: (args) => <MediaUploadStoryWrapper {...args} />,
3861
};
3962

4063
export const MultipleMode: Story = {
4164
args: {
4265
multiple: true,
4366
},
67+
render: (args) => <MediaUploadStoryWrapper {...args} />,
4468
};
4569

4670
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-
},
71+
render: (args) => (
72+
<MediaUploadStoryWrapper
73+
{...args}
74+
serverResponseUrl={[
75+
'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?q=80&w=1200&auto=format&fit=crop',
76+
]}
77+
/>
78+
),
5479
};

src/shared/ui/MediaUpload/MediaUpload.tsx

Lines changed: 22 additions & 28 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,27 +96,22 @@ 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 (
120-
<div className="max-h-[500px] w-full">
114+
<div className="max-h-[500px] w-full overflow-auto">
121115
<Flex justifyContent="between">
122116
<Body1 className="text-gray-400">{topAffix}</Body1>
123117
<RefreshButton handleReset={handleReset} isSelected={isSelected} />
@@ -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)