diff --git a/.gitignore b/.gitignore index a547bf36..a414912e 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ dist-ssr *.local # Editor directories and files +.prettierrc .vscode/* !.vscode/extensions.json .idea diff --git a/README.md b/README.md index 3c0710d2..19c92b2c 100644 --- a/README.md +++ b/README.md @@ -1 +1,14 @@ -# react-todo-list-precourse \ No newline at end of file +# react-todo-list-precourse \_ 전남대 FE 박건규 + +## 기능 요구사항 + +할 일 목록을 업데이트 하는 할 일 목록을 구현한다. + +- 할 일을 추가하고 삭제할 수 있다. + - 추가할 떄 사용자는 enter 키나 추가 버튼을 사용하여 목록에 추가할 수 있어야 한다. + - 아무것도 입력하지 않은 경우 할 일을 추가할 수 없다. +- 할 일의 목록을 볼 수 있다. +- 할 일의 완료 상태를 전환할 수 있다. +- 현재 진행중인 할 일, 완료된 할 일, 모든 할 일을 필터링 할 수 있다. +- 해야할 일의 총 개수를 확인할 수 있다. +- 새로고침을 하여도 이전에 작성한 데이터는 유지되어야 한다. diff --git a/index.html b/index.html index b021b5c8..e479bdc1 100644 --- a/index.html +++ b/index.html @@ -1,12 +1,16 @@ - - + + - + + TODO
- + diff --git a/src/App.css b/src/App.css new file mode 100644 index 00000000..1e61c23e --- /dev/null +++ b/src/App.css @@ -0,0 +1,5 @@ +#App > h1 { + font-size: 3rem; + text-align: center; + margin-bottom: 1rem; +} diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 00000000..ce50f104 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,42 @@ +import InputForm from "./components/InputForm/InputForm"; +import { FilterStateType, Todo } from "./Model/Todo"; +import TodoLists from "./components/TodoList/TodoLists"; +import "./App.css"; +import TodoFilter from "./components/TodoFilter/TodoFilter"; +import useTodos from "./hooks/useTodos"; + +const App = () => { + const { + todos, + leaseTodos, + filterState, + filteredTodos, + setTodos, + setFilterState, + setIsCompletedFromId, + deleteTodoFromId, + } = useTodos(); + + return ( +
+

todos

