Skip to content

Commit 75c2c93

Browse files
authored
Merge pull request #77 from COW-dev/feat/#46-image
[FEAT] MediaUpload, ImageGallery 컴포넌트 제작
2 parents b2f851e + 786c684 commit 75c2c93

File tree

11 files changed

+504
-0
lines changed

11 files changed

+504
-0
lines changed
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { Meta, StoryObj } from '@storybook/react';
2+
3+
import { ImageGallery } from './ImageGallery';
4+
5+
const meta = {
6+
title: 'components/ImageGallery',
7+
component: ImageGallery,
8+
tags: ['autodocs'],
9+
} satisfies Meta<typeof ImageGallery>;
10+
11+
export default meta;
12+
type Story = StoryObj<typeof meta>;
13+
14+
const SAMPLE_IMAGE = [
15+
{
16+
url: 'https://images.unsplash.com/photo-1500530855697-b586d89ba3ee?q=80&w=1200&auto=format&fit=crop',
17+
name: 'ImageGalleryExample1',
18+
},
19+
{
20+
url: 'https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?q=80&w=1200&auto=format&fit=crop',
21+
name: 'ImageGalleryExample2',
22+
},
23+
{
24+
url: 'https://images.unsplash.com/photo-1507525428034-b723cf961d3e?q=80&w=1200&auto=format&fit=crop',
25+
name: 'ImageGalleryExample3',
26+
},
27+
{
28+
url: 'https://images.unsplash.com/photo-1470770841072-f978cf4d019e?q=80&w=1200&auto=format&fit=crop',
29+
name: 'ImageGalleryExample4',
30+
},
31+
{
32+
url: 'https://images.unsplash.com/photo-1491553895911-0055eca6402d?q=80&w=1200&auto=format&fit=crop',
33+
name: 'ImageGalleryExample5',
34+
},
35+
{
36+
url: 'https://images.unsplash.com/photo-1499002238440-d264edd596ec?q=80&w=1200&auto=format&fit=crop',
37+
name: 'ImageGalleryExample6',
38+
},
39+
];
40+
41+
export const Basic: Story = {
42+
args: {
43+
images: SAMPLE_IMAGE,
44+
},
45+
parameters: {
46+
layout: 'centered',
47+
},
48+
render: (args) => <ImageGallery {...args} />,
49+
};
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { cn } from '@/shared/lib/core';
2+
3+
import { ImageGalleryProvider, useImageGallery } from './ImageGalleryContext';
4+
5+
import { Flex } from '../Flex';
6+
import { Icon } from '../Icon';
7+
8+
export type ImageGalleryItem = { url: string; name?: string };
9+
type Props = {
10+
/**
11+
* List of image name and url
12+
*/
13+
images: ImageGalleryItem[];
14+
15+
/**
16+
* Additional class for container
17+
*/
18+
className?: string;
19+
};
20+
21+
export function ImageGallery({ images, className }: Props) {
22+
return (
23+
<ImageGalleryProvider images={images}>
24+
<ImageGalleryContent className={className} />
25+
</ImageGalleryProvider>
26+
);
27+
}
28+
29+
function ImageGalleryContent({ className }: { className?: string }) {
30+
const { images, current, total, firstImage } = useImageGallery();
31+
const loading = firstImage ? 'eager' : 'lazy';
32+
33+
return (
34+
<Flex dir="col" alignItems="center" className={cn('w-full max-w-[500px]', className)}>
35+
<Flex
36+
alignItems="center"
37+
justifyContent="center"
38+
className="relative aspect-square w-full overflow-hidden bg-gray-50"
39+
>
40+
<img
41+
src={images[current].url}
42+
loading={loading}
43+
alt={images[current].name}
44+
width={500}
45+
height={500}
46+
className="h-full w-full object-contain"
47+
/>
48+
{total > 1 && (
49+
<>
50+
<ImageGalleryArrow direction="prev" />
51+
<ImageGalleryArrow direction="next" />
52+
</>
53+
)}
54+
</Flex>
55+
<Flex justifyContent="center" className="mt-2">
56+
<ImageGalleryDots />
57+
</Flex>
58+
</Flex>
59+
);
60+
}
61+
62+
function ImageGalleryDots() {
63+
const { images, current, goToIndex } = useImageGallery();
64+
return (
65+
<Flex alignItems="center" className="gap-2">
66+
{images.map((_, index) => {
67+
const isActive = index === current;
68+
return (
69+
<button
70+
key={index}
71+
type="button"
72+
aria-label={`Image ${index + 1}`}
73+
onClick={() => goToIndex(index)}
74+
className={cn('h-2 w-2 rounded-full', isActive ? 'bg-primary-300' : 'bg-gray-200')}
75+
/>
76+
);
77+
})}
78+
</Flex>
79+
);
80+
}
81+
82+
type ImageGalleryArrowProps = {
83+
direction: 'prev' | 'next';
84+
};
85+
function ImageGalleryArrow({ direction }: ImageGalleryArrowProps) {
86+
const { firstImage, lastImage, goPrev, goNext } = useImageGallery();
87+
const isPrev = direction === 'prev';
88+
const isHidden = isPrev ? firstImage : lastImage;
89+
const onClick = isPrev ? goPrev : goNext;
90+
91+
return (
92+
<button
93+
type="button"
94+
onClick={onClick}
95+
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',
97+
isPrev ? 'left-4' : 'right-4',
98+
isHidden && 'hidden'
99+
)}
100+
aria-label={`${direction} image button`}
101+
>
102+
<Icon name={isPrev ? 'arrowLeft' : 'arrowRight'} className="h-5 w-5 md:h-6 md:w-6" />
103+
</button>
104+
);
105+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { createContext, useCallback, useContext, useState } from 'react';
2+
3+
import { ImageGalleryItem } from './ImageGallery';
4+
5+
type ImageGalleryContextType = {
6+
images: ImageGalleryItem[];
7+
current: number;
8+
total: number;
9+
firstImage: boolean;
10+
lastImage: boolean;
11+
goPrev: () => void;
12+
goNext: () => void;
13+
goToIndex: (index: number) => void;
14+
};
15+
const initImageGalleryContext: ImageGalleryContextType = {
16+
images: [],
17+
current: 0,
18+
total: 0,
19+
firstImage: true,
20+
lastImage: false,
21+
goPrev: () => {},
22+
goNext: () => {},
23+
goToIndex: () => {},
24+
};
25+
const ImageGalleryContext = createContext<ImageGalleryContextType>(initImageGalleryContext);
26+
27+
/* eslint-disable react-refresh/only-export-components */
28+
export const useImageGallery = () => {
29+
return useContext(ImageGalleryContext);
30+
};
31+
32+
type ProviderProps = {
33+
images: ImageGalleryItem[];
34+
children: React.ReactNode;
35+
};
36+
37+
export function ImageGalleryProvider({ images, children }: ProviderProps) {
38+
const [current, setCurrent] = useState(0);
39+
const total = images.length;
40+
41+
const goPrev = useCallback(() => setCurrent((c) => Math.max(0, c - 1)), []);
42+
const goNext = useCallback(() => setCurrent((c) => Math.min(total - 1, c + 1)), [total]);
43+
const goToIndex = useCallback(
44+
(index: number) => setCurrent(Math.max(0, Math.min(total - 1, index))),
45+
[total]
46+
);
47+
48+
const value: ImageGalleryContextType = {
49+
images,
50+
current,
51+
total,
52+
firstImage: current === 0,
53+
lastImage: current === total - 1,
54+
goPrev,
55+
goNext,
56+
goToIndex,
57+
};
58+
59+
return <ImageGalleryContext.Provider value={value}>{children}</ImageGalleryContext.Provider>;
60+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { ImageGallery } from './ImageGallery';
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { Meta, StoryObj } from '@storybook/react';
2+
3+
import { MediaUpload, Props } from './MediaUpload';
4+
5+
const meta = {
6+
title: 'components/MediaUpload',
7+
component: MediaUpload,
8+
tags: ['autodocs'],
9+
} satisfies Meta<typeof MediaUpload>;
10+
11+
export default meta;
12+
type Story = StoryObj<Props>;
13+
14+
export const Basic: Story = {
15+
argTypes: {
16+
label: { control: { type: 'text' } },
17+
description: { control: { type: 'text' } },
18+
topAffix: { control: 'text' },
19+
maxSize: { control: { type: 'number', min: 1, step: 1 } },
20+
acceptedFormats: { control: 'text' },
21+
},
22+
render: (args) => <MediaUpload {...args} />,
23+
};
24+
25+
export const UploadFormats: Story = {
26+
args: {
27+
label: '이미지와 비디오를 업로드 할 수 있어요.',
28+
description: '3GB까지 업로드 가능해요.',
29+
maxSize: 3,
30+
acceptedFormats: ['image/*', 'video/*'],
31+
},
32+
};
33+
34+
export const WithTopAffix: Story = {
35+
args: {
36+
topAffix: '제목처럼 추가할 수 있어요',
37+
},
38+
};
39+
40+
export const MultipleMode: Story = {
41+
args: {
42+
multiple: true,
43+
},
44+
};

0 commit comments

Comments
 (0)