diff --git a/README.md b/README.md index 903c876f9..eb0985ed3 100644 --- a/README.md +++ b/README.md @@ -33,4 +33,4 @@ Implement a simple [TODO app](https://mate-academy.github.io/react_todo-app/) th - Implement a solution following the [React task guidelines](https://github.com/mate-academy/react_task-guideline#react-tasks-guideline). - Use the [React TypeScript cheat sheet](https://mate-academy.github.io/fe-program/js/extra/react-typescript). - Open another terminal and run tests with `npm test` to ensure your solution is correct. -- Replace `` with your GitHub username in the [DEMO LINK](https://.github.io/react_todo-app/) and add it to the PR description. +- Replace `` with your GitHub username in the [DEMO LINK](https://nineuito.github.io/react_todo-app/) and add it to the PR description. diff --git a/package.json b/package.json index 91d7489b9..82fc3da71 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ }, "devDependencies": { "@cypress/react18": "^2.0.1", - "@mate-academy/scripts": "^1.9.12", + "@mate-academy/scripts": "^2.1.0", "@mate-academy/students-ts-config": "*", "@mate-academy/stylelint-config": "*", "@types/node": "^20.14.10", diff --git a/src/App.scss b/src/App.scss new file mode 100644 index 000000000..14df43991 --- /dev/null +++ b/src/App.scss @@ -0,0 +1,24 @@ +.todoapp { + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + font-size: 24px; + font-weight: 300; + color: #4d4d4d; + margin: 40px 20px; + + &__content { + margin-bottom: 20px; + background: #fff; + box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2), + 0 25px 50px 0 rgba(0, 0, 0, 0.1); + } + + &__title { + font-size: 100px; + font-weight: 100; + text-align: center; + color: rgba(175, 47, 47, 0.15); + -webkit-text-rendering: optimizeLegibility; + -moz-text-rendering: optimizeLegibility; + text-rendering: optimizeLegibility; + } +} diff --git a/src/App.tsx b/src/App.tsx index a399287bd..d0ab90385 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,157 +1,14 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useState } from 'react'; -export const App: React.FC = () => { - return ( -
-

todos

- -
-
- {/* this button should have `active` class only if all todos are completed */} -
- -
- {/* This is a completed todo */} -
- - - - Completed Todo - - - {/* Remove button appears only on hover */} - -
- - {/* This todo is an active todo */} -
- - - - Not Completed Todo - +import './App.scss'; - -
+import { FilterType } from './types/FilterType'; - {/* This todo is being edited */} -
- +import { AppContent } from './components/AppContent'; - {/* This form is shown instead of the title and remove button */} -
- -
-
- - {/* This todo is in loadind state */} -
- - - - Todo is being saved now - - - -
-
- - {/* Hide the footer if there are no todos */} -
- - 3 items left - - - {/* Active link should have the 'selected' class */} - +export const App: React.FC = () => { + const [filterType, setFilterType] = useState(FilterType.ALL); - {/* this button should be disabled if there are no completed todos */} - -
-
-
- ); + return ; }; diff --git a/src/components/AppContent/AppContent.tsx b/src/components/AppContent/AppContent.tsx new file mode 100644 index 000000000..ced8ed42b --- /dev/null +++ b/src/components/AppContent/AppContent.tsx @@ -0,0 +1,47 @@ +import { useContext, useEffect, useRef } from 'react'; + +import { Header } from '../Header'; +import { Footer } from '../Footer'; +import { TodoList } from '../TodoList'; +import { ErrorNotification } from '../ErrorNotification'; + +import { TodoContext } from '../../context/TodoContext'; +import { AppContentProps } from '../../types/AppContentProps'; + +export const AppContent: React.FC = ({ + filterType, + setFilterType, +}) => { + const context = useContext(TodoContext); + if (!context) throw new Error('TodoContext must be used within TodoProvider'); + const { todos, errorMessage, setErrorMessage } = context; + + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + return ( +
+

todos

+
+
+ {todos.length > 0 && ( + + )} + {todos.length > 0 && ( +
+ )} + +
+
+ ); +}; diff --git a/src/components/AppContent/index.ts b/src/components/AppContent/index.ts new file mode 100644 index 000000000..84a200da8 --- /dev/null +++ b/src/components/AppContent/index.ts @@ -0,0 +1 @@ +export * from './AppContent'; diff --git a/src/components/ErrorNotification/ErrorNotification.scss b/src/components/ErrorNotification/ErrorNotification.scss new file mode 100644 index 000000000..dd1ba297b --- /dev/null +++ b/src/components/ErrorNotification/ErrorNotification.scss @@ -0,0 +1,32 @@ +.notification { + background-color: #f8d7da; + border: 1px solid #f5c6cb; + color: #721c24; + padding: 12px 16px; + border-radius: 4px; + font-size: 16px; + line-height: 1.5; + margin: 8px 0; + transition: opacity 1s, min-height 1s; + min-height: 36px; + position: relative; +} + +.notification .delete { + position: absolute; + right: 8px; + top: 8px; + background: none; + border: none; + font-size: 20px; + color: #721c24; + cursor: pointer; +} + +.notification.hidden { + min-height: 0; + opacity: 0; + pointer-events: none; + margin: 0; + padding: 0 16px; +} diff --git a/src/components/ErrorNotification/ErrorNotification.tsx b/src/components/ErrorNotification/ErrorNotification.tsx new file mode 100644 index 000000000..7919b9957 --- /dev/null +++ b/src/components/ErrorNotification/ErrorNotification.tsx @@ -0,0 +1,50 @@ +import React, { useEffect } from 'react'; + +import './errorNotification.scss'; + +import cn from 'classnames'; + +type Props = { + errorMessage: string; + setErrorMessage: (errorMessage: string) => void; +}; + +export const ErrorNotification: React.FC = ({ + errorMessage, + setErrorMessage, +}) => { + useEffect(() => { + let timeoutId: ReturnType; + + if (errorMessage) { + timeoutId = setTimeout(() => { + setErrorMessage(''); + }, 3000); + } + + return () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + }; + }, [errorMessage, setErrorMessage]); + + return ( +
+ + {errorMessage} +
+ ); +}; diff --git a/src/components/ErrorNotification/index.ts b/src/components/ErrorNotification/index.ts new file mode 100644 index 000000000..8cb478792 --- /dev/null +++ b/src/components/ErrorNotification/index.ts @@ -0,0 +1 @@ +export * from './ErrorNotification'; diff --git a/src/styles/filters.scss b/src/components/Filter/Filter.scss similarity index 100% rename from src/styles/filters.scss rename to src/components/Filter/Filter.scss diff --git a/src/components/Filter/Filter.tsx b/src/components/Filter/Filter.tsx new file mode 100644 index 000000000..be50b52af --- /dev/null +++ b/src/components/Filter/Filter.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import cn from 'classnames'; + +import './Filter.scss'; + +import { FilterType } from '../../types/FilterType'; + +type Props = { + filterType: FilterType; + onFilterType: (filterType: FilterType) => void; +}; + +export const Filter: React.FC = ({ filterType, onFilterType }) => { + const handleFilterChange = + (type: FilterType) => (event: React.MouseEvent) => { + event.preventDefault(); + onFilterType(type); + }; + + return ( + + ); +}; diff --git a/src/components/Filter/index.ts b/src/components/Filter/index.ts new file mode 100644 index 000000000..0eea77907 --- /dev/null +++ b/src/components/Filter/index.ts @@ -0,0 +1 @@ +export * from './Filter'; diff --git a/src/components/Footer/Footer.scss b/src/components/Footer/Footer.scss new file mode 100644 index 000000000..f5b741db3 --- /dev/null +++ b/src/components/Footer/Footer.scss @@ -0,0 +1,56 @@ +.todoapp { + &__footer { + display: flex; + justify-content: space-between; + align-items: center; + + box-sizing: content-box; + height: 20px; + padding: 10px 15px; + + font-size: 14px; + + color: #777; + text-align: center; + border-top: 1px solid #e6e6e6; + + box-shadow: + 0 1px 1px rgba(0, 0, 0, 0.2), + 0 8px 0 -3px #f6f6f6, + 0 9px 1px -3px rgba(0, 0, 0, 0.2), + 0 16px 0 -6px #f6f6f6, + 0 17px 2px -6px rgba(0, 0, 0, 0.2); + } + + &__clear-completed { + margin: 0; + padding: 0; + border: 0; + + font-family: inherit; + font-weight: inherit; + color: inherit; + text-decoration: none; + + cursor: pointer; + background: none; + + -webkit-appearance: none; + appearance: none; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + transition: opacity 0.3s; + + &:hover { + text-decoration: underline; + } + + &:active { + text-decoration: none; + } + + &:disabled { + visibility: hidden; + } + } +} diff --git a/src/components/Footer/Footer.tsx b/src/components/Footer/Footer.tsx new file mode 100644 index 000000000..5f968cbd8 --- /dev/null +++ b/src/components/Footer/Footer.tsx @@ -0,0 +1,54 @@ +import React, { RefObject, useMemo } from 'react'; +import './Footer.scss'; + +import { Filter } from '../Filter'; +import { FilterType } from '../../types/FilterType'; +import { TodoContext } from '../../context/TodoContext'; +import { useContext } from 'react'; + +type Props = { + filterType: FilterType; + onFilterType: (filterType: FilterType) => void; + inputRef: RefObject; +}; + +export const Footer: React.FC = ({ + filterType, + onFilterType, + inputRef, +}) => { + const context = useContext(TodoContext); + if (!context) throw new Error('TodoContext must be used within TodoProvider'); + + const { todos, clearCompleted } = context; + + const activeTodosCount = useMemo( + () => todos.filter(todo => !todo.completed).length, + [todos], + ); + + const handleClickClearBtn = () => { + clearCompleted(); + inputRef.current?.focus(); + }; + + return ( +
+ + {activeTodosCount} items left + + + + + +
+ ); +}; diff --git a/src/components/Footer/index.ts b/src/components/Footer/index.ts new file mode 100644 index 000000000..ddcc5a9cd --- /dev/null +++ b/src/components/Footer/index.ts @@ -0,0 +1 @@ +export * from './Footer'; diff --git a/src/components/Header/Header.scss b/src/components/Header/Header.scss new file mode 100644 index 000000000..43b2f3b51 --- /dev/null +++ b/src/components/Header/Header.scss @@ -0,0 +1,64 @@ +.todoapp { + &__header { + position: relative; + } + + &__toggle-all { + position: absolute; + + height: 100%; + width: 45px; + + display: flex; + justify-content: center; + align-items: center; + + font-size: 24px; + color: #e6e6e6; + + border: 0; + background-color: transparent; + cursor: pointer; + + &.active { + color: #737373; + } + + &::before { + content: '❯'; + transform: translateY(2px) rotate(90deg); + line-height: 0; + } + } + + &__new-todo { + width: 100%; + padding: 16px 16px 16px 60px; + + font-size: 24px; + line-height: 1.4em; + font-family: inherit; + font-weight: inherit; + color: inherit; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + border: none; + background: rgba(0, 0, 0, 0.01); + box-shadow: inset 0 -2px 1px rgba(0, 0, 0, 0.03); + box-sizing: border-box; + outline: none; + + &::placeholder { + font-style: italic; + font-weight: 300; + color: #e6e6e6; + } + + &:focus { + box-shadow: + inset 0 -2px 1px rgba(0, 0, 0, 0.03), + 0 0 0 2px rgba(175, 47, 47, 0.2); + } + } +} diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx new file mode 100644 index 000000000..51125d56b --- /dev/null +++ b/src/components/Header/Header.tsx @@ -0,0 +1,54 @@ +import React, { RefObject, useContext, useState } from 'react'; +import './Header.scss' +import cn from 'classnames'; + +import { TodoContext } from '../../context/TodoContext'; + +type Props = { + inputRef: RefObject; +}; + +export const Header: React.FC = ({ inputRef }) => { + const context = useContext(TodoContext); + if (!context) throw new Error('TodoContext must be used within TodoProvider'); + + const { todos, addTodo, toggleAll, setErrorMessage } = context; + const [inputValue, setInputValue] = useState(''); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + if (inputValue.length === 0) { + setErrorMessage('Need more than 0 char to create todo'); + } + + addTodo(inputValue); + setInputValue(''); + }; + + const allCompleted = todos.every(todo => todo.completed); + + return ( +
+ {todos.length > 0 && ( +
+ ); +}; diff --git a/src/components/Header/index.ts b/src/components/Header/index.ts new file mode 100644 index 000000000..266dec8a1 --- /dev/null +++ b/src/components/Header/index.ts @@ -0,0 +1 @@ +export * from './Header'; diff --git a/src/styles/todo-list.scss b/src/components/TodoItem/TodoItem.scss similarity index 67% rename from src/styles/todo-list.scss rename to src/components/TodoItem/TodoItem.scss index 4576af434..9e7eb04ef 100644 --- a/src/styles/todo-list.scss +++ b/src/components/TodoItem/TodoItem.scss @@ -15,13 +15,13 @@ &__status-label { cursor: pointer; - background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E'); + background-image: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23ededed%22%20stroke-width%3D%223%22/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: center left; } &.completed &__status-label { - background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E'); + background-image: url("data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23bddad5%22%20stroke-width%3D%223%22/%3E%3Cpath%20fill%3D%22%235dc2af%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22/%3E%3C/svg%3E"); } &__status { @@ -73,13 +73,12 @@ &__title-field { width: 100%; padding: 11px 14px; - + box-sizing: border-box; font-size: inherit; line-height: inherit; font-family: inherit; font-weight: inherit; color: inherit; - border: 1px solid #999; box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2); @@ -92,8 +91,58 @@ .overlay { position: absolute; - inset: 0; + top: 0; + left: 0; + right: 0; + height: 58px; opacity: 0.5; } } + +.item-enter { + max-height: 0; +} + +.item-enter-active { + overflow: hidden; + max-height: 58px; + transition: max-height 0.3s ease-in-out; +} + +.item-exit { + max-height: 58px; +} + +.item-exit-active { + overflow: hidden; + max-height: 0; + transition: max-height 0.3s ease-in-out; +} + +.temp-item-enter { + max-height: 0; +} + +.temp-item-enter-active { + overflow: hidden; + max-height: 58px; + transition: max-height 0.3s ease-in-out; +} + +.temp-item-exit { + max-height: 58px; +} + +.temp-item-exit-active { + transform: translateY(-58px); + max-height: 0; + opacity: 0; + transition: 0.3s ease-in-out; + transition-property: opacity, max-height, transform; +} + +.has-error .temp-item-exit-active { + transform: translateY(0); + overflow: hidden; +} diff --git a/src/components/TodoItem/TodoItem.tsx b/src/components/TodoItem/TodoItem.tsx new file mode 100644 index 000000000..bd1cd9792 --- /dev/null +++ b/src/components/TodoItem/TodoItem.tsx @@ -0,0 +1,175 @@ +import React, { RefObject, useContext, useEffect, useRef, useState } from 'react'; +import cn from 'classnames'; + +import './TodoItem.scss'; + +import { Todo } from '../../types/Todo'; +import { TodoContext } from '../../context/TodoContext'; + +type Props = { + todo: Todo; + inputRef: RefObject; +}; + +export const TodoItem: React.FC = ({ todo, inputRef }) => { + const context = useContext(TodoContext); + if (!context) throw new Error('TodoContext must be used within TodoProvider'); + + const { + deleteTodo, + updateTodo, + setSelectedTodoId, + loadingTodoIds, + setLoadingTodoIds, + setErrorMessage, + } = context; + + const [isEditing, setIsEditing] = useState(false); + const [editedTitle, setEditedTitle] = useState(todo.title); + const todoInputEditRef = useRef(null); + + const isLoading = loadingTodoIds.includes(todo.id); + + const handleToggle = () => { + setLoadingTodoIds(prev => [...prev, todo.id]); + + try { + updateTodo(todo.id, { completed: !todo.completed }); + setSelectedTodoId(todo.id); + } catch (error) { + setErrorMessage('Unable to toggle todo status'); + } finally { + setLoadingTodoIds(prev => prev.filter(id => id !== todo.id)); + } + }; + + const handleKeyUp = (event: React.KeyboardEvent) => { + if (event.key === 'Escape') { + setIsEditing(false); + setEditedTitle(todo.title); + } + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + + const newTitle = editedTitle.trim(); + + if (newTitle === todo.title) { + setIsEditing(false); + return; + } + + if (!newTitle) { + setLoadingTodoIds(prev => [...prev, todo.id]); + + try { + deleteTodo(todo.id); + } catch (error) { + setErrorMessage('Unable to delete todo'); + } finally { + setLoadingTodoIds(prev => prev.filter(id => id !== todo.id)); + } + return; + } + setLoadingTodoIds(prev => [...prev, todo.id]); + + try { + updateTodo(todo.id, { title: newTitle }); + setIsEditing(false); + } catch (error) { + setErrorMessage('Unable to update todo'); + } finally { + setLoadingTodoIds(prev => prev.filter(id => id !== todo.id)); + } + }; + + const handleDoubleClickEditTodo = () => { + setIsEditing(true); + setSelectedTodoId(todo.id); + }; + + const handleDelete = () => { + setLoadingTodoIds(prev => [...prev, todo.id]); + + try { + deleteTodo(todo.id); + inputRef.current?.focus(); + } catch (error) { + setErrorMessage('Unable to delete todo'); + } finally { + setLoadingTodoIds(prev => prev.filter(id => id !== todo.id)); + } + }; + + useEffect(() => { + if (isEditing && todoInputEditRef.current) { + todoInputEditRef.current.focus(); + } + }, [isEditing]); + + return ( +
+ + + {isEditing ? ( +
+ setEditedTitle(e.target.value)} + onBlur={handleSubmit} + onKeyUp={handleKeyUp} + ref={todoInputEditRef} + disabled={isLoading} + /> +
+ ) : ( + <> + + {todo.title} + + + + )} + + {isLoading && ( +
+
+
+
+ )} +
+ ); +}; diff --git a/src/components/TodoItem/index.ts b/src/components/TodoItem/index.ts new file mode 100644 index 000000000..21f4abac3 --- /dev/null +++ b/src/components/TodoItem/index.ts @@ -0,0 +1 @@ +export * from './TodoItem'; diff --git a/src/components/TodoList/TodoList.scss b/src/components/TodoList/TodoList.scss new file mode 100644 index 000000000..74187ca43 --- /dev/null +++ b/src/components/TodoList/TodoList.scss @@ -0,0 +1,5 @@ +.todoapp { + &__main { + border-top: 1px solid #e6e6e6; + } +} diff --git a/src/components/TodoList/TodoList.tsx b/src/components/TodoList/TodoList.tsx new file mode 100644 index 000000000..f56b786fb --- /dev/null +++ b/src/components/TodoList/TodoList.tsx @@ -0,0 +1,38 @@ +import React, { RefObject, useContext } from 'react'; +import './TodoList.scss'; +import { TodoContext } from '../../context/TodoContext'; +import { FilterType } from '../../types/FilterType'; +import { TodoItem } from '../TodoItem'; + +type Props = { + filterType: FilterType; + inputRef: RefObject; +}; + +export const TodoList: React.FC = ({ filterType, inputRef }) => { + const context = useContext(TodoContext); + if (!context) throw new Error('TodoContext must be used within TodoProvider'); + const { todos } = context; + + const getFilteredTodos = () => { + switch (filterType) { + case 'active': + return todos.filter(todo => !todo.completed); + case 'completed': + return todos.filter(todo => todo.completed); + case 'all': + default: + return todos; + } + }; + + const filteredTodos = getFilteredTodos(); + + return ( +
+ {filteredTodos.map(todo => ( + + ))} +
+ ); +}; diff --git a/src/components/TodoList/index.ts b/src/components/TodoList/index.ts new file mode 100644 index 000000000..f239f4345 --- /dev/null +++ b/src/components/TodoList/index.ts @@ -0,0 +1 @@ +export * from './TodoList'; diff --git a/src/context/TodoContext.tsx b/src/context/TodoContext.tsx new file mode 100644 index 000000000..98152275c --- /dev/null +++ b/src/context/TodoContext.tsx @@ -0,0 +1,74 @@ +import React, { useState } from 'react'; + +import { Todo } from '../types/Todo'; +import { TodoContextType } from '../types/TodoContextType'; + +import { useLocalStore } from '../hooks/useLocalStore'; + +export const TodoContext = React.createContext( + undefined, +); + +type Props = { + children: React.ReactNode; +}; + +export const TodoProvider: React.FC = ({ children }) => { + const [todos, setTodos] = useLocalStore('todos', []); + const [loadingTodoIds, setLoadingTodoIds] = useState([]); + const [selectedTodoId, setSelectedTodoId] = useState(null); + const [errorMessage, setErrorMessage] = useState(''); + + const addTodo = (title: string) => { + const trimmedTitle = title.trim(); + + if (!trimmedTitle) return; + + const newTodo: Todo = { + id: +new Date(), + userId: 1, + title: trimmedTitle, + completed: false, + }; + + setTodos((current: Todo[]) => [...current, newTodo]); + }; + + const deleteTodo = (id: number) => { + setTodos((current: Todo[]) => current.filter(todo => todo.id !== id)); + }; + + const updateTodo = (id: number, data: Partial) => { + setTodos((current: Todo[]) => + current.map(todo => (todo.id === id ? { ...todo, ...data } : todo)), + ); + }; + + const toggleAll = () => { + const allCompleted = todos.every(todo => todo.completed); + setTodos((current: Todo[]) => + current.map(todo => ({ ...todo, completed: !allCompleted })), + ); + }; + + const clearCompleted = () => { + setTodos((current: Todo[]) => current.filter(todo => !todo.completed)); + }; + + const value: TodoContextType = { + todos, + addTodo, + deleteTodo, + updateTodo, + toggleAll, + clearCompleted, + loadingTodoIds, + setLoadingTodoIds, + selectedTodoId, + setSelectedTodoId, + errorMessage, + setErrorMessage, + }; + + return {children}; +}; diff --git a/src/hooks/useLocalStore.ts b/src/hooks/useLocalStore.ts new file mode 100644 index 000000000..212132ce0 --- /dev/null +++ b/src/hooks/useLocalStore.ts @@ -0,0 +1,41 @@ +import { useState, useEffect } from 'react'; + +export function useLocalStore( + key: string, + startValue: T, +): [T, (v: T | ((prev: T) => T)) => void] { + const [value, setValue] = useState(() => { + const data = localStorage.getItem(key); + + if (data === null) { + return startValue; + } + + try { + return JSON.parse(data); + } catch { + return startValue; + } + }); + + useEffect(() => { + if (localStorage.getItem(key) === null) { + localStorage.setItem(key, JSON.stringify(value)); + } + }, []); + + const save = (newValue: T | ((prev: T) => T)) => { + setValue(prev => { + const updatedValue = + typeof newValue === 'function' + ? (newValue as (prev: T) => T)(prev) + : newValue; + + localStorage.setItem(key, JSON.stringify(updatedValue)); + + return updatedValue; + }); + }; + + return [value, save]; +} diff --git a/src/index.tsx b/src/index.tsx index b2c38a17a..a4207b1fc 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,7 +3,12 @@ import { createRoot } from 'react-dom/client'; import './styles/index.scss'; import { App } from './App'; +import { TodoProvider } from './context/TodoContext'; const container = document.getElementById('root') as HTMLDivElement; -createRoot(container).render(); +createRoot(container).render( + + + , +); diff --git a/src/styles/index.scss b/src/styles/index.scss index d8d324941..7a12e8519 100644 --- a/src/styles/index.scss +++ b/src/styles/index.scss @@ -7,19 +7,3 @@ body { max-width: 550px; margin: 0 auto; } - -.notification { - transition-property: opacity, min-height; - transition-duration: 1s; - min-height: 36px; -} - -.notification.hidden { - min-height: 0; - opacity: 0; - pointer-events: none; -} - -@import './todoapp'; -@import './todo-list'; -@import './filters'; diff --git a/src/styles/todoapp.scss b/src/styles/todoapp.scss deleted file mode 100644 index e289a9458..000000000 --- a/src/styles/todoapp.scss +++ /dev/null @@ -1,134 +0,0 @@ -.todoapp { - font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; - font-size: 24px; - font-weight: 300; - color: #4d4d4d; - margin: 40px 20px; - - &__content { - margin-bottom: 20px; - background: #fff; - box-shadow: - 0 2px 4px 0 rgba(0, 0, 0, 0.2), - 0 25px 50px 0 rgba(0, 0, 0, 0.1); - } - - &__title { - font-size: 100px; - font-weight: 100; - text-align: center; - color: rgba(175, 47, 47, 0.15); - -webkit-text-rendering: optimizeLegibility; - -moz-text-rendering: optimizeLegibility; - text-rendering: optimizeLegibility; - } - - &__header { - position: relative; - } - - &__toggle-all { - position: absolute; - - height: 100%; - width: 45px; - - display: flex; - justify-content: center; - align-items: center; - - font-size: 24px; - color: #e6e6e6; - - border: 0; - background-color: transparent; - cursor: pointer; - - &.active { - color: #737373; - } - - &::before { - content: '❯'; - transform: translateY(2px) rotate(90deg); - line-height: 0; - } - } - - &__new-todo { - width: 100%; - padding: 16px 16px 16px 60px; - - font-size: 24px; - line-height: 1.4em; - font-family: inherit; - font-weight: inherit; - color: inherit; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - - border: none; - background: rgba(0, 0, 0, 0.01); - box-shadow: inset 0 -2px 1px rgba(0, 0, 0, 0.03); - - &::placeholder { - font-style: italic; - font-weight: 300; - color: #e6e6e6; - } - } - - &__main { - border-top: 1px solid #e6e6e6; - } - - &__footer { - display: flex; - justify-content: space-between; - align-items: center; - - box-sizing: content-box; - height: 20px; - padding: 10px 15px; - - font-size: 14px; - - color: #777; - text-align: center; - border-top: 1px solid #e6e6e6; - - box-shadow: - 0 1px 1px rgba(0, 0, 0, 0.2), - 0 8px 0 -3px #f6f6f6, - 0 9px 1px -3px rgba(0, 0, 0, 0.2), - 0 16px 0 -6px #f6f6f6, - 0 17px 2px -6px rgba(0, 0, 0, 0.2); - } - - &__clear-completed { - margin: 0; - padding: 0; - border: 0; - - font-family: inherit; - font-weight: inherit; - color: inherit; - text-decoration: none; - - cursor: pointer; - background: none; - - -webkit-appearance: none; - appearance: none; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - - &:hover { - text-decoration: underline; - } - - &:active { - text-decoration: none; - } - } -} diff --git a/src/types/AppContentProps.ts b/src/types/AppContentProps.ts new file mode 100644 index 000000000..85d43d53d --- /dev/null +++ b/src/types/AppContentProps.ts @@ -0,0 +1,6 @@ +import { FilterType } from "./FilterType"; + +export type AppContentProps = { + filterType: FilterType; + setFilterType: (type: FilterType) => void; +}; diff --git a/src/types/FilterType.ts b/src/types/FilterType.ts new file mode 100644 index 000000000..d121a5303 --- /dev/null +++ b/src/types/FilterType.ts @@ -0,0 +1,5 @@ +export enum FilterType { + ALL = 'all', + ACTIVE = 'active', + COMPLETED = 'completed', +} diff --git a/src/types/Todo.ts b/src/types/Todo.ts new file mode 100644 index 000000000..3f52a5fdd --- /dev/null +++ b/src/types/Todo.ts @@ -0,0 +1,6 @@ +export interface Todo { + id: number; + userId: number; + title: string; + completed: boolean; +} diff --git a/src/types/TodoContextType.ts b/src/types/TodoContextType.ts new file mode 100644 index 000000000..479f7885c --- /dev/null +++ b/src/types/TodoContextType.ts @@ -0,0 +1,16 @@ +import { Todo } from "./Todo"; + +export interface TodoContextType { + todos: Todo[]; + addTodo: (title: string) => void; + deleteTodo: (id: number) => void; + updateTodo: (id: number, data: Partial) => void; + toggleAll: () => void; + clearCompleted: () => void; + loadingTodoIds: number[]; + setLoadingTodoIds: React.Dispatch>; + selectedTodoId: number | null; + setSelectedTodoId: (id: number | null) => void; + errorMessage: string; + setErrorMessage: (message: string) => void; +}