diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..0a72520 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,6 @@ +{ + "trailingComma": "es5", + "tabWidth": 2, + "semi": true, + "singleQuote": true +} diff --git a/README.md b/README.md index a77b4ce..d4862d9 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,40 @@ # 2주차 미션: React-Todo -# 서론 +# 미션 -안녕하세요 🙌🏻 19기 프론트엔드 운영진 **변지혜**입니다. +## 배포 링크 -다들 1주차 미션 Vanilla Todo 만드시느라 수고 많으셨습니다! 1주차 미션을 통해 여러분들께서 본격적인 React 사용에 앞서 Vanilla JS로 SPA를 만들때의 불편한 점을 느끼셨을 것 이라 생각합니다. +- [배포 링크](https://react-todo-19th.vercel.app/) -그리하여 이번 미션은, 1주차 스터의 미션으로 주어진 Todo list 만들기를 **React**로 리팩토링하는 것 입니다! -기존에 리액트를 잘 아시던 분들께는, 조금 더 효울적인 디자인 패턴에 대해 고민할수 있는 주차가 될 것이고, 리액트를 제대로 접해보지 못하신 분들께는 기존의 어플리케이션을 리액트로 포팅하는 과정을 통해 왜 프론트엔드 시장에 리액트가 등장하게 되었고, 리액트에서 사용하는 여러가지 방식들이 왜 바닐라에 비해 효율적인지 꺠닫는 주차가 될 것이라 생각합니다. +## 기능 구현 -비교적 가벼운 미션인 만큼 코드를 짜는 데 있어 여러분의 **창의성**을 충분히 발휘해보시기 바랍니다. _❕작동하기만 하면 되는 것보다 같은 코드를 짜는 여러가지 방식과 패턴에 대해 많이 고민해보시고, 본인이 작성할 수 있는 가장 창의적인 방법으로 코드를 작성해주셨으면 합니다.❕_ 여러분이 미션 수행을 하는 과정에서 한 생각과 고민만큼 스터디에서 더 많은 것을 얻어가실 수 있을 거라 기대합니다! +기존 기능 -막히는 부분이 있더라도 우선 스스로 공부하고 찾아보면서 미션을 진행하는 방식을 권고드리지만, 미션과 관련하여 운영진의 도움이 필요하시다면 얼마든지 슬랙 Q&A 채널이나 프론트엔드 카톡방에 질문을 남겨 주세요! +- 입력창을 통해 할 일 추가 +- 삭제 버튼 클릭으로 할 일 삭제 +- 체크 버튼 클릭으로 완료 표시, 밑줄 그어 완료 표시 +- 날짜 출력 +- 총 할 일 개수와 완료한 일 개수 출력 +- 로컬 스토리지 저장 -# 미션 +추가된 기능 + +- 반응형 구현 +- 버튼 컨테이너 애니메이션 효과 +- React.memo를 이용한 렌더링 최적화 -## 예시 +## 느낀 점 -- [리액트 투두 예시](https://react-todo-18th-lemon.vercel.app/) +안녕하세요, 19기 프론트 안혜연입니다! + +1주차 과제에서 추가로 몇몇 기능을 넣으려고 했으나,, 일단 필수 기능 위주로 구현했습니다. +추가로 스타일을 반응형으로 지정해주려고 했습니다. 평소 자주 쓰는 px 단위가 익숙해서 반응형 구현에 있어 어렵게 느껴졌습니다.. +다른 분들은 어떤 단위 위주로 사용하는지,, 피그마로 디자인을 볼 때 px 단위로 나타나는데 직접 다른 단위로 계산해서 사용하는지,, +반응형 구현은 미디어 쿼리로 지정해주시는지 궁금합니다.!! +그리고 Tailwind CSS를 사용하면 반응형 디자인 구현이 훨씬 편리하다는 것 같아 Tailwind CSS도 공부해서 사용해보고 싶습니다. + +할 일을 입력할 때마다 할 일 목록들이 불필요하게 렌더링되어 React.memo를 사용해 이를 방지하였습니다. +또 할 일 텍스트를 선택하면 버튼 컨테이너가 나오게끔 슬라이드 효과를 주고 싶어 애니메이션 효과를 지정해주었습니다. ## 미션 목표 @@ -27,33 +44,20 @@ - React Hooks에 대한 기초를 이해합니다. - Styled-Components를 통한 CSS-in-JS 및 CSS Preprocessor의 사용법을 익힙니다. -## 기한 - -- 2024년 3월 22일 금요일 - ## Key Questions -- Virtual-DOM은 무엇이고, 이를 사용함으로서 얻는 이점은 무엇인가요? -- 미션을 진행하면서 느낀, React를 사용함으로서 얻을수 있는 장점은 무엇이었나요? -- React에서 상태란 무엇이고 어떻게 관리할 수 있을까요? -- Styled-Components 사용 후기 (CSS와 비교) - -## 필수 요건 - -- 1주차 미션의 결과물을 그대로 React로 구현합니다. (‼️ todo / done 개수 잊지 마세요 ‼️) -- Functional Components를 사용합니다. -- React Hooks만을 사용해 상태를 관리합니다. -- (이번주는 Redux, MobX, Recoil, SWR등의 외부 상태관리 라이브러리를 사용하지 않아도 미션 수행에 지장이 없습니다.) +1. Virtual-DOM은 무엇이고, 이를 사용함으로서 얻는 이점은 무엇인가요? + Virtual DOM은 웹 개발에서 사용되는 개념으로, 실제 DOM(Document Object Model)의 가벼운 사본입니다. Virtual DOM은 메모리 내에 존재하며, 실제 DOM과 상호작용하는 대신에 애플리케이션의 UI 상태를 효율적으로 업데이트하는 데 사용됩니다. 애플리케이션의 상태가 변경될 때 이전 Virtual DOM에서 변경된 부분만 업데이트하여 실제 DOM 업데이트가 최소화되어 애플리케이션의 성능이 향상됩니다. -## 선택 요건 +2. 미션을 진행하면서 느낀, React를 사용함으로서 얻을수 있는 장점은 무엇이었나요? + UI를 독립적인 컴포넌트로 나누어 개발할 수 있다는 것이 장점으로 느껴졌습니다. 컴포넌트들은 재사용이 가능하고, 컴포넌트에서 자신이 개별적으로 상태 관리를 할 수 있어 복잡한 UI도 쉽게 관리하고 유지할 수 있습니다. -- 기존 Todo-list에 여러분들이 추가하고 싶은 기능과 디자인을 자유롭게 추가해보세요. - -## 로컬 실행방법 - ---- +3. React에서 상태란 무엇이고 어떻게 관리할 수 있을까요? + 상태란 컴포넌트의 상태를 저장하고 관리하는 데이터로 컴포넌트가 동적으로 데이터를 처리하고 UI를 적절히 업데이트할 수 있게 해줍니다. + 클래스 컴포넌트 내에서는 this.state를 사용하여 상태를 초기화하고, this.setState 메소드를 사용해 상태를 업데이트합니다. React 16.8부터 도입된 Hooks는 class없이 state를 사용할 수 있게 하여 함수 컴포넌트에서도 상태 관리를 가능하게 합니다. -`npm start` : 로컬에서 react application을 자동으로 리로드하여 실행시켜줍니다. +4. Styled-Components 사용 후기 (CSS와 비교) + css 파일로 스타일을 지정하는 것보다 Styled-Components가 더 편리하게 느껴졌습니다. 별도의 css 파일 없이 하나의 js 파일 안에서 컴포넌트 단위별로 스타일을 지정해줄 수 있다는 점에서 스타일을 관리하기 더 쉬웠습니다. 또한 props나 상태에 따라 해당 스타일을 쉽게 처리할 수 있어 편리했습니다. # 링크 및 참고자료 diff --git a/package-lock.json b/package-lock.json index e523af2..e6eb084 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,10 @@ "@testing-library/user-event": "^13.5.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-router-dom": "^6.22.3", "react-scripts": "5.0.1", + "styled-components": "^6.1.8", + "styled-reset": "^4.5.2", "web-vitals": "^2.1.4" } }, @@ -2283,6 +2286,24 @@ "postcss-selector-parser": "^6.0.10" } }, + "node_modules/@emotion/is-prop-valid": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.2.1.tgz", + "integrity": "sha512-61Mf7Ufx4aDxx1xlDeOm8aFFigGHE4z+0sKCa+IHCeZKiyP9RLD0Mmx7m8b9/Cf37f7NAvQOOJAbQQGVr5uERw==", + "dependencies": { + "@emotion/memoize": "^0.8.1" + } + }, + "node_modules/@emotion/memoize": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.1.tgz", + "integrity": "sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA==" + }, + "node_modules/@emotion/unitless": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.0.tgz", + "integrity": "sha512-VINS5vEYAscRl2ZUDiT3uMPlrFQupiKgHz5AA4bCH1miKBg4qtwkim1qPmJj/4WG6TreYMY111rEFsjupcOKHw==" + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -3352,6 +3373,14 @@ } } }, + "node_modules/@remix-run/router": { + "version": "1.15.3", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.15.3.tgz", + "integrity": "sha512-Oy8rmScVrVxWZVOpEF57ovlnhpZ8CCPlnIIumVcV9nFdiSIrus99+Lw78ekXyGvVDlIsFJbSfmSovJUhCWYV3w==", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@rollup/plugin-babel": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz", @@ -4534,6 +4563,11 @@ "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==" }, + "node_modules/@types/stylis": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.0.tgz", + "integrity": "sha512-n4sx2bqL0mW1tvDf/loQ+aMX7GQD3lc3fkCMC55VFNDu/vBOabO+LTIeXKM14xK0ppk5TUGcWRjiSpIlUpghKw==" + }, "node_modules/@types/testing-library__jest-dom": { "version": "5.14.9", "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.9.tgz", @@ -6037,6 +6071,14 @@ "node": ">= 6" } }, + "node_modules/camelize": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", + "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caniuse-api": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-3.0.0.tgz", @@ -6469,6 +6511,14 @@ "postcss": "^8.4" } }, + "node_modules/css-color-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", + "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", + "engines": { + "node": ">=4" + } + }, "node_modules/css-declaration-sorter": { "version": "6.4.1", "resolved": "https://registry.npmjs.org/css-declaration-sorter/-/css-declaration-sorter-6.4.1.tgz", @@ -6659,6 +6709,16 @@ "resolved": "https://registry.npmjs.org/css-select-base-adapter/-/css-select-base-adapter-0.1.1.tgz", "integrity": "sha512-jQVeeRG70QI08vSTwf1jHxp74JoZsr2XSgETae8/xC8ovSnL2WF87GTLO86Sbwdt2lK4Umg4HnnwMO4YF3Ce7w==" }, + "node_modules/css-to-react-native": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", + "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", + "dependencies": { + "camelize": "^1.0.0", + "css-color-keywords": "^1.0.0", + "postcss-value-parser": "^4.0.2" + } + }, "node_modules/css-tree": { "version": "1.0.0-alpha.37", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.0.0-alpha.37.tgz", @@ -15143,6 +15203,36 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "6.22.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.22.3.tgz", + "integrity": "sha512-dr2eb3Mj5zK2YISHK++foM9w4eBnO23eKnZEDs7c880P6oKbrjz/Svg9+nxqtHQK+oMW4OtjZca0RqPglXxguQ==", + "dependencies": { + "@remix-run/router": "1.15.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.22.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.22.3.tgz", + "integrity": "sha512-7ZILI7HjcE+p31oQvwbokjk6OA/bnFxrhJ19n82Ex9Ph8fNAq+Hm/7KchpMGlTgWhUxRHMMCut+vEtNpWpowKw==", + "dependencies": { + "@remix-run/router": "1.15.3", + "react-router": "6.22.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/react-scripts": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-5.0.1.tgz", @@ -15998,6 +16088,11 @@ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" }, + "node_modules/shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -16530,6 +16625,81 @@ "webpack": "^5.0.0" } }, + "node_modules/styled-components": { + "version": "6.1.8", + "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.1.8.tgz", + "integrity": "sha512-PQ6Dn+QxlWyEGCKDS71NGsXoVLKfE1c3vApkvDYS5KAK+V8fNWGhbSUEo9Gg2iaID2tjLXegEW3bZDUGpofRWw==", + "dependencies": { + "@emotion/is-prop-valid": "1.2.1", + "@emotion/unitless": "0.8.0", + "@types/stylis": "4.2.0", + "css-to-react-native": "3.2.0", + "csstype": "3.1.2", + "postcss": "8.4.31", + "shallowequal": "1.1.0", + "stylis": "4.3.1", + "tslib": "2.5.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/styled-components" + }, + "peerDependencies": { + "react": ">= 16.8.0", + "react-dom": ">= 16.8.0" + } + }, + "node_modules/styled-components/node_modules/csstype": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz", + "integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==" + }, + "node_modules/styled-components/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/styled-components/node_modules/tslib": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.5.0.tgz", + "integrity": "sha512-336iVw3rtn2BUK7ORdIAHTyxHGRIHVReokCR3XjbckJMK7ms8FysBfhLR8IXnAgy7T0PTPNBWKiH514FOW/WSg==" + }, + "node_modules/styled-reset": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/styled-reset/-/styled-reset-4.5.2.tgz", + "integrity": "sha512-dbAaaVEhweBs2FGfqGBdW6oMcMK8238C2X5KCxBhUQJX92m/QyUfzRADOXhdXiXNkIPELtMCd72YY9eCdORfIw==", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "styled-components": ">=4.0.0 || >=5.0.0 || >=6.0.0" + } + }, "node_modules/stylehacks": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/stylehacks/-/stylehacks-5.1.1.tgz", @@ -16545,6 +16715,11 @@ "postcss": "^8.2.15" } }, + "node_modules/stylis": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.1.tgz", + "integrity": "sha512-EQepAV+wMsIaGVGX1RECzgrcqRRU/0sYOHkeLsZ3fzHaHXZy4DaOOX0vOlGQdlsjkh3mFHAIlVimpwAs4dslyQ==" + }, "node_modules/sucrase": { "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", diff --git a/package.json b/package.json index 6c9c71f..99de6d7 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,10 @@ "@testing-library/user-event": "^13.5.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-router-dom": "^6.22.3", "react-scripts": "5.0.1", + "styled-components": "^6.1.8", + "styled-reset": "^4.5.2", "web-vitals": "^2.1.4" }, "scripts": { diff --git a/public/index.html b/public/index.html index aa069f2..0e7ed0c 100644 --- a/public/index.html +++ b/public/index.html @@ -1,5 +1,5 @@ - + @@ -24,7 +24,11 @@ work correctly both with client-side routing and a non-root public URL. Learn how to configure a non-root public URL by running `npm run build`. --> - React App + + To-Do-List diff --git a/src/App.js b/src/App.js index 3b90819..9d43aa0 100644 --- a/src/App.js +++ b/src/App.js @@ -1,9 +1,14 @@ -function App() { +import { BrowserRouter, Routes, Route } from 'react-router-dom'; +import ToDoList from './pages/ToDoList'; +import GlobalStyles from '../src/GlobalStyles'; + +export default function App() { return ( -
-

