diff options
| author | Armand Philippot <git@armandphilippot.com> | 2022-02-20 16:11:50 +0100 |
|---|---|---|
| committer | Armand Philippot <git@armandphilippot.com> | 2022-02-20 16:15:08 +0100 |
| commit | 73a5c7fae9ffbe9ada721148c8c454a643aceebe (patch) | |
| tree | c8fad013ed9b5dd589add87f8d45cf02bbfc6e91 /public/projects/react-small-apps/apps | |
| parent | b01239fbdcc5bbc5921f73ec0e8fee7bedd5c8e8 (diff) | |
chore!: restructure repo
I separated public files from the config/dev files. It improves repo
readability.
I also moved dotenv helper to public/inc directory and extract the
Matomo tracker in the same directory.
Diffstat (limited to 'public/projects/react-small-apps/apps')
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 + + + +## 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 Binary files differnew file mode 100644 index 0000000..a11777c --- /dev/null +++ b/public/projects/react-small-apps/apps/meme-generator/public/favicon.ico 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 Binary files differnew file mode 100644 index 0000000..fc44b0a --- /dev/null +++ b/public/projects/react-small-apps/apps/meme-generator/public/logo192.png 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 Binary files differnew file mode 100644 index 0000000..a4e47a6 --- /dev/null +++ b/public/projects/react-small-apps/apps/meme-generator/public/logo512.png 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 Binary files differnew file mode 100644 index 0000000..7d4579a --- /dev/null +++ b/public/projects/react-small-apps/apps/meme-generator/public/preview-meme-generator.jpg 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 | +| --- | --- | +|  |  | + +## 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 Binary files differnew file mode 100644 index 0000000..a11777c --- /dev/null +++ b/public/projects/react-small-apps/apps/notebook/public/favicon.ico 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 Binary files differnew file mode 100644 index 0000000..ef04f6b --- /dev/null +++ b/public/projects/react-small-apps/apps/notebook/public/preview-cover.jpg 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 Binary files differnew file mode 100644 index 0000000..ed238f9 --- /dev/null +++ b/public/projects/react-small-apps/apps/notebook/public/preview-page.jpg 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 | +| --- | --- | +|  |  | + +| Login Page | Account Page | +| --- | --- | +|  |  | + +## 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 Binary files differnew file mode 100644 index 0000000..b8332ac --- /dev/null +++ b/public/projects/react-small-apps/apps/todos/assets/preview-single-todo.jpg 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 Binary files differnew file mode 100644 index 0000000..6babb24 --- /dev/null +++ b/public/projects/react-small-apps/apps/todos/assets/preview-todo-account.jpg 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 Binary files differnew file mode 100644 index 0000000..41116d9 --- /dev/null +++ b/public/projects/react-small-apps/apps/todos/assets/preview-todo-login.jpg 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 Binary files differnew file mode 100644 index 0000000..95b327f --- /dev/null +++ b/public/projects/react-small-apps/apps/todos/assets/preview-todos-list.jpg 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 Binary files differnew file mode 100644 index 0000000..354202a --- /dev/null +++ b/public/projects/react-small-apps/apps/todos/public/favicon.ico 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 Binary files differnew file mode 100644 index 0000000..3362410 --- /dev/null +++ b/public/projects/react-small-apps/apps/todos/public/logo192.png diff --git a/public/projects/react-small-apps/apps/todos/public/logo512.png b/public/projects/react-small-apps/apps/todos/public/logo512.png Binary files differnew file mode 100644 index 0000000..b351622 --- /dev/null +++ b/public/projects/react-small-apps/apps/todos/public/logo512.png 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; |
