aboutsummaryrefslogtreecommitdiffstats
path: root/public/projects/react-small-apps/apps/todos/src
diff options
context:
space:
mode:
Diffstat (limited to 'public/projects/react-small-apps/apps/todos/src')
-rw-r--r--public/projects/react-small-apps/apps/todos/src/App.js47
-rw-r--r--public/projects/react-small-apps/apps/todos/src/App.scss42
-rw-r--r--public/projects/react-small-apps/apps/todos/src/components/forms/Button/Button.js17
-rw-r--r--public/projects/react-small-apps/apps/todos/src/components/forms/Fieldset/Fieldset.js10
-rw-r--r--public/projects/react-small-apps/apps/todos/src/components/forms/Form.scss163
-rw-r--r--public/projects/react-small-apps/apps/todos/src/components/forms/Input/Input.js39
-rw-r--r--public/projects/react-small-apps/apps/todos/src/components/forms/TextArea/TextArea.js24
-rw-r--r--public/projects/react-small-apps/apps/todos/src/components/forms/index.js7
-rw-r--r--public/projects/react-small-apps/apps/todos/src/components/layout/Footer/Footer.js15
-rw-r--r--public/projects/react-small-apps/apps/todos/src/components/layout/Footer/Footer.scss8
-rw-r--r--public/projects/react-small-apps/apps/todos/src/components/layout/Header/Header.js39
-rw-r--r--public/projects/react-small-apps/apps/todos/src/components/layout/Header/Header.scss18
-rw-r--r--public/projects/react-small-apps/apps/todos/src/components/layout/Header/UserOptions/UserOptions.js38
-rw-r--r--public/projects/react-small-apps/apps/todos/src/components/layout/Header/UserOptions/UserOptions.scss50
-rw-r--r--public/projects/react-small-apps/apps/todos/src/components/layout/Main/Main.js7
-rw-r--r--public/projects/react-small-apps/apps/todos/src/components/layout/Main/Main.scss3
-rw-r--r--public/projects/react-small-apps/apps/todos/src/components/layout/index.js5
-rw-r--r--public/projects/react-small-apps/apps/todos/src/index.js17
-rw-r--r--public/projects/react-small-apps/apps/todos/src/sass/abstracts/_mixins.scss24
-rw-r--r--public/projects/react-small-apps/apps/todos/src/sass/abstracts/_placeholders.scss5
-rw-r--r--public/projects/react-small-apps/apps/todos/src/sass/abstracts/_variables.scss12
-rw-r--r--public/projects/react-small-apps/apps/todos/src/services/LocalStorage.service.js26
-rw-r--r--public/projects/react-small-apps/apps/todos/src/store/auth/auth.slice.js23
-rw-r--r--public/projects/react-small-apps/apps/todos/src/store/index.js56
-rw-r--r--public/projects/react-small-apps/apps/todos/src/store/todos/todos.slice.js54
-rw-r--r--public/projects/react-small-apps/apps/todos/src/store/users/users.slice.js39
-rw-r--r--public/projects/react-small-apps/apps/todos/src/utilities/helpers.js20
-rw-r--r--public/projects/react-small-apps/apps/todos/src/utilities/hooks.js10
-rw-r--r--public/projects/react-small-apps/apps/todos/src/views/Account/Account.js26
-rw-r--r--public/projects/react-small-apps/apps/todos/src/views/Account/Account.scss15
-rw-r--r--public/projects/react-small-apps/apps/todos/src/views/LoginForm/LoginForm.js84
-rw-r--r--public/projects/react-small-apps/apps/todos/src/views/Logout/Logout.js18
-rw-r--r--public/projects/react-small-apps/apps/todos/src/views/Todo/Todo.js86
-rw-r--r--public/projects/react-small-apps/apps/todos/src/views/Todo/Todo.scss31
-rw-r--r--public/projects/react-small-apps/apps/todos/src/views/TodoForm/TodoForm.js42
-rw-r--r--public/projects/react-small-apps/apps/todos/src/views/TodoList/TodoList.js84
-rw-r--r--public/projects/react-small-apps/apps/todos/src/views/TodoList/TodoList.scss63
-rw-r--r--public/projects/react-small-apps/apps/todos/src/views/TodoList/TodoListFilters.js47
-rw-r--r--public/projects/react-small-apps/apps/todos/src/views/TodoList/TodoListItem.js55
39 files changed, 1369 insertions, 0 deletions
diff --git a/public/projects/react-small-apps/apps/todos/src/App.js b/public/projects/react-small-apps/apps/todos/src/App.js
new file mode 100644
index 0000000..66e7896
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/src/App.js
@@ -0,0 +1,47 @@
+import "modern-normalize";
+import { useSelector } from "react-redux";
+import { Navigate, Route, Routes } from "react-router-dom";
+import { Footer, Header, Main } from "./components/layout";
+import Account from "./views/Account/Account";
+import LoginForm from "./views/LoginForm/LoginForm";
+import Logout from "./views/Logout/Logout";
+import Todo from "./views/Todo/Todo";
+import TodoList from "./views/TodoList/TodoList";
+import "./App.scss";
+
+function App() {
+ const isLoggedIn = useSelector((state) => state.auth.isAuthenticated);
+
+ return (
+ <>
+ <Header />
+ <Main>
+ <Routes>
+ <Route
+ path="/account"
+ element={
+ isLoggedIn ? <Account /> : <Navigate replace to="/login" />
+ }
+ />
+ <Route path="/login" element={<LoginForm />} />
+ <Route path="/logout" element={<Logout />} />
+ <Route
+ path="/todo/:string"
+ element={isLoggedIn ? <Todo /> : <Navigate replace to="/login" />}
+ />
+ <Route
+ exact
+ strict
+ path="/"
+ element={
+ isLoggedIn ? <TodoList /> : <Navigate replace to="/login" />
+ }
+ />
+ </Routes>
+ </Main>
+ <Footer />
+ </>
+ );
+}
+
+export default App;
diff --git a/public/projects/react-small-apps/apps/todos/src/App.scss b/public/projects/react-small-apps/apps/todos/src/App.scss
new file mode 100644
index 0000000..81cb55d
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/src/App.scss
@@ -0,0 +1,42 @@
+@use "./sass/abstracts/variables" as var;
+
+a {
+ color: var.$primary-color;
+ text-decoration-thickness: 2px;
+ text-underline-offset: 3px;
+ transition: all 0.3s ease-in 0s;
+
+ &:hover,
+ &:focus {
+ color: var.$primary-color-light;
+ }
+
+ &:hover {
+ text-decoration-thickness: 1px;
+ }
+
+ &:active {
+ color: var.$primary-color-dark;
+ }
+}
+
+#app {
+ background: var.$background-color;
+ color: var.$foreground-color;
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
+ Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
+ font-size: 1rem;
+ line-height: 1.618;
+ display: flex;
+ flex-flow: column nowrap;
+ min-height: 100vh;
+}
+
+.container {
+ width: min(calc(100vw - 2rem), 80ch);
+ margin: auto;
+}
+
+.main {
+ flex: 1;
+}
diff --git a/public/projects/react-small-apps/apps/todos/src/components/forms/Button/Button.js b/public/projects/react-small-apps/apps/todos/src/components/forms/Button/Button.js
new file mode 100644
index 0000000..f9c7956
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/src/components/forms/Button/Button.js
@@ -0,0 +1,17 @@
+function Button({ children, modifiers, onClickHandler, type = "button" }) {
+ let classNames = "btn";
+
+ if (modifiers && modifiers.length > 0) {
+ for (let i = 0; i < modifiers.length; i++) {
+ classNames += ` btn--${modifiers[i]}`;
+ }
+ }
+
+ return (
+ <button type={type} className={classNames} onClick={onClickHandler}>
+ {children}
+ </button>
+ );
+}
+
+export default Button;
diff --git a/public/projects/react-small-apps/apps/todos/src/components/forms/Fieldset/Fieldset.js b/public/projects/react-small-apps/apps/todos/src/components/forms/Fieldset/Fieldset.js
new file mode 100644
index 0000000..53dafd4
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/src/components/forms/Fieldset/Fieldset.js
@@ -0,0 +1,10 @@
+function Fieldset({ children, legend }) {
+ return (
+ <fieldset className="form__fieldset">
+ <legend className="form__legend">{legend}</legend>
+ {children}
+ </fieldset>
+ );
+}
+
+export default Fieldset;
diff --git a/public/projects/react-small-apps/apps/todos/src/components/forms/Form.scss b/public/projects/react-small-apps/apps/todos/src/components/forms/Form.scss
new file mode 100644
index 0000000..1b07c07
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/src/components/forms/Form.scss
@@ -0,0 +1,163 @@
+@use "../../sass/abstracts/variables" as var;
+
+.form {
+ &__fieldset {
+ border: 2px solid var.$primary-color;
+ padding: 1rem 1rem 2rem;
+ width: max-content;
+ }
+
+ &--login & {
+ &__fieldset {
+ margin: auto;
+ }
+ }
+
+ &--todo {
+ margin-top: 1rem;
+ }
+}
+
+.form__legend {
+ color: var.$primary-color;
+ font-size: 1.1rem;
+ font-weight: 600;
+ padding: 0 1rem;
+}
+
+.form__label {
+ display: block;
+ margin-bottom: 0.5rem;
+ color: var.$primary-color-dark;
+ font-size: 0.9rem;
+ font-weight: 600;
+ letter-spacing: 1px;
+ text-transform: uppercase;
+ cursor: pointer;
+}
+
+.form__field {
+ border: 2px solid var.$primary-color;
+ transition: all 0.3s ease-in-out 0s;
+
+ &:not([type="checkbox"]) {
+ width: 100%;
+ padding: 0.5rem;
+
+ &:focus {
+ box-shadow: 2px 2px 2px var.$shadow-color;
+ outline: none;
+ transform: translateY(-2px) translateX(-2px);
+ }
+
+ & + * {
+ margin-top: 1rem;
+ }
+ }
+
+ &--textarea {
+ min-height: 10rem;
+ min-width: 20rem;
+ }
+}
+
+.btn {
+ display: block;
+ padding: clamp(0.5rem, 3vw, 0.8rem) clamp(0.5rem, 3vw, 1rem);
+ border: none;
+ border-radius: 3px;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+.btn--submit {
+ background: var.$primary-color;
+ color: hsl(0, 0%, 100%);
+ margin-left: auto;
+ margin-right: auto;
+ transition: all 0.3s ease-in-out 0s;
+
+ &:hover {
+ background-color: var.$primary-color-light;
+ transform: scale(1.1);
+ }
+
+ &:active {
+ background-color: var.$primary-color-dark;
+ transform: scale(1);
+ }
+}
+
+.btn--user {
+ background: var.$secondary-color;
+ border: 2px solid var.$primary-color;
+ border-radius: 50%;
+ width: 5rem;
+ height: 5rem;
+ padding: 1rem;
+
+ &:hover {
+ background: var.$secondary-color-light;
+ border-color: var.$primary-color-light;
+ }
+
+ &:active {
+ background: var.$secondary-color-dark;
+ border-color: var.$primary-color-dark;
+ }
+}
+
+.btn--action {
+ background-image: linear-gradient(
+ to left,
+ var.$background-color,
+ var.$background-color 50%,
+ var.$primary-color 50%
+ );
+ background-size: 201% 100%;
+ background-position: 100% 0;
+ background-repeat: no-repeat;
+ border: 3px solid var.$primary-color;
+ border-radius: 6px;
+ color: var.$primary-color;
+ transition: all 0.3s ease-in-out 0s;
+
+ &:hover {
+ background-position: 0 0;
+ color: var.$foreground-color-alt;
+ }
+
+ &:active {
+ background-position: 100% 0;
+ color: var.$primary-color-dark;
+ text-decoration: underline 1px;
+ }
+}
+
+.btn--delete {
+ background-image: linear-gradient(
+ to left,
+ var.$background-color,
+ var.$background-color 50%,
+ var.$delete-color 50%
+ );
+ border-color: var.$delete-color;
+ color: var.$delete-color;
+
+ &:hover {
+ color: var.$foreground-color-alt;
+ }
+
+ &:active {
+ color: var.$delete-color;
+ }
+}
+
+.btn--filters {
+ background: var.$background-color;
+ border: 1px solid #666;
+
+ &.btn--current {
+ background: #ededed;
+ }
+}
diff --git a/public/projects/react-small-apps/apps/todos/src/components/forms/Input/Input.js b/public/projects/react-small-apps/apps/todos/src/components/forms/Input/Input.js
new file mode 100644
index 0000000..86e660c
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/src/components/forms/Input/Input.js
@@ -0,0 +1,39 @@
+function Input({
+ label,
+ id,
+ name,
+ value,
+ updateValue,
+ onBlurHandler,
+ required,
+ type = "text",
+}) {
+ const handleChange = (e) => {
+ e.target.type === "checkbox"
+ ? updateValue(e.target.checked)
+ : updateValue(e.target.value);
+ };
+
+ return (
+ <>
+ {label && (
+ <label htmlFor={id} className="form__label">
+ {label}
+ </label>
+ )}
+ <input
+ type={type}
+ id={id}
+ name={name}
+ value={type === "checkbox" ? undefined : value}
+ checked={type === "checkbox" ? value : null}
+ required={required ? "required" : false}
+ onChange={handleChange}
+ onBlur={onBlurHandler}
+ className="form__field"
+ />
+ </>
+ );
+}
+
+export default Input;
diff --git a/public/projects/react-small-apps/apps/todos/src/components/forms/TextArea/TextArea.js b/public/projects/react-small-apps/apps/todos/src/components/forms/TextArea/TextArea.js
new file mode 100644
index 0000000..78a10b6
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/src/components/forms/TextArea/TextArea.js
@@ -0,0 +1,24 @@
+function TextArea({ label, id, value, updateValue }) {
+ const handleChange = (e) => {
+ updateValue(e.target.value);
+ };
+
+ return (
+ <>
+ {label ? (
+ <label htmlFor={id} className="form__label">
+ {label}
+ </label>
+ ) : (
+ ""
+ )}
+ <textarea
+ value={value}
+ onChange={handleChange}
+ className="form__field form__field--textarea"
+ />
+ </>
+ );
+}
+
+export default TextArea;
diff --git a/public/projects/react-small-apps/apps/todos/src/components/forms/index.js b/public/projects/react-small-apps/apps/todos/src/components/forms/index.js
new file mode 100644
index 0000000..76cc2c4
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/src/components/forms/index.js
@@ -0,0 +1,7 @@
+import Button from "./Button/Button";
+import Fieldset from "./Fieldset/Fieldset";
+import Input from "./Input/Input";
+import TextArea from "./TextArea/TextArea";
+import "./Form.scss";
+
+export { Button, Fieldset, Input, TextArea };
diff --git a/public/projects/react-small-apps/apps/todos/src/components/layout/Footer/Footer.js b/public/projects/react-small-apps/apps/todos/src/components/layout/Footer/Footer.js
new file mode 100644
index 0000000..8e5dc73
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/src/components/layout/Footer/Footer.js
@@ -0,0 +1,15 @@
+import "./Footer.scss";
+
+function Footer() {
+ return (
+ <footer className="footer">
+ <div className="container">
+ <p className="copyright">
+ React Redux ToDos. MIT 2021. Armand Philippot.
+ </p>
+ </div>
+ </footer>
+ );
+}
+
+export default Footer;
diff --git a/public/projects/react-small-apps/apps/todos/src/components/layout/Footer/Footer.scss b/public/projects/react-small-apps/apps/todos/src/components/layout/Footer/Footer.scss
new file mode 100644
index 0000000..eb34fd9
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/src/components/layout/Footer/Footer.scss
@@ -0,0 +1,8 @@
+.footer {
+ text-align: center;
+ padding: 1rem 0;
+}
+
+.copyright {
+ font-size: 0.9rem;
+}
diff --git a/public/projects/react-small-apps/apps/todos/src/components/layout/Header/Header.js b/public/projects/react-small-apps/apps/todos/src/components/layout/Header/Header.js
new file mode 100644
index 0000000..75ecf8b
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/src/components/layout/Header/Header.js
@@ -0,0 +1,39 @@
+import { useSelector } from "react-redux";
+import UserOptions from "./UserOptions/UserOptions";
+import "./Header.scss";
+import { useEffect, useRef, useState } from "react";
+import { useLocation } from "react-router";
+
+function Header() {
+ const [isExpanded, setIsExpanded] = useState(false);
+ const currentUser = useSelector((state) => state.auth.currentUser);
+ const headerRef = useRef(null);
+ const location = useLocation();
+
+ useEffect(() => {
+ setIsExpanded(false);
+ }, [location.pathname]);
+
+ const closeModal = (e) => {
+ if (!headerRef.current.contains(e.relatedTarget)) setIsExpanded(false);
+ };
+
+ return (
+ <header ref={headerRef} className="header" onBlur={closeModal}>
+ <div className="container">
+ <h1 className="branding">ToDos App</h1>
+ {currentUser ? (
+ <UserOptions
+ username={currentUser.username}
+ isExpanded={isExpanded}
+ setIsExpanded={setIsExpanded}
+ />
+ ) : (
+ ""
+ )}
+ </div>
+ </header>
+ );
+}
+
+export default Header;
diff --git a/public/projects/react-small-apps/apps/todos/src/components/layout/Header/Header.scss b/public/projects/react-small-apps/apps/todos/src/components/layout/Header/Header.scss
new file mode 100644
index 0000000..e1b0b27
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/src/components/layout/Header/Header.scss
@@ -0,0 +1,18 @@
+@use "../../../sass/abstracts/variables" as var;
+
+.header {
+ border-bottom: 1px solid var.$primary-color;
+
+ .container {
+ display: flex;
+ flex-flow: row wrap;
+ align-items: center;
+ justify-content: space-between;
+ padding: 1rem 0;
+ position: relative;
+ }
+}
+
+.branding {
+ color: var.$primary-color;
+}
diff --git a/public/projects/react-small-apps/apps/todos/src/components/layout/Header/UserOptions/UserOptions.js b/public/projects/react-small-apps/apps/todos/src/components/layout/Header/UserOptions/UserOptions.js
new file mode 100644
index 0000000..92e8687
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/src/components/layout/Header/UserOptions/UserOptions.js
@@ -0,0 +1,38 @@
+import { Link } from "react-router-dom";
+import { Button } from "../../../forms";
+import "./UserOptions.scss";
+
+function UserOptions({ username, isExpanded, setIsExpanded }) {
+ const displayUserOptions = () => {
+ return (
+ <nav className="nav nav--user">
+ <ul className="nav__list">
+ <li className="nav__item">
+ <Link to="/account" className="nav__link">
+ Account
+ </Link>
+ </li>
+ <li className="nav__item">
+ <Link to="/logout" className="nav__link">
+ Logout
+ </Link>
+ </li>
+ </ul>
+ </nav>
+ );
+ };
+
+ return (
+ <>
+ <Button
+ modifiers={["user"]}
+ onClickHandler={() => setIsExpanded(!isExpanded)}
+ >
+ {username}
+ </Button>
+ {isExpanded ? displayUserOptions() : ""}
+ </>
+ );
+}
+
+export default UserOptions;
diff --git a/public/projects/react-small-apps/apps/todos/src/components/layout/Header/UserOptions/UserOptions.scss b/public/projects/react-small-apps/apps/todos/src/components/layout/Header/UserOptions/UserOptions.scss
new file mode 100644
index 0000000..bb98c6a
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/src/components/layout/Header/UserOptions/UserOptions.scss
@@ -0,0 +1,50 @@
+@use "../../../../sass/abstracts/mixins" as mix;
+@use "../../../../sass/abstracts/placeholders";
+@use "../../../../sass/abstracts/variables" as var;
+
+.nav {
+ &__list {
+ @extend %list-reset;
+ }
+
+ &--user {
+ border: 1px solid var.$primary-color;
+ box-shadow: 0 2px 3px 0 var.$shadow-color;
+ position: absolute;
+ top: 100%;
+ right: 0;
+
+ &::before {
+ content: "";
+ display: block;
+ position: absolute;
+ top: -1.15rem;
+ left: calc(50% - 1.15rem / 2);
+ @include mix.triangle(1.2rem, var.$primary-color, up);
+ }
+
+ &::after {
+ content: "";
+ display: block;
+ position: absolute;
+ top: -1rem;
+ left: calc(50% - 1rem / 2);
+ @include mix.triangle(1rem, var.$background-color, up);
+ }
+ }
+
+ &--user & {
+ &__link {
+ display: block;
+ padding: 0.5rem 1rem;
+ background: var.$background-color;
+
+ &:focus {
+ background: var.$primary-color;
+ color: var.$foreground-color-alt;
+ outline: none;
+ transition: none;
+ }
+ }
+ }
+}
diff --git a/public/projects/react-small-apps/apps/todos/src/components/layout/Main/Main.js b/public/projects/react-small-apps/apps/todos/src/components/layout/Main/Main.js
new file mode 100644
index 0000000..7a9cb2d
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/src/components/layout/Main/Main.js
@@ -0,0 +1,7 @@
+import "./Main.scss";
+
+function Main({ children }) {
+ return <main className="main container">{children}</main>;
+}
+
+export default Main;
diff --git a/public/projects/react-small-apps/apps/todos/src/components/layout/Main/Main.scss b/public/projects/react-small-apps/apps/todos/src/components/layout/Main/Main.scss
new file mode 100644
index 0000000..42c950f
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/src/components/layout/Main/Main.scss
@@ -0,0 +1,3 @@
+.main {
+ padding: 3rem 0;
+}
diff --git a/public/projects/react-small-apps/apps/todos/src/components/layout/index.js b/public/projects/react-small-apps/apps/todos/src/components/layout/index.js
new file mode 100644
index 0000000..1e1af4c
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/src/components/layout/index.js
@@ -0,0 +1,5 @@
+import Footer from "./Footer/Footer";
+import Header from "./Header/Header";
+import Main from "./Main/Main";
+
+export { Footer, Header, Main };
diff --git a/public/projects/react-small-apps/apps/todos/src/index.js b/public/projects/react-small-apps/apps/todos/src/index.js
new file mode 100644
index 0000000..1c0aabf
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/src/index.js
@@ -0,0 +1,17 @@
+import React from "react";
+import ReactDOM from "react-dom";
+import { Provider } from "react-redux";
+import { BrowserRouter } from "react-router-dom";
+import App from "./App";
+import store from "./store";
+
+ReactDOM.render(
+ <React.StrictMode>
+ <Provider store={store}>
+ <BrowserRouter basename={process.env.PUBLIC_URL}>
+ <App />
+ </BrowserRouter>
+ </Provider>
+ </React.StrictMode>,
+ document.getElementById("app")
+);
diff --git a/public/projects/react-small-apps/apps/todos/src/sass/abstracts/_mixins.scss b/public/projects/react-small-apps/apps/todos/src/sass/abstracts/_mixins.scss
new file mode 100644
index 0000000..e1733dc
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/src/sass/abstracts/_mixins.scss
@@ -0,0 +1,24 @@
+@use "sass:math";
+
+/// Create a triangle with CSS.
+/// @link https://sass-lang.com/documentation/at-rules/control/if#else-if
+@mixin triangle($size, $color, $direction) {
+ height: 0;
+ width: 0;
+
+ border-color: transparent;
+ border-style: solid;
+ border-width: math.div($size, 2);
+
+ @if $direction == up {
+ border-bottom-color: $color;
+ } @else if $direction == right {
+ border-left-color: $color;
+ } @else if $direction == down {
+ border-top-color: $color;
+ } @else if $direction == left {
+ border-right-color: $color;
+ } @else {
+ @error "Unknown direction #{$direction}.";
+ }
+}
diff --git a/public/projects/react-small-apps/apps/todos/src/sass/abstracts/_placeholders.scss b/public/projects/react-small-apps/apps/todos/src/sass/abstracts/_placeholders.scss
new file mode 100644
index 0000000..565959c
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/src/sass/abstracts/_placeholders.scss
@@ -0,0 +1,5 @@
+%list-reset {
+ list-style-type: none;
+ margin: 0;
+ padding: 0;
+}
diff --git a/public/projects/react-small-apps/apps/todos/src/sass/abstracts/_variables.scss b/public/projects/react-small-apps/apps/todos/src/sass/abstracts/_variables.scss
new file mode 100644
index 0000000..8f1717e
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/src/sass/abstracts/_variables.scss
@@ -0,0 +1,12 @@
+$background-color: hsl(0, 0%, 100%);
+$foreground-color: hsl(0, 0%, 0%);
+$foreground-color-alt: hsl(0, 0%, 100%);
+$primary-color: hsl(209, 54%, 32%);
+$primary-color-light: hsl(209, 54%, 37%);
+$primary-color-dark: hsl(209, 54%, 27%);
+$secondary-color: hsl(32, 92%, 86%);
+$secondary-color-light: hsl(32, 92%, 91%);
+$secondary-color-dark: hsl(32, 92%, 81%);
+$shadow-color: rgba(26, 57, 86, 0.55);
+$done-color: hsl(32, 63%, 50%);
+$delete-color: hsl(0, 63%, 50%);
diff --git a/public/projects/react-small-apps/apps/todos/src/services/LocalStorage.service.js b/public/projects/react-small-apps/apps/todos/src/services/LocalStorage.service.js
new file mode 100644
index 0000000..c6fb76c
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/src/services/LocalStorage.service.js
@@ -0,0 +1,26 @@
+export const LocalStorage = {
+ get(key) {
+ try {
+ const serialItem = localStorage.getItem(key);
+ if (!serialItem) return undefined;
+ return JSON.parse(serialItem);
+ } catch (e) {
+ console.log(e);
+ return undefined;
+ }
+ },
+ set(key, value) {
+ try {
+ const serialItem = JSON.stringify(value);
+ localStorage.setItem(key, serialItem);
+ } catch (e) {
+ console.log(e);
+ }
+ },
+ remove(key) {
+ localStorage.removeItem(key);
+ },
+ clear() {
+ localStorage.clear();
+ },
+};
diff --git a/public/projects/react-small-apps/apps/todos/src/store/auth/auth.slice.js b/public/projects/react-small-apps/apps/todos/src/store/auth/auth.slice.js
new file mode 100644
index 0000000..9e2632d
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/src/store/auth/auth.slice.js
@@ -0,0 +1,23 @@
+import { createSlice } from "@reduxjs/toolkit";
+
+const initialState = {
+ currentUser: null,
+ isAuthenticated: false,
+};
+
+export const authSlice = createSlice({
+ name: "auth",
+ initialState,
+ reducers: {
+ login: (state, action) => {
+ return { ...state, currentUser: action.payload, isAuthenticated: true };
+ },
+ logout: () => {
+ return initialState;
+ },
+ },
+});
+
+export const { login, logout } = authSlice.actions;
+
+export default authSlice.reducer;
diff --git a/public/projects/react-small-apps/apps/todos/src/store/index.js b/public/projects/react-small-apps/apps/todos/src/store/index.js
new file mode 100644
index 0000000..8d4fe43
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/src/store/index.js
@@ -0,0 +1,56 @@
+import { configureStore } from "@reduxjs/toolkit";
+import { LocalStorage } from "../services/LocalStorage.service";
+import authReducer from "./auth/auth.slice";
+import todosReducer from "./todos/todos.slice";
+import usersReducer from "./users/users.slice";
+
+const reducer = {
+ auth: authReducer,
+ todos: todosReducer,
+ users: usersReducer,
+};
+
+const todosMiddleware = (store) => (next) => (action) => {
+ const { type } = action;
+ const result = next(action);
+
+ switch (type) {
+ case "auth/login":
+ const authState = store.getState().auth;
+ LocalStorage.set("todoUser", authState.currentUser);
+ break;
+ case "auth/logout":
+ LocalStorage.remove("todoUser");
+ break;
+ case "todos/updateTodo":
+ const todosState = store.getState().todos;
+ LocalStorage.set("todoList", todosState);
+ break;
+ default:
+ break;
+ }
+
+ return result;
+};
+
+const userFromLocalStorage = LocalStorage.get("todoUser");
+const preloadedAuth = userFromLocalStorage
+ ? { isAuthenticated: true, currentUser: userFromLocalStorage }
+ : undefined;
+
+const todosFromLocalStorage = LocalStorage.get("todoList");
+const preloadedTodos = todosFromLocalStorage
+ ? todosFromLocalStorage
+ : undefined;
+
+const preloadedState = {
+ auth: preloadedAuth,
+ todos: preloadedTodos,
+};
+
+export default configureStore({
+ reducer,
+ middleware: (getDefaultMiddleware) =>
+ getDefaultMiddleware().concat(todosMiddleware),
+ preloadedState,
+});
diff --git a/public/projects/react-small-apps/apps/todos/src/store/todos/todos.slice.js b/public/projects/react-small-apps/apps/todos/src/store/todos/todos.slice.js
new file mode 100644
index 0000000..1834d51
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/src/store/todos/todos.slice.js
@@ -0,0 +1,54 @@
+import { createSlice, nanoid } from "@reduxjs/toolkit";
+
+export const todosSlice = createSlice({
+ name: "todos",
+ initialState: [
+ {
+ id: nanoid(),
+ createdAt: new Date().toISOString(),
+ title: "Build a todo app",
+ body: "",
+ userId: "demo",
+ done: false,
+ },
+ ],
+ reducers: {
+ addTodo: {
+ reducer: (state, action) => {
+ state.push(action.payload);
+ },
+ prepare: ({ userId, title, body = "" }) => {
+ const id = nanoid();
+ const createdAt = new Date().toISOString();
+ return { payload: { id, createdAt, userId, title, body } };
+ },
+ },
+ deleteTodo: (state, action) => {
+ return state.filter((todo) => todo.id !== action.payload);
+ },
+ updateTodo: (state, action) => {
+ return state.map((todo) => {
+ if (todo.id !== action.payload.id) return todo;
+ return { ...todo, ...action.payload };
+ });
+ },
+ toggleTodo: (state, action) => {
+ const todoId = action.payload;
+ const index = state.findIndex((todo) => todo.id === todoId);
+ const todo = state[index];
+ return [
+ ...state.slice(0, index),
+ { ...todo, done: !todo.done },
+ ...state.slice(index + 1),
+ ];
+ },
+ deleteAllTodos: () => {
+ return [];
+ },
+ },
+});
+
+export const { addTodo, deleteTodo, updateTodo, toggleTodo, deleteAllTodos } =
+ todosSlice.actions;
+
+export default todosSlice.reducer;
diff --git a/public/projects/react-small-apps/apps/todos/src/store/users/users.slice.js b/public/projects/react-small-apps/apps/todos/src/store/users/users.slice.js
new file mode 100644
index 0000000..ecce733
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/src/store/users/users.slice.js
@@ -0,0 +1,39 @@
+import { createSlice, nanoid } from "@reduxjs/toolkit";
+
+export const usersSlice = createSlice({
+ name: "users",
+ initialState: [
+ {
+ id: "demo",
+ createdAt: new Date().toISOString(),
+ username: "Demo",
+ email: "demo@email.com",
+ password: "demo",
+ },
+ ],
+ reducers: {
+ addUser: {
+ reducer: (state, action) => {
+ state.push(action.payload);
+ },
+ prepare: (username, email, password) => {
+ const id = nanoid();
+ const createdAt = new Date().toISOString();
+ return { payload: { id, username, email, password, createdAt } };
+ },
+ },
+ deleteUser: (state, action) => {
+ state.filter((user) => user.id !== action.payload);
+ },
+ updateUser: (state, action) => {
+ state.map((user) => {
+ if (user.id !== action.payload.id) return user;
+ return { ...user, ...action.payload };
+ });
+ },
+ },
+});
+
+export const { addUser, deleteUser, updateUser } = usersSlice.actions;
+
+export default usersSlice.reducer;
diff --git a/public/projects/react-small-apps/apps/todos/src/utilities/helpers.js b/public/projects/react-small-apps/apps/todos/src/utilities/helpers.js
new file mode 100644
index 0000000..ab5ba41
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/src/utilities/helpers.js
@@ -0,0 +1,20 @@
+/**
+ * Convert a text into a slug or id.
+ * https://gist.github.com/codeguy/6684588#gistcomment-3332719
+ *
+ * @param {string} text Text to slugify.
+ */
+const slugify = (text) => {
+ return text
+ .toString()
+ .normalize("NFD")
+ .replace(/[\u0300-\u036f]/g, "")
+ .toLowerCase()
+ .trim()
+ .replace(/\s+/g, "-")
+ .replace(/[^\w-]+/g, "-")
+ .replace(/--+/g, "-")
+ .replace(/^-|-$/g, "");
+};
+
+export { slugify };
diff --git a/public/projects/react-small-apps/apps/todos/src/utilities/hooks.js b/public/projects/react-small-apps/apps/todos/src/utilities/hooks.js
new file mode 100644
index 0000000..0291324
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/src/utilities/hooks.js
@@ -0,0 +1,10 @@
+import { useCallback, useState } from "react";
+
+function useToggle(initialState = false) {
+ const [state, setState] = useState(initialState);
+ const toggle = useCallback(() => setState((state) => !state), []);
+
+ return [state, toggle];
+}
+
+export default useToggle;
diff --git a/public/projects/react-small-apps/apps/todos/src/views/Account/Account.js b/public/projects/react-small-apps/apps/todos/src/views/Account/Account.js
new file mode 100644
index 0000000..85aab6b
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/src/views/Account/Account.js
@@ -0,0 +1,26 @@
+import { useSelector } from "react-redux";
+import { Link } from "react-router-dom";
+import "./Account.scss";
+
+function Account() {
+ const currentUser = useSelector((state) => state.auth.currentUser);
+
+ return (
+ <div>
+ <Link to="/">Back to your todo list</Link>
+ <h2>Account</h2>
+ <dl className="account-details">
+ <dt className="account-details__headings">Username</dt>
+ <dd className="account-details__data">{currentUser.username}</dd>
+ <dt className="account-details__headings">Email</dt>
+ <dd className="account-details__data">{currentUser.email}</dd>
+ <dt className="account-details__headings">Creation date</dt>
+ <dd className="account-details__data">
+ {new Date(currentUser.createdAt).toLocaleDateString()}
+ </dd>
+ </dl>
+ </div>
+ );
+}
+
+export default Account;
diff --git a/public/projects/react-small-apps/apps/todos/src/views/Account/Account.scss b/public/projects/react-small-apps/apps/todos/src/views/Account/Account.scss
new file mode 100644
index 0000000..a8b2ba7
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/src/views/Account/Account.scss
@@ -0,0 +1,15 @@
+.account-details {
+ display: grid;
+ grid-template-columns: max-content minmax(0, 1fr);
+ row-gap: 0.5rem;
+ column-gap: 1rem;
+
+ &__headings {
+ grid-column: 1;
+ font-weight: 600;
+ }
+
+ &__data {
+ grid-column: 2;
+ }
+}
diff --git a/public/projects/react-small-apps/apps/todos/src/views/LoginForm/LoginForm.js b/public/projects/react-small-apps/apps/todos/src/views/LoginForm/LoginForm.js
new file mode 100644
index 0000000..6c90e67
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/src/views/LoginForm/LoginForm.js
@@ -0,0 +1,84 @@
+import { useState } from "react";
+import { useDispatch, useSelector } from "react-redux";
+import { useNavigate } from "react-router-dom";
+import { Button, Fieldset, Input } from "../../components/forms";
+import { login } from "../../store/auth/auth.slice";
+
+function LoginForm() {
+ const [inputEmailValue, setInputEmailValue] = useState("");
+ const [inputPasswordValue, setInputPasswordValue] = useState("");
+ const [errorMsg, setErrorMsg] = useState("");
+ const usersList = useSelector((state) => state.users);
+ const dispatch = useDispatch();
+ const navigate = useNavigate();
+
+ const getCurrentUser = (email) => {
+ return usersList.find((user) => user.email === email);
+ };
+
+ const isValidUser = (email) => {
+ const currentUser = getCurrentUser(email);
+ return currentUser ? true : false;
+ };
+
+ const isValidPassword = (currentUser, password) => {
+ return currentUser.password === password;
+ };
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ if (isValidUser(inputEmailValue)) {
+ const currentUser = getCurrentUser(inputEmailValue);
+
+ if (isValidPassword(currentUser, inputPasswordValue)) {
+ setErrorMsg("");
+ dispatch(login(currentUser));
+ navigate("/");
+ } else {
+ setErrorMsg("The password does not match.");
+ }
+ } else {
+ setErrorMsg("This email address does not exist.");
+ }
+ };
+
+ const displayError = (msg) => {
+ return msg ? <p>{msg}</p> : "";
+ };
+
+ return (
+ <form
+ action="#"
+ method="post"
+ className="form form--login"
+ onSubmit={handleSubmit}
+ >
+ {displayError(errorMsg)}
+ <Fieldset legend="Sign In">
+ <Input
+ label="Email"
+ id="login-email"
+ name="login-email"
+ value={inputEmailValue}
+ updateValue={setInputEmailValue}
+ type="email"
+ required
+ />
+ <Input
+ label="Password"
+ id="login-password"
+ name="login-password"
+ value={inputPasswordValue}
+ updateValue={setInputPasswordValue}
+ type="password"
+ required
+ />
+ <Button type="submit" modifiers={["submit"]}>
+ Log in
+ </Button>
+ </Fieldset>
+ </form>
+ );
+}
+
+export default LoginForm;
diff --git a/public/projects/react-small-apps/apps/todos/src/views/Logout/Logout.js b/public/projects/react-small-apps/apps/todos/src/views/Logout/Logout.js
new file mode 100644
index 0000000..cf9bbd0
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/src/views/Logout/Logout.js
@@ -0,0 +1,18 @@
+import { useEffect } from "react";
+import { useDispatch } from "react-redux";
+import { useNavigate } from "react-router-dom";
+import { logout } from "../../store/auth/auth.slice";
+
+function Logout() {
+ const dispatch = useDispatch();
+ const navigate = useNavigate();
+
+ useEffect(() => {
+ dispatch(logout());
+ navigate("/");
+ });
+
+ return <>Logging out...</>;
+}
+
+export default Logout;
diff --git a/public/projects/react-small-apps/apps/todos/src/views/Todo/Todo.js b/public/projects/react-small-apps/apps/todos/src/views/Todo/Todo.js
new file mode 100644
index 0000000..1160ab5
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/src/views/Todo/Todo.js
@@ -0,0 +1,86 @@
+import { useEffect, useRef } from "react";
+import { useDispatch, useSelector } from "react-redux";
+import { Link, useLocation } from "react-router-dom";
+import { updateTodo } from "../../store/todos/todos.slice";
+import useToggle from "../../utilities/hooks";
+import "./Todo.scss";
+
+function Todo() {
+ const [isTitleEditable, setIsTitleEditable] = useToggle();
+ const [isBodyEditable, setIsBodyEditable] = useToggle();
+ const titleRef = useRef(null);
+ const bodyRef = useRef(null);
+ const location = useLocation();
+ const todoId = location.state.todoId;
+ const currentTodo = useSelector((state) => state.todos).find(
+ (todo) => todo.id === todoId
+ );
+ const { title, body } = currentTodo;
+ const dispatch = useDispatch();
+
+ useEffect(() => {
+ titleRef.current && titleRef.current.focus();
+ bodyRef.current && bodyRef.current.focus();
+ });
+
+ const handleSubmit = (e) => {
+ console.log(e);
+ };
+
+ return (
+ <>
+ <Link to="/">Back to your todo list</Link>
+ <div className="todo">
+ {isTitleEditable ? (
+ <form
+ action="#"
+ method="post"
+ className="todo-form todo__title"
+ onSubmit={handleSubmit}
+ >
+ <input
+ ref={titleRef}
+ className="todo-form__field"
+ type="text"
+ name="title"
+ value={title}
+ onChange={(e) =>
+ dispatch(updateTodo({ ...currentTodo, title: e.target.value }))
+ }
+ onBlur={setIsTitleEditable}
+ />
+ </form>
+ ) : (
+ <div className="todo__title" onClick={setIsTitleEditable}>
+ {title}
+ </div>
+ )}
+ {isBodyEditable ? (
+ <form
+ action="#"
+ method="post"
+ className="todo-form todo__body"
+ onSubmit={handleSubmit}
+ >
+ <textarea
+ ref={bodyRef}
+ className="todo-form__field todo-form__field--textarea"
+ name="body"
+ value={body}
+ onChange={(e) =>
+ dispatch(updateTodo({ ...currentTodo, body: e.target.value }))
+ }
+ onBlur={setIsBodyEditable}
+ />
+ </form>
+ ) : (
+ <div className="todo__body" onClick={setIsBodyEditable}>
+ {body}
+ </div>
+ )}
+ </div>
+ </>
+ );
+}
+
+export default Todo;
diff --git a/public/projects/react-small-apps/apps/todos/src/views/Todo/Todo.scss b/public/projects/react-small-apps/apps/todos/src/views/Todo/Todo.scss
new file mode 100644
index 0000000..67baa2d
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/src/views/Todo/Todo.scss
@@ -0,0 +1,31 @@
+.todo {
+ border: 1px solid #000;
+ margin-top: 2rem;
+ padding: 1rem;
+
+ & &__title {
+ font-size: 1.2rem;
+ }
+
+ & &__body {
+ margin-top: 1rem;
+ min-height: 10rem;
+ }
+}
+
+.todo-form {
+ &__field {
+ border: none;
+ padding: 0;
+ margin: 0;
+
+ &--textarea {
+ resize: none;
+ line-height: inherit;
+ }
+
+ &:focus {
+ outline: none;
+ }
+ }
+}
diff --git a/public/projects/react-small-apps/apps/todos/src/views/TodoForm/TodoForm.js b/public/projects/react-small-apps/apps/todos/src/views/TodoForm/TodoForm.js
new file mode 100644
index 0000000..bc6ed97
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/src/views/TodoForm/TodoForm.js
@@ -0,0 +1,42 @@
+import { useState } from "react";
+import { useDispatch } from "react-redux";
+import { Button, Fieldset, Input, TextArea } from "../../components/forms";
+import { addTodo } from "../../store/todos/todos.slice";
+
+function TodoForm({ userId, closeForm }) {
+ const [titleValue, setTitleValue] = useState("");
+ const [bodyValue, setBodyValue] = useState("");
+ const dispatch = useDispatch();
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ closeForm((prev) => !prev);
+ };
+
+ const handleSave = () => {
+ const newTodo = { userId, title: titleValue, body: bodyValue };
+ dispatch(addTodo(newTodo));
+ };
+
+ return (
+ <form onSubmit={handleSubmit} className="form form--todo">
+ <Fieldset legend="Add a new todo">
+ <Input label="Title" value={titleValue} updateValue={setTitleValue} />
+ <TextArea
+ label="Details"
+ value={bodyValue}
+ updateValue={setBodyValue}
+ />
+ <Button
+ type="submit"
+ modifiers={["submit"]}
+ onClickHandler={handleSave}
+ >
+ Save
+ </Button>
+ </Fieldset>
+ </form>
+ );
+}
+
+export default TodoForm;
diff --git a/public/projects/react-small-apps/apps/todos/src/views/TodoList/TodoList.js b/public/projects/react-small-apps/apps/todos/src/views/TodoList/TodoList.js
new file mode 100644
index 0000000..c671459
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/src/views/TodoList/TodoList.js
@@ -0,0 +1,84 @@
+import { useEffect, useState } from "react";
+import { useDispatch, useSelector } from "react-redux";
+import { Button } from "../../components/forms";
+import TodoForm from "../TodoForm/TodoForm";
+import TodoListItem from "./TodoListItem";
+import "./TodoList.scss";
+import { deleteAllTodos } from "../../store/todos/todos.slice";
+import { LocalStorage } from "../../services/LocalStorage.service";
+import TodoListFilters from "./TodoListFilters";
+
+function TodoList() {
+ const [todosList, setTodosList] = useState([]);
+ const [currentView, setCurrentView] = useState("all");
+ const dispatch = useDispatch();
+ const [isToggled, setIsToggled] = useState(false);
+ const currentUser = useSelector((state) => state.auth.currentUser);
+ const allTodos = useSelector((state) => state.todos);
+
+ useEffect(() => {
+ const userTodos = allTodos.filter((todo) => todo.userId === currentUser.id);
+
+ setTodosList(() => {
+ let list;
+
+ switch (currentView) {
+ case "completed":
+ list = userTodos.filter((todo) => todo.done);
+ break;
+ case "ongoing":
+ list = userTodos.filter((todo) => !todo.done);
+ break;
+ default:
+ list = userTodos;
+ break;
+ }
+
+ return list;
+ });
+ }, [currentView, allTodos, currentUser.id]);
+
+ useEffect(() => {
+ LocalStorage.set("todoList", allTodos);
+ });
+
+ const userTodosList = todosList.map((todo) => (
+ <TodoListItem key={todo.id} todo={todo} />
+ ));
+
+ return (
+ <div>
+ <h2>Welcome back {currentUser.username}!</h2>
+ <div className="todos-actions">
+ <Button
+ modifiers={["action"]}
+ onClickHandler={() => setIsToggled(!isToggled)}
+ >
+ New todo
+ </Button>
+ <Button
+ modifiers={["action", "delete"]}
+ onClickHandler={() => dispatch(deleteAllTodos())}
+ >
+ Delete all
+ </Button>
+ </div>
+ {isToggled ? (
+ <TodoForm userId={currentUser.id} closeForm={setIsToggled} />
+ ) : (
+ ""
+ )}
+ <TodoListFilters
+ currentView={currentView}
+ setCurrentView={setCurrentView}
+ />
+ {userTodosList.length > 0 ? (
+ <ul className="todos-list">{userTodosList}</ul>
+ ) : (
+ ""
+ )}
+ </div>
+ );
+}
+
+export default TodoList;
diff --git a/public/projects/react-small-apps/apps/todos/src/views/TodoList/TodoList.scss b/public/projects/react-small-apps/apps/todos/src/views/TodoList/TodoList.scss
new file mode 100644
index 0000000..024fe3e
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/src/views/TodoList/TodoList.scss
@@ -0,0 +1,63 @@
+@use "../../sass/abstracts/placeholders";
+@use "../../sass/abstracts/variables" as var;
+
+.todos-actions {
+ display: flex;
+ gap: 1rem;
+}
+
+.todos-filters {
+ display: flex;
+ flex-flow: row wrap;
+ align-items: center;
+ gap: clamp(0.2rem, 1vw, 0.5rem);
+ margin-top: 1rem
+}
+
+.todos-list {
+ @extend %list-reset;
+ border: 1px solid #000;
+ margin-top: 1.5rem;
+
+ &__item {
+ display: flex;
+ flex-flow: row wrap;
+ align-items: center;
+ gap: 1rem;
+ padding: 1rem;
+
+ &:not(:first-child) {
+ border-top: 1px solid #000;
+ }
+
+ .form__label {
+ margin: 0;
+ letter-spacing: 0;
+ text-transform: none;
+ }
+
+ .todo__title {
+ background-image: linear-gradient(
+ to top,
+ transparent calc(50% - 3px),
+ var.$done-color calc(50% - 3px),
+ var.$done-color 50%,
+ transparent 50%
+ );
+ background-size: 0 100%;
+ background-repeat: no-repeat;
+ margin-right: auto;
+ transition: background-size 0.3s ease-in-out 0s;
+ }
+
+ &--done {
+ .todo__title {
+ background-size: 100% 100%;
+ }
+ }
+
+ .btn {
+ padding: 0.4rem 0.7rem;
+ }
+ }
+}
diff --git a/public/projects/react-small-apps/apps/todos/src/views/TodoList/TodoListFilters.js b/public/projects/react-small-apps/apps/todos/src/views/TodoList/TodoListFilters.js
new file mode 100644
index 0000000..338492a
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/src/views/TodoList/TodoListFilters.js
@@ -0,0 +1,47 @@
+import { Button } from "../../components/forms";
+
+function TodoListFilters({ currentView, setCurrentView }) {
+ let allModifiers = ["filters"];
+ let ongoingModifiers = ["filters"];
+ let completedModifiers = ["filters"];
+
+ switch (currentView) {
+ case "all":
+ allModifiers.push("current");
+ break;
+ case "ongoing":
+ ongoingModifiers.push("current");
+ break;
+ case "completed":
+ completedModifiers.push("current");
+ break;
+ default:
+ break;
+ }
+
+ return (
+ <div className="todos-filters">
+ Show:
+ <Button
+ modifiers={allModifiers}
+ onClickHandler={() => setCurrentView("all")}
+ >
+ All
+ </Button>
+ <Button
+ modifiers={ongoingModifiers}
+ onClickHandler={() => setCurrentView("ongoing")}
+ >
+ Ongoing
+ </Button>
+ <Button
+ modifiers={completedModifiers}
+ onClickHandler={() => setCurrentView("completed")}
+ >
+ Completed
+ </Button>
+ </div>
+ );
+}
+
+export default TodoListFilters;
diff --git a/public/projects/react-small-apps/apps/todos/src/views/TodoList/TodoListItem.js b/public/projects/react-small-apps/apps/todos/src/views/TodoList/TodoListItem.js
new file mode 100644
index 0000000..f76c1a1
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/src/views/TodoList/TodoListItem.js
@@ -0,0 +1,55 @@
+import { useEffect, useState } from "react";
+import { useDispatch } from "react-redux";
+import { Link } from "react-router-dom";
+import { Button, Input } from "../../components/forms";
+import { deleteTodo, toggleTodo } from "../../store/todos/todos.slice";
+import { slugify } from "../../utilities/helpers";
+
+function TodoListItem({ todo }) {
+ const { id, createdAt, title, done } = todo;
+ const [isChecked, setIsChecked] = useState(false);
+ const dispatch = useDispatch();
+
+ useEffect(() => {
+ if (done) setIsChecked(true);
+ }, [done]);
+
+ const handleTodoDone = (checkboxState) => {
+ setIsChecked(checkboxState);
+ dispatch(toggleTodo(id));
+ };
+
+ const todoSlug = slugify(title);
+
+ const classNames = `todos-list__item ${
+ isChecked ? "todos-list__item--done" : ""
+ }`;
+
+ return (
+ <li className={classNames}>
+ <span className="todo__date">
+ {new Date(createdAt).toLocaleDateString()}
+ </span>
+ <span className="todo__title">
+ <Link to={`/todo/${todoSlug}`} state={{ todoId: todo.id }}>
+ {title}
+ </Link>
+ </span>
+ <Input
+ type="checkbox"
+ label="Done?"
+ id={id}
+ value={isChecked}
+ updateValue={handleTodoDone}
+ />
+ <Button
+ modifiers={["action", "delete"]}
+ onClickHandler={() => dispatch(deleteTodo(id))}
+ >
+ Delete
+ </Button>
+ </li>
+ );
+}
+
+export default TodoListItem;