티스토리 뷰

필수 구현 기능

  • 로그인, 회원 가입
    • Authentication 에서 제공하는 api를 이용하여 아래 회원 가입, 로그인을 구현해 보세요.
      • 아이디(이메일), 패스워드
      • 소셜 로그인 (구글, 깃헙)
  • CRUD
    • Firestore 에서 제공하는 api를 이용하여 CRUD 데이터베이스 핸들링을 구현해 보세요.
    • CUD(등록, 수정, 삭제)가 일어날 때마다 R(조회)해서 자연스럽게 화면 변경을 해보세요.
  • 마이 페이지
    • 내 게시물 보기
      • Authentication 에서 제공하는 uid를 이용해서 내 게시물들이 모일 수 있게 해 보세요.
    • 프로필 수정 기능
      • Storage 에서 제공하는 api를 이용하여 이미지 업로드와 다운로드 url 을 받아서 이미지 핸들링을 해보세요.
  • 배포하기
    • Vercel 이라는 호스팅플랫폼을 이용해 배포합니다.
    • 배포에 적용될 브랜치는 main 또는 master 브랜치로 적용합니다.
  • Git을 최대한 활용해 보기!
    • Pull Request 활용하기!
      • Merge는 Pull Request를 활용하여 진행한다.
    • Branch 만들어 작업하기
import React, { useEffect, useRef, useState } from 'react';
import styled from 'styled-components';
import { Section } from 'styles/SharedStyle';
import { db, storage } from '../../firebase';
import { addDoc, collection, deleteDoc, doc, getDocs, query, updateDoc } from 'firebase/firestore';
import { deleteObject, getDownloadURL, ref, uploadBytes } from '@firebase/storage';
import { uuidv4 } from '@firebase/util';
import { useDispatch, useSelector } from 'react-redux';
import { completedEditBoard, deleteBoard, insertBoard, setBoard } from '../../redux/modules/board';
import imageFrames from '../../image/imageFrames.png';
import { useNavigate } from 'react-router';

