diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json
index 0211a3074..f6a2ae7aa 100644
--- a/cypress/tsconfig.json
+++ b/cypress/tsconfig.json
@@ -3,4 +3,4 @@
"compilerOptions": {
"sourceMap": false
}
-}
+}
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 1f19b4743..a7f06fc95 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,7 +18,7 @@
},
"devDependencies": {
"@cypress/react18": "^2.0.1",
- "@mate-academy/scripts": "^1.9.12",
+ "@mate-academy/scripts": "^2.1.3",
"@mate-academy/students-ts-config": "*",
"@mate-academy/stylelint-config": "*",
"@types/node": "^20.14.10",
@@ -1170,10 +1170,11 @@
}
},
"node_modules/@mate-academy/scripts": {
- "version": "1.9.12",
- "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-1.9.12.tgz",
- "integrity": "sha512-/OcmxMa34lYLFlGx7Ig926W1U1qjrnXbjFJ2TzUcDaLmED+A5se652NcWwGOidXRuMAOYLPU2jNYBEkKyXrFJA==",
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/@mate-academy/scripts/-/scripts-2.1.3.tgz",
+ "integrity": "sha512-a07wHTj/1QUK2Aac5zHad+sGw4rIvcNl5lJmJpAD7OxeSbnCdyI6RXUHwXhjF5MaVo9YHrJ0xVahyERS2IIyBQ==",
"dev": true,
+ "license": "MIT",
"dependencies": {
"@octokit/rest": "^17.11.2",
"@types/get-port": "^4.2.0",
diff --git a/package.json b/package.json
index 91d7489b9..446974833 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.3",
"@mate-academy/students-ts-config": "*",
"@mate-academy/stylelint-config": "*",
"@types/node": "^20.14.10",
diff --git a/src/App.tsx b/src/App.tsx
index a399287bd..ead313cd4 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,156 +1,87 @@
-/* eslint-disable jsx-a11y/control-has-associated-label */
-import React from 'react';
+import React, { useState, useContext, useRef, useEffect } from 'react';
+import classNames from 'classnames';
+import { TodoContext } from './components/TodoContext';
+import { TodoList } from './components/TodoList';
+import { Footer } from './components/Footer';
+import { Todo } from './types/Todo';
export const App: React.FC = () => {
+ const { todos, addTodo, toggleAll } = useContext(TodoContext);
+ const [filter, setFilter] = useState<'All' | 'Active' | 'Completed'>('All');
+ const [title, setTitle] = useState('');
+
+ const newTodoField = useRef(null);
+
+ useEffect(() => {
+ newTodoField.current?.focus();
+ }, [todos.length]);
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ const trimmedTitle = title.trim();
+
+ if (!trimmedTitle) {
+ return;
+ }
+
+ addTodo(trimmedTitle);
+ setTitle('');
+ };
+
+ const visibleTodos = todos.filter((todo: Todo) => {
+ if (filter === 'Active') {
+ return !todo.completed;
+ }
+
+ if (filter === 'Completed') {
+ return todo.completed;
+ }
+
+ return true;
+ });
+
+ const isAllCompleted =
+ todos.length > 0 && todos.every((todo: Todo) => todo.completed);
+
return (
todos
- {/* this button should have `active` class only if all todos are completed */}
-
+ {todos.length > 0 && (
+
+ )}
- {/* Add a todo on form submit */}
-
-
-
- {/* Hide the footer if there are no todos */}
-
-
- 3 items left
-
-
- {/* Active link should have the 'selected' class */}
-
-
- All
-
-
-
- Active
-
-
-
- Completed
-
-
-
- {/* this button should be disabled if there are no completed todos */}
-
- Clear completed
-
-
+ {todos.length > 0 && (
+ <>
+
+
+ >
+ )}
);
diff --git a/src/UserWarning.tsx b/src/UserWarning.tsx
new file mode 100644
index 000000000..fa25838e6
--- /dev/null
+++ b/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/components/ErrorNotification.tsx b/src/components/ErrorNotification.tsx
new file mode 100644
index 000000000..7ed40de02
--- /dev/null
+++ b/src/components/ErrorNotification.tsx
@@ -0,0 +1,23 @@
+type Prop = {
+ errorMessage: string;
+ setErrorMessage: (errorMessage: string) => void;
+};
+
+export const ErrorNotification = ({ errorMessage, setErrorMessage }: Prop) => {
+ return (
+
+ setErrorMessage('')}
+ />
+ {errorMessage}
+
+ );
+};
diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx
new file mode 100644
index 000000000..c616490b4
--- /dev/null
+++ b/src/components/Footer.tsx
@@ -0,0 +1,52 @@
+import { useContext } from 'react';
+import type { Dispatch, SetStateAction } from 'react';
+import classNames from 'classnames';
+import { TodoContext } from './TodoContext';
+
+type FilterType = 'All' | 'Active' | 'Completed';
+
+export const Footer: React.FC<{
+ filter: FilterType;
+ setFilter: Dispatch>;
+}> = ({ filter, setFilter }) => {
+ const { todos, clearCompleted } = useContext(TodoContext);
+
+ const activeCount = todos.filter(todo => !todo.completed).length;
+ const hasCompleted = todos.some(todo => todo.completed);
+
+ const filters: FilterType[] = ['All', 'Active', 'Completed'];
+
+ return (
+
+ );
+};
diff --git a/src/components/TodoContext.tsx b/src/components/TodoContext.tsx
new file mode 100644
index 000000000..2193d36af
--- /dev/null
+++ b/src/components/TodoContext.tsx
@@ -0,0 +1,78 @@
+import React, { createContext, useState, useEffect } from 'react';
+import { Todo } from '../types/Todo';
+
+type TodoContextType = {
+ todos: Todo[];
+ addTodo: (title: string) => void;
+ deleteTodo: (id: number) => void;
+ updateTodo: (updatedTodo: Todo) => void;
+ toggleAll: () => void;
+ clearCompleted: () => void;
+};
+
+export const TodoContext = createContext(
+ {} as TodoContextType,
+);
+
+export const TodoProvider = ({ children }: { children?: React.ReactNode }) => {
+ const [todos, setTodos] = useState(() => {
+ const saved = localStorage.getItem('todos');
+
+ return saved ? JSON.parse(saved) : [];
+ });
+
+ useEffect(() => {
+ localStorage.setItem('todos', JSON.stringify(todos));
+ }, [todos]);
+
+ const addTodo = (title: string) => {
+ const newTodo: Todo = {
+ id: +new Date(),
+ userId: 1,
+ title,
+ completed: false,
+ };
+
+ setTodos((prev: Todo[]) => [...prev, newTodo]);
+ };
+
+ const deleteTodo = (id: number) => {
+ setTodos((prev: Todo[]) => prev.filter((todo: Todo) => todo.id !== id));
+ };
+
+ const updateTodo = (updatedTodo: Todo) => {
+ setTodos((prev: Todo[]) =>
+ prev.map((todo: Todo) =>
+ todo.id === updatedTodo.id ? updatedTodo : todo,
+ ),
+ );
+ };
+
+ const toggleAll = () => {
+ const isAllCompleted =
+ todos.length > 0 && todos.every((todo: Todo) => todo.completed);
+
+ setTodos((prev: Todo[]) =>
+ prev.map((todo: Todo) => ({ ...todo, completed: !isAllCompleted })),
+ );
+ };
+
+ const clearCompleted = () => {
+ setTodos((prev: Todo[]) => prev.filter((todo: Todo) => !todo.completed));
+ };
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/components/TodoItem.tsx b/src/components/TodoItem.tsx
new file mode 100644
index 000000000..b30ac7463
--- /dev/null
+++ b/src/components/TodoItem.tsx
@@ -0,0 +1,97 @@
+import React, { useState, useRef, useEffect, useContext } from 'react';
+import classNames from 'classnames';
+import { Todo } from '../types/Todo';
+import { TodoContext } from './TodoContext';
+
+export const TodoItem: React.FC<{ todo: Todo }> = ({ todo }) => {
+ const { deleteTodo, updateTodo } = useContext(TodoContext);
+
+ const [isEditing, setIsEditing] = useState(false);
+ const [newTitle, setNewTitle] = useState(todo.title);
+ const editField = useRef(null);
+
+ useEffect(() => {
+ if (isEditing) {
+ editField.current?.focus();
+ }
+ }, [isEditing]);
+
+ const saveTitle = () => {
+ const trimmedTitle = newTitle.trim();
+
+ if (!trimmedTitle) {
+ deleteTodo(todo.id);
+ } else if (trimmedTitle !== todo.title) {
+ updateTodo({ ...todo, title: trimmedTitle });
+ }
+
+ setIsEditing(false);
+ setNewTitle(trimmedTitle || todo.title);
+ };
+
+ const handleKeyUp = (e: React.KeyboardEvent) => {
+ if (e.key === 'Escape') {
+ setIsEditing(false);
+ setNewTitle(todo.title);
+ }
+ };
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ saveTitle();
+ };
+
+ return (
+
+
+ updateTodo({ ...todo, completed: !todo.completed })}
+ />
+
+
+ {isEditing ? (
+
+ ) : (
+ <>
+ setIsEditing(true)}
+ >
+ {todo.title}
+
+ deleteTodo(todo.id)}
+ >
+ ×
+
+ >
+ )}
+
+ );
+};
diff --git a/src/components/TodoList.tsx b/src/components/TodoList.tsx
new file mode 100644
index 000000000..63ae0d06f
--- /dev/null
+++ b/src/components/TodoList.tsx
@@ -0,0 +1,14 @@
+import { TodoItem } from './TodoItem';
+import { Todo } from '../types/Todo';
+
+export const TodoList: React.FC<{
+ visibleTodos: Todo[];
+}> = ({ visibleTodos }) => {
+ return (
+
+ {visibleTodos.map((todo: Todo) => (
+
+ ))}
+
+ );
+};
diff --git a/src/index.tsx b/src/index.tsx
index b2c38a17a..2289baa5b 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,9 +1,17 @@
-import { createRoot } from 'react-dom/client';
-
-import './styles/index.scss';
-
+import React from 'react';
+import ReactDOM from 'react-dom/client';
import { App } from './App';
+import { TodoProvider } from './components/TodoContext';
+import './styles/index.scss';
-const container = document.getElementById('root') as HTMLDivElement;
+const root = ReactDOM.createRoot(
+ document.getElementById('root') as HTMLElement,
+);
-createRoot(container).render( );
+root.render(
+
+
+
+
+ ,
+);
diff --git a/src/styles/filter.scss b/src/styles/filter.scss
new file mode 100644
index 000000000..75b5804e5
--- /dev/null
+++ b/src/styles/filter.scss
@@ -0,0 +1,22 @@
+.filter {
+ display: flex;
+
+ &__link {
+ margin: 3px;
+ padding: 3px 7px;
+
+ color: inherit;
+ text-decoration: none;
+
+ border: 1px solid transparent;
+ border-radius: 3px;
+
+ &:hover {
+ border-color: rgba(175, 47, 47, 0.1);
+ }
+
+ &.selected {
+ border-color: rgba(175, 47, 47, 0.2);
+ }
+ }
+}
diff --git a/src/styles/index.scss b/src/styles/index.scss
index d8d324941..ac00217e1 100644
--- a/src/styles/index.scss
+++ b/src/styles/index.scss
@@ -20,6 +20,12 @@ body {
pointer-events: none;
}
+input:focus,
+.todo__input:focus,
+.todoapp__new-todo:focus {
+ outline: none;
+}
+
@import './todoapp';
@import './todo-list';
@import './filters';
diff --git a/src/styles/todo.scss b/src/styles/todo.scss
new file mode 100644
index 000000000..c7f93ff6b
--- /dev/null
+++ b/src/styles/todo.scss
@@ -0,0 +1,149 @@
+.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/types/Errors.ts b/src/types/Errors.ts
new file mode 100644
index 000000000..956245312
--- /dev/null
+++ b/src/types/Errors.ts
@@ -0,0 +1,7 @@
+export enum Errors {
+ Load = 'Unable to load todos',
+ TitleEmpty = 'Title should not be empty',
+ Add = 'Unable to add a todo',
+ Delete = 'Unable to delete a todo',
+ Update = 'Unable to update a todo',
+}
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/utils/fetchClient.ts b/src/utils/fetchClient.ts
new file mode 100644
index 000000000..acd865416
--- /dev/null
+++ b/src/utils/fetchClient.ts
@@ -0,0 +1,43 @@
+const BASE_URL = 'https://mate.academy/students-api';
+
+function wait(delay: number) {
+ return new Promise(resolve => {
+ setTimeout(resolve, delay);
+ });
+}
+
+type RequestMethod = 'GET' | 'POST' | 'PATCH' | 'DELETE';
+
+function request(
+ url: string,
+ method: RequestMethod = 'GET',
+ data?: D,
+): Promise {
+ const options: RequestInit = { method };
+
+ if (data !== undefined) {
+ options.body = JSON.stringify(data);
+ options.headers = {
+ 'Content-Type': 'application/json; charset=UTF-8',
+ };
+ }
+
+ 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?: D) =>
+ request(url, 'POST', data),
+ patch: (url: string, data?: D) =>
+ request(url, 'PATCH', data),
+ delete: (url: string) => request(url, 'DELETE'),
+};