React - TodoList(Pushup Challenge)

일상 코딩·2022년 7월 13일
0

React

목록 보기
41/45
post-thumbnail
post-custom-banner

01.개요

  • TodoList를 이용하여 팔굽혀펴기 챌린지기능을 만들었다.
  • 팔굽혀펴기 챌린지에서는 사용자가 직접 챌린지를 추가, 삭제, 할수 있으며 성공 여부를 버튼으로 체크하여 목표 달성률을 바로 확인할 수 있도록 만들었다.

참고한 블로그

  • https://react.vlpt.us/mashup-todolist/01-create-components.html
  • 해당 블로그를 참고하여 TodoList를 챌린지 기능에 적합하게 새로 수정하였다.

02.컴포넌트 만들기

만들어야 할 컴포넌트

TodoTemplate

  • 이 컴포넌트는 우리가 만들 투두리스트의 레이아웃을 설정하는 컴포넌트입니다.
  • 페이지의 중앙에 그림자가 적용된 흰색 박스를 보여줍니다.

TodoHead

  • 이 컴포넌트는 오늘의 날짜와 요일을 보여주고, 앞으로 해야 할 일이 몇개 남았는지 보여줍니다.

TodoList

  • 이 컴포넌트는 할 일에 대한 정보가 들어있는 todos 배열을 내장함수 map 을 사용하여 여러개의 TodoItem 컴포넌트를 렌더링해줍니다.

TodoItem

  • 각 할 일에 대한 정보를 렌더링해주는 컴포넌트입니다.
  • 좌측에 있는 원을 누르면 할 일의 완료 여부를 toggle 할 수 있습니다.
  • 할 일이 완료됐을 땐 좌측에 체크가 나타나고 텍스트의 색상이 연해집니다.
  • 마우스를 올리면 휴지통 아이콘이 나타나고 이를 누르면 항목이 삭제됩니다.

TodoCreate

  • 새로운 할 일을 등록할 수 있게 해주는 컴포넌트입니다.
  • TodoTemplate 의 하단부에 초록색 원 버튼을 렌더링해주고, 이를 클릭하면 할 일을 입력 할 수 있는 폼이 나타납니다.
  • 버튼을 다시 누르면 폼이 사라집니다.

TodoContext

  • Context API를 활용하여 TodoList 상태관리합니다.

PushUpGauge

  • React Gauge Chart를 이용하여 사용자에게 팔굽혀펴기 챌린지 성공률을 퍼센트로 변환하여 보여줍니다.

Pushup

  • 지금까지 만든 컴포넌트들을 import하여 하나의 컴포넌트 단위로 가져와서 Pushup 모달창에서 실행시킵니다.

03.TodoTemplate

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

04.TodoHead

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

05.TodoList

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

06.TodoItem

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)

07.TodoCreate

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)

08.TodoContext

  • 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
}

09.PushupGauge.js

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

10.Pushup.js

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

11.최종 결과

profile
일취월장(日就月將) - 「날마다 달마다 성장하고 발전한다.」
post-custom-banner

0개의 댓글