const Write = () => {
  // // 파이어베이스에 저장된 데이터 가져오기
  useEffect(() => {
    const fetchData = async () => {
      const boardData = query(collection(db, 'board'));
      const querySnapshot = await getDocs(boardData);

      const initialBoard = [];
      querySnapshot.forEach((doc) => {
        const data = {
          id: doc.id,
          ...doc.data()
        };
        initialBoard.push(data);
      });
      dispatch(setBoard(initialBoard));
    };
    fetchData();
  }, []);

  const nowUser = useSelector((state) => state.user.nowUser);

  const dispatch = useDispatch();
  const board = useSelector((item) => item.board);
  const navigate = useNavigate();

  // 게시물 state들
  const [title, setTitle] = useState('');
  const [contents, setContents] = useState('');
  const [category, setCategory] = useState('');
  const [thumbnail, setThumbnail] = useState('');

  // 이미지 id
  const thumbnailId = uuidv4();

  // 포커스 변수들
  const titleRef = useRef(null);
  const contentsRef = useRef(null);
  const categoryRef = useRef(null);

  // 게시물 state change 이벤트
  const titleChanged = (e) => {
    setTitle(e.target.value);
    setUpdateBoard((prevState) => ({ ...prevState, title: e.target.value }));
  };
  const contentChanged = (e) => {
    setContents(e.target.value);
    setUpdateBoard((prevState) => ({ ...prevState, contents: e.target.value }));
  };
  const categoryChanged = (e) => {
    setCategory(e.target.value);
    setUpdateBoard((prevState) => ({ ...prevState, category: e.target.value }));
  };
  const thumbnailChanged = (e) => {
    setThumbnail(e.target.files[0]);
    setUpdateBoard((prevState) => ({ ...prevState, thumbnail: e.target.value }));
  };

  // 게시물 등록
  const addBoardForm = async (e) => {
    e.preventDefault();

    try {
      // 게시물 등록일 함수
      const now = new Date();
      const regDate = now.toLocaleDateString('ko-KR', {
        year: 'numeric',
        month: 'long',
        day: 'numeric',
        hour: 'numeric',
        hour12: false, // 24시간 형식 표기
        minute: '2-digit'
      });

      // 스토리지에 이미지 등록
      const imgRef = ref(storage, 'thumbnail/' + thumbnailId);
      await uploadBytes(imgRef, thumbnail);

      // 이미지 다운로드 URL 가져오기
      const imageUrl = await getDownloadURL(imgRef);

      let newBoard = {
        category,
        title,
        contents,
        regDate,
        thumbnail: thumbnailId, // 이미지의 UUID를 게시물에 저장
        imageUrl,
        nickname: nowUser.nickname,
        user_id: nowUser.user_id,
        cnt: 0,
        liked: 0
      };

      // 유효성 검사
      if (!title) {
        alert('제목을 입력해 주세요');
        return titleRef.current.focus();
      } else if (!contents) {
        alert('내용을 입력해 주세요');
        return contentsRef.current.focus();
      } else if (!category) {
        alert('카테고리를 선택해 주세요');
        return categoryRef.current.focus();
      }

      // 파이어베이스 게시물 등록
      const collectionRef = collection(db, 'board');
      await addDoc(collectionRef, newBoard);

      dispatch(insertBoard(newBoard));

      setTitle('');
      setContents('');
      setCategory('');
      imgRemove();

      alert('게시물이 등록되었습니다.');
      navigate('/');
    } catch (error) {
      console.error('게시물 등록 실패', error);
    }
  };

  // 이미지 미리보기 삭제 함수
  const imgRemove = () => {
    setThumbnail('');
  };

  // 삭제
  const removeBoard = async (id, thumbnail) => {
    if (window.confirm('게시물을 삭제하시겠습니까?')) {
      try {
        // 이미지 삭제
        const imgRef = ref(storage, 'thumbnail/' + thumbnail);
        deleteObject(imgRef);

        // 게시물 삭제
        const boardRef = doc(db, 'board', id);
        await deleteDoc(boardRef);

        dispatch(deleteBoard(id, thumbnail));

        alert('게시물이 삭제되었습니다.');
      } catch (error) {
        console.error('삭제 실패', error);
      }
    }
  };

  // 수정
  const [isEditing, setIsEditing] = useState(false); // 수정 모드 여부 스테이트
  const [updateBoard, setUpdateBoard] = useState(''); //  수정 데이터 저장

  const editingBoard = async (item) => {
    if (window.confirm('게시물을 수정하시겠습니까?')) {
      setUpdateBoard(item); // 수정할 데이터를 상태에 저장
      setIsEditing(true); // 수정 모드로 변경
    }
  };

  const question = useSelector((state) => {
    return state.list.board.find((item) => item.id === updateBoard.id);
  });

  // 수정 완료 버튼 클릭 시
  const updateBoardForm = async (e) => {
    e.preventDefault();
    const imgRef = ref(storage, 'thumbnail/' + thumbnailId);
    await uploadBytes(imgRef, thumbnail);
    const imageUrl = await getDownloadURL(imgRef);
    try {
      const completedBoard = {
        ...updateBoard,
        category: updateBoard.category,
        title: updateBoard.title,
        contents: updateBoard.contents,
        thumbnail: updateBoard.thumbnail,
        imageUrl
      };
      console.log('imageUrl', imageUrl);
      await updateDoc(doc(db, 'board', question.id), completedBoard);
      console.log('completedBoard', completedBoard);
      dispatch(completedEditBoard(completedBoard));
      alert('게시물이 수정되었습니다.');
      navigate('/');
    } catch (error) {
      console.error('수정 실패', error);
    }
  };

  return (
    <Section>
      <AddBoard>
        <AddBoardForm onSubmit={isEditing ? updateBoardForm : addBoardForm}>
          <SelectBox value={isEditing ? updateBoard.category : category} onChange={categoryChanged} ref={categoryRef}>
            <option value="">카테고리를 선택해 주세요</option>
            <option value="discussion">커뮤니티</option>
            <option value="asklist">질문 및 답변</option>
          </SelectBox>
          <TitleInput
            value={isEditing ? updateBoard.title : title}
            onChange={titleChanged}
            ref={titleRef}
            type="text"
            placeholder="제목을 입력해 주세요"
          />
          <ContentDiv>
            {/* 회원이 이미지 파일을 업로드 한 경우 미리보기 */}
            {/* 인풋 파일에 이미지를 추가하면 URL.createObjectURL()함수가 이미지를 url로 변환해서 src에 넣어줌 */}
            {thumbnail ? (
              <PreviewDiv>
                <img src={URL.createObjectURL(thumbnail)} alt="이미지" />
                <button onClick={imgRemove}>이미지 삭제</button>
              </PreviewDiv>
            ) : (
              <ThumbnailDiv>
                <img src={isEditing ? updateBoard.imageUrl : imageFrames} alt="이미지" />

                <label htmlFor="thumbnail">
                  <ThumbnailBtn>{isEditing ? '이미지 변경' : '이미지 추가'}</ThumbnailBtn>
                </label>
                <ThumbnailInput onChange={thumbnailChanged} type="file" accept="image/*" id="thumbnail" />
              </ThumbnailDiv>
            )}

            <textarea
              value={isEditing ? updateBoard.contents : contents}
              onChange={contentChanged}
              ref={contentsRef}
              placeholder="내용을 입력해 주세요"
            ></textarea>
          </ContentDiv>
          <AddBtnDiv>
            <button type="submit">{isEditing ? '수정 완료' : '작성 완료'}</button>
          </AddBtnDiv>
        </AddBoardForm>
      </AddBoard>
      {/* 수정, 삭제를 위한 테스트 코드 */}
      <div>
        {board.map((item) => {
          return (
            <div key={item.id}>
              <img src={item.imageUrl} alt="" />
              <div>아이디 ***************************{item.id}</div>
              <div>{item.category}</div>
              <div>{item.title}</div>
              <div>{item.contents}</div>
              <div>{item.regDate}</div>
              <button onClick={() => editingBoard(item)}>수정</button>
              <button onClick={() => removeBoard(item.id, item.thumbnail)}>삭제</button>
            </div>
          );
        })}
      </div>
    </Section>
  );
};

