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

+ +
+
+ +
+ + + {tempTodo && } +
+ + {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} + + + + + ) : ( +
{ + e.preventDefault(); + handleEdit(editedTodoTitle); + }} + > + setEditedTodoTitle(e.target.value)} + onKeyUp={e => { + if (e.key === 'Escape') { + setEditedTodoTitle(todo.title); + setIsEditing(false); + } + }} + onBlur={() => handleEdit(editedTodoTitle)} + ref={inputRef} + /> +
+ )} + +
+
+
+
+
+ ); +}; 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'), +};