티스토리 뷰
필수 구현 기능
- 로그인, 회원 가입
- Authentication 에서 제공하는 api를 이용하여 아래 회원 가입, 로그인을 구현해 보세요.
- 아이디(이메일), 패스워드
- 소셜 로그인 (구글, 깃헙)
- Authentication 에서 제공하는 api를 이용하여 아래 회원 가입, 로그인을 구현해 보세요.
- CRUD
- Firestore 에서 제공하는 api를 이용하여 CRUD 데이터베이스 핸들링을 구현해 보세요.
- CUD(등록, 수정, 삭제)가 일어날 때마다 R(조회)해서 자연스럽게 화면 변경을 해보세요.
- 마이 페이지
- 내 게시물 보기
- Authentication 에서 제공하는 uid를 이용해서 내 게시물들이 모일 수 있게 해 보세요.
- 프로필 수정 기능
- Storage 에서 제공하는 api를 이용하여 이미지 업로드와 다운로드 url 을 받아서 이미지 핸들링을 해보세요.
- 내 게시물 보기
- 배포하기
- Vercel 이라는 호스팅플랫폼을 이용해 배포합니다.
- 배포에 적용될 브랜치는 main 또는 master 브랜치로 적용합니다.
- Git을 최대한 활용해 보기!
- Pull Request 활용하기!
- Merge는 Pull Request를 활용하여 진행한다.
- Branch 만들어 작업하기
- Pull Request 활용하기!
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/
테이블 구성
팀 프로젝트 발표 자료
https://www.notion.so/11-201564851b5f487e875509eff499c0d1
회고
리액트 숙련주차 과제가 드디어 끝났다.
명절 연휴 포함, 일주일이 어떻게 지나간 건가 싶을 정도로 잠도 못 자고 정말 정신없었던 거 같다
과제는 파이어베이스를 이용하여 뉴스피드를 만드는 것
내가 담당한 부분은 게시물 등록, 수정, 삭제 부분으로 게시물 등록과 수정 폼이 동일하다 보니 굳이 둘을 나누지 말고 같이 사용하는 게 더 좋지 않을까? 싶어 하나로 만들기로 했다.
하단 테스트코드를 통해 문제없음을 확인하고 분리된 리스트에 이벤트를 연결해야 하는데 문제가 생겼다. 분리된 리스트를 등록, 수정폼이 있는 컴포넌트에 연결하는 게 생각보다 어려웠고, 관련 자료들도 찾기 힘들었다는 것...
미리 다음 단계를 고려해서 자료를 찾아보고, 간단한 테스트를 한 뒤에 적용했어야 했는데 처음부터 단추를 잘못 끼워버렸다. 한정된 시간에 방법을 찾기 힘들 것 같고 개인과제가 아닌 팀과 제인만큼 다른 팀원분께 피해를 드릴 수 없어 결국 늦게나마 분리해서 진행하는 방법으로 변경했다.
게시물 리스트가 보이는 메인페이지와 게시물 상세 보기는 다른 팀원분이 담당해 주셨고 거기에 삭제와 수정 이벤트를 적용하기로 했다. 다행인 것은 미리 작성해 놓은 코드를 대부분 사용할 수 있다는 것. 하지만 변경해야 할 부분도 좀 많았고 생각 못한 오류들도 발생했지만 가장 큰 문제는 내 코드가 아닌 다른 사람이 만들어놓은 코드를 기반으로 해야 한다는 게 가장 힘들었다. 코드를 이해하기도 힘들어서 이 데이터는 어디서 받아와서 어떻게 이용되는지 한참 헤매기도 했다
그래도 어찌어찌 팀원들 도움도 받아가며 결국 과제를 완성했고 스파르타 시작 후 가장 힘들었지만 가장 뿌듯한 결과인 거 같기도 하다
위에 코드는 처음 등록과 수정폼을 합친 코드로 버리기엔 너무 아쉬워서 나중에라도 이걸 활용한 방법을 생각해 보기 위해 작성해 놨다 제일 처음 완성한 코드에 이것저것 수정했다 지웠다를 반복해서 온전히 잘 살린 건지 테스트를 해보진 않았지만 아마 무리 없이 되지 않을까 싶다 위에 작성해 놓은 힌트를 참고하여 완성시켜 보자!
'TIL > React' 카테고리의 다른 글
[리액트 React] 엑시오스 (Axios) (0) | 2024.02.19 |
---|---|
[리액트 React] 깃허브 리드미 작성 (Github_ReadMe) (0) | 2024.02.16 |
[리액트 React] 뉴스피드 팀 프로젝트_파이어베이스 환경 설정 (0) | 2024.02.08 |
[리액트 React] 뉴스피드 팀 프로젝트_ 파이어베이스 (Firebase) (0) | 2024.02.07 |
[리액트 React] 라우터 (Router) (0) | 2024.02.06 |