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;