diff options
| author | Armand Philippot <git@armandphilippot.com> | 2022-02-20 16:11:50 +0100 |
|---|---|---|
| committer | Armand Philippot <git@armandphilippot.com> | 2022-02-20 16:15:08 +0100 |
| commit | 73a5c7fae9ffbe9ada721148c8c454a643aceebe (patch) | |
| tree | c8fad013ed9b5dd589add87f8d45cf02bbfc6e91 /public/projects/react-small-apps/apps/todos/src | |
| parent | b01239fbdcc5bbc5921f73ec0e8fee7bedd5c8e8 (diff) | |
chore!: restructure repo
I separated public files from the config/dev files. It improves repo
readability.
I also moved dotenv helper to public/inc directory and extract the
Matomo tracker in the same directory.
Diffstat (limited to 'public/projects/react-small-apps/apps/todos/src')
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; |
