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 (
@@ -111,6 +143,18 @@ const NewTopic = () => { 지도 생성 + + + {showImage && } + 파일업로드 + + + { ); }; +const ImageInputLabel = styled.label` + height: 40px; + margin-left: 10px; + padding: 10px 10px; + color: ${({ theme }) => 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 NewTopic; diff --git a/frontend/src/pages/PinDetail.tsx b/frontend/src/pages/PinDetail.tsx index 66b17933..6f101df3 100644 --- a/frontend/src/pages/PinDetail.tsx +++ b/frontend/src/pages/PinDetail.tsx @@ -8,13 +8,15 @@ import { useSearchParams } from 'react-router-dom'; import Box from '../components/common/Box'; import UpdatedPinDetail from './UpdatedPinDetail'; import useFormValues from '../hooks/useFormValues'; -import { ModifyPinFormProps } from '../types/tmap'; +import { ModifyPinFormProps } from '../types/FormValues'; import useToast from '../hooks/useToast'; import Button from '../components/common/Button'; import Modal from '../components/Modal'; import { styled } from 'styled-components'; import { ModalContext } from '../context/ModalContext'; import AddToMyTopicList from '../components/ModalMyTopicList/addToMyTopicList'; +import { postApi } from '../apis/postApi'; +import PinImageContainer from '../components/PinImageContainer'; interface PinDetailProps { width: '372px' | '100vw'; @@ -42,7 +44,6 @@ const PinDetail = ({ onChangeInput, } = useFormValues({ name: '', - images: [], description: '', }); const { openModal } = useContext(ModalContext); @@ -61,7 +62,6 @@ const PinDetail = ({ setPin(pinData); setFormValues({ name: pinData.name, - images: pinData.images, description: pinData.description, }); }; @@ -74,7 +74,6 @@ const PinDetail = ({ setIsEditPinDetail(true); setErrorMessages({ name: '', - images: '', description: '', }); }; @@ -88,6 +87,32 @@ const PinDetail = ({ } }; + const onPinImageFileChange = async ( + event: React.ChangeEvent, + ) => { + const file = event.target.files && event.target.files[0]; + const formData = new FormData(); + + if (!file) { + showToast( + 'error', + '이미지를 선택하지 않았거나 추가하신 이미지를 찾을 수 없습니다. 다시 선택해 주세요.', + ); + return; + } + + formData.append('image', file); + + const data = JSON.stringify(pinId); + const jsonBlob = new Blob([data], { type: 'application/json' }); + + formData.append('pinId', jsonBlob); + + await postApi('/pins/images', formData); + + getPinData(); + }; + if (!pin) return <>; if (isEditPinDetail) @@ -143,25 +168,15 @@ const PinDetail = ({ - - - + 사진을 추가해주시면 더 알찬 정보를 제공해줄 수 있을 것 같아요. - - + 파일업로드 + + + @@ -257,10 +272,6 @@ const Wrapper = styled.section<{ } `; -const PinDetailImgContainer = styled(Flex)` - box-shadow: 8px 8px 8px 0px rgba(69, 69, 69, 0.15); -`; - const SaveToMyMapButton = styled(Button)` font-size: ${({ theme }) => theme.fontSize.default}; font-weight: ${({ theme }) => theme.fontWeight.bold}; @@ -294,4 +305,23 @@ const ButtonsWrapper = styled.div` margin: 0 auto; `; +const ImageInputLabel = styled.label` + width: 80px; + height: 40px; + margin-bottom: 10px; + padding: 10px 10px; + + color: ${({ theme }) => theme.color.black}; + background-color: ${({ theme }) => theme.color.lightGray}; + + font-size: ${({ theme }) => theme.fontSize.extraSmall}; + text-align: center; + + cursor: pointer; +`; + +const ImageInputButton = styled.input` + display: none; +`; + export default PinDetail; diff --git a/frontend/src/types/FormValues.ts b/frontend/src/types/FormValues.ts new file mode 100644 index 00000000..999ef09e --- /dev/null +++ b/frontend/src/types/FormValues.ts @@ -0,0 +1,30 @@ +export interface NewTopicFormProps { + name: string; + description: string; + image: string; + topics: []; +} + +export interface ModifyPinFormProps { + name: string; + description: string; +} + +export interface DefaultPinFormProps extends ModifyPinFormProps { + id: number; + address: string; + latitude: string; + longitude: string; + updatedAt: string; +} + +export interface NewPinFormProps { + topicId: number; + name: string; + images: string[]; + description: string; + address: string; + latitude: string; + longitude: string; + legalDongCode: string; +} diff --git a/frontend/src/types/Pin.ts b/frontend/src/types/Pin.ts index 509c1090..e226333a 100644 --- a/frontend/src/types/Pin.ts +++ b/frontend/src/types/Pin.ts @@ -8,5 +8,10 @@ export interface PinProps { longitude: number; canUpdate: boolean; updatedAt: string; - images: string[]; + images: ImageProps[]; } + +export interface ImageProps { + id: number; + imageUrl: string; +} \ No newline at end of file