CEOS 19기 프론트엔드 파이팅!( ¨̮ )و✧🔥

-
+ + + + } /> + + ); } - -export default App; diff --git a/src/GlobalStyles.js b/src/GlobalStyles.js new file mode 100644 index 0000000..f0c698f --- /dev/null +++ b/src/GlobalStyles.js @@ -0,0 +1,40 @@ +import { createGlobalStyle } from 'styled-components'; +import reset from 'styled-reset'; + +const GlobalStyles = createGlobalStyle` + ${reset} + a{ + text-decoration: none; + color: inherit; + } + *{ + box-sizing: border-box; + } + html, body, span, div, h1, h2, h3, h4, h5, h6, p, + a, dl, dt, dd, ol, ul, li, form, label, table{ + margin: 0; + padding: 0; + border: 0; + vertical-align: baseline; + } + body{ + font-family: pretendard; + background-color: rgb(231, 244, 253); + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; + margin: 0; + } + ol, ul{ + list-style: none; + } + button { + display: flex; + border: 0; + background: transparent; + cursor: pointer; + } +`; + +export default GlobalStyles; diff --git a/src/assets/img/grayCheck.svg b/src/assets/img/grayCheck.svg new file mode 100644 index 0000000..120713c --- /dev/null +++ b/src/assets/img/grayCheck.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/img/greenCheck.svg b/src/assets/img/greenCheck.svg new file mode 100644 index 0000000..a401f1f --- /dev/null +++ b/src/assets/img/greenCheck.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/img/plus.svg b/src/assets/img/plus.svg new file mode 100644 index 0000000..7751c75 --- /dev/null +++ b/src/assets/img/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/img/trash.svg b/src/assets/img/trash.svg new file mode 100644 index 0000000..d46e9e1 --- /dev/null +++ b/src/assets/img/trash.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/InputForm.js b/src/components/InputForm.js new file mode 100644 index 0000000..5f61d6c --- /dev/null +++ b/src/components/InputForm.js @@ -0,0 +1,62 @@ +import { useState } from 'react'; +import styled from 'styled-components'; +import plusImg from '../assets/img/plus.svg'; + +const InputContainer = styled.div` + position: relative; + width: 100%; +`; + +const InputStyle = styled.input` + background-color: #d9d9d9; + border: none; + outline: none; + border-radius: 10px; + padding: 5px 5px 5px 30px; // 텍스트와 이미지 겹침 방지를 위한 패딩 조정 + width: 100%; + line-height: 20px; + cursor: text; +`; + +const PlusImg = styled.img` + position: absolute; + left: 5px; // 이미지 위치 조정 + top: 50%; + transform: translateY(-50%); + width: 20px; + height: 20px; +`; + +export default function InputForm({ onAddToDo }) { + const [value, setValue] = useState(''); + + //입력 필드의 입력값 상태 업데이트하는 함수 + const handleInput = (event) => { + setValue(event.target.value); + }; + + //폼 제출 시 입력데이터 처리하는 함수 + const handleSubmit = (event) => { + event.preventDefault(); + if (!value.trim()) { + alert('공백 없이 입력해주세요.'); + return; + } + onAddToDo(value); + setValue(''); + }; + + return ( +
+ + + + +
+ ); +} diff --git a/src/components/List.js b/src/components/List.js new file mode 100644 index 0000000..acbf671 --- /dev/null +++ b/src/components/List.js @@ -0,0 +1,164 @@ +import React, { useState } from 'react'; +import styled, { keyframes } from 'styled-components'; +import deleteBtn from '../assets/img/trash.svg'; +import grayCheckBtn from '../assets/img/grayCheck.svg'; +import greenCheckBtn from '../assets/img/greenCheck.svg'; + +const ToDoBlock = styled.div` + background-color: white; + border-radius: 10px; + width: 100%; + height: 50vh; + display: flex; + flex-direction: column; + justify-content: flex-start; + overflow-y: auto; + overflow-x: hidden; + + /* 웹킷 기반 브라우저를 위한 스타일 */ + &::-webkit-scrollbar { + width: 5px; + } + + /* 파이어폭스 브라우저를 위한 스타일 */ + scrollbar-width: thin; + + @media (max-width: 768px) { + width: 100%; + } +`; + +const ItemContainer = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 10px 10px 30px; + position: relative; + min-height: 40px; // 버튼 컨테이너 상관없이 일관된 높이 + overflow-y: auto; // 각 항목별 스크롤바 생성 + overflow-x: hidden; + + &::after { + content: ''; // 가상 요소에 내용이 없음을 명시 + position: absolute; + bottom: 0; + left: 0; + width: calc(100% - 30px); // 전체 너비에서 30px만큼 줄임 + margin-left: 30px; + border-bottom: 1px solid #ccc; + } + + @media (max-width: 768px) { + padding: 5px; 5px; 5px; 15px; + &::after { + width: calc(100% - 20px); + margin-left: 20px; + } + } +`; + +const TextContainer = styled.div` + width: 100%; + font-size: 1em; + text-decoration: ${(props) => (props.completed ? 'line-through' : 'none')}; + word-wrap: break-word; + white-space: normal; + cursor: pointer; + + @media (max-width: 768px) { + font-size: 0.9em; + } +`; + +// 애니메이션 효과 +const SlideIn = keyframes` + from { + transform: translateX(100%); + } + + to { + transform: translateX(0); + } +`; + +const ButtonsContainer = styled.div` + display: flex; + animation: ${SlideIn} 0.5s forwards; // 0.5초 동안 애니메이션 실행 +`; + +const DeleteButtonImg = styled.img` + cursor: pointer; + width: 20px; + height: 20px; +`; + +const CheckButtonImg = styled.img` + width: 20px; + height: 20px; + cursor: pointer; + margin-right: 7px; +`; + +// React.memo를 사용하여 컴포넌트의 불필요한 렌더링을 방지 +export default React.memo(function List({ toDoData, setToDoData }) { + const [showButtons, setShowButtons] = useState(false); + const [selectedTextId, setSelectedTextId] = useState(null); + + // 텍스트 컨테이너 클릭 핸들러 + const handleTextClick = (id) => { + if (id === selectedTextId) { + // 같은 TextContainer 클릭 시 showButton 상태 변경 + setShowButtons(!showButtons); + } else { + // 다른 TextContainer가 클릭되면, showButtons를 true로 설정하고 selectedTextId를 업데이트 + setShowButtons(true); + setSelectedTextId(id); + } + }; + + // 할 일 목록 삭제하는 함수 + const handleDelete = (id) => { + const newToDoData = toDoData.filter((item) => item.id !== id); + setToDoData(newToDoData); + }; + + // 할 일 목록의 완료 상태 변경하는 함수 + const handleComplete = (id) => { + setToDoData((prev) => + prev.map((item) => + item.id === id ? { ...item, completed: !item.completed } : item + ) + ); + }; + + return ( + + {toDoData.map((item) => ( + + handleTextClick(item.id)} + > + {item.title} + + {selectedTextId === item.id && showButtons && ( + + handleComplete(item.id)} + /> + handleDelete(item.id)} + /> + + )} + + ))} + + ); +}); diff --git a/src/components/TodayDate.js b/src/components/TodayDate.js new file mode 100644 index 0000000..d2fa38e --- /dev/null +++ b/src/components/TodayDate.js @@ -0,0 +1,22 @@ +export default function TodayDate() { + const today = new Date(); + const days = [ + '일요일', + '월요일', + '화요일', + '수요일', + '목요일', + '금요일', + '토요일', + ]; + const date = `${today.getFullYear()}-${(today.getMonth() + 1) + .toString() + .padStart(2, '0')}-${today.getDate().toString().padStart(2, '0')}`; + const day = days[today.getDay()]; + + return ( +

