From 012c84af9390b88ffc8d0ef05086669817075059 Mon Sep 17 00:00:00 2001 From: artembaranovsky1 Date: Sun, 8 Mar 2026 17:35:25 +0200 Subject: [PATCH] add solution --- src/App.tsx | 205 ++++++++++---------------------- src/components/Footer.tsx | 88 ++++++++++++++ src/components/Header.tsx | 67 +++++++++++ src/components/TodoList.tsx | 112 +++++++++++++++++ src/components/TodoProvider.tsx | 98 +++++++++++++++ src/index.tsx | 7 +- src/localStorage/todoStorage.ts | 11 ++ src/types/Todo.ts | 5 + 8 files changed, 453 insertions(+), 140 deletions(-) create mode 100644 src/components/Footer.tsx create mode 100644 src/components/Header.tsx create mode 100644 src/components/TodoList.tsx create mode 100644 src/components/TodoProvider.tsx create mode 100644 src/localStorage/todoStorage.ts create mode 100644 src/types/Todo.ts diff --git a/src/App.tsx b/src/App.tsx index a399287bd..38fdc3b5b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,156 +1,83 @@ /* eslint-disable jsx-a11y/control-has-associated-label */ -import React from 'react'; +import React, { useContext, useEffect } from 'react'; +import { Todo } from './types/Todo'; +import { Footer } from './components/Footer'; +import { Header } from './components/Header'; +import { TodoList } from './components/TodoList'; +import { StateContext, DispatchContext } from './components/TodoProvider'; 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 - + const [query, setQuery] = React.useState(''); + const [sortBy, setSortBy] = React.useState('all'); - {/* Remove button appears only on hover */} - -
+ // Використовуємо окремі контексти для стану та діспатчу + const { todos } = useContext(StateContext); + const dispatch = useContext(DispatchContext); - {/* This todo is an active todo */} -
- + const inputRef = React.useRef(null); - - Not Completed Todo - + useEffect(() => { + localStorage.setItem('todos', JSON.stringify(todos)); + }, [todos]); - -
+ useEffect(() => { + inputRef.current?.focus(); + }, []); - {/* This todo is being edited */} -
- + const filteredTodos = React.useMemo(() => { + switch (sortBy) { + case 'active': + return todos.filter(t => !t.completed); + case 'completed': + return todos.filter(t => t.completed); + default: + return todos; + } + }, [todos, sortBy]); - {/* This form is shown instead of the title and remove button */} -
- -
-
+ const changeInputValue = (e: React.ChangeEvent) => { + setQuery(e.target.value); + }; - {/* This todo is in loadind state */} -
- + const deleteTodo = (todo: Todo) => { + dispatch({ type: 'DELETE', payload: todo.id }); + inputRef.current?.focus(); + }; - - Todo is being saved now - + const changeCompletedStatus = (todo: Todo) => { + dispatch({ type: 'CHANGE', payload: todo.id }); + }; - -
-
+ const completedTodos = todos.filter(t => t.completed); - {/* Hide the footer if there are no todos */} -
- - 3 items left - - - {/* Active link should have the 'selected' class */} - + return ( +
+

todos

- {/* this button should be disabled if there are no completed todos */} - -
+
+
+ + + +
); diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx new file mode 100644 index 000000000..22a226553 --- /dev/null +++ b/src/components/Footer.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { Todo } from '../types/Todo'; + +type Props = { + todos: Todo[]; + sortBy: string; + setSortBy: (value: string) => void; +}; + +export const Footer: React.FC = ({ + todos, + sortBy, + setSortBy, + deleteTodo, +}) => { + const completedTodos: Todo[] = todos.filter(todo => todo.completed); + + return ( + <> + {todos.length > 0 && ( + + )} + + ); +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 000000000..dfbd91ca3 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,67 @@ +import React from 'react'; +import { Todo } from '../types/Todo'; +import { Action } from './TodoProvider'; + +type Props = { + todos: Todo[]; + dispatch: React.Dispatch; + completedTodo: Todo[]; + query: string; + setQuery: React.Dispatch>; + inputRef: React.RefObject; + changeInputValue: (event: React.ChangeEvent) => void; +}; + +export const Header: React.FC = ({ + todos, + dispatch, + completedTodo, + query, + setQuery, + inputRef, + changeInputValue, +}) => { + const addTodo = (e: React.FormEvent) => { + e.preventDefault(); + + const trimmedQuery = query.trim(); + + if (trimmedQuery) { + dispatch({ + type: 'ADD', + payload: trimmedQuery, + }); + setQuery(''); + } + }; + + const isAllCompleted = + todos.length > 0 && completedTodo.length === todos.length; + + return ( +
+ {todos.length > 0 && ( +
+ ); +}; + +export default Header; diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx new file mode 100644 index 000000000..84e598ba8 --- /dev/null +++ b/src/components/TodoList.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { Todo } from '../types/Todo'; +import { Action } from './TodoProvider'; + +type Props = { + filteredTodos: Todo[]; + changeCompletedStatus: (todo: Todo) => void; + deleteTodo: (todo: Todo) => void; + dispatch: React.Dispatch; +}; + +export const TodoList: React.FC = ({ + filteredTodos, + changeCompletedStatus, + deleteTodo, + dispatch, +}) => { + const [editingTodo, setEditingTodo] = React.useState(null); + const [editingTitle, setEditingTitle] = React.useState(''); + + const handleSubmit = (e: React.FormEvent | React.FocusEvent, todo: Todo) => { + e.preventDefault(); + + const trimmedTitle = editingTitle.trim(); + + if (trimmedTitle === todo.title) { + setEditingTodo(null); + + return; + } + + if (trimmedTitle.length !== 0) { + dispatch({ + type: 'EDIT', + payload: { id: todo.id, title: trimmedTitle }, + }); + } else { + deleteTodo(todo); + } + + setEditingTodo(null); + }; + + const handleKeyUp = (e: React.KeyboardEvent, todo: Todo) => { + if (e.key === 'Escape') { + setEditingTodo(null); + setEditingTitle(todo.title); + } + }; + + return ( +
+ {filteredTodos.map(todo => ( +
+ + + {editingTodo !== todo.id ? ( + { + setEditingTodo(todo.id); + setEditingTitle(todo.title); + }} + > + {todo.title} + + ) : ( +
handleSubmit(e, todo)}> + handleKeyUp(e, todo)} + onChange={e => setEditingTitle(e.target.value)} + onBlur={e => handleSubmit(e, todo)} + /> +
+ )} + + {editingTodo !== todo.id && ( + + )} +
+ ))} +
+ ); +}; diff --git a/src/components/TodoProvider.tsx b/src/components/TodoProvider.tsx new file mode 100644 index 000000000..0d6adfc86 --- /dev/null +++ b/src/components/TodoProvider.tsx @@ -0,0 +1,98 @@ +import { Todo } from '../types/Todo'; +import { createContext } from 'react'; +import React, { Dispatch, useReducer } from 'react'; + +interface State { + todos: Todo[]; +} + +export type Action = + | { type: 'ADD'; payload: string } + | { type: 'DELETE'; payload: number } + | { type: 'CHANGE'; payload: number } + | { type: 'EDIT'; payload: { id: number; title: string } } + | { type: 'CLEAR_COMPLETED' } + | { type: 'TOGGLE_ALL' }; + +export function reducer(state: State, action: Action): State { + switch (action.type) { + case 'ADD': + return { + ...state, + todos: [ + ...state.todos, + { id: +new Date(), title: action.payload, completed: false }, + ], + }; + + case 'DELETE': + return { + ...state, + todos: state.todos.filter(t => t.id !== action.payload), + }; + + case 'CHANGE': + return { + ...state, + todos: state.todos.map(t => + t.id === action.payload ? { ...t, completed: !t.completed } : t, + ), + }; + + case 'EDIT': + return { + ...state, + todos: state.todos.map(t => + t.id === action.payload.id + ? { ...t, title: action.payload.title } + : t, + ), + }; + + case 'CLEAR_COMPLETED': + return { + ...state, + todos: state.todos.filter(t => !t.completed), + }; + + case 'TOGGLE_ALL': { + const hasActiveTodos = state.todos.some(todo => !todo.completed); + + return { + ...state, + todos: state.todos.map(t => ({ + ...t, + completed: hasActiveTodos, + })), + }; + } + + default: + return state; + } +} + +const initialState: State = { + todos: [], +}; + +const init = (): State => ({ + todos: JSON.parse(localStorage.getItem('todos') || '[]'), +}); + +export const StateContext = createContext(initialState); +export const DispatchContext = createContext>(() => {}); + +type Props = { + children: React.ReactNode; +}; + +export const GlobalStateProvider: React.FC = ({ children }) => { + const [state, dispatch] = useReducer(reducer, initialState, init); + + return ( + + {children} + + ); +}; diff --git a/src/index.tsx b/src/index.tsx index b2c38a17a..791c26725 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 { GlobalStateProvider } from './components/TodoProvider'; const container = document.getElementById('root') as HTMLDivElement; -createRoot(container).render(); +createRoot(container).render( + + + , +); diff --git a/src/localStorage/todoStorage.ts b/src/localStorage/todoStorage.ts new file mode 100644 index 000000000..9690c42e4 --- /dev/null +++ b/src/localStorage/todoStorage.ts @@ -0,0 +1,11 @@ +import { Todo } from '../types/Todo'; + +export const saveTodo = () => { + const saved = localStorage.getItem('todos'); + + if (!saved || saved === 'undefined') { + return []; + } + + return JSON.parse(saved) as Todo[]; +}; diff --git a/src/types/Todo.ts b/src/types/Todo.ts new file mode 100644 index 000000000..f9e06b381 --- /dev/null +++ b/src/types/Todo.ts @@ -0,0 +1,5 @@ +export interface Todo { + id: number; + title: string; + completed: boolean; +}