export default Write;

const AddBoard = styled.div`
  display: flex;
  flex-direction: column;
  align-items: center;
`;

const AddBoardForm = styled.form`
  width: 75%;
  height: 75vh;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: space-evenly;
  border: 0.2rem solid #f5f5f5;
  position: relative;
`;

const ContentDiv = styled.div`
  width: 95%;
  display: flex;
  flex-direction: row;

  textarea {
    width: 95%;
    height: 30rem;
    padding: 1rem;
    background-color: transparent;
    border: none;
    font-size: 1rem;
  }
`;
const PreviewDiv = styled.div`
  width: 50%;
  height: 30rem;
  text-align: center;
  img {
    width: 100%;
    max-height: 30rem;
  }
  button {
    background-color: transparent;
    border: none;
    font-size: 1rem;
    &:hover {
      transform: scale(1.1);
      /* ss */
    }
  }
`;

const ThumbnailDiv = styled.div`
  width: 50%;
  height: 30rem;
  text-align: center;
  img {
    width: 100%;
    max-height: 30rem;
  }
  button {
    cursor: pointer;
    &:hover {
      transform: scale(1.1);
    }
  }
`;
const ThumbnailBtn = styled.div`
  cursor: pointer;
  &:hover {
    transform: scale(1.1);
  }
`;
const ThumbnailInput = styled.input`
  display: none;
`;

const SelectBox = styled.select`
  width: 20%;
  padding: 0.2rem;
  position: absolute;
  top: 1rem;
  right: 4rem;
  border-color: #f5f5f5;
  cursor: pointer;
`;

const TitleInput = styled.input`
  width: 90%;
  margin: 2rem;
  padding: 0.7rem;
  border: none;
  border-bottom: 0.1rem solid #f5f5f5;
  background-color: transparent;
  font-size: 1rem;
`;

const AddBtnDiv = styled.div`
  width: 20%;
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: 1rem;
  position: absolute;
  bottom: -5%;
  right: -13%;
  button {
    background-color: transparent;
    border: none;
    font-size: 1rem;
    &:hover {
      transform: scale(1.1);
    }
  }
`;
Redux를 이용해 등록, 수정, 삭제를 하나의 컴포넌트에서 사용해 보자! 

 

1. GlobalState에 isEditing(false) state를 추가한다

2. action value를  상수로 만들고 type과 payload를 인자로 받는 action 함수를 만든다
(type(= action value) ISUPDATE , payload는 상세페이지에서 매개변수로 보내줄 boolean(true))
 
