aboutsummaryrefslogtreecommitdiffstats
path: root/public/projects/react-small-apps/apps
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2022-02-20 16:11:50 +0100
committerArmand Philippot <git@armandphilippot.com>2022-02-20 16:15:08 +0100
commit73a5c7fae9ffbe9ada721148c8c454a643aceebe (patch)
treec8fad013ed9b5dd589add87f8d45cf02bbfc6e91 /public/projects/react-small-apps/apps
parentb01239fbdcc5bbc5921f73ec0e8fee7bedd5c8e8 (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')
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/.env.example11
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/.gitignore24
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/README.md29
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/package.json39
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/public/favicon.icobin0 -> 3870 bytes
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/public/index.html20
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/public/logo192.pngbin0 -> 5347 bytes
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/public/logo512.pngbin0 -> 9664 bytes
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/public/manifest.json25
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/public/preview-meme-generator.jpgbin0 -> 67333 bytes
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/public/robots.txt3
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/src/components/Footer/Footer.js11
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/src/components/Header/Header.js9
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/src/components/Main/Main.js16
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/src/components/MemeForm/MemeFieldset/MemeFieldset.js103
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/src/components/MemeForm/MemeForm.js54
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/src/components/MemePreview/Headline/Headline.js120
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/src/components/MemePreview/MemePreview.js61
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/src/components/commons/Button.js11
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/src/components/commons/Fieldset.js10
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/src/components/commons/Form.js21
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/src/components/commons/Input.js41
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/src/components/commons/InputRange.js37
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/src/components/commons/Option.js5
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/src/components/commons/Select.js31
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/src/index.js21
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/src/logo.svg1
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/src/reportWebVitals.js13
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/src/setupTests.js5
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/src/style.css151
-rw-r--r--public/projects/react-small-apps/apps/notebook/.env.example11
-rw-r--r--public/projects/react-small-apps/apps/notebook/.gitignore24
-rw-r--r--public/projects/react-small-apps/apps/notebook/README.md31
-rw-r--r--public/projects/react-small-apps/apps/notebook/package.json41
-rw-r--r--public/projects/react-small-apps/apps/notebook/public/favicon.icobin0 -> 3870 bytes
-rw-r--r--public/projects/react-small-apps/apps/notebook/public/index.html20
-rw-r--r--public/projects/react-small-apps/apps/notebook/public/manifest.json25
-rw-r--r--public/projects/react-small-apps/apps/notebook/public/preview-cover.jpgbin0 -> 32434 bytes
-rw-r--r--public/projects/react-small-apps/apps/notebook/public/preview-page.jpgbin0 -> 34118 bytes
-rw-r--r--public/projects/react-small-apps/apps/notebook/public/robots.txt3
-rw-r--r--public/projects/react-small-apps/apps/notebook/src/App.css40
-rw-r--r--public/projects/react-small-apps/apps/notebook/src/App.js174
-rw-r--r--public/projects/react-small-apps/apps/notebook/src/App.test.js8
-rw-r--r--public/projects/react-small-apps/apps/notebook/src/components/commons/Button/Button.css78
-rw-r--r--public/projects/react-small-apps/apps/notebook/src/components/commons/Button/Button.js26
-rw-r--r--public/projects/react-small-apps/apps/notebook/src/components/commons/FormElements/Input/Input.js31
-rw-r--r--public/projects/react-small-apps/apps/notebook/src/components/commons/FormElements/TextArea/TextArea.js52
-rw-r--r--public/projects/react-small-apps/apps/notebook/src/components/commons/FormElements/index.js4
-rw-r--r--public/projects/react-small-apps/apps/notebook/src/components/commons/List/List.css3
-rw-r--r--public/projects/react-small-apps/apps/notebook/src/components/commons/List/List.js25
-rw-r--r--public/projects/react-small-apps/apps/notebook/src/components/commons/index.js5
-rw-r--r--public/projects/react-small-apps/apps/notebook/src/components/helpers/hooks/useToggle.js10
-rw-r--r--public/projects/react-small-apps/apps/notebook/src/components/layout/Footer/Footer.css8
-rw-r--r--public/projects/react-small-apps/apps/notebook/src/components/layout/Footer/Footer.js11
-rw-r--r--public/projects/react-small-apps/apps/notebook/src/components/layout/Header/Header.css7
-rw-r--r--public/projects/react-small-apps/apps/notebook/src/components/layout/Header/Header.js12
-rw-r--r--public/projects/react-small-apps/apps/notebook/src/components/layout/Main/Main.css3
-rw-r--r--public/projects/react-small-apps/apps/notebook/src/components/layout/Main/Main.js7
-rw-r--r--public/projects/react-small-apps/apps/notebook/src/components/layout/Nav/Nav.css65
-rw-r--r--public/projects/react-small-apps/apps/notebook/src/components/layout/Nav/Nav.js62
-rw-r--r--public/projects/react-small-apps/apps/notebook/src/components/layout/Nav/NavJump.js28
-rw-r--r--public/projects/react-small-apps/apps/notebook/src/components/layout/Page/Cover.css30
-rw-r--r--public/projects/react-small-apps/apps/notebook/src/components/layout/Page/Page.css83
-rw-r--r--public/projects/react-small-apps/apps/notebook/src/components/layout/Page/Page.js107
-rw-r--r--public/projects/react-small-apps/apps/notebook/src/components/layout/Page/PageToolbar.js27
-rw-r--r--public/projects/react-small-apps/apps/notebook/src/components/layout/index.js7
-rw-r--r--public/projects/react-small-apps/apps/notebook/src/config/pages.js16
-rw-r--r--public/projects/react-small-apps/apps/notebook/src/images/restore.svg24
-rw-r--r--public/projects/react-small-apps/apps/notebook/src/images/trash.svg48
-rw-r--r--public/projects/react-small-apps/apps/notebook/src/index.js20
-rw-r--r--public/projects/react-small-apps/apps/notebook/src/logo.svg1
-rw-r--r--public/projects/react-small-apps/apps/notebook/src/reportWebVitals.js13
-rw-r--r--public/projects/react-small-apps/apps/notebook/src/setupTests.js5
-rw-r--r--public/projects/react-small-apps/apps/todos/.env.example11
-rw-r--r--public/projects/react-small-apps/apps/todos/.gitignore24
-rw-r--r--public/projects/react-small-apps/apps/todos/README.md56
-rw-r--r--public/projects/react-small-apps/apps/todos/assets/preview-single-todo.jpgbin0 -> 25081 bytes
-rw-r--r--public/projects/react-small-apps/apps/todos/assets/preview-todo-account.jpgbin0 -> 23922 bytes
-rw-r--r--public/projects/react-small-apps/apps/todos/assets/preview-todo-login.jpgbin0 -> 25720 bytes
-rw-r--r--public/projects/react-small-apps/apps/todos/assets/preview-todos-list.jpgbin0 -> 45601 bytes
-rw-r--r--public/projects/react-small-apps/apps/todos/package.json48
-rw-r--r--public/projects/react-small-apps/apps/todos/public/favicon.icobin0 -> 3585 bytes
-rw-r--r--public/projects/react-small-apps/apps/todos/public/index.html43
-rw-r--r--public/projects/react-small-apps/apps/todos/public/logo192.pngbin0 -> 4153 bytes
-rw-r--r--public/projects/react-small-apps/apps/todos/public/logo512.pngbin0 -> 12066 bytes
-rw-r--r--public/projects/react-small-apps/apps/todos/public/manifest.json25
-rw-r--r--public/projects/react-small-apps/apps/todos/public/robots.txt2
-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
126 files changed, 3645 insertions, 0 deletions
diff --git a/public/projects/react-small-apps/apps/meme-generator/.env.example b/public/projects/react-small-apps/apps/meme-generator/.env.example
new file mode 100644
index 0000000..1b48ea9
--- /dev/null
+++ b/public/projects/react-small-apps/apps/meme-generator/.env.example
@@ -0,0 +1,11 @@
+# Create React App config.
+# See: https://create-react-app.dev/docs/advanced-configuration/
+
+# Development
+BROWSER='firefox-developer-edition'
+BUILD_PATH='build'
+PORT=3000
+HTTPS=false
+
+# Production
+PUBLIC_URL='./'
diff --git a/public/projects/react-small-apps/apps/meme-generator/.gitignore b/public/projects/react-small-apps/apps/meme-generator/.gitignore
new file mode 100644
index 0000000..0732bb1
--- /dev/null
+++ b/public/projects/react-small-apps/apps/meme-generator/.gitignore
@@ -0,0 +1,24 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+
+# testing
+/coverage
+
+# production
+/build
+
+# misc
+.DS_Store
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+.vscode
+
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
diff --git a/public/projects/react-small-apps/apps/meme-generator/README.md b/public/projects/react-small-apps/apps/meme-generator/README.md
new file mode 100644
index 0000000..e0d510a
--- /dev/null
+++ b/public/projects/react-small-apps/apps/meme-generator/README.md
@@ -0,0 +1,29 @@
+# React Meme Generator
+
+A meme generator implementation with React.
+
+## Requirements
+
+- Yarn
+
+## How to
+
+### Start the development version
+
+`yarn run start`
+
+### Start the build version:
+
+1. `yarn run build`
+2. (`yarn global add serve`)
+3. `serve -s build`
+
+## Preview
+
+You can see a live preview here: https://demo.armandphilippot.com/#meme-generator
+
+![Meme generator preview](./public/preview-meme-generator.jpg)
+
+## License
+
+This project is open source and available under the [MIT license](../LICENSE).
diff --git a/public/projects/react-small-apps/apps/meme-generator/package.json b/public/projects/react-small-apps/apps/meme-generator/package.json
new file mode 100644
index 0000000..0fd09d0
--- /dev/null
+++ b/public/projects/react-small-apps/apps/meme-generator/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "react-meme-generator",
+ "description": "A meme generator implementation with ReactJS.",
+ "version": "1.0.0",
+ "private": true,
+ "dependencies": {
+ "@testing-library/jest-dom": "^5.16.2",
+ "@testing-library/react": "^12.1.3",
+ "@testing-library/user-event": "^13.5.0",
+ "react": "^17.0.2",
+ "react-dom": "^17.0.2",
+ "react-scripts": "5.0.0",
+ "web-vitals": "^2.1.4"
+ },
+ "scripts": {
+ "start": "react-scripts start",
+ "build": "react-scripts build",
+ "test": "react-scripts test",
+ "eject": "react-scripts eject"
+ },
+ "eslintConfig": {
+ "extends": [
+ "react-app",
+ "react-app/jest"
+ ]
+ },
+ "browserslist": {
+ "production": [
+ ">0.2%",
+ "not dead",
+ "not op_mini all"
+ ],
+ "development": [
+ "last 1 chrome version",
+ "last 1 firefox version",
+ "last 1 safari version"
+ ]
+ }
+}
diff --git a/public/projects/react-small-apps/apps/meme-generator/public/favicon.ico b/public/projects/react-small-apps/apps/meme-generator/public/favicon.ico
new file mode 100644
index 0000000..a11777c
--- /dev/null
+++ b/public/projects/react-small-apps/apps/meme-generator/public/favicon.ico
Binary files differ
diff --git a/public/projects/react-small-apps/apps/meme-generator/public/index.html b/public/projects/react-small-apps/apps/meme-generator/public/index.html
new file mode 100644
index 0000000..fe333ac
--- /dev/null
+++ b/public/projects/react-small-apps/apps/meme-generator/public/index.html
@@ -0,0 +1,20 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8" />
+ <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <meta name="theme-color" content="#000000" />
+ <meta
+ name="description"
+ content="Meme generator app created using create-react-app."
+ />
+ <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
+ <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
+ <title>Meme Generator</title>
+ </head>
+ <body>
+ <noscript>You need to enable JavaScript to run this app.</noscript>
+ <div class="app" id="root"></div>
+ </body>
+</html>
diff --git a/public/projects/react-small-apps/apps/meme-generator/public/logo192.png b/public/projects/react-small-apps/apps/meme-generator/public/logo192.png
new file mode 100644
index 0000000..fc44b0a
--- /dev/null
+++ b/public/projects/react-small-apps/apps/meme-generator/public/logo192.png
Binary files differ
diff --git a/public/projects/react-small-apps/apps/meme-generator/public/logo512.png b/public/projects/react-small-apps/apps/meme-generator/public/logo512.png
new file mode 100644
index 0000000..a4e47a6
--- /dev/null
+++ b/public/projects/react-small-apps/apps/meme-generator/public/logo512.png
Binary files differ
diff --git a/public/projects/react-small-apps/apps/meme-generator/public/manifest.json b/public/projects/react-small-apps/apps/meme-generator/public/manifest.json
new file mode 100644
index 0000000..080d6c7
--- /dev/null
+++ b/public/projects/react-small-apps/apps/meme-generator/public/manifest.json
@@ -0,0 +1,25 @@
+{
+ "short_name": "React App",
+ "name": "Create React App Sample",
+ "icons": [
+ {
+ "src": "favicon.ico",
+ "sizes": "64x64 32x32 24x24 16x16",
+ "type": "image/x-icon"
+ },
+ {
+ "src": "logo192.png",
+ "type": "image/png",
+ "sizes": "192x192"
+ },
+ {
+ "src": "logo512.png",
+ "type": "image/png",
+ "sizes": "512x512"
+ }
+ ],
+ "start_url": ".",
+ "display": "standalone",
+ "theme_color": "#000000",
+ "background_color": "#ffffff"
+}
diff --git a/public/projects/react-small-apps/apps/meme-generator/public/preview-meme-generator.jpg b/public/projects/react-small-apps/apps/meme-generator/public/preview-meme-generator.jpg
new file mode 100644
index 0000000..7d4579a
--- /dev/null
+++ b/public/projects/react-small-apps/apps/meme-generator/public/preview-meme-generator.jpg
Binary files differ
diff --git a/public/projects/react-small-apps/apps/meme-generator/public/robots.txt b/public/projects/react-small-apps/apps/meme-generator/public/robots.txt
new file mode 100644
index 0000000..e9e57dc
--- /dev/null
+++ b/public/projects/react-small-apps/apps/meme-generator/public/robots.txt
@@ -0,0 +1,3 @@
+# https://www.robotstxt.org/robotstxt.html
+User-agent: *
+Disallow:
diff --git a/public/projects/react-small-apps/apps/meme-generator/src/components/Footer/Footer.js b/public/projects/react-small-apps/apps/meme-generator/src/components/Footer/Footer.js
new file mode 100644
index 0000000..fbbe582
--- /dev/null
+++ b/public/projects/react-small-apps/apps/meme-generator/src/components/Footer/Footer.js
@@ -0,0 +1,11 @@
+function Footer() {
+ return (
+ <footer className="footer">
+ <p className="footer__copyright">
+ Meme Generator. MIT 2021. Armand Philippot.
+ </p>
+ </footer>
+ );
+}
+
+export default Footer;
diff --git a/public/projects/react-small-apps/apps/meme-generator/src/components/Header/Header.js b/public/projects/react-small-apps/apps/meme-generator/src/components/Header/Header.js
new file mode 100644
index 0000000..118ca1a
--- /dev/null
+++ b/public/projects/react-small-apps/apps/meme-generator/src/components/Header/Header.js
@@ -0,0 +1,9 @@
+function Header() {
+ return (
+ <header className="header">
+ <h1 className="header__branding">Meme Generator</h1>
+ </header>
+ );
+}
+
+export default Header;
diff --git a/public/projects/react-small-apps/apps/meme-generator/src/components/Main/Main.js b/public/projects/react-small-apps/apps/meme-generator/src/components/Main/Main.js
new file mode 100644
index 0000000..8878002
--- /dev/null
+++ b/public/projects/react-small-apps/apps/meme-generator/src/components/Main/Main.js
@@ -0,0 +1,16 @@
+import { useState } from "react";
+import MemeForm from "../MemeForm/MemeForm";
+import MemePreview from "../MemePreview/MemePreview";
+
+function Main() {
+ const [headlines, setHeadlines] = useState([]);
+
+ return (
+ <main className="main">
+ <MemePreview headlines={headlines} setHeadlines={setHeadlines} />
+ <MemeForm headlines={headlines} setHeadlines={setHeadlines} />
+ </main>
+ );
+}
+
+export default Main;
diff --git a/public/projects/react-small-apps/apps/meme-generator/src/components/MemeForm/MemeFieldset/MemeFieldset.js b/public/projects/react-small-apps/apps/meme-generator/src/components/MemeForm/MemeFieldset/MemeFieldset.js
new file mode 100644
index 0000000..2c0520e
--- /dev/null
+++ b/public/projects/react-small-apps/apps/meme-generator/src/components/MemeForm/MemeFieldset/MemeFieldset.js
@@ -0,0 +1,103 @@
+import { useEffect, useState } from "react";
+import Button from "../../commons/Button";
+import Fieldset from "../../commons/Fieldset";
+import Input from "../../commons/Input";
+import InputRange from "../../commons/InputRange";
+import Select from "../../commons/Select";
+
+function MemeFieldset({ headline, setHeadline, xOptions, yOptions }) {
+ const { id, legend, text, fontSize, fontUnit, xPos, yPos } = headline;
+ const [inputTextValue, setInputTextValue] = useState(text);
+ const [inputRangeValue, setInputRangeValue] = useState(fontSize);
+ const [selectX, setSelectX] = useState(xPos);
+ const [selectY, setSelectY] = useState(yPos);
+
+ useEffect(() => {
+ setInputTextValue(text);
+ }, [text]);
+
+ useEffect(() => {
+ setHeadline((previous) => {
+ return previous.map((object) => {
+ if (object.id !== id) return object;
+ return {
+ ...object,
+ text: inputTextValue,
+ fontSize: inputRangeValue,
+ xPos: selectX,
+ yPos: selectY,
+ };
+ });
+ });
+ }, [setHeadline, id, inputTextValue, inputRangeValue, selectX, selectY]);
+
+ const onChange = (e) => {
+ switch (e.target.name) {
+ case "inputText":
+ setInputTextValue(e.target.value);
+ break;
+ case "inputRange":
+ setInputRangeValue(Number(e.target.value));
+ break;
+ case "selectX":
+ setSelectX(e.target.value);
+ break;
+ case "selectY":
+ setSelectY(e.target.value);
+ break;
+ default:
+ break;
+ }
+ };
+
+ const onClick = (e) => {
+ setHeadline((previous) => previous.filter((object) => object.id !== id));
+ };
+
+ return (
+ <Fieldset id={id} legend={legend}>
+ <Button body="Delete" modifier="delete" onClick={onClick} />
+ <div className="form__item">
+ <Input
+ label="Enter your text:"
+ id="inputText"
+ name="inputText"
+ value={inputTextValue}
+ onChangeHandler={onChange}
+ />
+ </div>
+ <div className="form__item">
+ <InputRange
+ label="Choose a font-size:"
+ id="inputRange"
+ name="inputRange"
+ value={inputRangeValue}
+ unit={fontUnit}
+ onChangeHandler={onChange}
+ />
+ </div>
+ <div className="form__item">
+ <Select
+ label="Select the vertical position:"
+ id="selectY"
+ name="selectY"
+ options={yOptions}
+ value={selectY}
+ onChangeHandler={onChange}
+ />
+ </div>
+ <div className="form__item">
+ <Select
+ label="Select the horizontal position:"
+ id="selectX"
+ name="selectX"
+ options={xOptions}
+ value={selectX}
+ onChangeHandler={onChange}
+ />
+ </div>
+ </Fieldset>
+ );
+}
+
+export default MemeFieldset;
diff --git a/public/projects/react-small-apps/apps/meme-generator/src/components/MemeForm/MemeForm.js b/public/projects/react-small-apps/apps/meme-generator/src/components/MemeForm/MemeForm.js
new file mode 100644
index 0000000..b6ce40f
--- /dev/null
+++ b/public/projects/react-small-apps/apps/meme-generator/src/components/MemeForm/MemeForm.js
@@ -0,0 +1,54 @@
+import { useState } from "react";
+import Button from "../commons/Button";
+import Form from "../commons/Form";
+import MemeFieldset from "./MemeFieldset/MemeFieldset";
+
+function MemeForm({ headlines, setHeadlines }) {
+ const [fieldsetId, setFieldsetId] = useState(1);
+ const horizontalOptions = ["Left", "Right", "Center"];
+ const verticalOptions = ["Top", "Bottom", "Middle"];
+
+ const fieldsetData = {
+ id: fieldsetId,
+ legend: `Text settings ${fieldsetId}`,
+ text: "Edit here...",
+ fontSize: 100,
+ fontUnit: "%",
+ xPos: horizontalOptions[(fieldsetId - 1) % horizontalOptions.length],
+ yPos: verticalOptions[(fieldsetId - 1) % verticalOptions.length],
+ };
+
+ const onSubmit = (e) => {
+ e.preventDefault();
+ };
+
+ const fieldsetsList = headlines.map((headline) => {
+ return (
+ <MemeFieldset
+ key={headline.id}
+ headline={headline}
+ setHeadline={setHeadlines}
+ xOptions={horizontalOptions}
+ yOptions={verticalOptions}
+ />
+ );
+ });
+
+ const addNewFieldset = () => {
+ setFieldsetId((previous) => previous + 1);
+ setHeadlines((array) => [...array, fieldsetData]);
+ };
+
+ return (
+ <div className="meme-form">
+ <Form onSubmitHandler={onSubmit}>
+ {fieldsetsList}
+ {fieldsetsList.length < 4 && (
+ <Button body="Add new text" onClick={addNewFieldset} modifier="add" />
+ )}
+ </Form>
+ </div>
+ );
+}
+
+export default MemeForm;
diff --git a/public/projects/react-small-apps/apps/meme-generator/src/components/MemePreview/Headline/Headline.js b/public/projects/react-small-apps/apps/meme-generator/src/components/MemePreview/Headline/Headline.js
new file mode 100644
index 0000000..e7ed579
--- /dev/null
+++ b/public/projects/react-small-apps/apps/meme-generator/src/components/MemePreview/Headline/Headline.js
@@ -0,0 +1,120 @@
+import { useEffect, useRef, useState } from "react";
+import Form from "../../commons/Form";
+import Input from "../../commons/Input";
+
+function Headline({ id, text, fontSize, xPos, yPos, setHeadlines }) {
+ const inputRef = useRef(null);
+ const [isEditing, setIsEditing] = useState(false);
+ useEffect(() => {
+ isEditing && inputRef.current.focus();
+ });
+
+ const [inputValue, setInputValue] = useState(text);
+ useEffect(() => {
+ setInputValue(text);
+ }, [text]);
+
+ const getXPos = () => {
+ let styles = {};
+ switch (xPos) {
+ case "Left":
+ styles = { gridColumn: 1, textAlign: "left" };
+ break;
+ case "Right":
+ styles = { gridColumn: 2, textAlign: "right" };
+ break;
+ case "Center":
+ styles = {
+ gridColumnStart: 1,
+ gridColumnEnd: "span 2",
+ justifySelf: "center",
+ textAlign: "center",
+ };
+ break;
+ default:
+ break;
+ }
+ return styles;
+ };
+
+ const getYPos = () => {
+ let styles = {};
+ switch (yPos) {
+ case "Top":
+ styles = { gridRow: 1 };
+ break;
+ case "Bottom":
+ styles = { gridRow: 3, alignSelf: "end" };
+ break;
+ case "Middle":
+ styles = { gridRow: 2, alignSelf: "center" };
+ break;
+ default:
+ break;
+ }
+ return styles;
+ };
+
+ const styles = {
+ fontSize: fontSize,
+ ...getYPos(),
+ ...getXPos(),
+ };
+
+ const onSubmit = (e) => {
+ e.preventDefault();
+ setIsEditing(false);
+ };
+
+ const updateText = (e) => {
+ setInputValue(e.target.value);
+ };
+
+ useEffect(() => {
+ setHeadlines((previous) => {
+ return previous.map((headline) => {
+ if (headline.id !== id) return headline;
+ return { ...headline, text: inputValue };
+ });
+ });
+ }, [setHeadlines, id, inputValue]);
+
+ useEffect(() => {
+ setHeadlines((previous) => {
+ return previous.map((headline) => {
+ if (headline.id !== id) return headline;
+ return { ...headline, text: inputValue };
+ });
+ });
+ }, [setHeadlines, id, inputValue]);
+
+ const onBlur = () => {
+ setIsEditing(false);
+ };
+
+ return (
+ <>
+ {isEditing ? (
+ <Form onSubmitHandler={onSubmit} styles={styles}>
+ <Input
+ value={inputValue}
+ ref={inputRef}
+ onChangeHandler={updateText}
+ onBlurHandler={onBlur}
+ additionalClasses="meme-preview__headline"
+ />
+ </Form>
+ ) : (
+ <p
+ className="meme-preview__headline"
+ onClick={() => setIsEditing(true)}
+ style={styles}
+ >
+ {inputValue}
+ </p>
+ )}
+ </>
+ );
+}
+
+export default Headline;
diff --git a/public/projects/react-small-apps/apps/meme-generator/src/components/MemePreview/MemePreview.js b/public/projects/react-small-apps/apps/meme-generator/src/components/MemePreview/MemePreview.js
new file mode 100644
index 0000000..6577e53
--- /dev/null
+++ b/public/projects/react-small-apps/apps/meme-generator/src/components/MemePreview/MemePreview.js
@@ -0,0 +1,61 @@
+import { useEffect, useState } from "react";
+import Button from "../commons/Button";
+import Headline from "./Headline/Headline";
+
+async function fetchMemes() {
+ const response = await fetch("https://api.imgflip.com/get_memes");
+ const result = await response.json();
+ return await result;
+}
+
+function MemePreview({ headlines, setHeadlines }) {
+ const [memesList, setMemesList] = useState([]);
+ const [isFetched, setIsFetched] = useState(false);
+ useEffect(() => {
+ fetchMemes().then((object) => setMemesList(object.data.memes));
+ setIsFetched(true);
+ return () => setIsFetched(false);
+ }, [setIsFetched]);
+
+ const [selectedMeme, setSelectedMeme] = useState({});
+ useEffect(() => {
+ setSelectedMeme(memesList[5]);
+ }, [memesList]);
+
+ const getRandomMeme = () => {
+ const randomIndex = Math.floor(Math.random() * memesList.length);
+ setSelectedMeme(memesList[randomIndex]);
+ };
+
+ const headlinesList = headlines.map((headline) => (
+ <Headline
+ key={headline.id}
+ id={headline.id}
+ text={headline.text}
+ fontSize={`${headline.fontSize}${headline.fontUnit}`}
+ xPos={headline.xPos}
+ yPos={headline.yPos}
+ setHeadlines={setHeadlines}
+ />
+ ));
+
+ return (
+ <div className="meme-preview">
+ <div className="meme-preview__meme">
+ {isFetched && selectedMeme ? (
+ <img
+ src={selectedMeme.url}
+ alt={selectedMeme.name}
+ className="meme-preview__image"
+ />
+ ) : (
+ "Loading..."
+ )}
+ {headlinesList}
+ </div>
+ <Button body="Random image" modifier="random" onClick={getRandomMeme} />
+ </div>
+ );
+}
+
+export default MemePreview;
diff --git a/public/projects/react-small-apps/apps/meme-generator/src/components/commons/Button.js b/public/projects/react-small-apps/apps/meme-generator/src/components/commons/Button.js
new file mode 100644
index 0000000..98967a8
--- /dev/null
+++ b/public/projects/react-small-apps/apps/meme-generator/src/components/commons/Button.js
@@ -0,0 +1,11 @@
+function Button({ body, modifier, onClick }) {
+ const classNames = `btn ${modifier ? `btn--${modifier}` : ""}`;
+
+ return (
+ <button className={classNames} onClick={onClick}>
+ {body}
+ </button>
+ );
+}
+
+export default Button;
diff --git a/public/projects/react-small-apps/apps/meme-generator/src/components/commons/Fieldset.js b/public/projects/react-small-apps/apps/meme-generator/src/components/commons/Fieldset.js
new file mode 100644
index 0000000..d76e3e7
--- /dev/null
+++ b/public/projects/react-small-apps/apps/meme-generator/src/components/commons/Fieldset.js
@@ -0,0 +1,10 @@
+function Fieldset({ children, legend = "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/meme-generator/src/components/commons/Form.js b/public/projects/react-small-apps/apps/meme-generator/src/components/commons/Form.js
new file mode 100644
index 0000000..5ab1948
--- /dev/null
+++ b/public/projects/react-small-apps/apps/meme-generator/src/components/commons/Form.js
@@ -0,0 +1,21 @@
+function Form({
+ children,
+ action = "#",
+ method = "post",
+ styles,
+ onSubmitHandler,
+}) {
+ return (
+ <form
+ action={action}
+ method={method}
+ onSubmit={onSubmitHandler}
+ className="form"
+ style={styles}
+ >
+ {children}
+ </form>
+ );
+}
+
+export default Form;
diff --git a/public/projects/react-small-apps/apps/meme-generator/src/components/commons/Input.js b/public/projects/react-small-apps/apps/meme-generator/src/components/commons/Input.js
new file mode 100644
index 0000000..68e4e77
--- /dev/null
+++ b/public/projects/react-small-apps/apps/meme-generator/src/components/commons/Input.js
@@ -0,0 +1,41 @@
+import { forwardRef } from "react";
+
+function Input(
+ {
+ label,
+ id,
+ name,
+ type = "text",
+ value,
+ onChangeHandler,
+ onBlurHandler,
+ additionalClasses = "",
+ },
+ ref
+) {
+ const classNames = `form__input ${additionalClasses}`;
+
+ return (
+ <>
+ {label ? (
+ <label className="form__label" htmlFor={id}>
+ {label}
+ </label>
+ ) : (
+ ""
+ )}
+ <input
+ id={id}
+ name={name}
+ ref={ref}
+ type={type}
+ value={value}
+ onChange={onChangeHandler}
+ onBlur={onBlurHandler}
+ className={classNames}
+ />
+ </>
+ );
+}
+
+export default forwardRef(Input);
diff --git a/public/projects/react-small-apps/apps/meme-generator/src/components/commons/InputRange.js b/public/projects/react-small-apps/apps/meme-generator/src/components/commons/InputRange.js
new file mode 100644
index 0000000..1172966
--- /dev/null
+++ b/public/projects/react-small-apps/apps/meme-generator/src/components/commons/InputRange.js
@@ -0,0 +1,37 @@
+function InputRange({
+ label,
+ id,
+ name,
+ min = 5,
+ max = 200,
+ step = 1,
+ unit = "px",
+ value,
+ onChangeHandler,
+}) {
+ return (
+ <>
+ {label ? (
+ <label className="form__label" htmlFor={id}>
+ {label}
+ </label>
+ ) : (
+ ""
+ )}
+ <input
+ type="range"
+ id={id}
+ name={name}
+ min={min}
+ max={max}
+ step={step}
+ value={value}
+ onChange={onChangeHandler}
+ title={`${value}${unit}`}
+ className="form__input form__input--range"
+ />
+ </>
+ );
+}
+
+export default InputRange;
diff --git a/public/projects/react-small-apps/apps/meme-generator/src/components/commons/Option.js b/public/projects/react-small-apps/apps/meme-generator/src/components/commons/Option.js
new file mode 100644
index 0000000..4064798
--- /dev/null
+++ b/public/projects/react-small-apps/apps/meme-generator/src/components/commons/Option.js
@@ -0,0 +1,5 @@
+function Option({ value, body }) {
+ return <option value={value}>{body}</option>;
+}
+
+export default Option;
diff --git a/public/projects/react-small-apps/apps/meme-generator/src/components/commons/Select.js b/public/projects/react-small-apps/apps/meme-generator/src/components/commons/Select.js
new file mode 100644
index 0000000..9517b23
--- /dev/null
+++ b/public/projects/react-small-apps/apps/meme-generator/src/components/commons/Select.js
@@ -0,0 +1,31 @@
+import Option from "./Option";
+
+function Select({ id, name, label, options, value, onChangeHandler }) {
+ const optionsList = options.map((option) => {
+ const optionValue = option.replace(" ", "-");
+ return <Option key={optionValue} value={optionValue} body={option} />;
+ });
+
+ return (
+ <>
+ {label ? (
+ <label className="form__label" htmlFor={id}>
+ {label}
+ </label>
+ ) : (
+ ""
+ )}
+ <select
+ id={id}
+ name={name}
+ className="form__select"
+ value={value}
+ onChange={onChangeHandler}
+ >
+ {optionsList}
+ </select>
+ </>
+ );
+}
+
+export default Select;
diff --git a/public/projects/react-small-apps/apps/meme-generator/src/index.js b/public/projects/react-small-apps/apps/meme-generator/src/index.js
new file mode 100644
index 0000000..09f9e0b
--- /dev/null
+++ b/public/projects/react-small-apps/apps/meme-generator/src/index.js
@@ -0,0 +1,21 @@
+import React from "react";
+import ReactDOM from "react-dom";
+import "./style.css";
+import reportWebVitals from "./reportWebVitals";
+import Header from "./components/Header/Header";
+import Main from "./components/Main/Main";
+import Footer from "./components/Footer/Footer";
+
+ReactDOM.render(
+ <React.StrictMode>
+ <Header />
+ <Main />
+ <Footer />
+ </React.StrictMode>,
+ document.getElementById("root")
+);
+
+// If you want to start measuring performance in your app, pass a function
+// to log results (for example: reportWebVitals(console.log))
+// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
+reportWebVitals();
diff --git a/public/projects/react-small-apps/apps/meme-generator/src/logo.svg b/public/projects/react-small-apps/apps/meme-generator/src/logo.svg
new file mode 100644
index 0000000..9dfc1c0
--- /dev/null
+++ b/public/projects/react-small-apps/apps/meme-generator/src/logo.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg> \ No newline at end of file
diff --git a/public/projects/react-small-apps/apps/meme-generator/src/reportWebVitals.js b/public/projects/react-small-apps/apps/meme-generator/src/reportWebVitals.js
new file mode 100644
index 0000000..5253d3a
--- /dev/null
+++ b/public/projects/react-small-apps/apps/meme-generator/src/reportWebVitals.js
@@ -0,0 +1,13 @@
+const reportWebVitals = onPerfEntry => {
+ if (onPerfEntry && onPerfEntry instanceof Function) {
+ import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
+ getCLS(onPerfEntry);
+ getFID(onPerfEntry);
+ getFCP(onPerfEntry);
+ getLCP(onPerfEntry);
+ getTTFB(onPerfEntry);
+ });
+ }
+};
+
+export default reportWebVitals;
diff --git a/public/projects/react-small-apps/apps/meme-generator/src/setupTests.js b/public/projects/react-small-apps/apps/meme-generator/src/setupTests.js
new file mode 100644
index 0000000..8f2609b
--- /dev/null
+++ b/public/projects/react-small-apps/apps/meme-generator/src/setupTests.js
@@ -0,0 +1,5 @@
+// jest-dom adds custom jest matchers for asserting on DOM nodes.
+// allows you to do things like:
+// expect(element).toHaveTextContent(/react/i)
+// learn more: https://github.com/testing-library/jest-dom
+import '@testing-library/jest-dom';
diff --git a/public/projects/react-small-apps/apps/meme-generator/src/style.css b/public/projects/react-small-apps/apps/meme-generator/src/style.css
new file mode 100644
index 0000000..ba33e89
--- /dev/null
+++ b/public/projects/react-small-apps/apps/meme-generator/src/style.css
@@ -0,0 +1,151 @@
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+ padding: 0;
+ margin: 0;
+}
+
+body {
+ background: rgb(241, 236, 236);
+ font-family: Arial, Helvetica, sans-serif;
+ font-size: 16px;
+ font-size: 1rem;
+ line-height: 1.618;
+}
+
+.app {
+ display: flex;
+ flex-flow: column nowrap;
+ width: min(calc(100vw - 2rem), 1200px);
+ min-height: 100vh;
+ margin: auto;
+}
+
+.header {
+ text-align: center;
+ padding: 2rem 0;
+}
+
+.main {
+ flex: 1;
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(0, 550px));
+ gap: 2rem;
+ margin: 2rem 0;
+}
+
+.footer {
+ text-align: center;
+ font-size: 0.9rem;
+ padding: 2rem 0;
+}
+
+.btn {
+ display: block;
+ padding: 0.5rem 1rem;
+}
+
+.btn--delete {
+ background: rgb(255, 193, 193);
+ border: 1px solid rgb(141, 68, 68);
+ font-weight: 500;
+}
+
+.form__fieldset {
+ padding: 0 1rem 1rem;
+}
+
+.form__legend {
+ font-weight: 600;
+ letter-spacing: 1px;
+ text-transform: uppercase;
+ padding: 0.5rem;
+}
+
+.form__item:not(:last-child) {
+ margin-bottom: 0.5rem;
+}
+
+.form__label {
+ display: block;
+ cursor: pointer;
+}
+
+.form__input {
+ font-family: inherit;
+ font-size: inherit;
+ line-height: inherit;
+ padding: 0.5rem 0.8rem;
+}
+
+.form__input--range {
+ padding: 0.5rem 0.8rem 0;
+}
+
+.form__select {
+ width: 100%;
+ padding: 0.5rem 0.8rem;
+}
+
+.meme-form .form {
+ display: flex;
+ flex-flow: row wrap;
+ align-items: flex-start;
+ gap: 1rem;
+}
+
+.meme-form .form__fieldset {
+ position: relative;
+}
+
+.meme-form .btn--delete {
+ position: absolute;
+ top: -1.5rem;
+ right: 0;
+ padding: 0.4rem;
+}
+
+.meme-form .btn--add {
+ align-self: center;
+ margin: auto;
+}
+
+.meme-preview {
+ font-size: 2rem;
+}
+
+.meme-preview__meme {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ grid-template-rows: repeat(3, minmax(0, 1fr));
+ position: relative;
+}
+
+.meme-preview__image {
+ grid-column: 1 / -1;
+ grid-row: 1 / -1;
+ width: 100%;
+}
+
+.meme-preview__headline {
+ color: #fff;
+ font-weight: 600;
+ text-shadow: 3px 3px 2px #000;
+ padding: 0 0.5rem;
+}
+
+.meme-preview .form__input {
+ background: transparent;
+ border: none;
+ text-align: inherit;
+ width: 100%;
+}
+
+.meme-preview .form__input:focus {
+ outline: none;
+}
+
+.meme-preview .btn--random {
+ margin: 2rem auto;
+}
diff --git a/public/projects/react-small-apps/apps/notebook/.env.example b/public/projects/react-small-apps/apps/notebook/.env.example
new file mode 100644
index 0000000..1b48ea9
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/.env.example
@@ -0,0 +1,11 @@
+# Create React App config.
+# See: https://create-react-app.dev/docs/advanced-configuration/
+
+# Development
+BROWSER='firefox-developer-edition'
+BUILD_PATH='build'
+PORT=3000
+HTTPS=false
+
+# Production
+PUBLIC_URL='./'
diff --git a/public/projects/react-small-apps/apps/notebook/.gitignore b/public/projects/react-small-apps/apps/notebook/.gitignore
new file mode 100644
index 0000000..0732bb1
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/.gitignore
@@ -0,0 +1,24 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+
+# testing
+/coverage
+
+# production
+/build
+
+# misc
+.DS_Store
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+.vscode
+
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
diff --git a/public/projects/react-small-apps/apps/notebook/README.md b/public/projects/react-small-apps/apps/notebook/README.md
new file mode 100644
index 0000000..06df8dd
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/README.md
@@ -0,0 +1,31 @@
+# React Notebook
+
+A notebook implementation with React.
+
+## Requirements
+
+- Yarn
+
+## How to
+
+### Start the development version
+
+`yarn run start`
+
+### Start the build version:
+
+1. `yarn run build`
+2. (`yarn global add serve`)
+3. `serve -s build`
+
+## Previews
+
+You can see a live preview here: https://demo.armandphilippot.com/#notebook
+
+| Cover | Page |
+| --- | --- |
+| ![Cover preview](./public/preview-cover.jpg) | ![Page preview](./public/preview-page.jpg) |
+
+## License
+
+This project is open source and available under the [MIT license](../LICENSE).
diff --git a/public/projects/react-small-apps/apps/notebook/package.json b/public/projects/react-small-apps/apps/notebook/package.json
new file mode 100644
index 0000000..2d0aa06
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/package.json
@@ -0,0 +1,41 @@
+{
+ "name": "react-notebook",
+ "description": "A notebook implementation with ReactJS.",
+ "version": "1.0.0",
+ "private": true,
+ "dependencies": {
+ "@testing-library/jest-dom": "^5.16.2",
+ "@testing-library/react": "^12.1.3",
+ "@testing-library/user-event": "^13.5.0",
+ "modern-normalize": "^1.1.0",
+ "react": "^17.0.2",
+ "react-dom": "^17.0.2",
+ "react-router-dom": "^6.2.1",
+ "react-scripts": "5.0.0",
+ "web-vitals": "^2.1.4"
+ },
+ "scripts": {
+ "start": "react-scripts start",
+ "build": "react-scripts build",
+ "test": "react-scripts test",
+ "eject": "react-scripts eject"
+ },
+ "eslintConfig": {
+ "extends": [
+ "react-app",
+ "react-app/jest"
+ ]
+ },
+ "browserslist": {
+ "production": [
+ ">0.2%",
+ "not dead",
+ "not op_mini all"
+ ],
+ "development": [
+ "last 1 chrome version",
+ "last 1 firefox version",
+ "last 1 safari version"
+ ]
+ }
+}
diff --git a/public/projects/react-small-apps/apps/notebook/public/favicon.ico b/public/projects/react-small-apps/apps/notebook/public/favicon.ico
new file mode 100644
index 0000000..a11777c
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/public/favicon.ico
Binary files differ
diff --git a/public/projects/react-small-apps/apps/notebook/public/index.html b/public/projects/react-small-apps/apps/notebook/public/index.html
new file mode 100644
index 0000000..95386ce
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/public/index.html
@@ -0,0 +1,20 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8" />
+ <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <meta name="theme-color" content="#000000" />
+ <meta
+ name="description"
+ content="A notebook app created using create-react-app."
+ />
+ <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
+ <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
+ <title>Notebook</title>
+ </head>
+ <body>
+ <noscript>You need to enable JavaScript to run this app.</noscript>
+ <div class="app" id="app"></div>
+ </body>
+</html>
diff --git a/public/projects/react-small-apps/apps/notebook/public/manifest.json b/public/projects/react-small-apps/apps/notebook/public/manifest.json
new file mode 100644
index 0000000..080d6c7
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/public/manifest.json
@@ -0,0 +1,25 @@
+{
+ "short_name": "React App",
+ "name": "Create React App Sample",
+ "icons": [
+ {
+ "src": "favicon.ico",
+ "sizes": "64x64 32x32 24x24 16x16",
+ "type": "image/x-icon"
+ },
+ {
+ "src": "logo192.png",
+ "type": "image/png",
+ "sizes": "192x192"
+ },
+ {
+ "src": "logo512.png",
+ "type": "image/png",
+ "sizes": "512x512"
+ }
+ ],
+ "start_url": ".",
+ "display": "standalone",
+ "theme_color": "#000000",
+ "background_color": "#ffffff"
+}
diff --git a/public/projects/react-small-apps/apps/notebook/public/preview-cover.jpg b/public/projects/react-small-apps/apps/notebook/public/preview-cover.jpg
new file mode 100644
index 0000000..ef04f6b
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/public/preview-cover.jpg
Binary files differ
diff --git a/public/projects/react-small-apps/apps/notebook/public/preview-page.jpg b/public/projects/react-small-apps/apps/notebook/public/preview-page.jpg
new file mode 100644
index 0000000..ed238f9
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/public/preview-page.jpg
Binary files differ
diff --git a/public/projects/react-small-apps/apps/notebook/public/robots.txt b/public/projects/react-small-apps/apps/notebook/public/robots.txt
new file mode 100644
index 0000000..e9e57dc
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/public/robots.txt
@@ -0,0 +1,3 @@
+# https://www.robotstxt.org/robotstxt.html
+User-agent: *
+Disallow:
diff --git a/public/projects/react-small-apps/apps/notebook/src/App.css b/public/projects/react-small-apps/apps/notebook/src/App.css
new file mode 100644
index 0000000..76d2aec
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/src/App.css
@@ -0,0 +1,40 @@
+body {
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
+ Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
+ font-size: 1rem;
+ line-height: 1.618;
+}
+
+.app {
+ display: flex;
+ flex-flow: column nowrap;
+ gap: clamp(1rem, 3vw, 2rem);
+ min-height: 100vh;
+ width: min(calc(100vw - 2rem), 1000px);
+ margin: auto;
+}
+
+.notebook {
+ background: hsl(0, 4%, 20%);
+ box-shadow: 2px 2px 2px 0 hsl(0, 0%, 13%), 2px 2px 5px 3px hsl(0, 0%, 40%);
+ display: grid;
+ grid-template-columns: repeat(2, 1fr);
+ min-height: 550px;
+ margin-top: 3rem;
+ padding: 0.8rem 1.3rem 1.2rem 1.3rem;
+ position: relative;
+}
+
+@media screen and (max-width: 1024px) {
+ .notebook {
+ padding: 0.8rem 1.3rem 1.2rem 0.1rem;
+ }
+}
+
+.notebook--cover {
+ padding: 1rem;
+}
+
+a {
+ color: hsl(212, 46%, 34%);
+}
diff --git a/public/projects/react-small-apps/apps/notebook/src/App.js b/public/projects/react-small-apps/apps/notebook/src/App.js
new file mode 100644
index 0000000..6057342
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/src/App.js
@@ -0,0 +1,174 @@
+import { Navigate, Route, Routes, useLocation } from "react-router-dom";
+import { Footer, Header, Main, Nav, Page } from "./components/layout";
+import { useCallback, useEffect, useState } from "react";
+import { defaultPages } from "./config/pages";
+import "./App.css";
+
+let pageId = 0;
+
+function App() {
+ const storedPages = JSON.parse(localStorage.getItem("notebook-pages"));
+ const initialPages = storedPages || defaultPages;
+ const [pages, setPages] = useState(initialPages);
+ const [currentPage, setCurrentPage] = useState({});
+ const [deletedPages, setDeletedPages] = useState([]);
+ const location = useLocation();
+
+ pageId = storedPages ? storedPages.at(storedPages.length - 1).id : pageId;
+
+ const isCover = () => currentPage && currentPage.id === 0;
+ const isPageExists = useCallback(
+ (id) => {
+ const pageIndex = pages.findIndex((page) => page.id === id);
+ return pageIndex === -1 ? false : true;
+ },
+ [pages]
+ );
+
+ const addNewPage = useCallback(() => {
+ pageId++;
+ const newPage = {
+ id: pageId,
+ body: "",
+ title: `Page ${pageId}`,
+ url: `/page/${pageId}`,
+ };
+ setPages((previous) => [...previous, newPage]);
+ }, []);
+
+ const removePage = useCallback(() => {
+ const currentPageId = currentPage.id;
+ const pagesCopy = pages.slice(0);
+ const currentPageIndex = pages.findIndex(
+ (page) => page.id === currentPageId
+ );
+ setDeletedPages((prev) => [...prev, currentPage]);
+ pagesCopy.splice(currentPageIndex, 1);
+ const newPages = pagesCopy.map((page) => {
+ if (page.id <= currentPageId) return page;
+ const newId = page.id - 1;
+ const newURL = `/page/${newId}`;
+ return { ...page, id: newId, url: newURL };
+ });
+ setCurrentPage(...newPages.filter((page) => page.id === currentPageId));
+ setPages(newPages);
+ pageId = pageId - 1;
+ }, [pages, currentPage]);
+
+ const restorePage = useCallback(() => {
+ const deletedPage = deletedPages.pop();
+ const pagesCopy = pages.slice(0);
+ const restoredPageIndex = pagesCopy.findIndex(
+ (page) => page.id === deletedPage.id
+ );
+ const newPages = pagesCopy.map((page) => {
+ if (page.id < deletedPage.id) return page;
+ const newId = page.id + 1;
+ const newURL = `/page/${newId}`;
+ return { ...page, id: newId, url: newURL };
+ });
+ newPages.splice(restoredPageIndex, 0, deletedPage);
+ setCurrentPage(...newPages.filter((page) => page.id === deletedPage.id));
+ setPages(newPages);
+ }, [pages, deletedPages]);
+
+ useEffect(() => {
+ !isPageExists(1) && addNewPage();
+ }, [isPageExists, addNewPage]);
+
+ useEffect(() => {
+ const requestedPage = pages.find((page) => page.url === location.pathname);
+ if (requestedPage) {
+ setCurrentPage(requestedPage);
+ } else {
+ setCurrentPage(() => pages.find((page) => page.url === "/404"));
+ }
+ }, [location.pathname, pages]);
+
+ useEffect(() => {
+ if (currentPage) document.title = `Notebook - ${currentPage.title}`;
+ }, [currentPage]);
+
+ useEffect(() => {
+ setPages((prevPages) => {
+ return prevPages.map((page) => {
+ if (page.id !== currentPage.id) return page;
+ return { ...page, body: currentPage.body };
+ });
+ });
+ }, [currentPage.id, currentPage.body]);
+
+ useEffect(() => {
+ setPages((prevPages) => {
+ return prevPages.map((page) => {
+ if (page.id !== currentPage.id) return page;
+ return { ...page, title: currentPage.title };
+ });
+ });
+ }, [currentPage.id, currentPage.title]);
+
+ useEffect(() => {
+ localStorage.setItem("notebook-pages", JSON.stringify(pages));
+ }, [pages]);
+
+ return (
+ <>
+ <Header />
+ <Main>
+ <div className={`notebook ${isCover() ? "notebook--cover" : ""}`}>
+ <div className="notebook-page notebook-page--mirror"></div>
+ <Routes>
+ <Route
+ path="/"
+ element={
+ <Page
+ page={currentPage}
+ setPage={setCurrentPage}
+ removePage={removePage}
+ restorePage={restorePage}
+ deletedPages={deletedPages}
+ />
+ }
+ />
+ <Route path="/page" element={<Navigate replace to="/404" />} />
+ <Route path="/page/0" element={<Navigate replace to="/" />} />
+ <Route
+ path="/page/:number"
+ element={
+ <Page
+ page={currentPage}
+ setPage={setCurrentPage}
+ removePage={removePage}
+ restorePage={restorePage}
+ deletedPages={deletedPages}
+ />
+ }
+ />
+ <Route
+ element={
+ <Page
+ page={currentPage}
+ setPage={setCurrentPage}
+ removePage={removePage}
+ restorePage={restorePage}
+ deletedPages={deletedPages}
+ />
+ }
+ path="/404"
+ />
+ <Route path="*" element={<Navigate replace to="/404" />} />
+ </Routes>
+ </div>
+ <Nav
+ pages={pages}
+ currentPage={currentPage}
+ addNewPage={addNewPage}
+ isPageExists={isPageExists}
+ />
+ </Main>
+ <Footer />
+ </>
+ );
+}
+
+export default App;
diff --git a/public/projects/react-small-apps/apps/notebook/src/App.test.js b/public/projects/react-small-apps/apps/notebook/src/App.test.js
new file mode 100644
index 0000000..1f03afe
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/src/App.test.js
@@ -0,0 +1,8 @@
+import { render, screen } from '@testing-library/react';
+import App from './App';
+
+test('renders learn react link', () => {
+ render(<App />);
+ const linkElement = screen.getByText(/learn react/i);
+ expect(linkElement).toBeInTheDocument();
+});
diff --git a/public/projects/react-small-apps/apps/notebook/src/components/commons/Button/Button.css b/public/projects/react-small-apps/apps/notebook/src/components/commons/Button/Button.css
new file mode 100644
index 0000000..16268d3
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/src/components/commons/Button/Button.css
@@ -0,0 +1,78 @@
+.btn {
+ cursor: pointer;
+}
+
+.btn--delete {
+ background: hsl(0, 44%, 44%);
+ border: none;
+ border-radius: 50%;
+ box-shadow: 0 0 0 2px hsl(0, 44%, 29%), 1px 2px 1px 2px hsl(0, 44%, 29%);
+ width: 2.4rem;
+ height: 2.4rem;
+ padding: 0.5rem;
+ transition: transform 0.3s ease-in-out 0s;
+}
+
+.btn--delete:hover,
+.btn--delete:focus {
+ background: hsl(0, 44%, 50%);
+ transform: scale(1.1);
+}
+
+.btn--delete:active {
+ background: hsl(0, 44%, 40%);
+ transform: scale(1);
+}
+
+.btn .icon {
+ height: 100%;
+ width: 100%;
+}
+
+.btn--delete #trash-lid-handle {
+ stroke: #fff;
+ stroke-width: 4;
+}
+
+.btn--delete #trash-container,
+.btn--delete #trash-lid {
+ fill: hsl(0, 44%, 49%);
+ stroke: #fff;
+ stroke-width: 5;
+}
+
+.btn--delete #trash-stroke1,
+.btn--delete #trash-stroke2,
+.btn--delete #trash-stroke3 {
+ fill: #fff;
+ stroke: #fff;
+ stroke-width: 1;
+}
+
+.btn--restore {
+ background: hsl(212, 44%, 44%);
+ border: none;
+ border-radius: 50%;
+ box-shadow: 0 0 0 2px hsl(212, 44%, 29%), 1px 2px 1px 2px hsl(212, 44%, 29%);
+ width: 2.4rem;
+ height: 2.4rem;
+ padding: 0.5rem;
+ transition: transform 0.3s ease-in-out 0s;
+}
+
+.btn--restore:hover {
+ background: hsl(212, 44%, 50%);
+ transform: scale(1.1);
+}
+
+.btn--restore:active {
+ background: hsl(212, 44%, 40%);
+ transform: scale(1);
+}
+
+.btn--restore #restore-circle,
+.btn--restore #restore-arrow,
+.btn--restore #restore-first-clock-hand,
+.btn--restore #restore-second-clock-hand {
+ fill: #fff;
+}
diff --git a/public/projects/react-small-apps/apps/notebook/src/components/commons/Button/Button.js b/public/projects/react-small-apps/apps/notebook/src/components/commons/Button/Button.js
new file mode 100644
index 0000000..4580815
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/src/components/commons/Button/Button.js
@@ -0,0 +1,26 @@
+import "./Button.css";
+
+function Button({
+ children,
+ onClickHandler,
+ onBlurHandler,
+ modifier,
+ additionalClassnames,
+}) {
+ let classNames = modifier ? `btn btn--${modifier}` : "btn";
+ classNames = additionalClassnames
+ ? `${classNames} ${additionalClassnames}`
+ : classNames;
+
+ return (
+ <button
+ className={classNames}
+ onClick={onClickHandler}
+ onBlur={onBlurHandler}
+ >
+ {children}
+ </button>
+ );
+}
+
+export default Button;
diff --git a/public/projects/react-small-apps/apps/notebook/src/components/commons/FormElements/Input/Input.js b/public/projects/react-small-apps/apps/notebook/src/components/commons/FormElements/Input/Input.js
new file mode 100644
index 0000000..7d8cb45
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/src/components/commons/FormElements/Input/Input.js
@@ -0,0 +1,31 @@
+import { forwardRef } from "react";
+
+function Input(
+ {
+ type = "text",
+ name,
+ value,
+ onChangeHandler,
+ onBlurHandler,
+ additionalClasses,
+ },
+ ref
+) {
+ const classNames = additionalClasses
+ ? `form__input ${additionalClasses}`
+ : "form__input";
+
+ return (
+ <input
+ ref={ref}
+ type={type}
+ name={name}
+ className={classNames}
+ value={value}
+ onChange={onChangeHandler}
+ onBlur={onBlurHandler}
+ />
+ );
+}
+
+export default forwardRef(Input);
diff --git a/public/projects/react-small-apps/apps/notebook/src/components/commons/FormElements/TextArea/TextArea.js b/public/projects/react-small-apps/apps/notebook/src/components/commons/FormElements/TextArea/TextArea.js
new file mode 100644
index 0000000..ca2a52e
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/src/components/commons/FormElements/TextArea/TextArea.js
@@ -0,0 +1,52 @@
+import { forwardRef, useEffect, useState } from "react";
+
+function autoGrow(field, initialValue = null) {
+ let fieldHeight = initialValue ?? field.style.height;
+ if (field.scrollHeight > field.clientHeight) {
+ fieldHeight = field.scrollHeight + "px";
+ }
+ return fieldHeight;
+}
+
+function isSetHeightNeeded(e) {
+ const key = e.key;
+ const isBackspace = key === "Backspace";
+ const isDelete = key === "Delete";
+ const isCtrlZ = e.ctrlKey && e.key === "z";
+ const isCut = e.ctrlKey && e.key === "x";
+ return isBackspace || isDelete || isCtrlZ || isCut;
+}
+
+function TextArea(
+ { value, name, onBlurHandler, onChangeHandler, additionalClasses },
+ ref
+) {
+ const [fieldHeight, setFieldHeight] = useState();
+ const classNames = additionalClasses
+ ? `form__textarea ${additionalClasses}`
+ : "form__textarea";
+
+ useEffect(() => {
+ ref && setFieldHeight(autoGrow(ref.current));
+ }, [ref]);
+
+ return (
+ <textarea
+ ref={ref}
+ className={classNames}
+ name={name}
+ value={value}
+ onChange={(e) => {
+ onChangeHandler(e);
+ setFieldHeight(autoGrow(e.target));
+ }}
+ onKeyDown={(e) => {
+ if (isSetHeightNeeded(e)) setFieldHeight(autoGrow(e.target, "auto"));
+ }}
+ onBlur={onBlurHandler}
+ style={{ height: fieldHeight }}
+ />
+ );
+}
+
+export default forwardRef(TextArea);
diff --git a/public/projects/react-small-apps/apps/notebook/src/components/commons/FormElements/index.js b/public/projects/react-small-apps/apps/notebook/src/components/commons/FormElements/index.js
new file mode 100644
index 0000000..1d1f610
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/src/components/commons/FormElements/index.js
@@ -0,0 +1,4 @@
+import Input from "./Input/Input";
+import TextArea from "./TextArea/TextArea";
+
+export { Input, TextArea };
diff --git a/public/projects/react-small-apps/apps/notebook/src/components/commons/List/List.css b/public/projects/react-small-apps/apps/notebook/src/components/commons/List/List.css
new file mode 100644
index 0000000..ae897a5
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/src/components/commons/List/List.css
@@ -0,0 +1,3 @@
+.list__item + .list__item {
+ margin-top: 0.5rem;
+}
diff --git a/public/projects/react-small-apps/apps/notebook/src/components/commons/List/List.js b/public/projects/react-small-apps/apps/notebook/src/components/commons/List/List.js
new file mode 100644
index 0000000..631e6a5
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/src/components/commons/List/List.js
@@ -0,0 +1,25 @@
+import "./List.css";
+
+function List({ type = "ul", data = [], modifier = "" }) {
+ const classNames = modifier ? `list list--${modifier}` : "list";
+
+ const listItems = data.map((object) => {
+ return (
+ <li key={object.id} className="list__item">
+ {object.body}
+ </li>
+ );
+ });
+
+ return (
+ <>
+ {type === "ol" ? (
+ <ol className={classNames}>{listItems}</ol>
+ ) : (
+ <ul className={classNames}>{listItems}</ul>
+ )}
+ </>
+ );
+}
+
+export default List;
diff --git a/public/projects/react-small-apps/apps/notebook/src/components/commons/index.js b/public/projects/react-small-apps/apps/notebook/src/components/commons/index.js
new file mode 100644
index 0000000..f0ca17b
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/src/components/commons/index.js
@@ -0,0 +1,5 @@
+import Button from "./Button/Button";
+import { Input, TextArea } from "./FormElements";
+import List from "./List/List";
+
+export { Button, Input, List, TextArea };
diff --git a/public/projects/react-small-apps/apps/notebook/src/components/helpers/hooks/useToggle.js b/public/projects/react-small-apps/apps/notebook/src/components/helpers/hooks/useToggle.js
new file mode 100644
index 0000000..0291324
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/src/components/helpers/hooks/useToggle.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/notebook/src/components/layout/Footer/Footer.css b/public/projects/react-small-apps/apps/notebook/src/components/layout/Footer/Footer.css
new file mode 100644
index 0000000..31db439
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/src/components/layout/Footer/Footer.css
@@ -0,0 +1,8 @@
+.footer {
+ padding: 0 0 1rem;
+}
+
+.footer__copyright {
+ font-size: 0.9rem;
+ text-align: center;
+}
diff --git a/public/projects/react-small-apps/apps/notebook/src/components/layout/Footer/Footer.js b/public/projects/react-small-apps/apps/notebook/src/components/layout/Footer/Footer.js
new file mode 100644
index 0000000..20a87f2
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/src/components/layout/Footer/Footer.js
@@ -0,0 +1,11 @@
+import "./Footer.css";
+
+function Footer() {
+ return (
+ <footer className="footer">
+ <p className="footer__copyright">Notebook. MIT 2021. Armand Philippot.</p>
+ </footer>
+ );
+}
+
+export default Footer;
diff --git a/public/projects/react-small-apps/apps/notebook/src/components/layout/Header/Header.css b/public/projects/react-small-apps/apps/notebook/src/components/layout/Header/Header.css
new file mode 100644
index 0000000..a413a4f
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/src/components/layout/Header/Header.css
@@ -0,0 +1,7 @@
+.header {
+ padding: 1rem 0 0;
+}
+
+.header__branding {
+ font-size: 2.5rem;
+}
diff --git a/public/projects/react-small-apps/apps/notebook/src/components/layout/Header/Header.js b/public/projects/react-small-apps/apps/notebook/src/components/layout/Header/Header.js
new file mode 100644
index 0000000..03757fa
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/src/components/layout/Header/Header.js
@@ -0,0 +1,12 @@
+import "./Header.css";
+
+function Header({ children }) {
+ return (
+ <header className="header">
+ <h1 className="header__branding">Notebook</h1>
+ {children}
+ </header>
+ );
+}
+
+export default Header;
diff --git a/public/projects/react-small-apps/apps/notebook/src/components/layout/Main/Main.css b/public/projects/react-small-apps/apps/notebook/src/components/layout/Main/Main.css
new file mode 100644
index 0000000..f6ee8fe
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/src/components/layout/Main/Main.css
@@ -0,0 +1,3 @@
+.main {
+ flex: 1;
+}
diff --git a/public/projects/react-small-apps/apps/notebook/src/components/layout/Main/Main.js b/public/projects/react-small-apps/apps/notebook/src/components/layout/Main/Main.js
new file mode 100644
index 0000000..23e7b9d
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/src/components/layout/Main/Main.js
@@ -0,0 +1,7 @@
+import "./Main.css";
+
+function Main({ children }) {
+ return <main className="main">{children}</main>;
+}
+
+export default Main;
diff --git a/public/projects/react-small-apps/apps/notebook/src/components/layout/Nav/Nav.css b/public/projects/react-small-apps/apps/notebook/src/components/layout/Nav/Nav.css
new file mode 100644
index 0000000..9f5f90c
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/src/components/layout/Nav/Nav.css
@@ -0,0 +1,65 @@
+.nav {
+ position: relative;
+ display: flex;
+ flex-flow: row wrap;
+ justify-content: center;
+ margin: 2rem auto 0;
+}
+
+.nav .list--nav {
+ list-style-type: none;
+ position: absolute;
+ bottom: 100%;
+ width: 80vw;
+ margin: 0;
+ padding: 1rem;
+ background: #fff;
+ border: 1px solid #ccc;
+}
+
+@media screen and (min-width: 1024px) {
+ .nav .list--nav {
+ width: 50%;
+ }
+}
+
+.nav__link {
+ background: #fff;
+ border: none;
+ color: hsl(212, 46%, 34%);
+ text-decoration: underline;
+ display: inline-block;
+ margin: 0 1px;
+ padding: 0.8rem 1rem;
+}
+
+.nav .list__link {
+ display: block;
+ padding: 0.2rem;
+}
+
+.nav .list__link--current {
+ background: hsl(212, 46%, 34%);
+ color: #fff;
+}
+
+.nav__link:hover,
+.nav__link:focus,
+.nav .list__link:hover,
+.nav .list__link:focus {
+ text-decoration-thickness: 4px;
+}
+
+.nav__link:focus,
+.nav .list__link:focus {
+ color: inherit;
+}
+
+.nav .list__link--current:focus {
+ color: hsl(0, 0%, 89%);
+}
+
+.nav__link:active {
+ color: hsl(212, 46%, 20%);
+ text-decoration-thickness: 2px;
+}
diff --git a/public/projects/react-small-apps/apps/notebook/src/components/layout/Nav/Nav.js b/public/projects/react-small-apps/apps/notebook/src/components/layout/Nav/Nav.js
new file mode 100644
index 0000000..4e2a916
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/src/components/layout/Nav/Nav.js
@@ -0,0 +1,62 @@
+import { useEffect, useState } from "react";
+import { Link } from "react-router-dom";
+import { Button } from "../../commons";
+import NavJump from "./NavJump";
+import "./Nav.css";
+
+function Nav({ pages, currentPage, addNewPage, isPageExists }) {
+ const [isJumpEnabled, setIsJumpEnabled] = useState(false);
+
+ const isCover = () => currentPage && currentPage.id === 0;
+ const isFirstPage = () => currentPage && currentPage.id === 1;
+ const is404 = () => currentPage && currentPage.id === null;
+
+ useEffect(() => {
+ setIsJumpEnabled(false);
+ }, [currentPage.id]);
+
+ return (
+ <nav
+ className="nav"
+ onBlur={(e) => !e.relatedTarget && setIsJumpEnabled(false)}
+ >
+ {!isCover() && (
+ <Link className="nav__link" to="/" onClick={(e) => e.target.blur()}>
+ Back to cover
+ </Link>
+ )}
+ {!isCover() && !isFirstPage() && isPageExists(currentPage.id - 1) && (
+ <Link
+ className="nav__link"
+ to={`/page/${currentPage.id - 1}`}
+ onFocus={() => setIsJumpEnabled(false)}
+ onClick={(e) => e.target.blur()}
+ >
+ Previous page
+ </Link>
+ )}
+ <Button
+ additionalClassnames="nav__link"
+ onClickHandler={() => setIsJumpEnabled(!isJumpEnabled)}
+ >
+ Jump to
+ </Button>
+ {isJumpEnabled && <NavJump pages={pages} />}
+ {!is404() && (
+ <Link
+ className="nav__link"
+ to={`/page/${currentPage.id + 1}`}
+ onClick={(e) => {
+ !isPageExists(currentPage.id + 1) && addNewPage();
+ e.target.blur();
+ }}
+ onFocus={() => setIsJumpEnabled(false)}
+ >
+ Next page
+ </Link>
+ )}
+ </nav>
+ );
+}
+
+export default Nav;
diff --git a/public/projects/react-small-apps/apps/notebook/src/components/layout/Nav/NavJump.js b/public/projects/react-small-apps/apps/notebook/src/components/layout/Nav/NavJump.js
new file mode 100644
index 0000000..9d2a049
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/src/components/layout/Nav/NavJump.js
@@ -0,0 +1,28 @@
+import { NavLink } from "react-router-dom";
+import { List } from "../../commons";
+
+function NavJump({ pages }) {
+ const links = pages
+ .filter((page) => page.id > 0)
+ .map((page) => {
+ return {
+ id: page.id,
+ body: (
+ <NavLink
+ key={page.id}
+ className={({ isActive }) =>
+ isActive ? "list__link--current" : "list__link"
+ }
+ aria-current="page"
+ to={page.url}
+ >
+ {page.title}
+ </NavLink>
+ ),
+ };
+ });
+
+ return <List data={links} modifier="nav" />;
+}
+
+export default NavJump;
diff --git a/public/projects/react-small-apps/apps/notebook/src/components/layout/Page/Cover.css b/public/projects/react-small-apps/apps/notebook/src/components/layout/Page/Cover.css
new file mode 100644
index 0000000..bf915dd
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/src/components/layout/Page/Cover.css
@@ -0,0 +1,30 @@
+.notebook--cover .notebook-page--mirror {
+ display: none;
+}
+
+.notebook--cover .notebook-page {
+ grid-column: 1 / -1;
+ background: hsl(0, 4%, 20%);
+ color: rgb(234, 235, 236);
+ letter-spacing: 1px;
+ text-shadow: 1px 1px 0 #000;
+ box-shadow: none;
+}
+
+.notebook--cover .notebook-page {
+ justify-content: center;
+ text-align: center;
+}
+
+.notebook--cover .notebook-page__title {
+ font-size: 3rem;
+ font-weight: 600;
+}
+
+.notebook--cover .notebook-page .notebook-page__content {
+ flex: 0;
+}
+
+.notebook--cover .notebook-page .notebook-page__title .form__input {
+ text-align: center;
+}
diff --git a/public/projects/react-small-apps/apps/notebook/src/components/layout/Page/Page.css b/public/projects/react-small-apps/apps/notebook/src/components/layout/Page/Page.css
new file mode 100644
index 0000000..873df47
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/src/components/layout/Page/Page.css
@@ -0,0 +1,83 @@
+.notebook-page {
+ display: flex;
+ flex-flow: column nowrap;
+ padding: clamp(1rem, 3vw, 2rem) 0;
+ background: #fff;
+ border: 1px solid #cacaca;
+ box-shadow: 1px 1px 0 0 #ebebeb, 1px 1px 0 1px #bebebe, 2px 2px 0 1px #ebebeb,
+ 2px 2px 0 2px #bebebe, 3px 3px 0 2px #ebebeb, 3px 3px 0 3px #bebebe;
+}
+
+.notebook-page--mirror {
+ box-shadow: -1px 1px 0 0 #ebebeb, -1px 1px 0 1px #bebebe,
+ -2px 2px 0 1px #ebebeb, -2px 2px 0 2px #bebebe, -3px 3px 0 2px #ebebeb,
+ -3px 3px 0 3px #bebebe;
+}
+
+@media screen and (max-width: 1023px) {
+ .notebook-page {
+ grid-column: 1/-1;
+ }
+ .notebook-page--mirror {
+ display: none;
+ }
+}
+
+.notebook-page__header,
+.notebook-page__footer,
+.notebook-page__content {
+ padding: 0 3rem 0 2rem;
+}
+
+.notebook-page__title {
+ font-size: 1.8rem;
+ font-weight: 600;
+ margin: 1rem 0 0;
+}
+
+.notebook-page__title .form__input {
+ border: none;
+ font-weight: inherit;
+ padding: 0;
+ width: 100%;
+}
+
+.notebook-page__title .form__input:focus {
+ outline: none;
+}
+
+.notebook-page__content {
+ flex: 1;
+ display: flex;
+ margin: clamp(1rem, 3vw, 2rem) 0 clamp(2rem, 3vw, 3rem);
+ white-space: pre-wrap;
+ word-break: break-all;
+ hyphens: auto;
+}
+
+.notebook-page__content .form__textarea {
+ border: none;
+ line-height: inherit;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ padding: 0;
+}
+
+.notebook-page__content .form__textarea:focus {
+ outline: none;
+ resize: none;
+}
+
+.notebook-page__footer {
+ text-align: right;
+}
+
+.notebook-page__toolbar {
+ display: flex;
+ flex-flow: row;
+ gap: 1rem;
+ position: absolute;
+ top: -4rem;
+ right: 1rem;
+}
diff --git a/public/projects/react-small-apps/apps/notebook/src/components/layout/Page/Page.js b/public/projects/react-small-apps/apps/notebook/src/components/layout/Page/Page.js
new file mode 100644
index 0000000..19e072c
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/src/components/layout/Page/Page.js
@@ -0,0 +1,107 @@
+import { useEffect, useRef } from "react";
+import { Input, TextArea } from "../../commons";
+import useToggle from "../../helpers/hooks/useToggle";
+import PageToolbar from "./PageToolbar";
+import "./Cover.css";
+import "./Page.css";
+
+function Page({ page, setPage, removePage, restorePage, deletedPages }) {
+ const [isTitleEditable, setIsTitleEditable] = useToggle();
+ const [isBodyEditable, setIsBodyEditable] = useToggle();
+ const inputRef = useRef(null);
+ const textareaRef = useRef(null);
+
+ const isCover = () => page && page.id === 0;
+ const is404 = () => page && page.id === null;
+
+ useEffect(() => {
+ inputRef.current && inputRef.current.focus();
+ textareaRef.current && textareaRef.current.focus();
+ });
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+ };
+
+ const handleOnChange = (e) => {
+ let newValue = {};
+
+ switch (e.target.name) {
+ case "notebook-title":
+ newValue = { title: e.target.value };
+ break;
+ case "notebook-body":
+ newValue = { body: e.target.value };
+ break;
+ default:
+ break;
+ }
+
+ setPage((previous) => {
+ return { ...previous, ...newValue };
+ });
+ };
+
+ return (
+ <article
+ className={`notebook-page ${isCover() ? "notebook-page--cover" : ""}`}
+ >
+ <header className="notebook-page__header">
+ {!isTitleEditable && (
+ <h2
+ className="notebook-page__title"
+ onClick={() => {
+ if (!is404()) setIsTitleEditable();
+ }}
+ >
+ {page.title}
+ </h2>
+ )}
+ {isTitleEditable && (
+ <form className="notebook-page__title" onSubmit={handleSubmit}>
+ <Input
+ ref={inputRef}
+ name="notebook-title"
+ value={page.title}
+ onChangeHandler={handleOnChange}
+ onBlurHandler={setIsTitleEditable}
+ />
+ </form>
+ )}
+ </header>
+ {!isBodyEditable && (
+ <div
+ className="notebook-page__content"
+ onClick={() => {
+ if (!is404()) setIsBodyEditable();
+ }}
+ >
+ {page.body}
+ </div>
+ )}
+ {isBodyEditable && (
+ <form className="notebook-page__content" onSubmit={handleSubmit}>
+ <TextArea
+ ref={textareaRef}
+ name="notebook-body"
+ value={page.body}
+ onChangeHandler={handleOnChange}
+ onBlurHandler={setIsBodyEditable}
+ />
+ </form>
+ )}
+ <footer className="notebook-page__footer">
+ {!isCover() && <div className="notebook-page__number">{page.id}</div>}
+ {!isCover() && (
+ <PageToolbar
+ removePage={removePage}
+ restorePage={restorePage}
+ deletedPages={deletedPages}
+ />
+ )}
+ </footer>
+ </article>
+ );
+}
+
+export default Page;
diff --git a/public/projects/react-small-apps/apps/notebook/src/components/layout/Page/PageToolbar.js b/public/projects/react-small-apps/apps/notebook/src/components/layout/Page/PageToolbar.js
new file mode 100644
index 0000000..a16aa22
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/src/components/layout/Page/PageToolbar.js
@@ -0,0 +1,27 @@
+import { Button } from "../../commons";
+import { ReactComponent as TrashIcon } from "../../../images/trash.svg";
+import { ReactComponent as RestoreIcon } from "../../../images/restore.svg";
+
+function PageToolbar({ removePage, restorePage, deletedPages }) {
+ return (
+ <div className="notebook-page__toolbar toolbar">
+ <div className="toolbar__item">
+ {deletedPages.length > 0 && (
+ <Button modifier="restore" onClickHandler={restorePage}>
+ <RestoreIcon
+ title="Undo page deletion"
+ className="icon icon--restore"
+ />
+ </Button>
+ )}
+ </div>
+ <div className="toolbar__item">
+ <Button modifier="delete" onClickHandler={removePage}>
+ <TrashIcon title="Delete this page" className="icon icon--trash" />
+ </Button>
+ </div>
+ </div>
+ );
+}
+
+export default PageToolbar;
diff --git a/public/projects/react-small-apps/apps/notebook/src/components/layout/index.js b/public/projects/react-small-apps/apps/notebook/src/components/layout/index.js
new file mode 100644
index 0000000..1b8d583
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/src/components/layout/index.js
@@ -0,0 +1,7 @@
+import Footer from "./Footer/Footer";
+import Header from "./Header/Header";
+import Main from "./Main/Main";
+import Nav from "./Nav/Nav";
+import Page from "./Page/Page";
+
+export { Footer, Header, Main, Nav, Page };
diff --git a/public/projects/react-small-apps/apps/notebook/src/config/pages.js b/public/projects/react-small-apps/apps/notebook/src/config/pages.js
new file mode 100644
index 0000000..0836aba
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/src/config/pages.js
@@ -0,0 +1,16 @@
+const defaultPages = [
+ {
+ id: null,
+ body: "",
+ title: "Page not found",
+ url: "/404",
+ },
+ {
+ id: 0,
+ body: "",
+ title: "My Notebook",
+ url: "/",
+ },
+];
+
+export { defaultPages };
diff --git a/public/projects/react-small-apps/apps/notebook/src/images/restore.svg b/public/projects/react-small-apps/apps/notebook/src/images/restore.svg
new file mode 100644
index 0000000..c2977d6
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/src/images/restore.svg
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<svg
+ viewBox="0 0 80 80"
+ xmlns="http://www.w3.org/2000/svg">
+ <path
+ id="restore-circle"
+ d="M 43.267598,3.2675985 C 23.008066,3.2675985 6.5351966,19.740468 6.5351966,40 c 0,0.171468 0.00482,0.342043 0.00717,0.512962 h 4.5915504 c -0.0027,-0.170945 -0.0072,-0.341371 -0.0072,-0.512962 0,-17.778077 14.362775,-32.1408513 32.140851,-32.1408513 17.778077,0 32.140852,14.3627743 32.140852,32.1408513 0,17.778076 -14.362775,32.140851 -32.140852,32.140851 -8.338958,0 -15.925965,-3.160587 -21.6323,-8.350881 H 15.300394 C 22.042368,71.704396 32.076566,76.732402 43.267598,76.732402 63.52713,76.732402 80,60.259532 80,40 80,19.740468 63.52713,3.2675985 43.267598,3.2675985 Z" />
+ <path
+ id="restore-arrow"
+ d="m 17.457497,40.45113 h -17.45749705 L 8.7287481,50.692775 Z" />
+ <rect
+ id="restore-first-clock-hand"
+ width="5"
+ height="20"
+ x="37.5"
+ y="22.075739" />
+ <rect
+ id="restore-second-clock-hand"
+ width="5"
+ height="20"
+ x="1.6809042"
+ y="56.336418"
+ transform="rotate(-40)" />
+</svg>
diff --git a/public/projects/react-small-apps/apps/notebook/src/images/trash.svg b/public/projects/react-small-apps/apps/notebook/src/images/trash.svg
new file mode 100644
index 0000000..3c11d3b
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/src/images/trash.svg
@@ -0,0 +1,48 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!-- Created with Inkscape (http://www.inkscape.org/) -->
+
+<svg
+ width="80"
+ height="80"
+ viewBox="0 0 80 80"
+ xmlns="http://www.w3.org/2000/svg">
+ <path
+ fill="none"
+ stroke="#000000"
+ stroke-width="4"
+ id="trash-lid-handle"
+ d="M 39.999977,2.0049906 A 10.941541,10.901606 0 0 0 29.380829,10.392059 c 6.823427,-0.19706 14.435517,-0.202894 21.257435,-0.01905 A 10.941541,10.901606 0 0 0 39.999977,2.00502 Z M 39.865976,23.799924 a 10.941541,10.901606 0 0 0 0.134001,0.0096 10.941541,10.901606 0 0 0 0.358917,-0.0096 z" />
+ <path
+ fill="#ffffff"
+ stroke="#000000"
+ stroke-width="4"
+ id="trash-container"
+ d="m 15.029909,24.102259 h 49.940182 v 53.89774 H 15.029909 Z" />
+ <path
+ fill="#ffffff"
+ stroke="#000000"
+ stroke-width="4"
+ id="trash-lid"
+ d="m 70.398893,12.759297 c 0,3.026127 0,11.039803 0,11.039803 H 9.601107 c 0,0 0,-7.816365 0,-11.039803 0,-3.2958374 60.797786,-3.4333596 60.797786,0 z" />
+ <rect
+ fill="#000000"
+ id="trash-stroke1"
+ width="3.0539479"
+ height="32.859192"
+ x="23.934427"
+ y="34.621532" />
+ <rect
+ fill="#000000"
+ id="trash-stroke2"
+ width="3.0539479"
+ height="32.859192"
+ x="38.47303"
+ y="34.621532" />
+ <rect
+ fill="#000000"
+ id="trash-stroke3"
+ width="3.0539479"
+ height="32.859192"
+ x="53.011623"
+ y="34.621532" />
+</svg>
diff --git a/public/projects/react-small-apps/apps/notebook/src/index.js b/public/projects/react-small-apps/apps/notebook/src/index.js
new file mode 100644
index 0000000..7f2be6e
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/src/index.js
@@ -0,0 +1,20 @@
+import "modern-normalize";
+import React from "react";
+import { render } from "react-dom";
+import { BrowserRouter } from "react-router-dom";
+import reportWebVitals from "./reportWebVitals";
+import App from "./App";
+
+render(
+ <BrowserRouter basename={process.env.PUBLIC_URL}>
+ <React.StrictMode>
+ <App />
+ </React.StrictMode>
+ </BrowserRouter>,
+ document.getElementById("app")
+);
+
+// If you want to start measuring performance in your app, pass a function
+// to log results (for example: reportWebVitals(console.log))
+// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
+reportWebVitals();
diff --git a/public/projects/react-small-apps/apps/notebook/src/logo.svg b/public/projects/react-small-apps/apps/notebook/src/logo.svg
new file mode 100644
index 0000000..9dfc1c0
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/src/logo.svg
@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg> \ No newline at end of file
diff --git a/public/projects/react-small-apps/apps/notebook/src/reportWebVitals.js b/public/projects/react-small-apps/apps/notebook/src/reportWebVitals.js
new file mode 100644
index 0000000..5253d3a
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/src/reportWebVitals.js
@@ -0,0 +1,13 @@
+const reportWebVitals = onPerfEntry => {
+ if (onPerfEntry && onPerfEntry instanceof Function) {
+ import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
+ getCLS(onPerfEntry);
+ getFID(onPerfEntry);
+ getFCP(onPerfEntry);
+ getLCP(onPerfEntry);
+ getTTFB(onPerfEntry);
+ });
+ }
+};
+
+export default reportWebVitals;
diff --git a/public/projects/react-small-apps/apps/notebook/src/setupTests.js b/public/projects/react-small-apps/apps/notebook/src/setupTests.js
new file mode 100644
index 0000000..8f2609b
--- /dev/null
+++ b/public/projects/react-small-apps/apps/notebook/src/setupTests.js
@@ -0,0 +1,5 @@
+// jest-dom adds custom jest matchers for asserting on DOM nodes.
+// allows you to do things like:
+// expect(element).toHaveTextContent(/react/i)
+// learn more: https://github.com/testing-library/jest-dom
+import '@testing-library/jest-dom';
diff --git a/public/projects/react-small-apps/apps/todos/.env.example b/public/projects/react-small-apps/apps/todos/.env.example
new file mode 100644
index 0000000..1b48ea9
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/.env.example
@@ -0,0 +1,11 @@
+# Create React App config.
+# See: https://create-react-app.dev/docs/advanced-configuration/
+
+# Development
+BROWSER='firefox-developer-edition'
+BUILD_PATH='build'
+PORT=3000
+HTTPS=false
+
+# Production
+PUBLIC_URL='./'
diff --git a/public/projects/react-small-apps/apps/todos/.gitignore b/public/projects/react-small-apps/apps/todos/.gitignore
new file mode 100644
index 0000000..0732bb1
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/.gitignore
@@ -0,0 +1,24 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+
+# testing
+/coverage
+
+# production
+/build
+
+# misc
+.DS_Store
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+.vscode
+
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
diff --git a/public/projects/react-small-apps/apps/todos/README.md b/public/projects/react-small-apps/apps/todos/README.md
new file mode 100644
index 0000000..1288e36
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/README.md
@@ -0,0 +1,56 @@
+# React Redux ToDos
+
+A ToDo app implementation with React and Redux.
+
+## Description
+
+This a simple ToDo application used to learn Redux.
+
+The app has different views:
+
+- login (and a logout link)
+- a todos list where user can add new todos and mark them as done
+- a single todo view where user can see todo details and edit data
+- an account view
+
+## Requirements
+
+- Yarn
+
+## How to
+
+### Start the development version
+
+`yarn run start`
+
+### Start the build version:
+
+1. `yarn run build`
+2. (`yarn global add serve`)
+3. `serve -s build`
+
+## Login
+
+User: `demo@email.com`
+
+Password: `demo`
+
+## Preview
+
+You can see a live preview here: https://demo.armandphilippot.com/#todos
+
+| Todos list | Single todo |
+| --- | --- |
+| ![List preview](./assets/preview-todos-list.jpg) | ![Todo preview](./assets/preview-single-todo.jpg) |
+
+| Login Page | Account Page |
+| --- | --- |
+| ![Login preview](./assets/preview-todo-login.jpg) | ![Account preview](./assets/preview-todo-account.jpg) |
+
+## Disclaimer
+
+This app is not intended to be used. There is no registration form and password encryption is not implemented. The only user available is a demo user.
+
+## License
+
+This project is open-source and available under [MIT license](../LICENSE).
diff --git a/public/projects/react-small-apps/apps/todos/assets/preview-single-todo.jpg b/public/projects/react-small-apps/apps/todos/assets/preview-single-todo.jpg
new file mode 100644
index 0000000..b8332ac
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/assets/preview-single-todo.jpg
Binary files differ
diff --git a/public/projects/react-small-apps/apps/todos/assets/preview-todo-account.jpg b/public/projects/react-small-apps/apps/todos/assets/preview-todo-account.jpg
new file mode 100644
index 0000000..6babb24
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/assets/preview-todo-account.jpg
Binary files differ
diff --git a/public/projects/react-small-apps/apps/todos/assets/preview-todo-login.jpg b/public/projects/react-small-apps/apps/todos/assets/preview-todo-login.jpg
new file mode 100644
index 0000000..41116d9
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/assets/preview-todo-login.jpg
Binary files differ
diff --git a/public/projects/react-small-apps/apps/todos/assets/preview-todos-list.jpg b/public/projects/react-small-apps/apps/todos/assets/preview-todos-list.jpg
new file mode 100644
index 0000000..95b327f
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/assets/preview-todos-list.jpg
Binary files differ
diff --git a/public/projects/react-small-apps/apps/todos/package.json b/public/projects/react-small-apps/apps/todos/package.json
new file mode 100644
index 0000000..924c0dc
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/package.json
@@ -0,0 +1,48 @@
+{
+ "name": "react-redux-todos",
+ "description": "A ToDos app implementation with React and Redux.",
+ "version": "1.0.0",
+ "license": "MIT",
+ "author": {
+ "name": "Armand Philippot",
+ "url": "https://www.armandphilippot.com",
+ "email": "contact@armandphilippot.com"
+ },
+ "private": true,
+ "dependencies": {
+ "@reduxjs/toolkit": "^1.5.1",
+ "@testing-library/jest-dom": "^5.16.2",
+ "@testing-library/react": "^12.1.3",
+ "@testing-library/user-event": "^13.5.0",
+ "modern-normalize": "^1.1.0",
+ "react": "^17.0.2",
+ "react-dom": "^17.0.2",
+ "react-redux": "^7.2.3",
+ "react-router-dom": "^6.2.1",
+ "react-scripts": "5.0.0"
+ },
+ "scripts": {
+ "start": "react-scripts start",
+ "build": "react-scripts build",
+ "test": "react-scripts test",
+ "eject": "react-scripts eject"
+ },
+ "eslintConfig": {
+ "extends": "react-app"
+ },
+ "browserslist": {
+ "production": [
+ ">0.2%",
+ "not dead",
+ "not op_mini all"
+ ],
+ "development": [
+ "last 1 chrome version",
+ "last 1 firefox version",
+ "last 1 safari version"
+ ]
+ },
+ "devDependencies": {
+ "sass": "^1.42.1"
+ }
+}
diff --git a/public/projects/react-small-apps/apps/todos/public/favicon.ico b/public/projects/react-small-apps/apps/todos/public/favicon.ico
new file mode 100644
index 0000000..354202a
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/public/favicon.ico
Binary files differ
diff --git a/public/projects/react-small-apps/apps/todos/public/index.html b/public/projects/react-small-apps/apps/todos/public/index.html
new file mode 100644
index 0000000..8a93f4d
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/public/index.html
@@ -0,0 +1,43 @@
+<!DOCTYPE html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8" />
+ <link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <meta name="theme-color" content="#000000" />
+ <meta
+ name="description"
+ content="A todo app created using create-react-app & redux."
+ />
+ <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
+ <!--
+ manifest.json provides metadata used when your web app is installed on a
+ user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
+ -->
+ <link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
+ <!--
+ Notice the use of %PUBLIC_URL% in the tags above.
+ It will be replaced with the URL of the `public` folder during the build.
+ Only files inside the `public` folder can be referenced from the HTML.
+
+ Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
+ work correctly both with client-side routing and a non-root public URL.
+ Learn how to configure a non-root public URL by running `npm run build`.
+ -->
+ <title>ToDo App</title>
+ </head>
+ <body>
+ <noscript>You need to enable JavaScript to run this app.</noscript>
+ <div id="app"></div>
+ <!--
+ This HTML file is a template.
+ If you open it directly in the browser, you will see an empty page.
+
+ You can add webfonts, meta tags, or analytics to this file.
+ The build step will place the bundled scripts into the <body> tag.
+
+ To begin the development, run `npm start` or `yarn start`.
+ To create a production bundle, use `npm run build` or `yarn build`.
+ -->
+ </body>
+</html>
diff --git a/public/projects/react-small-apps/apps/todos/public/logo192.png b/public/projects/react-small-apps/apps/todos/public/logo192.png
new file mode 100644
index 0000000..3362410
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/public/logo192.png
Binary files differ
diff --git a/public/projects/react-small-apps/apps/todos/public/logo512.png b/public/projects/react-small-apps/apps/todos/public/logo512.png
new file mode 100644
index 0000000..b351622
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/public/logo512.png
Binary files differ
diff --git a/public/projects/react-small-apps/apps/todos/public/manifest.json b/public/projects/react-small-apps/apps/todos/public/manifest.json
new file mode 100644
index 0000000..080d6c7
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/public/manifest.json
@@ -0,0 +1,25 @@
+{
+ "short_name": "React App",
+ "name": "Create React App Sample",
+ "icons": [
+ {
+ "src": "favicon.ico",
+ "sizes": "64x64 32x32 24x24 16x16",
+ "type": "image/x-icon"
+ },
+ {
+ "src": "logo192.png",
+ "type": "image/png",
+ "sizes": "192x192"
+ },
+ {
+ "src": "logo512.png",
+ "type": "image/png",
+ "sizes": "512x512"
+ }
+ ],
+ "start_url": ".",
+ "display": "standalone",
+ "theme_color": "#000000",
+ "background_color": "#ffffff"
+}
diff --git a/public/projects/react-small-apps/apps/todos/public/robots.txt b/public/projects/react-small-apps/apps/todos/public/robots.txt
new file mode 100644
index 0000000..01b0f9a
--- /dev/null
+++ b/public/projects/react-small-apps/apps/todos/public/robots.txt
@@ -0,0 +1,2 @@
+# https://www.robotstxt.org/robotstxt.html
+User-agent: *
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;