23/12/13 til 심화팀과제 리팩토링
2023. 12. 14. 08:46ㆍ카테고리 없음
우리팀이 짠코드를 들여다 봅시다. <Homepage.jsx>
export default function Homepage() {
const navigate = useNavigate();
const {isLogin} = useSelector(state => state.auth);
useReadFirestore();
const list = useSelector(state => state.fixList);
return (
<ScBody>
<ScFixbar>
<span>최근 Fix 한 곳</span>
{isLogin ? <AddNew /> : <></>} //내가 만든 홈페이지 작스에는 <AddNew />
</ScFixbar>
//로그인시 들어갈수 있는 AddNew 컴포넌트가 있다 팀원 혜민님 이 만드심 들여다 봅시다.
<AddNew.jsx
import React from 'react';
import Btn from '../components/UI/Button'; // 미희님의 공용화한 버튼
import {useDispatch, useSelector} from 'react-redux';
import {openAddmodal} from '../redux/modules/modalSlice'; //자신이 만든 리듀서를 임포트
import Modal from './Modal/Modal'; //만드신 모달도 임포트
function AddNew() {
const modal = useSelector(state => state.modal); //스토어에서 모달 불러오시고
const dispatch = useDispatch();
const openWriteModal = () => {
dispatch(openAddmodal()); //여기서 함수 선언하셔서
}; //모달을 추가하는 스테이트 변경용 디스패치 인가봄
return (
<>
<Btn onClick={openWriteModal}>Fix 하러가기</Btn> //온클릭시 사용
{modal.isUseInput && <Modal />} //모달에 유즈 인풋이 있으면 모달창을 연다라...
//그럼 모달.isUseInput은 있다 없다 하나보다
</>
);
}
export default AddNew;
//일단 리듀서 어떻게 만드셨는지가 궁금하니까 들여다보자
<configureStore.js>는 평범하니 패스
<modalSlice.js>
import {createSlice} from '@reduxjs/toolkit';
const initialState = {isUseInput: false}; //첫스테이트를 폴스로 두셨구먼
const modalSlice = createSlice({
name: 'modal', //리듀서네임은 평범하고...
initialState,
reducers: {
closeAddModal: (state, action) => {
state.isUseInput = false; //리듀서 이름대로 스테이트의
}, // isUseInput 의 불리언값을 토글하는 형식이구먼
openAddmodal: (state, action) => {
state.isUseInput = true;
},
},
});
export const {closeAddModal, openAddmodal} = modalSlice.actions; //잘 익스포트 하셨구먼
export default modalSlice.reducer;
//오케 확인됐고 <Modal />
//이제 요 스테이트의 isUseInput가 트루 일때 열리는 <Modal /> 을 들여다 봅시다.
<Modal.jsx>
import React, {useEffect} from 'react';
import styled from 'styled-components';
import WriteNewFix from './WriteNewFix'; //요것도 많이 보던건데
import {useDispatch, useSelector} from 'react-redux';
import {showPublicModal} from '../../redux/modules/publicModalSlice'; //리듀서가 하나 더 있으시군
import PublicModal from './PublicModal'; //공용모달을 만드신건가?
function Modal() {
const publicModal = useSelector(state => state.publicModal); //다른 리듀서 불러오시고
const dispatch = useDispatch();
const closeModalOutside = event => { //네이밍으로 봤을때 모달바깥쪽과 연관된듯
if (event.target === event.currentTarget) { //타겟과 커런트타겟 일치시에만 발생이라
openPublicModal();
}
};
const openPublicModal = () => { //스테이트 변경로직이 발생하는구먼
dispatch(
showPublicModal({
isUse: true,
title: '😯 정말 나가시겠어요?',
message: '저장하지 않은 내용은 사라져요.',
btnMsg: '계속 작성',
btnType: 'continue',
btnMsg2: '나가기',
btnType2: 'exit',
}),
);
};
// 모달이 열릴 때 배경 스크롤을 막는 부분
useEffect(() => {
if (publicModal.isUse) { //요값이 트루이면
//메인모달용
document.body.style.overflow = 'hidden';
} else {
//공용모달용
document.body.style.overflow = 'hidden'; //이거 위아래 같은 로직 아닌가?
}
return () => {
// 모달이 닫힐 때 배경 스크롤을 다시 허용하는 부분
document.body.style.overflow = 'unset'; //와 언마운트 될때 로직도 있으시네?
}; //나 이거 잘모르는데
}, [publicModal.isUse]); // publicModal.isUse의 상태변화시 발생하는 유즈이펙트..
return (
<div>
{publicModal.isUse && <PublicModal />} //앞서 언급한것과 비슷한 isuse상태에 따라서
//퍼블릭모달을 보여주시는구나 <ScDiv
onClick={event => {
closeModalOutside(event); //요 이벤트는 온클릭시 발생하는군
}} //근데 화면의 거의 전체에 온클릭 주셨군
> //이래서 이벤트 타겟과 커런트 타겟을 주셨구나
<ScDivContainer> //요 태그 자체에 대해서만 이벤트 발생하도록 하신거구먼
<WriteNewFix />
</ScDivContainer>
</ScDiv>
</div>
);
}
export default Modal;
//음 대충 들여다본거 같으니까 리듀서 하나더 있으신듯 한데 그것을 보자구
<publicModalSlice.js>
import {createSlice} from '@reduxjs/toolkit';
const initialState = {
isUse: false, //아까 보니까 isUse 상태에 따라 토글되는 컴포넌트가 있었고
title: '제목',
message: '메세지',
btnMsg: '',
btnType: '',
btnMsg2: '',
btnType2: '',
};
const publicModalSlice = createSlice({
name: 'publicModal',
initialState,
reducers: {
closePublicModal: (state, action) => {
state.isUse = false; //이건 토글용이고
},
showPublicModal: (state, action) => {
return {...state, ...action.payload}; //추가 로직인거 같다. // 근데 페이로드도 스프레드
}, //오퍼레이터 문법 쓰였네 흐음
}, // showPublicModal 아까 위에 파일에서 이거 dispatch 하셔서 내용 추가 하셨었네
});
export const {closePublicModal, showPublicModal} = publicModalSlice.actions;
export default publicModalSlice.reducer;
// 오케오케 다음에 열리는 모달 <PublicModal />
이걸 봅시다.
<PublicModal.jsx>
import React from 'react';
import {useSelector} from 'react-redux';
import styled from 'styled-components';
import PublicHook from './PublicHook';
function PublicModal() { //아하 아까 dispatch해서 왜 요런 키와 벨류를 주나했더니
const {title, message, btnMsg, btnType, btnMsg2, btnType2} = useSelector(state => state.publicModal);
//여기서 그내용을 쓰시는군 const {handleContinueWriting, handleExit} = PublicHook(); //오 퍼블릭 훅도 있나보네 이따보자
const btnFn = btnType === 'continue' ? handleContinueWriting : null;
const btnFn2 = btnType2 === 'exit' ? handleExit : null; //여기서 굳이 삼항 안쓰고 &&쓰셔도 됐을텐데
//뭐 같으니까 상관없고 return (
<>
<ScDiv>
<ScDivContainer>
<ScDivTitleAndContent>
<h2>{title}</h2>
<p>{message}</p>
</ScDivTitleAndContent>
<ScDivButton>
{btnMsg && <ScButtonFirst onClick={btnFn}>{btnMsg} </ScButtonFirst>}
{btnMsg2 && <ScButtonSecond onClick={btnFn2}>{btnMsg2} </ScButtonSecond>}
</ScDivButton> // 아하 버튼 메시지가 뭐냐에 있냐 없냐에 따라 보이는 버튼을
</ScDivContainer> //두개 두셨구먼 그리고 PublicHook에서 만든함수를 넣어주는구먼
</ScDiv>
</>
);
}
//오케 재밌구먼 PublicHook 을 보자구
<PublicHook.jsx>
import {useDispatch} from 'react-redux';
import {useNavigate} from 'react-router-dom';
import {closePublicModal} from '../../redux/modules/publicModalSlice';
import {closeAddModal} from '../../redux/modules/modalSlice';
function PublicHook() {
const dispatch = useDispatch(); //퍼블릭훅은 리덕스 용이구먼
const navigate = useNavigate(); //네비게이트도 있고
const handleContinueWriting = () => {
dispatch(closePublicModal()); //처음본 리듀서에 따라서 모달 을 꺼버리는 로직
};
const handleExit = () => {
dispatch(closePublicModal()); //요기도 있네?
dispatch(closeAddModal()); // 새글작성모달 닫기 //아예나가는거니까 쪼그만 모달도 닫는건가?
navigate('/'); //홈으로 이동하고
};
return {handleContinueWriting, handleExit};
}
export default PublicHook;
재밌구먼 아까
<WriteNexFix.jsx> 이전파일들은 모달들을 끄고 켜고 하는 로직이라면 진짜 비지니스 로직은 여기있네
이게 메인인데 좀 시간 걸릴듯 하니까 내일해야징 css가 중요해보여서 시간걸릴듯하다
import React, {useState} from 'react';
import styled from 'styled-components';
import {auth, db, storage} from '../../shared/firebase';
import {getDownloadURL, ref, uploadBytes} from 'firebase/storage';
import {addDoc, collection} from 'firebase/firestore';
import {closeAddModal} from '../../redux/modules/modalSlice';
import {useDispatch} from 'react-redux';
import {toast} from 'react-toastify';
import {useNavigate} from 'react-router-dom';
import pinImg from '../../asset/pin.png';
import {showPublicModal} from '../../redux/modules/publicModalSlice';
import {addList} from '../../redux/modules/fixList';
import bonobono from '../../asset/bonobono.jpg';
import {Map, MapMarker} from 'react-kakao-maps-sdk';
import useKakaoLoader from '../useKaKaoLoader';
function WriteNewFix() {
useKakaoLoader();
//지도
const [latitude, seLatitude] = useState(33.450701); //위도
const [longitude, setLongitude] = useState(126.570667); //경도
const [buildingName, setBuildingName] = useState(''); //경도
const searchAddress = () => {
// Kakao Maps에서 제공하는 주소 검색 대화상자 열기
if (window.daum && window.daum.Postcode) {
new window.daum.Postcode({
oncomplete: function (addrData) {
const geocoder = new window.kakao.maps.services.Geocoder();
// 주소로 상세 정보를 검색
geocoder.addressSearch(addrData.address, function (result, status) {
// 정상적으로 검색이 완료됐으면
if (status === window.kakao.maps.services.Status.OK) {
//첫번째 결과의 값을 활용
// 해당 주소에 대한 좌표를 받아서
const currentPos = new window.kakao.maps.LatLng(result[0].y, result[0].x);
seLatitude(currentPos.Ma);
setLongitude(currentPos.La);
// 최종 주소 변수-> 주소 정보를 해당 필드에 넣는다.
// 선택한 주소로 입력 필드 업데이트
setAddrInput(addrData.address);
setBuildingName(addrData.buildingName);
}
});
},
}).open();
} else {
alert('카카오map 로드가 안됨');
}
};
const dispatch = useDispatch();
const navigate = useNavigate();
const [selectedFile, setSelectedFile] = useState('');
const [previewFile, setPreviewFile] = useState('');
const {email, displayName, uid, photoURL} = auth.currentUser;
const [addrInput, setAddrInput] = useState('');
const [formState, setFormState] = useState({
title: '',
content: '',
});
const {title, content} = formState;
//공용 함수
const onChangeHandler = event => {
const {name, value} = event.target;
setFormState(prev => ({...prev, [name]: value}));
};
//파일 삭제
const handleFileDelete = event => {
setPreviewFile('');
return;
};
//파일 선택
const handleFileSelect = event => {
setSelectedFile(event.target.files[0]);
setPreviewFile(URL.createObjectURL(event.target.files[0]));
};
//이미지 파일 업로드
const handleUpload = async () => {
//[파일선택] 버튼 안눌러서 선택한 파일 없는경우
if (selectedFile === '') {
return '';
}
const imageRef = ref(storage, `${auth.currentUser.uid}/${selectedFile.name}`);
//const imageRef = ref(storage, `${userUid}/${selectedFile.name}`);
try {
await uploadBytes(imageRef, selectedFile);
return await getDownloadURL(imageRef);
} catch (error) {
throw error;
}
};
let formattedDate = new Intl.DateTimeFormat('ko-KR', {
dateStyle: 'full',
timeStyle: 'short',
}).format(new Date());
let cancelBtn = () => {
dispatch(
showPublicModal({
isUse: true,
title: '😯 정말 나가시겠어요?',
message: '저장하지 않은 내용은 사라져요.',
btnMsg: '계속 작성',
btnType: 'continue',
btnMsg2: '나가기',
btnType2: 'exit', // 함수 대신 타입 지정
}),
);
//dispatch(closeAddModal()); // 새글작성모달 닫기
navigate('/');
};
const formOnSubmit = async event => {
event.preventDefault();
try {
//1. 이미지 파일 업로드
const uploadImageUrl = await handleUpload();
//2. 모달창에 입력된 새로운 데이터
const newData = {
title,
content,
date: formattedDate,
createdAt: new Date(),
image_url: uploadImageUrl ? uploadImageUrl : bonobono,
uid,
displayName,
email,
photoURL: photoURL ? photoURL : pinImg,
addrInput,
latitude,
longitude,
buildingName,
};
//3. 파이어스토어에 데이터 저장
const collectionRef = collection(db, 'fixs');
const res = await addDoc(collectionRef, newData);
//4. 모달닫기
dispatch(addList({...newData, id: res.id}));
dispatch(closeAddModal());
toast.success('저장되었습니다.');
} catch (Error) {}
};
return (
<>
<form onSubmit={formOnSubmit}>
<ScDiv>
<h1>어디로 '픽스' 할까요?</h1>
<div>
<ScInputTitle
name="title"
value={title}
onChange={onChangeHandler}
placeholder="제목을 입력해주세요."
maxLength={30}
required
></ScInputTitle>
</div>
<div>
<ScTextareaContent
name="content"
placeholder="내용을 입력해주세요"
value={content}
onChange={onChangeHandler}
required
></ScTextareaContent>
</div>
{!previewFile && (
<ScDivFileUpload>
<input type="file" name="selectedFile" id="fileAttach" onChange={handleFileSelect}></input>
<label htmlFor="fileAttach">사진 선택</label>
</ScDivFileUpload>
)}
{previewFile && (
<>
<ScDivPreview>
<img name="previewFile" size="large" src={previewFile} />
<ScButtonDelete type="button" id="fileDelete" onClick={handleFileDelete}></ScButtonDelete>
<label htmlFor="fileDelete">사진 삭제</label>
</ScDivPreview>
</>
)}
{/* 맵 바꾸기 */}
<ScDivMapSearch>
<div required onClick={searchAddress}>
<input
required
id="addr"
placeholder=" 📍 장소 검색"
value={addrInput}
onChange={event => setAddrInput(event.target.value)}
/>
<button type="button">장소 검색</button>
</div>
<Map center={{lat: latitude, lng: longitude}} style={{width: '100%', height: '230px'}}>
<MapMarker
key={`${latitude}-${longitude}`}
position={{lat: latitude, lng: longitude}}
image={{
src: 'https://velog.velcdn.com/images/jetiiin/post/6eff67e2-349b-4fe4-854f-12d1e384536a/image.png', // 마커이미지의 주소입니다
size: {
width: 64,
height: 69,
}, // 마커이미지의 크기입니다
options: {
offset: {
x: 27,
y: 69,
}, // 마커이미지의 옵션입니다. 마커의 좌표와 일치시킬 이미지 안에서의 좌표를 설정합니다.
},
}}
></MapMarker>
</Map>
</ScDivMapSearch>
<ScDivButton>
<ScButtonFix type="submit">Fix하기</ScButtonFix>
<ScButtonFix type="button" onClick={cancelBtn}>
취소
</ScButtonFix>
</ScDivButton>
</ScDiv>
</form>
</>
);
}
const ScDiv = styled.div`
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
gap: 7px;
margin: 10px auto;
& h1 {
font-size: 25px;
margin: 15px auto;
text-align: center;
font-weight: 600;
}
& div {
width: 100%;
padding-right: 20px;
padding-left: 30px;
display: flex;
gap: 20px;
align-items: center;
}
& img {
object-fit: cover;
width: 200px;
height: 150px;
}
`;
const ScDivMapSearch = styled.div`
display: flex;
justify-content: flex-start;
align-items: center;
flex-direction: column;
margin: 0;
padding-left: 0;
& div {
padding: 0;
}
& button {
display: none;
}
& input {
width: 100%;
border: 1px solid var(--deep-blue);
border-radius: 8px;
cursor: pointer;
height: 30px;
&:hover {
border: 1px solid var(--deep-blue);
box-shadow: rgba(57, 167, 255, 0.4) 0px 0px 0px 3px;
}
}
`;
const ScDivFileUpload = styled.div`
display: flex;
justify-content: flex-start;
align-items: center;
padding-left: 10px;
& input {
width: 0.1px;
height: 0.1px;
opacity: 0;
overflow: hidden;
position: absolute;
z-index: -1;
}
& label {
border: 1px solid var(--deep-blue);
background-color: #fff;
color: var(--deep-blue);
border-radius: 8px;
padding: 6px 14px;
font-weight: 500;
font-size: 14px;
outline: none;
cursor: pointer;
&:hover {
border: 1px solid var(--deep-blue);
box-shadow: rgba(57, 167, 255, 0.4) 0px 0px 0px 3px;
}
}
`;
const ScDivButton = styled.div`
display: flex;
justify-content: center;
gap: 10px;
`;
const ScButtonDelete = styled.button`
display: none;
`;
const ScDivPreview = styled.div`
display: flex;
justify-content: center;
flex-direction: column;
& label {
border: 1px solid var(--deep-blue);
background-color: #fff;
color: var(--deep-blue);
border-radius: 8px;
padding: 6px 14px;
font-weight: 500;
font-size: 14px;
outline: none;
cursor: pointer;
&:hover {
border: 1px solid var(--deep-blue);
box-shadow: rgba(57, 167, 255, 0.4) 0px 0px 0px 3px;
}
}
`;
const ScInputTitle = styled.input`
width: 100%;
outline: none;
font-size: 19px;
margin-top: 8px;
margin-bottom: 8px;
//padding-bottom: 10px;
border: none;
font-weight: 500;
border: 1px solid var(--deep-blue);
border-radius: 8px;
//background-color: var(--light-blue);
//padding: 20px auto;
padding: 10px;
&::placeholder {
color: #bbb;
}
`;
const ScTextareaContent = styled.textarea`
min-height: 14vh;
max-height: 30vh;
overflow-y: auto;
box-sizing: content-box;
outline: none;
line-height: 1.6em;
margin-bottom: 5px;
font-size: 15px;
word-break: keep-all;
border: none;
resize: none;
width: 100%;
padding-top: 10px;
color: var(--black);
//background-color: var(--light-blue);
border: 1px solid var(--deep-blue);
border-radius: 8px;
//padding-left: 13px;
padding: 10px;
&::placeholder {
color: #bbb;
}
`;
const ScButtonFix = styled.button`
width: 20%;
height: 34px;
margin-top: 10px;
font-weight: 600;
border-radius: 8px;
font-size: 15px;
background-color: var(--deep-blue);
color: white;
&:hover {
border: 1px solid var(--deep-blue);
box-shadow: rgb(57, 167, 255, 0.4) 0px 0px 0px 3px;
cursor: pointer;
}
border: none;
`;
export default WriteNewFix;