3. Reducer switch문에 payload (true)로  isEditing(false) => isEditing(true) 상태 변경 후 return 하는 케이스를 추가한다
(리듀서에는 isEditing을 true로 만드는 것만 있으면 됨. 왜? 수정전 데이터는 상세 페이지에서 state에 담아 수정 폼으로 보내줄 것이기 때문)

4. 마지막으로 상세 페이지에서 수정 버튼을 클릭할 때 dispatch에 payload(true)만 보내주면 등록, 수정 컴포넌트에서 렌더 할 때(ex.useEffect 등) isEditing 상태에 따라 등록 폼인지 수정 폼인지 나눠지게 되고 수정할 데이터는 상세페이에서 수정을 클릭할 때 useNavigate를 이용해 수정 전 데이터를 담은 state를 수정 폼에 전달하면 된다

5. 삭제 또한 마찬가지로 상세 페이지에서 삭제 버튼을 눌렀을 때 디스패치의 payload로 삭제할 게시물의 id를 보내주고 게시물 데이터를 state에 담아 삭제 이벤트가 있는 컴포넌트로 보내주면 된다

 


 

뉴스피드 팀 프로젝트 배포 링크
https://deve11og.vercel.app/ 
 

Deve11og

 

deve11og.vercel.app

테이블 구성

팀 프로젝트 발표 자료
https://www.notion.so/11-201564851b5f487e875509eff499c0d1

 

회고

리액트 숙련주차 과제가 드디어 끝났다.

명절 연휴 포함, 일주일이 어떻게 지나간 건가 싶을 정도로 잠도 못 자고 정말 정신없었던 거 같다

과제는 파이어베이스를 이용하여 뉴스피드를 만드는 것

 

내가 담당한 부분은 게시물 등록, 수정, 삭제 부분으로 게시물 등록과 수정 폼이 동일하다 보니 굳이 둘을 나누지 말고 같이 사용하는 게 더 좋지 않을까? 싶어 하나로 만들기로 했다.

하단 테스트코드를 통해 문제없음을 확인하고 분리된 리스트에 이벤트를 연결해야 하는데 문제가 생겼다. 분리된 리스트를 등록, 수정폼이 있는 컴포넌트에 연결하는 게 생각보다 어려웠고, 관련 자료들도 찾기 힘들었다는 것...

미리 다음 단계를 고려해서 자료를 찾아보고, 간단한 테스트를 한 뒤에 적용했어야 했는데 처음부터 단추를 잘못 끼워버렸다. 한정된 시간에 방법을 찾기 힘들 것 같고 개인과제가 아닌 팀과 제인만큼 다른 팀원분께 피해를 드릴 수 없어 결국 늦게나마 분리해서 진행하는 방법으로 변경했다.

게시물 리스트가 보이는 메인페이지와 게시물 상세 보기는 다른 팀원분이 담당해 주셨고 거기에 삭제와 수정 이벤트를 적용하기로 했다. 다행인 것은 미리 작성해 놓은 코드를 대부분 사용할 수 있다는 것. 하지만 변경해야 할 부분도 좀 많았고 생각 못한 오류들도 발생했지만 가장 큰 문제는 내 코드가 아닌 다른 사람이 만들어놓은 코드를 기반으로 해야 한다는 게 가장 힘들었다. 코드를 이해하기도 힘들어서 이 데이터는 어디서 받아와서 어떻게 이용되는지 한참 헤매기도 했다

그래도 어찌어찌 팀원들 도움도 받아가며 결국 과제를 완성했고 스파르타 시작 후 가장 힘들었지만 가장 뿌듯한 결과인 거 같기도 하다

 

위에 코드는 처음 등록과 수정폼을 합친 코드로 버리기엔 너무 아쉬워서 나중에라도 이걸 활용한 방법을 생각해 보기 위해 작성해 놨다 제일 처음 완성한 코드에 이것저것 수정했다 지웠다를 반복해서 온전히 잘 살린 건지 테스트를 해보진 않았지만 아마 무리 없이 되지 않을까 싶다 위에 작성해 놓은 힌트를 참고하여 완성시켜 보자!

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2024/12   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30 31
글 보관함