TodoList
를 이용하여 팔굽혀펴기 챌린지기능을 만들었다.- 팔굽혀펴기 챌린지에서는 사용자가 직접 챌린지를 추가, 삭제, 할수 있으며 성공 여부를 버튼으로 체크하여 목표 달성률을 바로 확인할 수 있도록 만들었다.
참고한 블로그
https://react.vlpt.us/mashup-todolist/01-create-components.html
- 해당 블로그를 참고하여
TodoList
를 챌린지 기능에 적합하게 새로 수정하였다.
만들어야 할 컴포넌트
TodoTemplate
- 이 컴포넌트는 우리가 만들 투두리스트의 레이아웃을 설정하는 컴포넌트입니다.
- 페이지의 중앙에 그림자가 적용된 흰색 박스를 보여줍니다.
TodoHead
- 이 컴포넌트는 오늘의 날짜와 요일을 보여주고, 앞으로 해야 할 일이 몇개 남았는지 보여줍니다.
TodoList
- 이 컴포넌트는 할 일에 대한 정보가 들어있는
todos
배열을 내장함수map
을 사용하여 여러개의TodoItem
컴포넌트를 렌더링해줍니다.TodoItem
- 각 할 일에 대한 정보를 렌더링해주는 컴포넌트입니다.
- 좌측에 있는 원을 누르면 할 일의 완료 여부를
toggle
할 수 있습니다.- 할 일이 완료됐을 땐 좌측에 체크가 나타나고 텍스트의 색상이 연해집니다.
- 마우스를 올리면 휴지통 아이콘이 나타나고 이를 누르면 항목이 삭제됩니다.
TodoCreate
- 새로운 할 일을 등록할 수 있게 해주는 컴포넌트입니다.
TodoTemplate
의 하단부에 초록색 원 버튼을 렌더링해주고, 이를 클릭하면 할 일을 입력 할 수 있는 폼이 나타납니다.- 버튼을 다시 누르면 폼이 사라집니다.
TodoContext
Context API
를 활용하여TodoList
상태관리합니다.PushUpGauge
React Gauge Chart
를 이용하여 사용자에게 팔굽혀펴기 챌린지 성공률을 퍼센트로 변환하여 보여줍니다.Pushup
- 지금까지 만든 컴포넌트들을
import
하여 하나의 컴포넌트 단위로 가져와서Pushup
모달창에서 실행시킵니다.
component/modal/Navigation/Challenge/PushupTodoList/TodoTemplate.js
import React from "react" import styled from "styled-components" const TodoTemplateBlock = styled.div` width: 700px; height: 420px; position: relative; /* 추후 박스 하단에 추가 버튼을 위치시키기 위한 설정 */ background: #fff; border-radius: 16px; box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.04); margin: 0 auto; /* 페이지 중앙에 나타나도록 설정 */ display: flex; flex-direction: column; ` function TodoTemplate({ children }) { return <TodoTemplateBlock>{children}</TodoTemplateBlock> } export default TodoTemplate
component/modal/Navigation/Challenge/PushupTodoList/TodoHead.js
import React from "react" import styled from "styled-components" import { useTodoState } from "./TodoContext" import PushupGauge from "./PushupGauge.js" const TodoHeadBlock = styled.div` width: 640px; height: 80px; padding-top: 10px; padding-left: 32px; padding-right: 32px; padding-bottom: 10px; border-bottom: 1px solid #e9ecef; h1 { position: absolute; top: 10px; left: 280px; margin: 0; font-size: 20px; color: #343a40; } .day { position: absolute; top: 40px; left: 320px; margin-top: 10px; color: #868e96; font-size: 17px; font-weight: bold; } .tasks-left { position: absolute; top: 80px; left: 30px; color: #20c997; font-size: 15px; font-weight: bold; } .tasks-right { position: absolute; top: 80px; right: 30px; color: #20c997; font-size: 15px; font-weight: bold; } .tasks-top-right { position: absolute; top: 50px; right: 30px; color: #20c997; font-size: 15px; font-weight: bold; } ` let battery = 0, result = 0 // 성공 갯수를 퍼샌트로 변환하는 함수 function Bat(gauge, total, success) { // (성공갯수 / 전체 챌린지 갯수) * 100 gauge = Math.floor((parseInt(success) / parseInt(total)) * 100) return gauge } function TodoHead() { const todos = useTodoState() const undoneTasks = todos.filter((todo) => todo.id) const success = todos.filter((todo) => todo.success) result = Bat(battery, undoneTasks.length, success.length) const today = new Date() const dateString = today.toLocaleDateString("ko-KR", { year: "numeric", month: "long", day: "numeric", }) const dayName = today.toLocaleDateString("ko-KR", { weekday: "long" }) return ( <> <TodoHeadBlock> <h1>{dateString}</h1> <div className="day">{dayName}</div> <div className="tasks-left">총 챌린지 갯수: {undoneTasks.length}</div> <div className="tasks-right">챌린지 성공 갯수: {success.length}</div> </TodoHeadBlock> <PushupGauge total={undoneTasks.length} value={result} /> </> ) } export default TodoHead
component/modal/Navigation/Challenge/PushupTodoList/TodoList.js
import React from "react" import styled from "styled-components" import TodoItem from "./TodoItem" import { useTodoState } from "./TodoContext" const TodoListBlock = styled.div` flex: 1; padding: 20px 32px; padding-bottom: 48px; overflow-y: auto; // background: gray; /* 사이즈 조정이 잘 되고 있는지 확인하기 위한 임시 스타일 */ ` function TodoList() { const todos = useTodoState() return ( <TodoListBlock> {todos.map((todo) => ( <TodoItem key={todo.id} id={todo.id} text={todo.text} success={todo.success} /> ))} </TodoListBlock> ) } export default TodoList
component/modal/Navigation/Challenge/PushupTodoList/TodoItem.js
import React from "react" import styled, { css } from "styled-components" import { MdDone, MdDelete } from "react-icons/md" import { useTodoDispatch } from "./TodoContext" import { PushUp } from "../../../../../image/index.js" import { Bat, array } from "./TodoHead.js" const Icon = styled.img` width: 50px; height: 50px; border-radius: 16px; border: 1px solid #ced4da; margin-right: 20px; ` const Remove = styled.div` display: flex; align-items: center; justify-content: center; color: #dee2e6; font-size: 24px; cursor: pointer; &:hover { color: #ff6b6b; } display: none; ` const TodoItemBlock = styled.div` display: flex; align-items: center; padding-top: 12px; padding-bottom: 12px; &:hover { ${Remove} { display: initial; } } ` const Text = styled.div` font-size: 20px; font-weight: bold; flex: 1; color: #495057; ${(props) => (props.success || props.fail) && css` color: #ced4da; `} ` const Btn = styled.button` width: 50px; height: 50px; border-radius: 50%; font-size: 15px; border: 1px solid #ced4da; display: flex; align-items: center; justify-content: center; margin-right: 20px; cursor: pointer; ` const SuccessBtn = styled(Btn)` ${(props) => props.success && css` border: 1px solid #38d9a9; color: #38d9a9; `} ` function TodoItem({ id, success, text }) { const dispatch = useTodoDispatch() const onSuccess = () => dispatch({ type: "SUCCESS", id }) const onRemove = () => dispatch({ type: "REMOVE", id }) return ( <> <TodoItemBlock> <Icon src={PushUp} /> <Text>{text}</Text> <SuccessBtn success={success} onClick={() => { onSuccess() }} > {success && <MdDone />} 성공 </SuccessBtn> <Remove onClick={onRemove}> <MdDelete /> </Remove> </TodoItemBlock> </> ) } export default React.memo(TodoItem)
component/modal/Navigation/Challenge/PushupTodoList/TodoCreate.js
import React, { useState } from "react" import styled, { css } from "styled-components" import { MdAdd } from "react-icons/md" import { useTodoDispatch, useTodoNextId } from "./TodoContext" const CircleButton = styled.button` background: #38d9a9; &:hover { background: #63e6be; } &:active { background: #20c997; } z-index: 5; cursor: pointer; width: 40px; height: 40px; display: block; align-items: center; justify-content: center; font-size: 60px; position: absolute; left: 50%; bottom: 0px; transform: translate(-50%, 50%); color: white; border-radius: 50%; border: none; outline: none; display: flex; align-items: center; justify-content: center; transition: 0.125s all ease-in; ${(props) => props.open && css` background: #ff6b6b; &:hover { background: #ff8787; } &:active { background: #fa5252; } transform: translate(-50%, 50%) rotate(45deg); `} ` const InsertFormPositioner = styled.div` width: 100%; bottom: 0; left: 0; position: absolute; ` const InsertForm = styled.form` background: #f8f9fa; padding-left: 32px; padding-top: 32px; padding-right: 32px; padding-bottom: 72px; border-bottom-left-radius: 16px; border-bottom-right-radius: 16px; border-top: 1px solid #e9ecef; ` const Input = styled.input` padding: 12px; border-radius: 4px; border: 1px solid #dee2e6; width: 100%; outline: none; font-size: 18px; box-sizing: border-box; position: absolute; left: 0px; ` function TodoCreate() { const [open, setOpen] = useState(false) const [value, setValue] = useState("") const dispatch = useTodoDispatch() const nextId = useTodoNextId() const onToggle = () => setOpen(!open) const onTimerChange = (e) => setValue(e.target.value) const onSubmit = (e) => { e.preventDefault() // 새로고침 방지 dispatch({ type: "CREATE", todo: { id: nextId.current, text: value, success: false, }, }) setValue("") setOpen(false) nextId.current += 1 } return ( <> {open && ( <InsertFormPositioner> <InsertForm onSubmit={onSubmit}> <Input autoFocus placeholder="추가할 챌린지를 입력하세요" onChange={onTimerChange} value={value} /> </InsertForm> </InsertFormPositioner> )} <CircleButton onClick={onToggle} open={open}> <MdAdd /> </CircleButton> </> ) } export default React.memo(TodoCreate)
Context API
를 활용하여 TodoList
상태관리하기component/modal/Navigation/Challenge/PushupTodoList/TodoContext.js
import React, { useReducer, createContext, useContext, useRef } from "react" const initialTodos = [ { id: 1, text: "팔굽혀펴기 연속 40개", done: true, }, { id: 2, text: "팔굽혀펴기 연속 60개", done: true, }, { id: 3, text: "팔굽혀펴기 연속 80개", done: false, }, { id: 4, text: "팔굽혀펴기 연속 100개", done: false, }, ] function todoReducer(state, action) { switch (action.type) { case "CREATE": return state.concat(action.todo) case "SUCCESS": return state.map((todo) => todo.id === action.id ? { ...todo, success: !todo.success } : todo ) case "REMOVE": return state.filter((todo) => todo.id !== action.id) default: throw new Error(`Unhandled action type: ${action.type}`) } } const TodoStateContext = createContext() const TodoDispatchContext = createContext() const TodoNextIdContext = createContext() export function TodoProvider({ children }) { const [state, dispatch] = useReducer(todoReducer, initialTodos) const nextId = useRef(5) return ( <TodoStateContext.Provider value={state}> <TodoDispatchContext.Provider value={dispatch}> <TodoNextIdContext.Provider value={nextId}> {children} </TodoNextIdContext.Provider> </TodoDispatchContext.Provider> </TodoStateContext.Provider> ) } export function useTodoState() { const context = useContext(TodoStateContext) if (!context) { throw new Error("Cannot find TodoProvider") } return context } export function useTodoDispatch() { const context = useContext(TodoDispatchContext) if (!context) { throw new Error("Cannot find TodoProvider") } return context } export function useTodoNextId() { const context = useContext(TodoNextIdContext) if (!context) { throw new Error("Cannot find TodoProvider") } return context }
component/modal/Navigation/Challenge/PushupTodoList/PushupGauge.js
import React from "react" import BatteryGauge from "react-battery-gauge" import styled from "styled-components" const Container = styled.div` width: 400px; height: 470px; position: absolute; top: -20px; left: -460px; border-radius: 50px; background-color: #ffb6c1; display: flex; justify-content: center; align-items: center; ` export let Pushupgauge = 0 const PushupGauge = (props) => { Pushupgauge = props.total === 0 ? 1000000 : 5000 / props.value return ( <> <Container> <BatteryGauge maxValue={Pushupgauge} orientation={"vertical"} animated={true} style={{ width: "400px", height: "400px", }} /> </Container> </> ) } export default PushupGauge
component/modal/Navigation/Challenge/Pushup.js
import React from "react" import styled from "styled-components" import { Close } from "../../../../image/index.js" import Menubar from "../Menubar.js" import PushUpModal from "react-modal" // TodoList import TodoTemplate from "./PushupTodoList/TodoTemplate.js" import TodoHead from "./PushupTodoList/TodoHead.js" import TodoList from "./PushupTodoList/TodoList.js" import TodoCreate from "./PushupTodoList/TodoCreate.js" import { TodoProvider } from "./PushupTodoList/TodoContext.js" const ModalContainer = styled.div` position: absolute; top: 0px; right: 0px; width: 1350px; height: 890px; ` const ModalHead = styled.div` width: 1350px; height: 100px; display: flex; justify-content: center; background-color: #96a5ff; ` const ModalBody = styled.div` width: 1350px; height: 690px; position: absolute; top: 100px; ` const ModalFooter = styled.div` position: absolute; width: 1350px; height: 100px; bottom: 0px; background-color: #b4b4b4; ` const Closebtn = styled.img` width: 35px; height: 35px; position: absolute; top: 3%; right: 3%; z-index: 1; &:hover { cursor: pointer; } ` const Title = styled.div` position: absolute; top: 30px; font-size: 40px; font-weight: bold; color: #fff; ` const PushUpBg = styled.div` width: 1250px; height: 630px; position: absolute; left: 50px; top: 30px; border-radius: 50px; background-color: #ffb6c1; display: flex; justify-content: center; ` const PushUpBg2 = styled.div` width: 1250px; height: 530px; position: absolute; top: 50px; background-color: #f2ebe9; ` const TodoListContainer = styled.div` width: 750px; height: 470px; position: absolute; top: 30px; right: 30px; border-radius: 30px; border: 2px solid black; background-color: #47ff9c; display: flex; flex-wrap: wrap; justify-content: center; align-content: center; ` const Pushup = ({ isModal, setModal }) => { return ( <PushUpModal isOpen={isModal} onRequestClose={() => setModal(false)} ariaHideApp={false} style={{ overlay: { position: "absolute", top: "0px", left: "0px", height: "100%", width: "90%", }, content: { position: "fixed", top: "0px", bottom: "0px", left: "-200px", right: "0px", margin: "auto", width: "1610px", height: "850px", borderRadius: "30px", }, }} > <Menubar /> <ModalContainer> <ModalHead> <Title>Mission</Title> <Closebtn src={Close} onClick={() => setModal(false)} /> </ModalHead> <ModalBody> <PushUpBg> <PushUpBg2> <TodoListContainer> <TodoProvider> <TodoTemplate> <TodoHead /> <TodoList /> <TodoCreate /> </TodoTemplate> </TodoProvider> </TodoListContainer> </PushUpBg2> </PushUpBg> </ModalBody> <ModalFooter></ModalFooter> </ModalContainer> </PushUpModal> ) } export default Pushup