diff --git a/README.md b/README.md
index 903c876f9..62b0e5788 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://GGLUTT.github.io/react_todo-app/) and add it to the PR description.
diff --git a/package-lock.json b/package-lock.json
index 0adcc869f..1f19b4743 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,7 +18,7 @@
},
"devDependencies": {
"@cypress/react18": "^2.0.1",
- "@mate-academy/scripts": "^1.8.5",
+ "@mate-academy/scripts": "^1.9.12",
"@mate-academy/students-ts-config": "*",
"@mate-academy/stylelint-config": "*",
"@types/node": "^20.14.10",
@@ -1170,9 +1170,9 @@
}
},
"node_modules/@mate-academy/scripts": {
- "version": "1.8.5",
- "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.8.5.tgz",
- "integrity": "sha512-mHRY2FkuoYCf5U0ahIukkaRo5LSZsxrTSgMJheFoyf3VXsTvfM9OfWcZIDIDB521kdPrScHHnRp+JRNjCfUO5A==",
+ "version": "1.9.12",
+ "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.9.12.tgz",
+ "integrity": "sha512-/OcmxMa34lYLFlGx7Ig926W1U1qjrnXbjFJ2TzUcDaLmED+A5se652NcWwGOidXRuMAOYLPU2jNYBEkKyXrFJA==",
"dev": true,
"dependencies": {
"@octokit/rest": "^17.11.2",
diff --git a/package.json b/package.json
index e6134ce84..91d7489b9 100644
--- a/package.json
+++ b/package.json
@@ -14,7 +14,7 @@
},
"devDependencies": {
"@cypress/react18": "^2.0.1",
- "@mate-academy/scripts": "^1.8.5",
+ "@mate-academy/scripts": "^1.9.12",
"@mate-academy/students-ts-config": "*",
"@mate-academy/stylelint-config": "*",
"@types/node": "^20.14.10",
diff --git a/src/api/todos.ts b/src/api/todos.ts
new file mode 100644
index 000000000..272e49fd2
--- /dev/null
+++ b/src/api/todos.ts
@@ -0,0 +1,28 @@
+import { Todo } from '../src/types/Todo';
+import { client } from '../src/utils/fetchClient';
+
+export const USER_ID = 1011;
+
+export const getTodos = () => {
+ return client.get(`/todos?userId=${USER_ID}`);
+};
+
+export const addTodo = ({
+ title,
+ completed,
+ userId,
+}: Omit & { userId: number }) => {
+ return client.post('/todos', {
+ title,
+ completed,
+ userId,
+ });
+};
+
+export const deleteTodo = (todoId: number) => {
+ return client.delete(`/todos/${todoId}`);
+};
+
+export const updateTodo = (todo: Todo) => {
+ return client.patch(`/todos/${todo.id}`, todo);
+};
diff --git a/src/index.tsx b/src/index.tsx
index a9689cb38..43492351a 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,11 +1,12 @@
import { createRoot } from 'react-dom/client';
-
-import './styles/index.css';
-import './styles/todo-list.css';
-import './styles/filters.css';
-
+import 'bulma/css/bulma.css';
+import '@fortawesome/fontawesome-free/css/all.css';
+import './styles/index.scss';
import { App } from './App';
+import { TodoProvider } from './src/components/context/TodoContext';
-const container = document.getElementById('root') as HTMLDivElement;
-
-createRoot(container).render();
+createRoot(document.getElementById('root') as HTMLDivElement).render(
+
+
+ ,
+);
diff --git a/src/src/App.scss b/src/src/App.scss
new file mode 100644
index 000000000..a2df3c7e9
--- /dev/null
+++ b/src/src/App.scss
@@ -0,0 +1,4 @@
+html {
+ font-family: Arial, "Helvetica Neue", Helvetica, sans-serif;
+
+}
\ No newline at end of file
diff --git a/src/src/App.tsx b/src/src/App.tsx
new file mode 100644
index 000000000..a47931054
--- /dev/null
+++ b/src/src/App.tsx
@@ -0,0 +1,56 @@
+import React, { useEffect, useRef } from 'react';
+import { Filter } from './types/Filter';
+import { TodoList } from './components/TodoList/TodoList';
+import { Header } from './components/Header/Header';
+import { Footer } from './components/Footer/Footer';
+import { ErrorNotification } from './components/Error/ErrorNotify';
+import { TodoItem } from './components/TodoItem/TodoItem';
+import { useTodoContext } from './components/context/TodoContext';
+
+export const App: React.FC = () => {
+ const { state } = useTodoContext();
+ const { todos, filter, tempTodo } = state;
+ const textField = useRef(null);
+
+ const completedTodos = todos.filter(todo => todo.completed);
+ const activeTodos = todos.filter(todo => !todo.completed);
+
+ const filteredTodos = () => {
+ switch (filter) {
+ case Filter.Completed:
+ return completedTodos;
+ case Filter.Active:
+ return activeTodos;
+ default:
+ return todos;
+ }
+ };
+
+ useEffect(() => {
+ if (textField.current) {
+ textField.current.focus();
+ }
+ }, [todos.length]);
+
+ return (
+
+
todos
+
+
+
+
+
+
+ {todos.length > 0 && (
+
+ )}
+
+
+
+
+ );
+};
diff --git a/src/src/UserWarning.tsx b/src/src/UserWarning.tsx
new file mode 100644
index 000000000..fa25838e6
--- /dev/null
+++ b/src/src/UserWarning.tsx
@@ -0,0 +1,15 @@
+import React from 'react';
+
+export const UserWarning: React.FC = () => (
+
+
+ Please get your userId {' '}
+
+ here
+ {' '}
+ and save it in the app
const USER_ID = ...
+ All requests to the API must be sent with this
+ userId.
+
+
+);
diff --git a/src/src/components/Error/ErrorNotify.tsx b/src/src/components/Error/ErrorNotify.tsx
new file mode 100644
index 000000000..71fbbcfa3
--- /dev/null
+++ b/src/src/components/Error/ErrorNotify.tsx
@@ -0,0 +1,42 @@
+import React, { useEffect } from 'react';
+import classNames from 'classnames';
+import { useTodoContext } from '../context/TodoContext';
+
+export const ErrorNotification: React.FC = () => {
+ const { state, dispatch } = useTodoContext();
+ const { errorText } = state;
+
+ useEffect(() => {
+ if (errorText) {
+ const timer = setTimeout(() => {
+ dispatch({ type: 'SET_ERROR', payload: null });
+ }, 3000);
+
+ return () => clearTimeout(timer);
+ }
+
+ return () => {};
+ }, [errorText, dispatch]);
+
+ if (!errorText) {
+ return null;
+ }
+
+ return (
+
+
+ );
+};
diff --git a/src/src/components/Footer/Footer.tsx b/src/src/components/Footer/Footer.tsx
new file mode 100644
index 000000000..67ad74d34
--- /dev/null
+++ b/src/src/components/Footer/Footer.tsx
@@ -0,0 +1,75 @@
+import React from 'react';
+import classNames from 'classnames';
+import { Filter } from '../../types/Filter';
+import { Error } from '../../types/Error';
+import { useTodoContext } from '../context/TodoContext';
+import { Todo } from '../../types/Todo';
+
+type Props = {
+ activeTodos: Todo[];
+ completedTodos: Todo[];
+};
+
+export const Footer: React.FC = ({ activeTodos, completedTodos }) => {
+ const { state, dispatch } = useTodoContext();
+ const { filter } = state;
+
+ const handleClearCompleted = async () => {
+ const completedIds = completedTodos.map(todo => todo.id);
+
+ dispatch({ type: 'SET_DELETING_IDS', payload: completedIds });
+
+ try {
+ dispatch({ type: 'CLEAR_COMPLETED' });
+ } catch {
+ dispatch({
+ type: 'SET_ERROR',
+ payload: Error.unableToDelete,
+ });
+ setTimeout(() => {
+ dispatch({ type: 'SET_ERROR', payload: null });
+ }, 3000);
+ } finally {
+ dispatch({ type: 'SET_DELETING_IDS', payload: [] });
+ }
+ };
+
+ return (
+
+ );
+};
diff --git a/src/src/components/Header/Header.tsx b/src/src/components/Header/Header.tsx
new file mode 100644
index 000000000..5ead3719c
--- /dev/null
+++ b/src/src/components/Header/Header.tsx
@@ -0,0 +1,89 @@
+import React from 'react';
+import classNames from 'classnames';
+import { Todo } from '../../types/Todo';
+import { Error } from '../../types/Error';
+import { useTodoContext } from '../context/TodoContext';
+
+type Props = {
+ completedTodos: Todo[];
+ textField: React.RefObject;
+};
+
+export const Header: React.FC = ({ completedTodos, textField }) => {
+ const { state, dispatch } = useTodoContext();
+ const { todos, query, isLoading } = state;
+
+ const handleSubmit = (event: React.FormEvent) => {
+ event.preventDefault();
+
+ if (!query.trim()) {
+ dispatch({ type: 'SET_ERROR', payload: Error.titleShouldNotBeEmpty });
+ setTimeout(() => {
+ dispatch({ type: 'SET_ERROR', payload: null });
+ }, 3000);
+
+ return;
+ }
+
+ const newTodo: Todo = {
+ id: Date.now(),
+ title: query.trim(),
+ completed: false,
+ };
+
+ dispatch({ type: 'SET_LOADING', payload: true });
+ dispatch({ type: 'SET_TEMP_TODO', payload: newTodo });
+
+ try {
+ dispatch({ type: 'ADD_TODO', payload: newTodo });
+ dispatch({ type: 'SET_QUERY', payload: '' });
+ } catch (error) {
+ dispatch({ type: 'SET_ERROR', payload: Error.unableToAdd });
+ setTimeout(() => {
+ dispatch({ type: 'SET_ERROR', payload: null });
+ }, 3000);
+ } finally {
+ dispatch({ type: 'SET_LOADING', payload: false });
+ dispatch({ type: 'SET_TEMP_TODO', payload: null });
+ }
+ };
+
+ const handleToggleAll = () => {
+ const areAllCompleted = todos.every(todo => todo.completed);
+
+ dispatch({ type: 'TOGGLE_ALL', payload: !areAllCompleted });
+ };
+
+ return (
+
+ {todos.length > 0 && (
+
+ )}
+
+
+
+ );
+};
diff --git a/src/src/components/Header/header.scss b/src/src/components/Header/header.scss
new file mode 100644
index 000000000..a48c011a5
--- /dev/null
+++ b/src/src/components/Header/header.scss
@@ -0,0 +1,3 @@
+ header{
+ background-color: azure;
+ }
\ No newline at end of file
diff --git a/src/src/components/TodoItem/TodoItem.tsx b/src/src/components/TodoItem/TodoItem.tsx
new file mode 100644
index 000000000..379bfb818
--- /dev/null
+++ b/src/src/components/TodoItem/TodoItem.tsx
@@ -0,0 +1,174 @@
+import React, { useState, useEffect, useRef } from 'react';
+import classNames from 'classnames';
+import { Error } from '../../types/Error';
+import { Todo } from '../../types/Todo';
+import { useTodoContext } from '../context/TodoContext';
+
+type Props = {
+ todo: Todo;
+ isLoading?: boolean;
+};
+
+export const TodoItem: React.FC = ({
+ todo,
+ isLoading: externalLoading = false,
+}) => {
+ const { dispatch, state } = useTodoContext();
+ const { deletingTodoIds } = state;
+ const [isLoading, setIsLoading] = useState(false);
+ const [isEditing, setIsEditing] = useState(false);
+ const [editedTodoTitle, setEditedTodoTitle] = useState(todo.title);
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ if (isEditing) {
+ inputRef.current?.focus();
+ }
+ }, [isEditing]);
+
+ const handleDelete = () => {
+ setIsLoading(true);
+ try {
+ dispatch({ type: 'DELETE_TODO', payload: todo.id });
+ } catch {
+ dispatch({
+ type: 'SET_ERROR',
+ payload: Error.unableToDelete,
+ });
+ setTimeout(() => {
+ dispatch({ type: 'SET_ERROR', payload: null });
+ }, 3000);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleToggle = () => {
+ setIsLoading(true);
+ try {
+ dispatch({
+ type: 'UPDATE_TODO',
+ payload: { ...todo, completed: !todo.completed },
+ });
+ } catch {
+ dispatch({
+ type: 'SET_ERROR',
+ payload: Error.unableToUpdate,
+ });
+ setTimeout(() => {
+ dispatch({ type: 'SET_ERROR', payload: null });
+ }, 3000);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ const handleEdit = (newTitle: string) => {
+ setIsLoading(true);
+ if (newTitle.trim() === todo.title) {
+ setIsEditing(false);
+ setIsLoading(false);
+
+ return;
+ }
+
+ if (newTitle.trim() === '') {
+ handleDelete();
+
+ return;
+ }
+
+ try {
+ dispatch({
+ type: 'UPDATE_TODO',
+ payload: { ...todo, title: newTitle.trim() },
+ });
+ setIsEditing(false);
+ } catch {
+ inputRef.current?.focus();
+ dispatch({
+ type: 'SET_ERROR',
+ payload: Error.unableToUpdate,
+ });
+ setTimeout(() => {
+ dispatch({ type: 'SET_ERROR', payload: null });
+ }, 3000);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ return (
+
+
+
+ {!isEditing ? (
+ <>
+
setIsEditing(true)}
+ >
+ {todo.title}
+
+
+
+ >
+ ) : (
+
+ )}
+
+
+
+ );
+};
diff --git a/src/src/components/TodoList/TodoList.tsx b/src/src/components/TodoList/TodoList.tsx
new file mode 100644
index 000000000..dd087c29c
--- /dev/null
+++ b/src/src/components/TodoList/TodoList.tsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import { Todo } from '../../types/Todo';
+import { TodoItem } from '../TodoItem/TodoItem';
+
+type Props = {
+ todos: Todo[];
+};
+
+export const TodoList: React.FC = ({ todos }) => {
+ return (
+ <>
+ {todos.map(todo => (
+
+ ))}
+ >
+ );
+};
diff --git a/src/src/components/context/TodoContext.tsx b/src/src/components/context/TodoContext.tsx
new file mode 100644
index 000000000..167b088b6
--- /dev/null
+++ b/src/src/components/context/TodoContext.tsx
@@ -0,0 +1,142 @@
+import React, { createContext, useContext, useReducer, useEffect } from 'react';
+import {
+ TodoContextType,
+ TodoState,
+ TodoAction,
+} from '../../types/TodoContextType';
+import { Filter } from '../../types/Filter';
+import { useLocalStorage } from '../../hook/useLocalStorage';
+import { Todo } from '../../types/Todo';
+
+const initialState: TodoState = {
+ todos: [],
+ filter: Filter.All,
+ errorText: null,
+ query: '',
+ isLoading: false,
+ tempTodo: null,
+ deletingTodoIds: [],
+};
+
+const TodoContext = createContext(undefined);
+
+export const todoReducer = (
+ state: TodoState,
+ action: TodoAction,
+): TodoState => {
+ switch (action.type) {
+ case 'ADD_TODO':
+ return {
+ ...state,
+ todos: [...state.todos, action.payload],
+ };
+
+ case 'DELETE_TODO':
+ return {
+ ...state,
+ todos: state.todos.filter(todo => todo.id !== action.payload),
+ };
+
+ case 'UPDATE_TODO':
+ return {
+ ...state,
+ todos: state.todos.map(todo =>
+ todo.id === action.payload.id ? action.payload : todo,
+ ),
+ };
+
+ case 'TOGGLE_TODO':
+ return {
+ ...state,
+ todos: state.todos.map(todo =>
+ todo.id === action.payload
+ ? { ...todo, completed: !todo.completed }
+ : todo,
+ ),
+ };
+
+ case 'TOGGLE_ALL':
+ return {
+ ...state,
+ todos: state.todos.map(todo => ({
+ ...todo,
+ completed: action.payload,
+ })),
+ };
+
+ case 'CLEAR_COMPLETED':
+ return {
+ ...state,
+ todos: state.todos.filter(todo => !todo.completed),
+ };
+
+ case 'SET_FILTER':
+ return {
+ ...state,
+ filter: action.payload,
+ };
+
+ case 'SET_ERROR':
+ return {
+ ...state,
+ errorText: action.payload,
+ };
+
+ case 'SET_QUERY':
+ return {
+ ...state,
+ query: action.payload,
+ };
+
+ case 'SET_LOADING':
+ return {
+ ...state,
+ isLoading: action.payload,
+ };
+
+ case 'SET_TEMP_TODO':
+ return {
+ ...state,
+ tempTodo: action.payload,
+ };
+
+ case 'SET_DELETING_IDS':
+ return {
+ ...state,
+ deletingTodoIds: action.payload,
+ };
+
+ default:
+ return state;
+ }
+};
+
+export const TodoProvider: React.FC<{ children: React.ReactNode }> = ({
+ children,
+}) => {
+ const [savedTodos, setSavedTodos] = useLocalStorage('todos', []);
+ const [state, dispatch] = useReducer(todoReducer, {
+ ...initialState,
+ todos: savedTodos,
+ });
+
+ useEffect(() => {
+ setSavedTodos(state.todos);
+ }, [state.todos, setSavedTodos]);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useTodoContext = () => {
+ const context = useContext(TodoContext);
+
+ if (!context) {
+ throw new Error('useTodoContext must be used within a TodoProvider');
+ }
+
+ return context;
+};
diff --git a/src/src/components/index.tsx b/src/src/components/index.tsx
new file mode 100644
index 000000000..65af8adaf
--- /dev/null
+++ b/src/src/components/index.tsx
@@ -0,0 +1,4 @@
+export * from './Error/ErrorNotify';
+export * from './Footer/Footer';
+export * from './TodoList/TodoList';
+export * from './TodoItem/TodoItem';
diff --git a/src/src/hook/useLocalStorage.ts b/src/src/hook/useLocalStorage.ts
new file mode 100644
index 000000000..f074fb6c6
--- /dev/null
+++ b/src/src/hook/useLocalStorage.ts
@@ -0,0 +1,24 @@
+import { useState, useEffect } from 'react';
+
+export function useLocalStorage(
+ key: string,
+ initialValue: T,
+): [T, (value: T) => void] {
+ const [storedValue, setStoredValue] = useState(() => {
+ try {
+ const item = window.localStorage.getItem(key);
+
+ return item ? JSON.parse(item) : initialValue;
+ } catch (error) {
+ return initialValue;
+ }
+ });
+
+ useEffect(() => {
+ try {
+ window.localStorage.setItem(key, JSON.stringify(storedValue));
+ } catch (error) {}
+ }, [key, storedValue]);
+
+ return [storedValue, setStoredValue];
+}
diff --git a/src/src/index.tsx b/src/src/index.tsx
new file mode 100644
index 000000000..96936a7e2
--- /dev/null
+++ b/src/src/index.tsx
@@ -0,0 +1,12 @@
+import { createRoot } from 'react-dom/client';
+import 'bulma/css/bulma.css';
+import '@fortawesome/fontawesome-free/css/all.css';
+import './styles/index.scss';
+import { App } from './App';
+import { TodoProvider } from './components/context/TodoContext';
+
+createRoot(document.getElementById('root') as HTMLDivElement).render(
+
+
+ ,
+);
diff --git a/src/src/styles/index.scss b/src/src/styles/index.scss
new file mode 100644
index 000000000..bccd80c8b
--- /dev/null
+++ b/src/src/styles/index.scss
@@ -0,0 +1,25 @@
+iframe {
+ display: none;
+}
+
+body {
+ min-width: 230px;
+ 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";
+@import "./filter";
diff --git a/src/src/styles/todo.scss b/src/src/styles/todo.scss
new file mode 100644
index 000000000..c90e19a8e
--- /dev/null
+++ b/src/src/styles/todo.scss
@@ -0,0 +1,133 @@
+.todo {
+ position: relative;
+ display: grid;
+ grid-template-columns: 45px 1fr;
+ justify-items: stretch;
+ font-size: 24px;
+ line-height: 1.4em;
+ border-bottom: 1px solid #ededed;
+ &:last-child {
+ border-bottom: 0;
+ }
+
+ &__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-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");
+ }
+
+ &__status {
+ opacity: 0;
+ }
+ &__title {
+ padding: 12px 15px;
+ word-break: break-all;
+ transition: color 0.4s;
+ }
+ &.completed &__title {
+ color: #d9d9d9;
+ text-decoration: line-through;
+ }
+ &__remove {
+ position: absolute;
+ right: 12px;
+ top: 0;
+ bottom: 0;
+ font-size: 120%;
+ line-height: 1;
+ font-family: inherit;
+ font-weight: inherit;
+ color: #cc9a9a;
+ float: right;
+ border: 0;
+ background: none;
+ cursor: pointer;
+ transform: translateY(-2px);
+ opacity: 0;
+ transition: color 0.2s ease-out;
+ &:hover {
+ color: #af5b5e;
+ }
+ }
+ &:hover &__remove {
+ opacity: 1;
+ }
+ &__title-field {
+ width: 100%;
+ padding: 11px 14px;
+ 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);
+ &::placeholder {
+ font-style: italic;
+ font-weight: 300;
+ color: #e6e6e6;
+ }
+ }
+
+ .overlay {
+ position: absolute;
+ 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/src/styles/todoapp.scss b/src/src/styles/todoapp.scss
new file mode 100644
index 000000000..86d531c68
--- /dev/null
+++ b/src/src/styles/todoapp.scss
@@ -0,0 +1,117 @@
+.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;
+ transition: opacity 0.3s;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ &:active {
+ text-decoration: none;
+ }
+
+ &:disabled {
+ visibility: hidden;
+ }
+ }
+}
diff --git a/src/src/types/Error.ts b/src/src/types/Error.ts
new file mode 100644
index 000000000..d26c1a0ac
--- /dev/null
+++ b/src/src/types/Error.ts
@@ -0,0 +1,8 @@
+export enum Error {
+ none = '',
+ unableToLoad = 'Unable to load todos',
+ titleShouldNotBeEmpty = 'Title should not be empty',
+ unableToAdd = 'Unable to add a todo',
+ unableToDelete = 'Unable to delete a todo',
+ unableToUpdate = 'Unable to update a todo',
+}
diff --git a/src/src/types/Filter.ts b/src/src/types/Filter.ts
new file mode 100644
index 000000000..66887875b
--- /dev/null
+++ b/src/src/types/Filter.ts
@@ -0,0 +1,5 @@
+export enum Filter {
+ All = 'All',
+ Active = 'Active',
+ Completed = 'Completed',
+}
diff --git a/src/src/types/Todo.ts b/src/src/types/Todo.ts
new file mode 100644
index 000000000..f9e06b381
--- /dev/null
+++ b/src/src/types/Todo.ts
@@ -0,0 +1,5 @@
+export interface Todo {
+ id: number;
+ title: string;
+ completed: boolean;
+}
diff --git a/src/src/types/TodoContextType.ts b/src/src/types/TodoContextType.ts
new file mode 100644
index 000000000..492917248
--- /dev/null
+++ b/src/src/types/TodoContextType.ts
@@ -0,0 +1,31 @@
+import { Todo } from './Todo';
+import { Filter } from './Filter';
+
+export type TodoState = {
+ todos: Todo[];
+ filter: Filter;
+ errorText: string | null;
+ query: string;
+ isLoading: boolean;
+ tempTodo: Todo | null;
+ deletingTodoIds: number[];
+};
+
+export type TodoAction =
+ | { type: 'ADD_TODO'; payload: Todo }
+ | { type: 'DELETE_TODO'; payload: number }
+ | { type: 'UPDATE_TODO'; payload: Todo }
+ | { type: 'TOGGLE_TODO'; payload: number }
+ | { type: 'TOGGLE_ALL'; payload: boolean }
+ | { type: 'CLEAR_COMPLETED' }
+ | { type: 'SET_FILTER'; payload: Filter }
+ | { type: 'SET_ERROR'; payload: string | null }
+ | { type: 'SET_QUERY'; payload: string }
+ | { type: 'SET_LOADING'; payload: boolean }
+ | { type: 'SET_TEMP_TODO'; payload: Todo | null }
+ | { type: 'SET_DELETING_IDS'; payload: number[] };
+
+export type TodoContextType = {
+ state: TodoState;
+ dispatch: React.Dispatch;
+};
diff --git a/src/src/utils/fetchClient.ts b/src/src/utils/fetchClient.ts
new file mode 100644
index 000000000..708ac4c17
--- /dev/null
+++ b/src/src/utils/fetchClient.ts
@@ -0,0 +1,46 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+const BASE_URL = 'https://mate.academy/students-api';
+
+// returns a promise resolved after a given delay
+function wait(delay: number) {
+ return new Promise(resolve => {
+ setTimeout(resolve, delay);
+ });
+}
+
+// To have autocompletion and avoid mistypes
+type RequestMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE';
+
+function request(
+ url: string,
+ method: RequestMethod = 'GET',
+ data: any = null, // we can send any data to the server
+): Promise {
+ const options: RequestInit = { method };
+
+ if (data) {
+ // We add body and Content-Type only for the requests with data
+ options.body = JSON.stringify(data);
+ options.headers = {
+ 'Content-Type': 'application/json; charset=UTF-8',
+ };
+ }
+
+ // DON'T change the delay it is required for tests
+ return wait(100)
+ .then(() => fetch(BASE_URL + url, options))
+ .then(response => {
+ if (!response.ok) {
+ throw new Error();
+ }
+
+ return response.json();
+ });
+}
+
+export const client = {
+ get: (url: string) => request(url),
+ post: (url: string, data: any) => request(url, 'POST', data),
+ patch: (url: string, data: any) => request(url, 'PATCH', data),
+ delete: (url: string) => request(url, 'DELETE'),
+};