From c82e13889c7e10e517f93f7849b5e3e7f07922a2 Mon Sep 17 00:00:00 2001
From: ParkGeunCheol <72205402+GC-Park@users.noreply.github.com>
Date: Tue, 19 Sep 2023 15:32:26 +0900
Subject: [PATCH] =?UTF-8?q?[FE]=20Feat/#386=20=EC=A7=80=EB=8F=84,=20?=
=?UTF-8?q?=ED=95=80=20=EC=9D=B4=EB=AF=B8=EC=A7=80=EC=97=90=20s3=20?=
=?UTF-8?q?=EC=A0=81=EC=9A=A9=20(#409)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat: 지도 추가 때 필요한 이미지 s3 적용
* feat: pin 생성에서 이미지 s3 적용
* feat: 핀 디테일 페이지에서 핀 사진 추가 구현
* refator: prettier 적용
* refactor: 잘못된 경로 수정
* refactor: 필요없는 console.log 삭제
* refactor: event handler method명 컨벤션에 맞게 수정
* refactor: 변경이 필요한 컴포넌트, 변수 이름 수정
* refactor: alt 및 error 처리 시 알림 메시지 사용자가 이해하기 쉽도록 수정
* refactor: 인라인 스타일 태그 styled component로 수정
* refactor: 지도 만들 때 기본 이미지 설정 해주도록 수정
* fix: 핀 이미지 추가할 때 무한 렌더링 에러 해결
* refactor: type 이름 컨벤션에 맞게 수정
* refactor: 필요없는 type 삭제
* refactor: postApi와 postFormApi 합치기 및 필요없는 코드 삭제
* fix: 핀 추가할 때 이미지 여러개 등록하면 하나만 등록되는 에러 해결
* refactor: 지도, 핀 추가 시 이미지 필수 아니도록 설정
---
frontend/src/apis/getApi.ts | 10 --
frontend/src/apis/postApi.ts | 34 ++++--
frontend/src/apis/putApi.ts | 8 --
.../components/PinImageContainer/index.tsx | 40 +++++++
frontend/src/components/TopicCard/index.tsx | 2 +-
frontend/src/components/TopicInfo/index.tsx | 2 +-
frontend/src/pages/NewPin.tsx | 104 +++++++++++++++++-
frontend/src/pages/NewTopic.tsx | 83 ++++++++++++--
frontend/src/pages/PinDetail.tsx | 84 +++++++++-----
frontend/src/types/FormValues.ts | 30 +++++
frontend/src/types/Pin.ts | 7 +-
11 files changed, 332 insertions(+), 72 deletions(-)
create mode 100644 frontend/src/components/PinImageContainer/index.tsx
create mode 100644 frontend/src/types/FormValues.ts
diff --git a/frontend/src/apis/getApi.ts b/frontend/src/apis/getApi.ts
index 60b27a80..f7b59bb2 100644
--- a/frontend/src/apis/getApi.ts
+++ b/frontend/src/apis/getApi.ts
@@ -1,15 +1,5 @@
-// const API_URL =
-// process.env.NODE_ENV === 'production'
-// ? process.env.REACT_APP_API_DEFAULT_PROD
-// : process.env.REACT_APP_API_DEFAULT_DEV;
-
import { DEFAULT_PROD_URL } from '../constants';
-const API_URL =
- process.env.NODE_ENV === 'development'
- ? 'http://localhost:3000'
- : DEFAULT_PROD_URL;
-
interface Headers {
'content-type': string;
[key: string]: string;
diff --git a/frontend/src/apis/postApi.ts b/frontend/src/apis/postApi.ts
index 6850f94b..fb78cf44 100644
--- a/frontend/src/apis/postApi.ts
+++ b/frontend/src/apis/postApi.ts
@@ -1,25 +1,43 @@
-// const API_URL =
-// process.env.NODE_ENV === 'production'
-// ? process.env.REACT_APP_API_DEFAULT_PROD
-// : process.env.REACT_APP_API_DEFAULT_DEV;
-
import { DEFAULT_PROD_URL } from '../constants';
import { ContentTypeType } from '../types/Api';
-const API_URL = process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : DEFAULT_PROD_URL
-
interface Headers {
'content-type': string;
[key: string]: string;
}
+interface HeadersForm {
+ [key: string]: string;
+}
+
export const postApi = async (
url: string,
- payload: {},
+ payload: {} | FormData,
contentType?: ContentTypeType,
) => {
const apiUrl = `${DEFAULT_PROD_URL + url}`;
const userToken = localStorage.getItem('userToken');
+
+ if (payload instanceof FormData) {
+ const headers: HeadersForm = {};
+
+ if (userToken) {
+ headers['Authorization'] = `Bearer ${userToken}`;
+ }
+
+ const response = await fetch(apiUrl, {
+ method: 'POST',
+ headers,
+ body: payload,
+ });
+
+ if (response.status >= 400) {
+ throw new Error('[SERVER] POST 요청에 실패했습니다.');
+ }
+
+ return response;
+ }
+
const headers: Headers = {
'content-type': 'application/json',
};
diff --git a/frontend/src/apis/putApi.ts b/frontend/src/apis/putApi.ts
index 2a3c1a9c..7e467265 100644
--- a/frontend/src/apis/putApi.ts
+++ b/frontend/src/apis/putApi.ts
@@ -1,13 +1,5 @@
-// const API_URL =
-// process.env.NODE_ENV === 'production'
-// ? process.env.REACT_APP_API_DEFAULT_PROD
-// : process.env.REACT_APP_API_DEFAULT_DEV;
-
import { DEFAULT_PROD_URL } from '../constants';
import { ContentTypeType } from '../types/Api';
-
-const API_URL = process.env.NODE_ENV === 'development' ? 'http://localhost:3000' : DEFAULT_PROD_URL
-
interface Headers {
'content-type': string;
[key: string]: string;
diff --git a/frontend/src/components/PinImageContainer/index.tsx b/frontend/src/components/PinImageContainer/index.tsx
new file mode 100644
index 00000000..8b2b5f96
--- /dev/null
+++ b/frontend/src/components/PinImageContainer/index.tsx
@@ -0,0 +1,40 @@
+import styled from 'styled-components';
+import { ImageProps } from '../../types/Pin';
+import Image from '../common/Image';
+
+interface PinImageContainerProps {
+ images: ImageProps[];
+}
+
+const PinImageContainer = ({ images }: PinImageContainerProps) => {
+ return (
+ <>
+
+ {images.map((image, index) => (
+
+
+
+ ))}
+
+ >
+ );
+};
+
+const FilmList = styled.ul`
+ width: 330px;
+ display: flex;
+ flex-direction: row;
+
+ overflow: hidden;
+`;
+
+const ImageWrapper = styled.li`
+ margin-right: 10px;
+`;
+
+export default PinImageContainer;
diff --git a/frontend/src/components/TopicCard/index.tsx b/frontend/src/components/TopicCard/index.tsx
index 86953c29..91f54434 100644
--- a/frontend/src/components/TopicCard/index.tsx
+++ b/frontend/src/components/TopicCard/index.tsx
@@ -72,7 +72,7 @@ const TopicCard = ({
height="138px"
width="138px"
src={image}
- alt="토픽 이미지"
+ alt="사진 이미지"
$objectFit="cover"
onError={(e: SyntheticEvent) => {
e.currentTarget.src = DEFAULT_TOPIC_IMAGE;
diff --git a/frontend/src/components/TopicInfo/index.tsx b/frontend/src/components/TopicInfo/index.tsx
index d0118ca3..4337205c 100644
--- a/frontend/src/components/TopicInfo/index.tsx
+++ b/frontend/src/components/TopicInfo/index.tsx
@@ -95,7 +95,7 @@ const TopicInfo = ({
height="168px"
width="100%"
src={topicImage}
- alt="토픽 이미지"
+ alt="사진 이미지"
$objectFit="cover"
onError={(e: React.SyntheticEvent) => {
e.currentTarget.src = DEFAULT_TOPIC_IMAGE;
diff --git a/frontend/src/pages/NewPin.tsx b/frontend/src/pages/NewPin.tsx
index 7f716880..1e1233f4 100644
--- a/frontend/src/pages/NewPin.tsx
+++ b/frontend/src/pages/NewPin.tsx
@@ -8,7 +8,7 @@ import { FormEvent, useContext, useEffect, useState } from 'react';
import { getApi } from '../apis/getApi';
import { TopicCardProps } from '../types/Topic';
import useNavigator from '../hooks/useNavigator';
-import { NewPinFormProps } from '../types/tmap';
+import { NewPinFormProps } from '../types/FormValues';
import useFormValues from '../hooks/useFormValues';
import { MarkerContext } from '../context/MarkerContext';
import { CoordinatesContext } from '../context/CoordinatesContext';
@@ -35,6 +35,7 @@ const NewPin = () => {
const { navbarHighlights: _ } = useSetNavbarHighlight('addMapOrPin');
const [topic, setTopic] = useState(null);
const [selectedTopic, setSelectedTopic] = useState(null);
+ const [showedImages, setShowedImages] = useState([]);
const { clickedMarker } = useContext(MarkerContext);
const { clickedCoordinate, setClickedCoordinate } =
useContext(CoordinatesContext);
@@ -49,6 +50,8 @@ const NewPin = () => {
const { width } = useSetLayoutWidth(SIDEBAR);
const { openModal, closeModal } = useContext(ModalContext);
+ const [formImages, setFormImages] = useState([]);
+
const goToBack = () => {
routePage(-1);
};
@@ -57,6 +60,8 @@ const NewPin = () => {
let postTopicId = topic?.id;
let postName = formValues.name;
+ const formData = new FormData();
+
if (!topic) {
//토픽이 없으면 selectedTopic을 통해 토픽을 생성한다.
postTopicId = selectedTopic?.topicId;
@@ -66,7 +71,11 @@ const NewPin = () => {
postTopicId = selectedTopic.topicId;
}
- await postApi('/pins', {
+ formImages.forEach((file) => {
+ formData.append('images', file);
+ });
+
+ const objectData = {
topicId: postTopicId,
name: postName,
address: clickedCoordinate.address,
@@ -74,8 +83,14 @@ const NewPin = () => {
latitude: clickedCoordinate.latitude,
longitude: clickedCoordinate.longitude,
legalDongCode: '',
- images: [],
- });
+ };
+
+ const data = JSON.stringify(objectData);
+ const jsonBlob = new Blob([data], { type: 'application/json' });
+
+ formData.append('request', jsonBlob);
+
+ await postApi('/pins', formData);
};
const onSubmit = async (e: FormEvent) => {
@@ -102,8 +117,8 @@ const NewPin = () => {
}
setClickedCoordinate({
- latitude: '',
- longitude: '',
+ latitude: 0,
+ longitude: 0,
address: '',
});
@@ -162,6 +177,36 @@ const NewPin = () => {
});
};
+ const onPinImageChange = (event: React.ChangeEvent) => {
+ const imageLists = event.target.files;
+ let imageUrlLists = [...showedImages];
+
+ if (!imageLists) {
+ showToast(
+ 'error',
+ '추가하신 이미지를 찾을 수 없습니다. 다시 선택해 주세요.',
+ );
+ return;
+ }
+
+ for (let i = 0; i < imageLists.length; i++) {
+ const currentImageUrl = URL.createObjectURL(imageLists[i]);
+ imageUrlLists.push(currentImageUrl);
+ }
+
+ if (imageUrlLists.length > 8) {
+ showToast(
+ 'info',
+ '이미지 개수는 최대 8개까지만 선택 가능합니다. 다시 선택해 주세요.',
+ );
+ imageUrlLists = imageUrlLists.slice(0, 8);
+ return;
+ }
+
+ setFormImages([...formImages, ...imageLists]);
+ setShowedImages(imageUrlLists);
+ };
+
useEffect(() => {
const getTopicId = async () => {
if (topicId && topicId.split(',').length === 1) {
@@ -221,6 +266,32 @@ const NewPin = () => {
+
+ 사진 선택
+
+
+
+ 파일찾기
+
+
+
+
+ {showedImages.map((image, id) => (
+
+
+
+
+ ))}
+
+
+
+
theme.color.black};
+ background-color: ${({ theme }) => theme.color.lightGray};
+
+ font-size: ${({ theme }) => theme.fontSize.extraSmall};
+ cursor: pointer;
+`;
+
+const ShowImage = styled.img`
+ width: 80px;
+ height: 80px;
+`;
+
+const ImageInputButton = styled.input`
+ display: none;
+`;
+
export default NewPin;
diff --git a/frontend/src/pages/NewTopic.tsx b/frontend/src/pages/NewTopic.tsx
index 9227d645..a778b8f8 100644
--- a/frontend/src/pages/NewTopic.tsx
+++ b/frontend/src/pages/NewTopic.tsx
@@ -4,18 +4,19 @@ import Flex from '../components/common/Flex';
import Space from '../components/common/Space';
import Button from '../components/common/Button';
import useNavigator from '../hooks/useNavigator';
-import { NewTopicFormProps } from '../types/tmap';
+import { NewTopicFormProps } from '../types/FormValues';
import useFormValues from '../hooks/useFormValues';
import { useLocation } from 'react-router-dom';
import useToast from '../hooks/useToast';
import InputContainer from '../components/InputContainer';
import { hasErrorMessage, hasNullValue } from '../validations';
import useSetLayoutWidth from '../hooks/useSetLayoutWidth';
-import { DEFAULT_TOPIC_IMAGE, LAYOUT_PADDING, SIDEBAR } from '../constants';
+import { LAYOUT_PADDING, SIDEBAR } from '../constants';
import useSetNavbarHighlight from '../hooks/useSetNavbarHighlight';
import { TagContext } from '../context/TagContext';
import usePost from '../apiHooks/usePost';
import AuthorityRadioContainer from '../components/AuthorityRadioContainer';
+import styled from 'styled-components';
type NewTopicFormValuesType = Omit;
@@ -38,6 +39,9 @@ const NewTopic = () => {
const [isAll, setIsAll] = useState(true); // 모두 : 지정 인원
const [authorizedMemberIds, setAuthorizedMemberIds] = useState([]);
+ const [showImage, setShowImage] = useState('');
+ const [formImage, setFormImage] = useState(null);
+
const goToBack = () => {
routePage(-1);
};
@@ -69,16 +73,28 @@ const NewTopic = () => {
};
const postToServer = async () => {
+ const formData = new FormData();
+
+ if (formImage) {
+ formData.append('image', formImage);
+ }
+
+ const objectData = {
+ name: formValues.name,
+ description: formValues.description,
+ pins: pulledPinIds ? pulledPinIds.split(',') : [],
+ publicity: isPrivate ? 'PRIVATE' : 'PUBLIC',
+ permissionType: isAll && !isPrivate ? 'ALL_MEMBERS' : 'GROUP_ONLY',
+ };
+
+ const data = JSON.stringify(objectData);
+ const jsonBlob = new Blob([data], { type: 'application/json' });
+
+ formData.append('request', jsonBlob);
+
return fetchPost({
url: '/topics/new',
- payload: {
- image: formValues.image || DEFAULT_TOPIC_IMAGE,
- name: formValues.name,
- description: formValues.description,
- pins: pulledPinIds ? pulledPinIds.split(',') : [],
- publicity: isPrivate ? 'PRIVATE' : 'PUBLIC',
- permissionType: isAll && !isPrivate ? 'ALL_MEMBERS' : 'GROUP_ONLY',
- },
+ payload: formData,
errorMessage:
'지도 생성에 실패하였습니다. 입력하신 항목들을 다시 확인해주세요.',
onSuccess: () => {
@@ -100,6 +116,22 @@ const NewTopic = () => {
});
};
+ const onTopicImageFileChange = (
+ event: React.ChangeEvent,
+ ) => {
+ const file = event.target.files && event.target.files[0];
+ if (!file) {
+ showToast(
+ 'error',
+ '추가하신 이미지를 찾을 수 없습니다. 다시 선택해 주세요.',
+ );
+ return;
+ }
+
+ setFormImage(file);
+ setShowImage(URL.createObjectURL(file));
+ };
+
return (