aboutsummaryrefslogtreecommitdiffstats
path: root/public/projects/react-small-apps/apps/meme-generator/src
diff options
context:
space:
mode:
Diffstat (limited to 'public/projects/react-small-apps/apps/meme-generator/src')
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/src/components/Footer/Footer.js11
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/src/components/Header/Header.js9
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/src/components/Main/Main.js16
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/src/components/MemeForm/MemeFieldset/MemeFieldset.js103
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/src/components/MemeForm/MemeForm.js54
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/src/components/MemePreview/Headline/Headline.js120
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/src/components/MemePreview/MemePreview.js61
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/src/components/commons/Button.js11
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/src/components/commons/Fieldset.js10
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/src/components/commons/Form.js21
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/src/components/commons/Input.js41
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/src/components/commons/InputRange.js37
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/src/components/commons/Option.js5
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/src/components/commons/Select.js31
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/src/index.js21
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/src/logo.svg1
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/src/reportWebVitals.js13
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/src/setupTests.js5
-rw-r--r--public/projects/react-small-apps/apps/meme-generator/src/style.css151
19 files changed, 721 insertions, 0 deletions
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;
+}