+ {date} {day} +

+ ); +} diff --git a/src/index.js b/src/index.js index 8db5acb..593edf1 100644 --- a/src/index.js +++ b/src/index.js @@ -1,8 +1,8 @@ -import React from "react"; -import ReactDOM from "react-dom/client"; -import App from "./App"; +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; -const root = ReactDOM.createRoot(document.getElementById("root")); +const root = ReactDOM.createRoot(document.getElementById('root')); root.render( diff --git a/src/pages/ToDoList.js b/src/pages/ToDoList.js new file mode 100644 index 0000000..e40bd95 --- /dev/null +++ b/src/pages/ToDoList.js @@ -0,0 +1,84 @@ +import { useState, useEffect } from 'react'; +import styled from 'styled-components'; +import InputForm from '../components/InputForm'; +import List from '../components/List'; +import TodayDate from '../components/TodayDate'; + +const Container = styled.div` + margin: auto; + width: 600px; + padding: 20px; + background-color: #f5f5f5; + border-radius: 15px; + box-shadow: 0 4px 6px rgba(50, 50, 93, 0.11); + + @media (max-width: 768px) { + width: 100%; + } +`; + +const Title = styled.h1` + margin: 10px 0; + font-size: 2.5em; + font-weight: bold; + + @media (max-width: 768px) { + font-size: 2em; + } +`; + +const Subtitle = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + + h3 { + font-size: 1.3em; + font-weight: 700; + margin-top: 20px; + margin-bottom: 10px; + } + + @media (max-width: 768px) { + h3 { + font-size: 0.8em; + } + } +`; + +export default function ToDoList() { + const savedToDos = JSON.parse(localStorage.getItem('toDoData') || '[]'); // 로컬 스토리지에서 불러오기 + const [toDoData, setToDoData] = useState(savedToDos); + const totalItems = toDoData.length; + const completedItems = toDoData.filter((item) => item.completed).length; + + // toDoData 수정 시 로컬 스토리지에 저장 + useEffect(() => { + localStorage.setItem('toDoData', JSON.stringify(toDoData)); + }, [toDoData]); + + // 새로운 할 일 객체를 생성하고 현재 할 일 목록에 추가하는 함수로 InputForm 컴포넌트에 prop으로 전달 + const addToDo = (newToDoTitle) => { + let newToDo = { + id: Date.now(), + title: newToDoTitle, + completed: false, + }; + + setToDoData((prev) => [...prev, newToDo]); // 새로운 할 일을 할 일 목록에 추가 + }; + + return ( + + To-Do-List + + + +

+ {completedItems}/{totalItems} +

+
+ +
+ ); +}