diff --git a/.husky/.gitignore b/.husky/.gitignore new file mode 100644 index 00000000..31354ec1 --- /dev/null +++ b/.husky/.gitignore @@ -0,0 +1 @@ +_ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 00000000..9c5e0906 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +npm run pre:commit diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 00000000..89dd637e --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +npm run pre:push diff --git a/.lintstagedrc.js b/.lintstagedrc.js new file mode 100644 index 00000000..084f9d12 --- /dev/null +++ b/.lintstagedrc.js @@ -0,0 +1,8 @@ +module.exports = { + "**/*.js": [ + "npm run lint:js", + "npm run lint:format:check", + "npm run test:related", + ], + "*.{css,scss,html,md,json,yml,yaml}": ["npm run lint:format:check"], +}; diff --git a/public/index.html b/public/index.html index aa069f27..5a7b7d11 100644 --- a/public/index.html +++ b/public/index.html @@ -3,6 +3,16 @@ + + + + -
-
-

Hola mundo

-
-
- - ); +import React, { Component } from "react"; +import { Route } from "react-router-dom"; +import { v4 as uuid } from "uuid"; + +import { LOCAL_STORAGE_KEY } from "./utils/contants"; +import { + getPreviousLocalStorageValues, + setLocalStorageValues, +} from "./utils/methods"; + +import TodoList from "./components/TodoList"; + +class App extends Component { + constructor(props) { + super(props); + + this.state = { + todos: [], + darkMode: false, + }; + + this.handleAddTodo = this.handleAddTodo.bind(this); + this.handleMarkTodoAsDone = this.handleMarkTodoAsDone.bind(this); + this.handleDeleteTodo = this.handleDeleteTodo.bind(this); + this.handleEditTodo = this.handleEditTodo.bind(this); + this.handleThemeChange = this.handleThemeChange.bind(this); + this.handleClearCompletedTodos = this.handleClearCompletedTodos.bind(this); + this.handleIsEditingTodo = this.handleIsEditingTodo.bind(this); + } + + componentDidMount() { + const previousTodos = getPreviousLocalStorageValues(LOCAL_STORAGE_KEY); + + if (Array.isArray(previousTodos) && previousTodos.length > 0) { + this.setState({ + todos: previousTodos, + }); + } + } + + componentDidUpdate() { + const { todos } = this.state; + setLocalStorageValues(LOCAL_STORAGE_KEY, todos); + } + + handleMarkTodoAsDone(todoId) { + const { todos } = this.state; + + const updatedTodos = todos.map((todo) => { + if (todo.id === todoId) { + return { + ...todo, + done: !todo.done, + isEditing: false, + }; + } + return todo; + }); + + this.setState({ todos: updatedTodos }); + } + + handleDeleteTodo(todoId) { + const { todos } = this.state; + const filteredTodos = todos.filter((todo) => todo.id !== todoId); + this.setState({ todos: filteredTodos }); + } + + handleAddTodo(text) { + this.setState((prevState) => ({ + todos: [ + ...prevState.todos, + { + id: uuid(), + text: text, + done: false, + isEditing: false, + }, + ], + })); + } + + handleEditTodo(todoId, editedText) { + const { todos } = this.state; + + const updatedTodos = todos.map((todo) => { + if (todo.id === todoId) { + return { + ...todo, + text: editedText, + isEditing: false, + }; + } + + return todo; + }); + + this.setState({ todos: updatedTodos }); + } + + handleThemeChange() { + this.setState((prevState) => ({ + darkMode: !prevState.darkMode, + })); + } + + handleClearCompletedTodos() { + const { todos } = this.state; + const updatedTodos = todos.filter((todo) => !todo.done); + this.setState({ todos: updatedTodos }); + } + + handleIsEditingTodo(todoId) { + const { todos } = this.state; + + const mappedTodos = todos.map((todo) => { + if (todo.id === todoId) { + return { + ...todo, + isEditing: true, + }; + } + + return { + ...todo, + isEditing: false, + }; + }); + + this.setState({ + todos: mappedTodos, + }); + } + + render() { + const { todos, darkMode } = this.state; + const activeTodos = todos.filter((todo) => !todo.done); + const completedTodos = todos.filter((todo) => todo.done); + const activeTodosCount = todos.filter((todo) => !todo.done).length; + + return ( + <> + ( + + )} + /> + ( + + )} + /> + ( + + )} + /> + + ); + } } export default App; diff --git a/src/components/AppFooter/AppFooter.js b/src/components/AppFooter/AppFooter.js new file mode 100644 index 00000000..85c5f597 --- /dev/null +++ b/src/components/AppFooter/AppFooter.js @@ -0,0 +1,83 @@ +import React from "react"; +import { NavLink } from "react-router-dom"; +import classNames from "classnames"; + +import "./AppFooter.scss"; + +function AppFooter({ todosLeft, darkMode, handleClearCompletedTodos }) { + const darkModeTodosLeft = classNames({ + "items-left footer-item": true, + "footer-item-dark": darkMode, + }); + + const darkModeClear = classNames({ + "clear-all-div footer-item clear-button": true, + "footer-item-dark": darkMode, + }); + + const darkModeNavLink = classNames({ + "nav-link": true, + "nav-link-dark": darkMode, + }); + + const darkModeActive = classNames({ + active: true, + "active-dark": darkMode, + }); + + return ( +
+

{todosLeft} todos left

+ + +
+ ); +} + +export default AppFooter; diff --git a/src/components/AppFooter/AppFooter.scss b/src/components/AppFooter/AppFooter.scss new file mode 100644 index 00000000..6cf49f81 --- /dev/null +++ b/src/components/AppFooter/AppFooter.scss @@ -0,0 +1,76 @@ +.app-footer { + display: flex; + flex-direction: column; + align-items: flex-start; + + @media screen and(min-width: 560px) { + flex-direction: row; + justify-content: space-between; + align-items: center; + } + + font-size: 14px; + color: #969696; + border-top: 1px solid lightgray; +} + +.footer-item { + margin-bottom: 0.5rem; + + &:last-child { + margin-bottom: 0; + } + + @media screen and(min-width: 560px) { + margin-bottom: 0; + } + + &-dark { + color: white; + } + + &-dark:hover { + font-weight: bold; + } + + .navbar-nav { + @media screen and(min-width: 560px) { + justify-content: space-between; + flex-direction: row !important; + } + + .active { + color: black; + font-weight: 700; + + &-dark { + color: white; + } + } + } + + .nav-link { + @media screen and(min-width: 560px) { + padding: 8px; + } + + color: #969696; + + &:hover { + color: black; + font-weight: 700; + } + + &-dark:hover { + color: white; + } + } +} + +.clear-button { + border: none; + padding: 0; + background-color: transparent; + font-weight: normal; + display: inline; +} diff --git a/src/components/AppFooter/index.js b/src/components/AppFooter/index.js new file mode 100644 index 00000000..569644b5 --- /dev/null +++ b/src/components/AppFooter/index.js @@ -0,0 +1 @@ +export { default } from "./AppFooter"; diff --git a/src/components/CreateTodo/CreateTodo.js b/src/components/CreateTodo/CreateTodo.js new file mode 100644 index 00000000..12c209c3 --- /dev/null +++ b/src/components/CreateTodo/CreateTodo.js @@ -0,0 +1,96 @@ +import React, { Component } from "react"; +import classNames from "classnames"; + +import "./CreateTodo.scss"; + +class CreateTodo extends Component { + constructor(props) { + super(props); + + this.state = { + text: "", + hasError: false, + errorMessage: "", + }; + this.handleSubmit = this.handleSubmit.bind(this); + this.handleTodoInputChange = this.handleTodoInputChange.bind(this); + } + + handleSubmit(event) { + event.preventDefault(); + const { text: newText } = this.state; + const { handleAddTodo } = this.props; + + if (newText === "") { + this.setState({ + errorMessage: "Please enter a todo name", + hasError: true, + }); + } else { + this.setState({ + text: "", + errorMessage: "", + hasError: false, + }); + + handleAddTodo(newText); + } + } + + handleTodoInputChange(event) { + this.setState({ + text: event.target.value, + }); + } + + render() { + const { text, errorMessage, hasError } = this.state; + const { darkMode } = this.props; + + const backgroundDarkModeClass = classNames({ + row: true, + "create-todo-section": true, + "custom-section": true, + "custom-section-dark": darkMode, + }); + + const inputDarkModeClass = classNames({ + "addtodo-input": true, + "addtodo-input-dark": darkMode, + }); + + return ( +
+
+
+
+
+
+ +
+
+ {hasError && ( +
+
+

{errorMessage}

+
+
+ )} +
+ ); + } +} + +export default CreateTodo; diff --git a/src/components/CreateTodo/CreateTodo.scss b/src/components/CreateTodo/CreateTodo.scss new file mode 100644 index 00000000..e2d4d754 --- /dev/null +++ b/src/components/CreateTodo/CreateTodo.scss @@ -0,0 +1,54 @@ +@use "../../styles/partials/colors"; + +.create-todo-section { + margin: 2rem 0rem; + padding: 1.5rem 1rem; + width: 100%; +} + +.error-message-wrapper { + display: block; + margin-top: 1rem; + padding: 0.5rem 1rem; + background-color: hsl(10, 68%, 91%); + color: darkred; +} + +.checkbox-wrapper { + margin-right: 20px; +} + +.custom-checkbox { + border: none; + border: 2px solid #7e76b7; + color: #7e76b7; + border-radius: 50%; + width: 22px; + height: 22px; + + i { + width: fit-content; + height: fit-content; + } +} + +form { + flex-grow: 1; + background-color: transparent; +} + +.addtodo-input { + background-color: transparent; + flex-grow: 1; + border: none; + font-size: 15px; + transition: 0.4s; + + &-dark { + color: white; + } +} + +.addtodo-input:focus { + outline: none; +} diff --git a/src/components/CreateTodo/index.js b/src/components/CreateTodo/index.js new file mode 100644 index 00000000..fb11201a --- /dev/null +++ b/src/components/CreateTodo/index.js @@ -0,0 +1 @@ +export { default } from "./CreateTodo"; diff --git a/src/components/Layout/Layout.js b/src/components/Layout/Layout.js new file mode 100644 index 00000000..480de4a9 --- /dev/null +++ b/src/components/Layout/Layout.js @@ -0,0 +1,67 @@ +import React from "react"; +import classNames from "classnames"; + +import CreateTodo from "../CreateTodo"; +import AppFooter from "../AppFooter"; + +import lightModeImage from "../../img/header-light-mode-background-image.jpeg"; +import darkModeImage from "../../img/header-dark-mode-background-image.jpeg"; + +function Layout({ + handleThemeChange, + handleAddTodo, + darkMode, + todosLeft, + handleClearCompletedTodos, + children, +}) { + const bottomBackgroundClasses = classNames({ + "bottom-background": true, + "bottom-background-dark": darkMode, + }); + + const todosSectionClasses = classNames({ + "col todo-list-section custom-section mx-0 px-0 d-flex": true, + "custom-section-dark": darkMode, + }); + + return ( + <> +
+
+
+ bck-img +
+
+
+
+
+

TODOS

+ +
+ +
+ {children} + +
+
+ + ); +} + +export default Layout; diff --git a/src/components/Layout/index.js b/src/components/Layout/index.js new file mode 100644 index 00000000..d4dca0dc --- /dev/null +++ b/src/components/Layout/index.js @@ -0,0 +1 @@ +export { default } from "./Layout"; diff --git a/src/components/NoTodos/NoTodos.js b/src/components/NoTodos/NoTodos.js new file mode 100644 index 00000000..c5b389d7 --- /dev/null +++ b/src/components/NoTodos/NoTodos.js @@ -0,0 +1,141 @@ +import React from "react"; + +export default function NoTodos() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/components/NoTodos/index.js b/src/components/NoTodos/index.js new file mode 100644 index 00000000..d9736b02 --- /dev/null +++ b/src/components/NoTodos/index.js @@ -0,0 +1 @@ +export { default } from "./NoTodos"; diff --git a/src/components/Todo/Todo.js b/src/components/Todo/Todo.js new file mode 100644 index 00000000..d473ec31 --- /dev/null +++ b/src/components/Todo/Todo.js @@ -0,0 +1,145 @@ +import React, { Component, createRef } from "react"; +import classNames from "classnames"; + +import "./Todo.scss"; + +class Todo extends Component { + constructor(props) { + super(props); + + this.state = { + todoText: "", + }; + + this.inputRef = createRef(null); + + this.handleDoneCheckboxChange = this.handleDoneCheckboxChange.bind(this); + this.handleTodoDelete = this.handleTodoDelete.bind(this); + this.handleTodoNameChange = this.handleTodoNameChange.bind(this); + this.handleTodoSubmit = this.handleTodoSubmit.bind(this); + this.handleOpenForm = this.handleOpenForm.bind(this); + } + + componentDidMount() { + const { text } = this.props; + + this.setState({ + todoText: text, + }); + } + + componentDidUpdate() { + const { isEditing } = this.props; + + if (isEditing && this.inputRef.current) { + this.inputRef.current.focus(); + } + } + + handleDoneCheckboxChange(event) { + const { id, handleMarkTodoAsDone } = this.props; + handleMarkTodoAsDone(id, event); + } + + handleTodoDelete() { + const { id, handleDeleteTodo } = this.props; + handleDeleteTodo(id); + } + + handleTodoNameChange(event) { + this.setState({ + todoText: event.target.value, + }); + } + + handleTodoSubmit(event) { + event.preventDefault(); + + const { id, handleEditTodo } = this.props; + const { todoText } = this.state; + + handleEditTodo(id, todoText, event); + } + + handleOpenForm() { + const { handleIsEditingTodo, id } = this.props; + handleIsEditingTodo(id); + } + + render() { + const { done, darkMode, isEditing } = this.props; + const { todoText } = this.state; + + const todoInputClasses = classNames({ + "todo-item__text": true, + "todo-item__text--done": done, + "todo-item__text--dark": darkMode, + }); + + const todoButtonClasses = classNames({ + "todo-item__text": true, + "todo-item__text--done": done, + "todo-item__text--dark": darkMode, + }); + + const closeClasses = classNames({ + "close uil uil-times": true, + "close-dark": darkMode, + }); + + return ( +
  • +
    + +
    + +
    +
    + {isEditing ? ( +
    + +
    + ) : ( + + )} + +
  • + ); + } +} + +export default Todo; diff --git a/src/components/Todo/Todo.scss b/src/components/Todo/Todo.scss new file mode 100644 index 00000000..235caa30 --- /dev/null +++ b/src/components/Todo/Todo.scss @@ -0,0 +1,104 @@ +@use "../../styles/partials/colors" as c; + +.todo-item { + list-style-type: none; + width: 100%; + height: 75px; + border-bottom: 1px solid lightgray; + + &__text { + background-color: transparent; + cursor: pointer; + font-size: 15px; + width: 95%; + margin: 0; + outline: none; + border: none; + text-align: left; + padding: 0.5rem; + + &:focus { + outline: 2px solid lightgray; + } + + &--dark { + color: white; + } + + &--done { + color: rgb(139, 148, 156); + text-decoration: line-through; + } + } +} + +.checkbox-wrapper { + margin-right: 20px; + position: relative; + + input { + position: absolute; + opacity: 0; + cursor: pointer; + height: 100%; + width: 100%; + } +} + +.custom-checkbox { + border: none; + border: 2px solid #7e76b7; + color: #7e76b7; + border-radius: 50%; + width: 22px; + height: 22px; + + i { + width: fit-content; + height: fit-content; + } +} + +input:checked ~ .custom-checkbox { + color: white; + border: none; + + background-image: linear-gradient(125deg, #b62b86, #9f589e, #7e76b7, #3a8fd1); +} + +form { + flex-grow: 1; +} + +.edit-todo-input { + background-color: transparent; + cursor: pointer; + font-size: 15px; + width: 95%; + margin: 0; + outline: none; + border: none; + text-align: left; + padding: 0.5rem; + + &:focus { + outline: 2px solid lightgray; + } + + &-dark { + color: white; + } +} + +button { + border: none; + background-color: transparent; +} + +.close { + font-size: 18px; + + &-dark { + color: white; + } +} diff --git a/src/components/Todo/index.js b/src/components/Todo/index.js new file mode 100644 index 00000000..012972be --- /dev/null +++ b/src/components/Todo/index.js @@ -0,0 +1 @@ +export { default } from "./Todo"; diff --git a/src/components/TodoList/TodoList.js b/src/components/TodoList/TodoList.js new file mode 100644 index 00000000..56b95d52 --- /dev/null +++ b/src/components/TodoList/TodoList.js @@ -0,0 +1,55 @@ +import React from "react"; + +import "./TodoList.scss"; +import Todo from "../Todo"; +import NoTodos from "../NoTodos"; +import Layout from "../Layout"; + +function TodoList({ + todos, + hasTodos, + darkMode, + handleMarkTodoAsDone, + handleDeleteTodo, + handleEditTodo, + handleClearCompletedTodos, + todosLeft, + handleIsEditingTodo, + handleThemeChange, + handleAddTodo, +}) { + return ( + +
      + {!hasTodos ? ( +
      + +
      + ) : ( + todos.map((todo) => ( + + )) + )} +
    +
    + ); +} + +export default TodoList; diff --git a/src/components/TodoList/TodoList.scss b/src/components/TodoList/TodoList.scss new file mode 100644 index 00000000..9bf55157 --- /dev/null +++ b/src/components/TodoList/TodoList.scss @@ -0,0 +1,29 @@ +.todo-list-section { + display: flex; + flex-direction: column; + + @media screen and(min-width: 560px) { + max-height: auto; + min-height: 400px; + } +} + +.todos-list { + max-height: 330px; + margin-bottom: 0; + overflow-y: auto; + + @media screen and(min-width: 560px) { + flex-grow: 1; + } +} + +.no-todos { + width: 100%; + height: 100%; + + svg { + width: 60%; + height: auto; + } +} diff --git a/src/components/TodoList/index.js b/src/components/TodoList/index.js new file mode 100644 index 00000000..8d439448 --- /dev/null +++ b/src/components/TodoList/index.js @@ -0,0 +1 @@ +export { default } from "./TodoList"; diff --git a/src/img/header-dark-mode-background-image.jpeg b/src/img/header-dark-mode-background-image.jpeg new file mode 100644 index 00000000..6b87b6f3 Binary files /dev/null and b/src/img/header-dark-mode-background-image.jpeg differ diff --git a/src/img/header-light-mode-background-image.jpeg b/src/img/header-light-mode-background-image.jpeg new file mode 100644 index 00000000..2e14b759 Binary files /dev/null and b/src/img/header-light-mode-background-image.jpeg differ diff --git a/src/index.js b/src/index.js index 19bb154c..d09e8d51 100644 --- a/src/index.js +++ b/src/index.js @@ -1,14 +1,18 @@ import React from "react"; import ReactDOM from "react-dom"; +import { BrowserRouter } from "react-router-dom"; import "bootstrap/dist/css/bootstrap.min.css"; +import "./styles/styles.scss"; import App from "./App"; import reportWebVitals from "./reportWebVitals"; ReactDOM.render( - + + + , document.getElementById("root"), ); diff --git a/src/styles/partials/_colors.scss b/src/styles/partials/_colors.scss new file mode 100644 index 00000000..1f525fe0 --- /dev/null +++ b/src/styles/partials/_colors.scss @@ -0,0 +1,9 @@ +/* -------------------------------------------------------------------------- */ +/* LIGHT MODE */ +/* -------------------------------------------------------------------------- */ +$lightBgColor: #fafafa; + +/* -------------------------------------------------------------------------- */ +/* DARK MODE */ +/* -------------------------------------------------------------------------- */ +$darkBgColor: #121818; diff --git a/src/styles/partials/_typography.scss b/src/styles/partials/_typography.scss new file mode 100644 index 00000000..825ceb51 --- /dev/null +++ b/src/styles/partials/_typography.scss @@ -0,0 +1 @@ +$mainFont: "Work Sans", sans-serif; diff --git a/src/styles/styles.scss b/src/styles/styles.scss new file mode 100644 index 00000000..9b60bf3e --- /dev/null +++ b/src/styles/styles.scss @@ -0,0 +1,116 @@ +@use "./partials/colors" as c; +@use "./partials/typography" as t; + +* { + padding: 0; + margin: 0; + box-sizing: border-box; + font-family: "Work Sans", sans-serif; + transition: 0.4s; + + ::placeholder { + color: lightgray; + opacity: 1; + } + + :disabled { + background-color: transparent; + } +} + +body { + height: 100vh; +} + +#root { + position: relative; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.general-background { + height: 100vh; +} + +.top-background { + height: 40%; + position: relative; +} + +.gradient { + position: absolute; + width: 100%; + height: 100%; + background-image: linear-gradient(90deg, #b62b86, #9f589e, #7e76b7, #3a8fd1); + + mix-blend-mode: multiply; + opacity: 85%; + z-index: 1; +} + +.img-background { + object-fit: cover; + height: 100%; + width: 100%; +} + +.bottom-background { + background-color: c.$lightBgColor; + flex-grow: 1; + + &-dark { + background-color: c.$darkBgColor; + } +} + +.main-container { + position: absolute; + z-index: 1; + height: fit-content; + margin-bottom: 110px; + + @media screen and (min-width: 1024px) { + width: 100%; + max-width: 640px; + } +} + +.main-header { + display: flex; + justify-content: space-between; + align-items: center; + color: white; + + &-title { + font-weight: 300; + } + + button { + background: none; + outline: none; + border: none; + color: inherit; + + i { + cursor: pointer; + font-size: 24px; + } + } +} + +.custom-section { + border-radius: 5px; + -moz-box-shadow: 0px 0px 30px rgba(68, 68, 68, 0.6); + -webkit-box-shadow: 0px 0px 30px rgba(68, 68, 68, 0.6); + box-shadow: 0px 0px 30px rgba(68, 68, 68, 0.6); + background-color: white; + + &-dark { + color: white; + border: 1px solid white; + box-shadow: none; + background-color: c.$darkBgColor; + } +} diff --git a/src/utils/contants.js b/src/utils/contants.js new file mode 100644 index 00000000..8f5b1dc9 --- /dev/null +++ b/src/utils/contants.js @@ -0,0 +1 @@ +export const LOCAL_STORAGE_KEY = "reactjs-todo-list"; diff --git a/src/utils/methods.js b/src/utils/methods.js new file mode 100644 index 00000000..efc83e51 --- /dev/null +++ b/src/utils/methods.js @@ -0,0 +1,18 @@ +export function getPreviousLocalStorageValues(localStorageKey) { + const previousTodos = localStorage.getItem(localStorageKey); + + // If there are no todos + if (!previousTodos) { + return []; + } + // If there are previous todos + try { + return JSON.parse(previousTodos); + } catch (error) { + return []; + } +} + +export function setLocalStorageValues(localStorageKey, data) { + localStorage.setItem(localStorageKey, JSON.stringify(data)); +}