+ setTodos((prev) => [...prev, newTodo])} + /> + + {todos.length !== 0 && ( + setFilterState(state)} + /> + )} +
+ ); +}; + +export default App; diff --git a/src/Model/Todo.ts b/src/Model/Todo.ts new file mode 100644 index 00000000..3a391609 --- /dev/null +++ b/src/Model/Todo.ts @@ -0,0 +1,7 @@ +export interface Todo { + id: number; + text: string; + isCompleted: boolean; +} + +export type FilterStateType = "all" | "active" | "completed"; diff --git a/src/components/InputForm/InputForm.css b/src/components/InputForm/InputForm.css new file mode 100644 index 00000000..25dfc71c --- /dev/null +++ b/src/components/InputForm/InputForm.css @@ -0,0 +1,9 @@ +#InputForm { + display: flex; +} +#InputForm > input { + flex: 1; + font-size: 1.5rem; + padding-block: 1rem; + padding-inline: 3rem; +} diff --git a/src/components/InputForm/InputForm.tsx b/src/components/InputForm/InputForm.tsx new file mode 100644 index 00000000..493eafb2 --- /dev/null +++ b/src/components/InputForm/InputForm.tsx @@ -0,0 +1,33 @@ +import { useState } from "react"; +import { Todo } from "../../Model/Todo"; +import "./InputForm.css"; + +interface InputFormProps { + setTodos: (newTodo: Todo) => void; +} + +const InputForm = ({ setTodos }: InputFormProps) => { + const [text, setText] = useState(""); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!text.trim()) return; + const id = new Date().getTime(); + const newTodo: Todo = { id, isCompleted: false, text }; + setTodos(newTodo); + setText(""); + }; + + return ( +
+ setText(e.target.value)} + /> +
+ ); +}; + +export default InputForm; diff --git a/src/components/TodoFilter/TodoFilter.css b/src/components/TodoFilter/TodoFilter.css new file mode 100644 index 00000000..0f0f743e --- /dev/null +++ b/src/components/TodoFilter/TodoFilter.css @@ -0,0 +1,11 @@ +#TodoFilter { + display: flex; + justify-content: space-between; + margin: 20px 0; +} + +#TodoFilter > ul { + display: flex; + gap: 1rem; + padding: 0; +} diff --git a/src/components/TodoFilter/TodoFilter.tsx b/src/components/TodoFilter/TodoFilter.tsx new file mode 100644 index 00000000..9f28bb33 --- /dev/null +++ b/src/components/TodoFilter/TodoFilter.tsx @@ -0,0 +1,45 @@ +import { useEffect, useState } from "react"; +import { FilterStateType } from "../../Model/Todo"; +import "./TodoFilter.css"; + +interface TodoFilterProps { + leaseTodos: number; + filterState: FilterStateType; + setFilterState: (state: FilterStateType) => void; +} + +const FilterState: FilterStateType[] = ["all", "active", "completed"]; + +const TodoFilter = ({ + leaseTodos, + filterState, + setFilterState, +}: TodoFilterProps) => { + const [filter, setFilter] = useState(filterState); + + useEffect(() => { + setFilter(filterState); + }, [filterState]); + + return ( +
+ {leaseTodos} items left! +
    + {FilterState.map((state, i) => ( +
  • + +
  • + ))} +
