- 질문 리스트 불러오기
- 페이지네이션
- 최신순/이름순 정렬
- 로컬스토리지에 따른 답변페이지 이동
- 답변 리스트 불러오기 및 더보기
- 답변 거절, 수정, 삭제
- 질문 전체삭제
- 카카오톡 공유하기
- 로컬스토리지에 userid 저장
- GlobalStyle 설정
- 404페이지 애니메이션
- 질문 리스트 불러오기 및 더보기
- 질문 작성 모달창
- 링크복사, 페이스북 공유하기
1. 프로젝트 소개
- 개발환경
- 시연영상
2. 프로젝트 구성
- User Flow
- 폴더 구조
3. 주요 기능
질문과 답변을 통해 마음을 열고 대화 나누는 소통 플랫폼
제작기간: 24.04.30 ~ 24.05.17
openmind_main.mp4
openmind_list.mp4
openmind_feed.mp4
openmind_answer.mp4
openmind_404.mp4
src ┣ api ┣ assets ┃ ┣ icons ┃ ┗ images ┣ components ┃ ┣ feed ┃ ┃ ┣ answer ┃ ┃ ┗ post ┃ ┗ list ┣ pages ┃ ┣ Answer.js ┃ ┣ List.js ┃ ┣ Main.js ┃ ┣ NotFound.js ┃ ┗ Post.js ┣ styles ┗ utils
- 첫 렌더링시 사용자들을 불러와 state로 관리하고 사용자가 입력한 닉네임과 비교하여 닉네임 중복을 막았습니다.
- 또한 생성에 성공했을때 로컬스토리지에 userId값을 저장해 생성한 유저의 답변페이지로 넘어가는 기능을 리액트스러운 방법(navigate)으로 구현했습니다.
const [inputName, setInputName] = useState("");
const [enrolledLists, setErolledLists] = useState(false);
const postNewUser = () => {
const isExist = enrolledLists.includes(inputName);
if (inputName === "") {
alert("이름을 입력해주세요!");
} else if (isExist === true) {
alert("이미 존재하는 이름입니다.");
} else {
fetchPostSubject();
}
};
const linkToUser = (userId) => {
if (userId !== "") {
navigate(`/post/${userId}/answer`);
localStorage.setItem("userId", `${userId}`);
} else {
alert("나의 페이지가 생성되지 않았어요.");
}
};
const fetchPostSubject = async () => {
try {
const res = await postNewSubject(inputName);
linkToUser(res.data.id); // id 페이지이동
} catch (error) {
console.log(error);
alert("포스팅이 안되었어요.");
}
};- list페이지 렌더링시 처음부터 모든 사용자들을 불러와 페이지변경시 보여지는 카드들과 사이즈변경시 보여지는 카드의 갯수가 바뀔때 데이터를 다시 요청하지 않고 불러온 데이터를 페이지에따라, 사이즈에따라 데이터를 정해서 보여주는 방식으로 처리하였습니다.
- 이에따라 페이지네이션, 사이즈변경, 정렬시 api를 요청해서 보여주는 방식이 아니라 갖고있는 데이터들을 가공하여 보여주기 때문에 응답속도를 높혔습니다.
const [data, setData] = useState([]);
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(8);
function renderPageButtons(length, isTablet, isMobile) {
const size = isTablet || isMobile ? 6 : 8;
setItemsPerPage(size);
setTotalPages(Math.ceil(length / size));
}
// 페이지 렌더링시 모든 데이터를 불러오고 사이즈에 따라 보여지는 갯수를 정한뒤, 전체 페이지를 계산함.
useEffect(() => {
const fetchData = async () => {
try {
const res = await getListData();
setData(res.results);
renderPageButtons(res.count, isTablet, isMobile);
} catch (e) {
console.error(e);
}
};
fetchData();
}, []);
// 지정한 사이즈로 바뀔때마다 함수를 실행하여 동적으로 계산함.
useEffect(() => {
renderPageButtons(data.length, isTablet, isMobile);
}, [isTablet, isMobile]);
// 현재 페이지가 첫번째 페이지와 마지막 페이지일 경우 return시켜 0페이지나 마지막페이지 이상으로 못가게막음.
const handlePageChange = (page) => {
if (
page < 1 ||
page > totalPages ||
(page === 1 && currentPage === 1) ||
(page === totalPages && currentPage === totalPages)
)
return;
setCurrentPage(page);
};
const indexOfLastItem = currentPage * itemsPerPage;
const indexOfFirstItem = indexOfLastItem - itemsPerPage;
const currentItems = sortData(data, order).slice(
indexOfFirstItem,
indexOfLastItem
);- 렌더링을 했을때 불러온 데이터들 목록의 이름과 검색창에 있는 이름을 비교하여 일치하는 이름들만 나오도록 했습니다.
const [searchTerm, setSearchTerm] = useState("");
const filteredData = data.filter(item =>
item.name.toLowerCase().includes(searchTerm.toLowerCase())
);
const currentItems = sortData(filteredData, order).slice(
indexOfFirstItem,
indexOfLastItem
);
export default function Search({ searchTerm, onSearchChange }) {
return (
<input
type="text"
placeholder="검색어를 입력하세요"
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
/>
);
}- 페이지네이션 구현중 페이지가 많아지면 모든페이지를 다 출력하는 문제가 있어서 총 페이지 갯수가 8개 이상일시 현제 페이지 앞뒤와 처음, 마지막 페이지를 제외한 페이지들을 ...으로 처리했습니다.
- 또한 보여지는 아이템이 없어서 0페이지 일경우 1페이지가 되도록 설정했습니다. (검색결과가 없을때 자연스러운 흐름을 위해)
function renderPageButtons(length, isTablet, isMobile) {
...
setTotalPages(Math.ceil(length / size) === 0 ? 1 : Math.ceil(length / size));
}
if (totalPages <= 7) {
// 페이지가 7개 이하일 때는 모든 페이지 번호를 표시
...
} else {
// 페이지가 8개 이상일 때
...
if (currentPage <= 4) {
// 현재 페이지가 4 이하일 때
...
} else if (currentPage >= totalPages - 3) {
// 현재 페이지가 마지막에서 3 이상일 때
...
} else {
// 현재 페이지가 5에서 마지막에서 4 사이일 때
...- api 내에서 좋아요, 싫어요 취소 기능이 없어서 로컬스토리지를 이용하여 좋아요나 싫어요를 누르면 다시 못누르게 막았습니다.
const [reaction, setReaction] = useState(null);
const [counts, setCounts] = useState({ like: like, dislike: dislike });
useEffect(() => {
const storedReaction = localStorage.getItem(`${id}_reaction`);
if (storedReaction) {
setReaction(storedReaction);
}
}, [id]);
const handleReactionClick = (type) => {
if (reaction === null) {
postReaction(id, type);
localStorage.setItem(`${id}_reaction`, type);
setReaction(type);
setCounts((prev) => ({
...prev,
[type]: prev[type] + 1,
}));
}
};
return (
<div className="FeedCard-reactionContainer">
<div
className={`FeedCard-reaction ${reaction === "like" ? "clicked" : reaction === "dislike" ? "another" : ""}`}
onClick={() => handleReactionClick("like")}>
<img
src={reaction === "like" ? likeIconOn : likeIconOff}
alt="likeIcon"
className="FeedCard-reactionIcon"
/>
좋아요 {counts.like > 0 && counts.like}
</div>
<div
className={`FeedCard-reaction ${reaction === "dislike" ? "clicked" : reaction === "like" ? "another" : ""}`}
onClick={() => handleReactionClick("dislike")}>
<img
src={
reaction === "dislike" ? dislikeIconOn : dislikeIconOff
}
alt="dislikeIcon"
className="FeedCard-reactionIcon"
/>
싫어요 {counts.dislike > 0 && counts.dislike}
</div>
</div>
);- 피드페이지(post)에서 질문을 작성하려면 맨 밑으로 내려야하는데 맨밑으로 내리면 무한스크롤이 작동하여 모든 질문을 봐야 질문을 할수있는 문제가 있어서 무한스크롤을 더보기 버튼으로 바꾸었습니다.
- 또한 useEffect의 deps list를 활용하여 질문목록이 바뀌었을때 (질문작성시) 질문리스트가 재렌더링 되게 설계했습니다.
const [questions, setQuestions] = useState([]);
const [nextPage, setNextPage] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
handleLoadMore();
}, [QuestionList]);
const handleLoadMore = async () => {
setLoading(true);
try {
const data = await getQuestionList(id, nextPage);
setQuestions([...questions, ...data.results]);
setNextPage(data.next);
} catch (e) {
console.error("Error fetching more data", e);
} finally {
setLoading(false);
}
};
return (
{loading && <div className="LoadMore">...</div>}
{!loading && nextPage && (
<div className="LoadMore button" onClick={handleLoadMore}>
더보기
</div>
)}
);- 컴포넌트로 분리해서 코드의 재사용성을 높이고, useState를 사용해 모달의 렌더링을 관리했습니다.
- props로 전달받은 onSubmit 콜백 함수를 호출하는 handleQuestionSubmit 함수를 만들었고, 질문 보내기 버튼의 onClick 프로퍼티로 전달해서 실제 서버에 질문이 추가되도록 했습니다.
export default function Modal({ userData, setIsModalOpen, onSubmit }) {
const modalBackgroundRef = useRef();
const [input, setInput] = useState({
createdDate: new Date(),
content: "",
});
const onChangeInput = (e) => {
const { name, value } = e.target;
setInput({
...input,
[name]: value,
});
};
const handleQuestionSubmit = async () => {
try {
const questionData = {
createdDate: new Date(),
content: input.content,
};
await onSubmit(questionData);
setIsModalOpen(false);
window.scrollTo(0, 0);
} catch (e) {
console.error("Failed to add question", e);
}
};
const handleModal = (e) => {
if (e.target === modalBackgroundRef.current) {
setIsModalOpen(false);
}
};- 답변 페이지에서 상단에있는 삭제하기를 누르면 답변자(subject)삭제와 케밥에있는 수정하기, 거절하기, 삭제하기 기능을 구현했습니다.
- 또한 이부분을 컴포넌트로 분리하고 모두 props로 전달하여 리액트스럽게 코드를 짰습니다.
const handleEditClick = () => {...};
const handleDeleteQuestion = () => {...};
const handleRejectAnswer = async () => {...};
<AnswerDropdown
handleDeleteQuestion={handleDeleteQuestion}
handleEditClick={handleEditClick}
handleRejectAnswer={handleRejectAnswer}
handleDropdownClick={handleDropdownClick}
isDropdownOpen={isDropdownOpen}
hasAnswer={hasAnswer}
isRejected={isRejected}
isEdit={isEdit}
/>- 카카오톡 개발자 api를 받아서 카카오톡 공유하기 기능을 만들었습니다.
import { shareKakaoLink } from "../utils/shareKakaoLink";
useEffect(() => {
const script = document.createElement("script");
script.src = "https://developers.kakao.com/sdk/js/kakao.js";
script.async = true;
document.body.appendChild(script);
return () => document.body.removeChild(script);
}, []);
<img
className="Header-shareIcon"
src={shareKakao}
alt="shareKakao"
onClick={() =>
shareKakaoLink(currentUrl, userData)
}
/>


