Skip to content

Commit

Permalink
feat: 사진 업로드 기능 (#723)
Browse files Browse the repository at this point in the history
* fix: 기존에 잘못된 url 주소 변경

* feat: carousel 구현

* fix: api 요청 방법 변경

* fix: image post api 수정

* feat: image 불러오기 위한 기능 구현

* fix: 스크롤바가 보이지 않도록 변경

* design: Carousel 디자인 변경

* fix: 사진 추가 버튼 커스텀

* feat: images 불러오는 api 추가

* feat: Carousel component 구현 완료

* feat: 사진 추가 페이지 구현

* feat: 요청으로 불러오는 Images 타입 선언

* feat: 홈에서 이미지를 확인할 수 있는 기능 추가

* fix: 행사 생성 시 다른 query들 초기화하도록 변경

* fix: style이 제대로 적용되지 않던 문제 해결

* style: lint 적용

* fix: mock url 변경

* feat: imageDelete api 추가

* fix: service type 수정

* refactor: AddImagesPage 구조 변경 및 delete api 추가

* fix: 불필요한 z-index 제거₩

* fix: merge 충돌 해결
  • Loading branch information
Todari committed Oct 10, 2024
1 parent 9061bd2 commit 38da5a1
Show file tree
Hide file tree
Showing 34 changed files with 673 additions and 31 deletions.
6 changes: 6 additions & 0 deletions client/src/GlobalStyle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,4 +145,10 @@ export const GlobalStyle = css`
max-width: 768px;
margin: 0 auto;
}
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
&::-webkit-scrollbar {
display: none; /* Chrome, Safari, Opera */
}
`;
1 change: 1 addition & 0 deletions client/src/apis/baseUrl.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export const BASE_URL = {
HD: process.env.API_BASE_URL,
S3: process.env.S3_URL,
};
51 changes: 51 additions & 0 deletions client/src/apis/request/images.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import {EventId, Images} from 'types/serviceType';

import {BASE_URL} from '@apis/baseUrl';
import {ADMIN_API_PREFIX, USER_API_PREFIX} from '@apis/endpointPrefix';
import {requestDelete, requestGet, requestPostWithoutResponse} from '@apis/fetcher';
import {WithEventId} from '@apis/withId.type';

export interface RequestPostImages {
formData: FormData;
}

export const requestPostImages = async ({eventId, formData}: WithEventId<RequestPostImages>) => {
// return await requestPostWithoutResponse({
// baseUrl: BASE_URL.HD,
// endpoint: `${ADMIN_API_PREFIX}/${eventId}/images`,
// headers: {
// 'Content-Type': 'multipart/form-data',
// },
// body: formData,
// });

// TODO: (@todari): 기존의 request 방식들은 기본적으로
// header를 Content-Type : application/json 으로 보내주고 있음
// multipart/form-data 요청을 보내기 위해선 header Content-Type을 빈 객체로 전달해야 함
fetch(`${BASE_URL.HD}${ADMIN_API_PREFIX}/${eventId}/images`, {
credentials: 'include',
// headers: {
// 'Content-Type': 'multipart/form-data',
// },
method: 'POST',
body: formData,
});
};

export const requestGetImages = async ({eventId}: WithEventId) => {
return await requestGet<Images>({
baseUrl: BASE_URL.HD,
endpoint: `${USER_API_PREFIX}/${eventId}/images`,
});
};

export interface RequestDeleteImage {
imageId: number;
}

export const requestDeleteImage = async ({eventId, imageId}: WithEventId<RequestDeleteImage>) => {
return await requestDelete({
baseUrl: BASE_URL.HD,
endpoint: `${ADMIN_API_PREFIX}/${eventId}/images/${imageId}`,
});
};
4 changes: 4 additions & 0 deletions client/src/assets/image/photoButton.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type {Meta, StoryObj} from '@storybook/react';

import Carousel from './Carousel';

const meta = {
title: 'Components/Carousel',
component: Carousel,
tags: ['autodocs'],
parameters: {
layout: 'centered',
width: 430,
},
argTypes: {},
args: {
urls: [
'https://wooteco-crew-wiki.s3.ap-northeast-2.amazonaws.com/%EC%BF%A0%ED%82%A4(6%EA%B8%B0)/image.png',
'https://wooteco-crew-wiki.s3.ap-northeast-2.amazonaws.com/%EC%BF%A0%ED%82%A4%286%EA%B8%B0%29/4tyq1x19rsn.jpg',
'https://img.danawa.com/images/descFiles/5/896/4895281_1_16376712347542321.gif',
],
},
} satisfies Meta<typeof Carousel>;

export default meta;

type Story = StoryObj<typeof meta>;

export const Playground: Story = {};
94 changes: 94 additions & 0 deletions client/src/components/Design/components/Carousel/Carousel.style.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import {css} from '@emotion/react';

import {Theme} from '@components/Design/theme/theme.type';

export const carouselWrapperStyle = css`
position: relative;
overflow: hidden;
display: flex;
`;

interface ImageCardContainerStyleProps {
currentIndex: number;
length: number;
translateX: number;
isDragging: boolean;
}

export const imageCardContainerStyle = ({
currentIndex,
length,
translateX,
isDragging,
}: ImageCardContainerStyleProps) => css`
display: flex;
gap: 1rem;
margin-inline: 2rem;
transform: translateX(
calc(
(100vw - 3rem) * ${-currentIndex} +
${(currentIndex === 0 && translateX > 0) || (currentIndex === length - 1 && translateX < 0) ? 0 : translateX}px
)
);
transition: ${isDragging ? 'none' : '0.2s'};
transition-timing-function: cubic-bezier(0.7, 0.62, 0.62, 1.16);
`;

interface ImageCardStyleProps {
theme: Theme;
}

export const imageCardStyle = ({theme}: ImageCardStyleProps) => css`
position: relative;
display: flex;
justify-content: center;
align-items: center;
clip-path: inset(0 round 1rem);
background-color: ${theme.colors.gray};
`;

export const imageStyle = css`
width: calc(100vw - 4rem);
aspect-ratio: 3/4;
object-fit: contain;
`;

export const deleteButtonStyle = css`
position: absolute;
top: 1rem;
right: 1rem;
padding: 0.5rem;
opacity: 0.48;
background-color: rgba(0, 0, 0, 0.5);
border-radius: 50%;
display: flex;
justify-content: center;
align-items: center;
`;

export const indicatorContainerStyle = css`
position: absolute;
left: 50%;
bottom: 1rem;
transform: translateX(-50%);
display: flex;
gap: 0.25rem;
width: 8rem;
`;

interface IndicatorStyleProps {
index: number;
currentIndex: number;
theme: Theme;
}

export const indicatorStyle = ({index, currentIndex, theme}: IndicatorStyleProps) => css`
width: 100%;
height: 0.125rem;
border-radius: 0.0625rem;
opacity: ${index !== currentIndex ? 0.48 : 1};
background-color: ${index !== currentIndex ? theme.colors.lightGrayContainer : theme.colors.primary};
transition: 0.2s;
transition-timing-function: cubic-bezier(0.7, 0.62, 0.62, 1.16);
content: ' ';
`;
36 changes: 36 additions & 0 deletions client/src/components/Design/components/Carousel/Carousel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/** @jsxImportSource @emotion/react */
import {CarouselProps} from './Carousel.type';
import CarouselIndicator from './CarouselIndicator';
import CarouselDeleteButton from './CarouselDeleteButton';
import {carouselWrapperStyle, imageCardContainerStyle, imageCardStyle, imageStyle} from './Carousel.style';
import useCarousel from './useCarousel';

const Carousel = ({urls, onClickDelete}: CarouselProps) => {
const {handleDragStart, handleDrag, handleDragEnd, theme, currentIndex, translateX, isDragging, handleClickDelete} =
useCarousel({urls, onClickDelete});

return (
<div css={carouselWrapperStyle}>
<div
css={imageCardContainerStyle({currentIndex, length: urls.length, translateX, isDragging})}
onMouseDown={handleDragStart}
onMouseMove={handleDrag}
onMouseUp={handleDragEnd}
onTouchStart={handleDragStart}
onTouchMove={handleDrag}
onTouchEnd={handleDragEnd}
>
{urls &&
urls.map((url, index) => (
<div key={url} css={imageCardStyle({theme})}>
<img src={url} alt={`업로드된 이미지 ${index + 1}`} css={imageStyle} />
{onClickDelete && <CarouselDeleteButton onClick={() => handleClickDelete(index)} />}
</div>
))}
</div>
{urls.length !== 1 && <CarouselIndicator length={urls.length} currentIndex={currentIndex} />}
</div>
);
};

export default Carousel;
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export interface CarouselProps {
urls: string[];
onClickDelete?: (index: number) => void;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import Icon from '../Icon/Icon';

import {deleteButtonStyle} from './Carousel.style';

interface Props {
onClick: () => void;
}

const CarouselDeleteButton = ({onClick}: Props) => {
return (
<button css={deleteButtonStyle} onClick={onClick}>
<Icon iconType="x" />
</button>
);
};

export default CarouselDeleteButton;
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import {useTheme} from '@components/Design/theme/HDesignProvider';

import {indicatorContainerStyle, indicatorStyle} from './Carousel.style';

interface Props {
length: number;
currentIndex: number;
}

const CarouselIndicator = ({length, currentIndex}: Props) => {
const {theme} = useTheme();

return (
<div css={indicatorContainerStyle}>
{Array.from({length}).map((_, index) => (
<div key={index} css={indicatorStyle({index, currentIndex, theme})} />
))}
</div>
);
};

export default CarouselIndicator;
48 changes: 48 additions & 0 deletions client/src/components/Design/components/Carousel/useCarousel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {useRef, useState} from 'react';

import {useTheme} from '@components/Design/theme/HDesignProvider';

import {CarouselProps} from './Carousel.type';

const useCarousel = ({urls, onClickDelete}: CarouselProps) => {
const startX = useRef(0);
const [translateX, setTranslateX] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const [currentIndex, setCurrentIndex] = useState(0);
const {theme} = useTheme();

const handleDragStart = (e: React.TouchEvent | React.MouseEvent) => {
setIsDragging(true);
startX.current = 'touches' in e ? e.touches[0].clientX : e.clientX;
};

const handleDrag = (e: React.TouchEvent | React.MouseEvent) => {
if (!isDragging) return;
const currentX = 'touches' in e ? e.touches[0].clientX : e.clientX;
const deltaX = currentX - startX.current;
setTranslateX(deltaX);
};

const threshold = window.screen.width / 10;

const handleDragEnd = () => {
setIsDragging(false);
if (-translateX > threshold) {
setCurrentIndex(prev => (prev !== urls.length - 1 ? prev + 1 : prev));
}
if (+translateX > threshold) {
setCurrentIndex(prev => (prev !== 0 ? prev - 1 : prev));
}
setTranslateX(0);
};

const handleClickDelete = (index: number) => {
if (!onClickDelete) return;
onClickDelete(index);
if (urls.length !== 1 && index === urls.length - 1) setCurrentIndex(prev => prev - 1);
};

return {handleDragStart, handleDrag, handleDragEnd, theme, currentIndex, translateX, isDragging, handleClickDelete};
};

export default useCarousel;
31 changes: 17 additions & 14 deletions client/src/components/Design/components/Flex/Flex.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,20 +29,23 @@ const Flex = forwardRef<HTMLDivElement, StrictPropsWithChildren<FlexProps>>(({ch
return (
<div
ref={ref}
css={[flexStyle({
theme,
justifyContent,
alignItems,
flexDirection,
gap,
padding,
paddingInline,
margin,
width,
height,
backgroundColor,
minHeight,
}), cssProp]}
css={[
flexStyle({
theme,
justifyContent,
alignItems,
flexDirection,
gap,
padding,
paddingInline,
margin,
width,
height,
backgroundColor,
minHeight,
}),
cssProp,
]}
{...htmlProps}
>
{children}
Expand Down
3 changes: 1 addition & 2 deletions client/src/components/Design/components/Flex/Flex.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,4 @@ export type FlexProps = React.HTMLAttributes<HTMLDivElement> & {
minHeight?: string;

cssProp?: CSSObject;
}

};
1 change: 1 addition & 0 deletions client/src/components/Design/components/Icon/Icon.style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const ICON_DEFAULT_COLOR: Record<IconType, IconColor> = {
meatballs: 'black',
editPencil: 'gray',
heundeut: 'gray',
photoButton: 'white',
};

export const iconStyle = ({iconType, theme, iconColor}: IconStylePropsWithTheme) => {
Expand Down
2 changes: 2 additions & 0 deletions client/src/components/Design/components/Icon/Icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import X from '@assets/image/x.svg';
import PencilMini from '@assets/image/pencil_mini.svg';
import Meatballs from '@assets/image/meatballs.svg';
import EditPencil from '@assets/image/editPencil.svg';
import PhotoButton from '@assets/image/photoButton.svg';
import {IconProps} from '@HDcomponents/Icon/Icon.type';
import {useTheme} from '@theme/HDesignProvider';

Expand All @@ -35,6 +36,7 @@ const ICON = {
meatballs: <Meatballs />,
editPencil: <EditPencil />,
heundeut: <img src={`${process.env.IMAGE_URL}/heundeut.svg`} />,
photoButton: <PhotoButton />,
};

export const Icon: React.FC<IconProps> = ({iconColor, iconType, ...htmlProps}: IconProps) => {
Expand Down
Loading

0 comments on commit 38da5a1

Please sign in to comment.