+
+ ); +}; + +export default TodoFilter; diff --git a/src/components/TodoList/TodoLists.tsx b/src/components/TodoList/TodoLists.tsx new file mode 100644 index 00000000..f89bbbc6 --- /dev/null +++ b/src/components/TodoList/TodoLists.tsx @@ -0,0 +1,31 @@ +import { Todo } from "../../Model/Todo"; +import TodoListItem from "../TodoListItem/TodoListItem"; + +interface TodoListProps { + todos: Todo[]; + setIsCompleted: (index: number) => void; + deleteTodoFromId: (index: number) => void; +} + +const TodoLists = ({ + todos, + setIsCompleted, + deleteTodoFromId, +}: TodoListProps) => { + return ( + + ); +}; + +export default TodoLists; diff --git a/src/components/TodoListItem/TodoListItem.css b/src/components/TodoListItem/TodoListItem.css new file mode 100644 index 00000000..4e11a67d --- /dev/null +++ b/src/components/TodoListItem/TodoListItem.css @@ -0,0 +1,40 @@ +#TodoListItem { + display: flex; + justify-content: space-between; + align-items: center; + padding-inline: 10px; + border-bottom: 1px solid #ccc; +} + +#TodoListItem:hover { + background-color: #f9f9f9; +} + +#TodoListItem > button { + outline: none; + border: none; + color: inherit; + background-color: transparent; +} + +#TodoListItem-content { + width: 100%; + height: 100%; + display: flex; + justify-content: start; + align-items: center; + padding: 1rem; + border: 1px solid black; + background-color: black; + font-size: 1.2rem; + gap: 1rem; +} + +#TodoListItem-delete:hover { + color: red; +} + +.completed { + text-decoration: line-through; + color: #ccc; +} diff --git a/src/components/TodoListItem/TodoListItem.tsx b/src/components/TodoListItem/TodoListItem.tsx new file mode 100644 index 00000000..c22a5405 --- /dev/null +++ b/src/components/TodoListItem/TodoListItem.tsx @@ -0,0 +1,48 @@ +import { useState } from "react"; +import "./TodoListItem.css"; + +interface TodoListItemProps { + id: number; + text: string; + isCompleted: boolean; + setIsCompleted: (id: number) => void; + deleteTodoFromId: (id: number) => void; +} + +const TodoListItem = ({ + id, + text, + isCompleted, + setIsCompleted, + deleteTodoFromId, +}: TodoListItemProps) => { + const [isMouseEnter, setIsMouseEnter] = useState(false); + + const handleIsCompleted = (id: number) => { + setIsCompleted(id); + }; + + return ( +
  • setIsMouseEnter(true)} + onMouseLeave={() => setIsMouseEnter(false)} + > + + {isMouseEnter && ( + + )} +
  • + ); +}; + +export default TodoListItem; diff --git a/src/hooks/useTodos.tsx b/src/hooks/useTodos.tsx new file mode 100644 index 00000000..81d8bf70 --- /dev/null +++ b/src/hooks/useTodos.tsx @@ -0,0 +1,49 @@ +import { useEffect, useState } from "react"; +import { FilterStateType, Todo } from "../Model/Todo"; +import { filterTodos } from "../utils/todo"; +import { + getTodosFromLocalStorage, + setTodosToLocalStorage, +} from "../utils/localstorage"; + +const useTodos = () => { + const [todos, setTodos] = useState([]); + const [filterState, setFilterState] = useState("all"); + const filteredTodos = filterTodos(todos, filterState); + const leaseTodos = filteredTodos.length; + + useEffect(() => { + const savedTodos = getTodosFromLocalStorage(); + if (savedTodos) setTodos(savedTodos); + }, []); + + useEffect(() => { + if (todos.length === 0) return; + setTodosToLocalStorage(todos); + }, [todos]); + + const setIsCompletedFromId = (id: number) => { + setTodos((prev) => + prev.map((todo) => { + if (todo.id === id) return { ...todo, isCompleted: !todo.isCompleted }; + return todo; + }) + ); + }; + + const deleteTodoFromId = (id: number) => { + setTodos((prev) => prev.filter((todo) => todo.id !== id)); + }; + return { + todos, + setTodos, + filterState, + setFilterState, + filteredTodos, + leaseTodos, + setIsCompletedFromId, + deleteTodoFromId, + }; +}; + +export default useTodos; diff --git a/src/index.css b/src/index.css new file mode 100644 index 00000000..9010ce9b --- /dev/null +++ b/src/index.css @@ -0,0 +1,10 @@ +body { + font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; + background-color: whitesmoke; +} +#app { + max-width: 35rem; + margin-inline: auto; + background-color: white; +} diff --git a/src/main.js b/src/main.js deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 00000000..028a17c6 --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,12 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App"; +import "./index.css"; + +const root = document.getElementById("app")!; + +createRoot(root).render( + + + +); diff --git a/src/utils/localstorage.ts b/src/utils/localstorage.ts new file mode 100644 index 00000000..b2246e18 --- /dev/null +++ b/src/utils/localstorage.ts @@ -0,0 +1,10 @@ +import { Todo } from "../Model/Todo"; + +export const setTodosToLocalStorage = (todos: Todo[]) => { + localStorage.setItem("todos", JSON.stringify(todos)); +}; + +export const getTodosFromLocalStorage = (): Todo[] => { + const todos = localStorage.getItem("todos"); + return todos ? JSON.parse(todos) : []; +}; diff --git a/src/utils/todo.ts b/src/utils/todo.ts new file mode 100644 index 00000000..f052118c --- /dev/null +++ b/src/utils/todo.ts @@ -0,0 +1,10 @@ +import { FilterStateType, Todo } from "../Model/Todo"; + +export const filterTodos = (todos: Todo[], filterState: FilterStateType) => { + return todos.filter((todo) => { + if (filterState === "all") return true; + if (filterState === "active") return !todo.isCompleted; + if (filterState === "completed") return todo.isCompleted; + return false; + }); +};