솔로 프로젝트로 투두리스트를 만들 것이다. 나 혼자이기에 일단 구현부터 들어가겠다.
(1)에는 기본적인 구조 및 CRUD를 구성할 것이고, 캘린더나 날짜 데이터별 CRUD 등은 조금씩 기능 추가하는 식으로 전개하겠다. 웹 앱 형식으로 모바일부터 반응형도 추가하는 식으로 디자인해보겠다.
여기서는 피그마 + 리액트로 간단한 Todo 리스트를 만들어봤다!
당연히 create-react-app 모듈을 사용해 만들고 시작한다.
npx create-react-app 폴더이름
CSS 툴: Styled-Components
상태관리: Redux toolkit(아니면 recoil? 근데 작은 규모라면 안써도 뭐) 도전!
기타: json-server, axios
서버를 만들어야하나 static.js 를 따로 둬서 localstorage를 통해 저장되야하나 싶다.. 이거는 구현하고 고민해봐야될 문제.
피그마로 기획했다. 기본적으로 핸드폰 화면부터 디자인했다. 하지만 반응형을 배웠으니 미디어 쿼리를 이용한 반응형 웹 앱으로 나중에 다시 디자인하여 기획해보자
CDD 방식으로 컴포넌트부터 차근차근 올리면서 개발하는 방법을 택했다.
먼저 CRUD 작업을 위한 json-server로 일단 임시 서버를 만들자!
npm i -g json-server
json-server --watch 이름.json --port 3001
{
"habitTodos": [
{
"id": "7b567643-918d-4a67-866a-2377f257485e",
"text": "먹기",
"done": false
},
{
"id": "f4e033f5-4249-4931-a8b1-d52092406daf",
"text": "씻기",
"done": true
},
{
"id": "fee8d5b2-e7a5-4638-a82e-363b593c63d1",
"text": "공부하기",
"done": false
},
{
"id": "5dfa1223-1bf9-47a9-9124-357cea38a0df",
"text": "⭐️게임하기",
"done": false
}
],
"dailyTodos": [
{
"id": "5731047f-f256-4a20-b744-6599559eec7d",
"text": "무신사에서 옷사기",
"done": false
},
{
"id": "e5cd6c87-2f97-4d8a-93e1-abe8e407e0ff",
"text": "Todo List 만들기",
"done": false
}
]
}
이제 이렇게 만들었으니 params로 habitTodos
, dailyTodos
로 되고, 각 id 별로 불러올 수 있다!
처음엔 주석처럼 작성했다가 가시성을 이유로 리팩토링 + 비동기로 처리했음
import axios from "axios";
// export const listCreate = (url, data) => {
// axios
// .post(url, {
// data: JSON.stringify(data),
// })
// .catch((error) => {
// console.error("Error", error);
// });
// };
export const listCreate = async (url, data) => {
try {
await axios.post(url, {
data: JSON.stringify(data),
});
} catch (error) {
console.error("Error", error);
}
};
// Read는 useFetch로
export const listUpdate = async (url, id, data) => {
try {
await axios.put(`${url}/${id}`, { data: JSON.stringify(data) });
} catch (error) {
console.error("Error", error);
}
};
export const listDelete = async (url, id) => {
try {
await axios.delete(`${url}/${id}`);
} catch (error) {
console.error("Error", error);
}
};
get은 useFetch로 커스텀하여 만들어본다. 변할 때마다 리렌더링 해주기 위해서!
import { useState, useEffect } from "react";
import axios from "axios";
function useFetch(url) {
//null설정한 이유: 모든 data가 같진 않기 때문
const [list, setList] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
axios
.get(url)
.then((response) => {
setList(response.data);
})
.catch((err) => {
setError(err);
})
.finally(() => {
setLoading(false);
});
}, [url]);
return { list, loading, error };
}
export default useFetch;
먼저 컴포넌트를 작성하자! CSS는 Styled-Component로 !
일단 헤더에 오늘 날짜를 나오게 만들었다. 초기 기획했던 것과는 달리 시간 상 날짜 별로 골라 투두리스트를 만들기 어려웠다. 나중에 프로젝트 진행하며 기능 추가하는 식으로 전개하겠다.
import React, { useEffect, useState } from "react";
import Calendar from "react-calendar";
import moment from "moment";
import "./Calendar.css"; // css import
import styled from "styled-components";
const HeaderContainer = styled.div`
display: flex;
justify-content: center;
flex-direction: column;
position: relative;
cursor: pointer;
border-bottom: 1px solid #0f4c75;
height: 96px;
`;
const DayToYear = styled.div`
font-size: 30px;
margin: auto;
`;
const DayOfWeek = styled.div`
font-size: 25px;
margin: auto auto 13px;
color: #bbe1fa;
`;
const Header = () => {
// 캘린더 상태값
const [value, onChange] = useState(new Date());
// TODO: 날짜 데이터를 이런 형식으로 넘기기 moment().format("YYYY/MM/DD");
return (
<>
<HeaderContainer onClick={modalHandler}>
<DayToYear>{moment(value).format("YYYY년 MM월 DD일")}</DayToYear>
<DayOfWeek>{moment(value).format("dddd")}</DayOfWeek>
</HeaderContainer>
</>
);
};
export default Header;
리스트를 담을 그릇인 List와 Lists 컴포넌트를 만들어 보자!
아이콘 받을 모듈인 material-ui를 설치하고 받을 건 import 해오자.
npm i @mui/material @emotion/react @emotion/styled
import DeleteIcon from "@material-ui/icons/Delete";
import DoneIcon from "@material-ui/icons/Done";
List를 모아 map을 해줘 뿌린다!
import React from "react";
import styled from "styled-components";
import List from "./List";
const TodoListBlock = styled.div`
flex: 1;
padding: 20px 32px;
padding-bottom: 48px;
overflow-y: auto;
`;
const StyledText = styled.div`
margin-top: 70px;
text-align: center;
font-weight: 700;
`;
const Lists = ({ listData = [], handleItemToggle, handleItemRemove }) => {
return (
// 얘는 리스트 페이지마다 놔줘야하나?
<TodoListBlock>
{listData.length ? (
listData.map((list) => (
<List
key={list.id}
id={list.id}
text={list.text}
done={list.done}
handleToggle={handleItemToggle}
handleRemove={handleItemRemove}
/>
))
) : (
<StyledText>아직 할일이 없어요.</StyledText>
)}
</TodoListBlock>
);
};
export default Lists;
여기서 이제 재료, API를 연결해 CRUD하고, props로 상태들을 공유해줄 것이다!
import React, { useEffect } from "react";
import Lists from "../components/Lists";
import useFetch from "../util/useFetch";
import Loading from "../components/Loading";
const Habit = () => {
// side effect 때문에 listData가 구성될때 한번 리렌더링되게 하기!
useEffect(() => {}, [list]);
return (
<>
<Lists />
</>
);
};
export default Habit;
이제 Habit
으로 만들었으니 Daily.js
도 치장할 차례다. list의 구성, CSS는 이미 만들어 놨으니 Habit
에서 복사해 url만 고쳐주자! 간편해!
const url = "http://localhost:3001/dailyTodos";
이제 막바지, Footer
다.
기획으로는 border는 없고 중간에 create, 습관-일일 과제로 라우팅 하면 된다. 일단 Footer
의 구성을 구축하고 라우팅하고 버튼 기능을 구현하겠다.
// App.js
function App() {
return (
<BrowserRouter>
<GlobalStyle />
<div className="App">
<Header />
<Routes>
<Route exact path="/" element={<Habit />} />
<Route exact path="/daily" element={<Daily />} />
</Routes>
</div>
</BrowserRouter>
);
}
// Footer.js
import React, { useState } from "react";
import styled, { css } from "styled-components";
import { Link } from "react-router-dom";
const FooterContainer = styled.div`
display: flex;
height: 96px;
position: absolute;
bottom: 0;
width: 100%;
`;
const HabitButton = styled.div`
cursor: pointer;
font-size: 25px;
display: flex;
flex: 1;
justify-content: center;
padding: 20px;
${(props) =>
props.curruntPage &&
css`
color: #bbe1fa;
`}
`;
const CreateButtonContainer = styled.div`
cursor: pointer;
background-color: #3282b8;
border-radius: 10px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24);
transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
width: 89px;
height: 89px;
&:hover {
box-shadow: 10px 14px 28px rgba(0, 0, 0, 0.25), 10px 10px 10px rgba(0, 0, 0, 0.22);
}
`;
const CreateButton = styled.div`
font-size: 50px;
width: 100%;
height: 100%;
color: #bbe1fa;
text-align: center;
`;
const DailyButton = styled.div`
cursor: pointer;
font-size: 25px;
display: flex;
flex: 1;
justify-content: center;
padding: 20px;
${(props) =>
props.curruntPage &&
css`
color: #bbe1fa;
`}
`;
const LinkConainer = styled(Link)`
text-decoration: none;
color: white;
`;
const Footer = ({ curruntPage, handleItemCreate }) => {
return (
<>
<FooterContainer>
<HabitButton curruntPage={curruntPage}>
<LinkConainer to={`/`}>습관</LinkConainer>
</HabitButton>
<CreateButtonContainer>
<CreateButton onClick={modalOpenHandler}>+</CreateButton>
</CreateButtonContainer>
<DailyButton curruntPage={curruntPage}>
<LinkConainer to={`/daily`}>일일 과제</LinkConainer>
</DailyButton>
</FooterContainer>
</>
);
};
export default Footer;
useFetch 가져와서 url을 집어넣어 데이터를 읽어와보자!
// habit.js
import React from "react";
import Lists from "../components/Lists";
import useFetch from "../util/useFetch";
import Loading from "../components/Loading";
const Habit = ({ habitLists }) => {
const { list } = useFetch("http://localhost:3001/habitTodos");
console.log(list);
return (
<>
<Lists listData={list} />
</>
);
};
export default Habit;
계속 null이 떠서 데이터를 못가져오는 불상사가 일어났었다.
Uncaught TypeError: Cannot read properties of undefined (reading 'TodoItemBlock')
왜냐면 위 코드를 보면 바로 list
데이터를 뿌려줬는데, fetch의 속도와 렌더링된 속도가 달라 null을 건네주기에 리액트에선 데이터를 읽지 못한다. 당연하다.. 그 당연한 걸 내가 생각 못했을 뿐이지.. 그래서
{list ? <Lists listData={list} /> : <Loading />}
이렇게 list가 오면 데이터가 나오고, 아니면 Loading
이 나오게 해 오류가 나지 않으면서도 UX를 올렸다. 이거 은근 어려웠다.. 1시간을 잡아먹게 했다.
여기도 많이 해맸다. id
를 추출하지 못해 복습을 좀 했고 깨달은 것이 '이벤트를 바로 할당하는 것이 아닌 실행값을 할당해야한다'라는 걸 깨달았다.
List.js
을 위해
// Habit.js
const Habit = () => {
const { list } = useFetch("http://localhost:3001/habitTodos");
const handleItemRemove = (id) => {
listDelete("http://localhost:3001/habitTodos", id);
};
return <>{list ? <Lists listData={list} handleItemRemove={handleItemRemove} /> : <Loading />}</>;
};
// Lists.js
const Lists = ({ listData = [], handleItemToggle, handleItemRemove }) => {
// side effect 때문에 listData가 구성될때 한번 리렌더링되게 하기!
useEffect(() => {}, [listData]);
return (
// 얘는 리스트 페이지마다 놔줘야하나?
<TodoListBlock>
{listData.length ? (
listData.map((list) => (
<List
key={list.id}
id={list.id}
text={list.text}
done={list.done}
handleToggle={handleItemToggle}
handleRemove={handleItemRemove}
/>
))
) : (
<StyledText>아직 할일이 없어요.</StyledText>
)}
</TodoListBlock>
);
};
// List.js
const List = ({ id, text, done, handleToggle, handleRemove }) => {
const onRemove = () => {
handleRemove(id);
};
return (
<>
<TodoItemBlock>
<CheckCircle></CheckCircle>
<Text>{text}</Text>
<Remove onClick={onRemove} />
</TodoItemBlock>
</>
);
};
json-server라 그런지 핫로딩이 안돼 삭제버튼을 눌러도 바로 반영이 안된다. 결국 api window.location.reload();
에 이걸 넣었는데 이러면 솔직히 CRA의 장점인 '새로고침이 없음'이 퇴색돼버린다. 이걸 해결하려면 node로 직접 만들어야하나.. next나 express로 나중에 서버를 제대로 구축해봐야겠다.
글 자체를 클릭하면 수정되게끔 구현해보자!
import React, { useState } from "react";
import styled, { css } from "styled-components";
import { AiOutlineDelete } from "react-icons/ai";
import { AiOutlineCheck } from "react-icons/ai";
...
const Input = styled.input`
width: 100%;
height: 40px;
padding: 10px;
font-size: 20px;
color: #333;
border: 1px solid #ccc;
/* border-radius: 4px; */
&:focus {
outline: none;
border-color: #333;
}
`;
const ModalContainer = styled.div`
margin: auto;
position: relative;
z-index: 2;
`;
const ModalBackdrop = styled.div`
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
background: rgba(0, 0, 0, 0.5);
`;
const ModalView = styled.div.attrs((props) => ({
role: "dialog",
}))`
position: absolute;
top: calc(50vh - 100px);
left: calc(50vw - 200px);
background-color: white;
display: flex;
justify-content: center;
align-items: center;
border-radius: 10px;
width: 400px;
height: 150px;
`;
const ModalCloseBtn = styled.button`
background-color: #fff;
color: #000;
text-decoration: none;
border: none;
position: absolute;
top: 10%;
cursor: pointer;
font-size: 20px;
`;
const List = ({ id, text, done, handleToggle, handleRevise, handleRemove }) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [revisedText, setRevisedText] = useState(text);
const modalOpenHandler = (e) => {
setIsModalOpen(true);
};
const modalCloseHandler = () => {
setIsModalOpen(false);
};
const onChange = (e) => {
setRevisedText(e.target.value);
};
const onRevise = () => {
handleRevise(id, revisedText);
modalCloseHandler();
};
const onKeyPress = (e) => {
if (e.key === "Enter") {
onRevise();
}
};
const onRemove = () => {
handleRemove(id);
};
return (
<>
<TodoItemBlock>
<CheckCircle></CheckCircle>
<Text onClick={modalOpenHandler}>{text}</Text>
<Remove onClick={onRemove} />
</TodoItemBlock>
<ModalContainer>
{isModalOpen && (
<ModalBackdrop>
<ModalView>
<ModalCloseBtn onClick={modalCloseHandler}>x</ModalCloseBtn>
<Input type="text" value={revisedText} onChange={onChange} onKeyPress={onKeyPress}></Input>
</ModalView>
</ModalBackdrop>
)}
</ModalContainer>
</>
);
};
export default List;
글을 클릭하면 인풋 창이 나오게 했다.
그리고 Lists, Habit 각각 handleItemToggle
로 props 내려줬다.
// Lists.js
const Lists = ({ listData = [], handleItemToggle, handleItemRevise, handleItemRemove }) => {
return (
<TodoListBlock>
{listData.length ? (
listData.map((list) => (
<List
key={list.id}
id={list.id}
text={list.text}
done={list.done}
handleToggle={handleItemToggle}
handleRevise={handleItemRevise}
handleRemove={handleItemRemove}
/>
))
) : (
<StyledText>아직 할일이 없어요.</StyledText>
)}
</TodoListBlock>
);
};
// Habit.js
return (
<>
{!loading && list ? (
<Lists listData={list} handleItemRevise={handleItemRevise} handleItemRemove={handleItemRemove} />
) : (
<Loading />
)}
</>
);
Update로 넘겨주면 "\"공부하자!\"",
로 데이터가 넘어가는 문제가 있었다.
이걸 어떻게 해결해야하나.. 모르겠다..
투두리스트 핵심! 체크 버전을 누르면 글이 투명해지고 체크가 생긴다.
styled-component의 props 기능으로 props가 해당되면 css가 적용케 했다.
import React, { useState } from "react";
import styled, { css } from "styled-components";
import { AiOutlineDelete } from "react-icons/ai";
import { AiOutlineCheck } from "react-icons/ai";
const CheckCircle = styled(AiOutlineCheck)`
color: #1b262c;
width: 20px;
height: 20px;
border-radius: 10px;
border: 1px solid #ced4da;
font-size: 13px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 10px;
cursor: pointer;
${(props) =>
props.done &&
css`
border: 1px solid #bbe1fa;
color: #bbe1fa;
`}
`;
const Text = styled.div`
font-size: 20px;
cursor: pointer;
${(props) =>
props.done &&
css`
text-decoration: line-through;
color: rgba(187, 225, 250, 0.5);
`}
`;
...
const List = ({ id, text, done, handleToggle, handleRevise, handleRemove }) => {
...
const onToggle = () => {
handleToggle(id, !done);
};
const onRemove = () => {
handleRemove(id);
};
return (
<>
<TodoItemBlock>
<CheckCircle done={done} onClick={onToggle}></CheckCircle>
<Text onClick={modalOpenHandler} done={done}>
{text}
</Text>
<Remove onClick={onRemove} />
</TodoItemBlock>
<ModalContainer>
{isModalOpen && (
<ModalBackdrop>
<ModalView>
<ModalCloseBtn onClick={modalCloseHandler}>x</ModalCloseBtn>
<Input type="text" value={revisedText} onChange={onChange} onKeyPress={onKeyPress}></Input>
</ModalView>
</ModalBackdrop>
)}
</ModalContainer>
</>
);
};
export default List;
CheckCircle
, Text
에 props done
을 입력해 서버에서 가져온 데이터를 받아주면서 CSS도 적용하게 됐다. 그리고 onToggle
에 !
를 적용해 기존 값의 반대 값을 api로 전송하게 했다. 이건 쉽네!
이제 막바지, Footer
의 추가 버튼 구현이다!.
Footer
의 구성을 구축하고 버튼 기능을 구현하겠다.
import React, { useState } from "react";
import styled, { css } from "styled-components";
import { Link } from "react-router-dom";
...
const ModalContainer = styled.div`
margin: auto;
position: relative;
z-index: 2;
`;
const ModalBackdrop = styled.div`
position: fixed;
top: 0;
left: 0;
bottom: 0;
right: 0;
background: rgba(0, 0, 0, 0.5);
`;
const ModalView = styled.div.attrs(() => ({
role: "dialog",
}))`
position: absolute;
top: calc(50vh - 100px);
left: calc(50vw - 185px);
background-color: #1b262c;
display: flex;
justify-content: center;
align-items: center;
border-radius: 10px;
width: 400px;
height: 150px;
`;
const ModalCloseBtn = styled.button`
background-color: #1b262c;
color: #eaeaea;
text-decoration: none;
border: none;
position: absolute;
top: 10%;
cursor: pointer;
font-size: 20px;
`;
const Input = styled.input`
width: 100%;
height: 40px;
padding: 10px;
font-size: 20px;
color: #333;
border: 1px solid #0f4c75;
background-color: #1b262c;
color: #eaeaea;
&:focus {
outline: none;
border-color: #0f4c75;
}
`;
const Footer = ({ curruntPage, handleItemCreate }) => {
const [isModalOpen, setIsModalOpen] = useState(false);
const [text, setText] = useState("");
const modalOpenHandler = (e) => {
setIsModalOpen(true);
};
const modalCloseHandler = () => {
setIsModalOpen(false);
};
const onChange = (e) => {
setText(e.target.value);
};
const onCreate = () => {
handleItemCreate(text);
modalCloseHandler();
};
const onKeyPress = (e) => {
if (e.key === "Enter") {
onCreate();
setText("");
}
};
return (
<>
<FooterContainer>
...
</FooterContainer>
<ModalContainer>
{isModalOpen && (
<ModalBackdrop>
<ModalView>
<ModalCloseBtn onClick={modalCloseHandler}>x</ModalCloseBtn>
<Input type="text" value={text} onChange={onChange} onKeyPress={onKeyPress}></Input>
</ModalView>
</ModalBackdrop>
)}
</ModalContainer>
</>
);
};
export default Footer;
List
에서 썼던 모달을 재사용해 제출을 하려고 한다.(재사용을 했으니 컴포넌트로 따로 커스텀할까?)