PWA CodePen Clone
Tags: react, pwa, ghpages, codepen
Published: 2020-09-29 21:14:27

This article is going to based on a Youtube tutorial to create a CodePen Clone using React, additionally we are going to make it a PWA and upload it to GitHub Pages.
You will be able to write HTML, CSS and JavaScript and render the result in the page. It will also save your work to not loose what you been working on if the page is refreshed and continue to work later.

You can watch the original tutorial [How To Build CodePen With React](*https://www.youtube.com/watch?v=wcVxX7lu2d4*)

You can also watch the live site at https://mariavla.github.io/codepen-clone/

This solution uses this two npm package [`codemirror`](*https://github.com/codemirror/CodeMirror*) and [`react-codemirror2`](*https://www.npmjs.com/package/react-codemirror2*) to add a text editor to React.

Note: The site is responsive but is not very easy to use in mobile.

### Initial Setup

- `$ npx create-react-app codepen-clone`
- `$ cd codepen-clone`
- `$ yarn start`

Make sure everything works.

### Install The Necessary Libraries

- `$ npm i codemirror react-codemirror2`
- `$ npm i --save @fortawesome/fontawesome-svg-core @fortawesome/free-solid-svg-icons @fortawesome/react-fontawesome`

Let's create a `components` folder and move `App.js` inside.

### Editor Component

Inside `components` create a file name `Editor.js`.

This component is going to have:

- the editor calling `Controlled` from `react-codemirror2`
- a button to expand and collapse the editor

```javascript
import React, { useState } from "react";
import "codemirror/lib/codemirror.css";
import "codemirror/theme/material.css";
import "codemirror/mode/xml/xml";
import "codemirror/mode/javascript/javascript";
import "codemirror/mode/css/css";
import { Controlled as ControlledEditor } from "react-codemirror2";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faCompressAlt, faExpandAlt } from "@fortawesome/free-solid-svg-icons";

export default function Editor(props) {
  const { language, displayName, value, onChange } = props;
  const [open, setOpen] = useState(true);

  function handleChange(editor, data, value) {
    onChange(value);
  }

  return (
    <div className={`editor-container ${open ? "" : "collapsed"}`}>
      <div className="editor-title">
        {displayName}
        <button
          type="button"
          className="expand-collapse-btn"
          onClick={() => setOpen((prevOpen) => !prevOpen)}
        >
          <FontAwesomeIcon icon={open ? faCompressAlt : faExpandAlt} />
        </button>
      </div>
      <ControlledEditor
        onBeforeChange={handleChange}
        value={value}
        className="code-mirror-wrapper"
        options={{
          lineWrapping: true,
          lint: true,
          mode: language,
          theme: "material",
          lineNumbers: true,
        }}
      />
    </div>
  );
}
```

You can see other themes in codemirror website [https://codemirror.net/theme/](https://codemirror.net/theme/) with demo at [https://codemirror.net/demo/theme.html](https://codemirror.net/demo/theme.html#gruvbox-dark).

You can also see all the languages codemirror supports [https://codemirror.net/mode/](https://codemirror.net/mode/).

### App.js

This component is going to have:

- The basic layout of the page
  - 3 codemirror editors
  - an [iframe](https://www.w3schools.com/tags/tag_iframe.asp) to render all the HTML, CSS and JavaScript

```javascript
import React, { useState, useEffect } from "react";
import Editor from "./Editor";

function App() {
	const [html, setHtml] = useState("");
  const [css, setCss] = useState("");
  const [js, setJs] = useState("");
  const [srcDoc, setSrcDoc] = useState("");

  useEffect(() => {
    const timeout = setTimeout(() => {
      setSrcDoc(`
        <html>
          <body>${html}</body>
          <style>${css}</style>
          <script>${js}</script>
        </html>
      `);
    }, 250);

    return () => clearTimeout(timeout);
  }, [html, css, js]);

  return (
    <>
      <div className="pane top-pane">
        <Editor
          language="xml"
          displayName="HTML"
          value={html}
          onChange={setHtml}
        />
        <Editor
          language="css"
          displayName="CSS"
          value={css}
          onChange={setCss}
        />
        <Editor
          language="javascript"
          displayName="JS"
          value={js}
          onChange={setJs}
        />
      </div>
      <div className="pane">
        <iframe
          srcDoc={srcDoc}
          title="output"
          sandbox="allow-scripts"
          frameBorder="0"
          width="100%"
          height="100%"
        />
      </div>
    </>
  );
}

export default App;
```

### Let's check iframe attributes

- srcDoc: [https://www.w3schools.com/tags/att_iframe_srcdoc.asp](https://www.w3schools.com/tags/att_iframe_srcdoc.asp)
- `sandbox="allow-scripts"` → Enables an extra set of restrictions for the content in an <iframe>.

    The sandbox attribute enables an extra set of restrictions for the content in the iframe.

    When the sandbox attribute is present, and it will:

    - treat the content as being from a unique origin
    - block form submission
    - block script execution
    - disable APIs
    - prevent links from targeting other browsing contexts
    - prevent content from using plugins (through <embed>, <object>, <applet>, or other)
    - prevent the content to navigate its top-level browsing context
    - block automatically triggered features (such as automatically playing a video or automatically focusing a form control)

    The value of the sandbox attribute can either be just sandbox (then all restrictions are applied), or a space-separated list of pre-defined values that will REMOVE the particular restrictions. In this case is going to allow scripts.


To render all the HTML, CSS and JS in the iframe we need to pass the `srcDoc`. When we pass the `srcDoc` to the iframe is going to render immediately, which is going to slow down the browser. For this we use `useEffect` and set a timeout to update `srcDoc`. Now, every time the `html`, `css` or `js` change, the `srcDoc` is going to be updated. 

If we make changes before the timeout completes we are going to restart the timeout, for this add:  `return () => clearTimeout(timeout);`

### Styles

Let's add some styles at  `src/index.css`  to give it structure and make it responsive.

```css
body {
  margin: 0;
}

.top-pane {
  background-color: hsl(225, 6%, 25%);
  flex-wrap: wrap;
  justify-content: center;
  max-height: 50vh;
  overflow: auto;
}

.pane {
  height: 50vh;
  display: flex;
}

.editor-container {
  flex-grow: 1;
  flex-basis: 0;
  display: flex;
  flex-direction: column;
  padding: 0.5rem;
  background-color: hsl(225, 6%, 25%);
  flex: 1 1 300px; /*  Stretching: */
}



.editor-container.collapsed {
  flex-grow: 0;
}

.editor-container.collapsed .CodeMirror-scroll {
  position: absolute;
  overflow: hidden !important;
}

.expand-collapse-btn {
  margin-left: 0.5rem;
  background: none;
  border: none;
  color: white;
  cursor: pointer;
}

.editor-title {
  display: flex;
  justify-content: space-between;
  background-color: hsl(225, 6%, 13%);
  color: white;
  padding: 0.5rem 0.5rem 0.5rem 1rem;
  border-top-right-radius: 0.5rem;
  border-top-left-radius: 0.5rem;
}

.CodeMirror {
  height: 100% !important;
}

.code-mirror-wrapper {
  flex-grow: 1;
  border-bottom-right-radius: 0.5rem;
  border-bottom-left-radius: 0.5rem;
  overflow: hidden;
}
```

## Add the possibility to save

For this we use localStorage and hooks.

### Custom Hook to use Local Storage

In `src` create a folder name `hooks` and inside create a file named `useLocalStorage.js`.

To do this we are going to add a function in `useState` because getting the values from local storage is pretty slow, so we want to get the value once. For more info on this here is an article about [how-to-store-a-function-with-the-usestate-hook-in-react](https://medium.com/swlh/how-to-store-a-function-with-the-usestate-hook-in-react-8a88dd4eede1).

```javascript
import { useEffect, useState } from "react";

const PREFIX = "codepen-clone-";

export default function useLocalStorage(key, initialValue) {
  const prefixedKey = PREFIX + key;

  const [value, setValue] = useState(() => {
    const jsonValue = localStorage.getItem(prefixedKey);
    if (jsonValue != null) return JSON.parse(jsonValue);

    if (typeof initialValue === "function") {
      return initialValue();
    } else {
      return initialValue;
    }
  });

  useEffect(() => {
    localStorage.setItem(prefixedKey, JSON.stringify(value));
  }, [prefixedKey, value]);

  return [value, setValue];
}
```

In `App.js` change the `useState` hooks to `useLocalStorage` custom hook.

```javascript
import useLocalStorage from "../hooks/useLocalStorage";
...
const [html, setHtml] = useLocalStorage("html", "");
const [css, setCss] = useLocalStorage("css", "");
const [js, setJs] = useLocalStorage("js", "");
```

### Final Directory
![Screen_Shot_2020-09-28_at_14.34.23](https://dev-to-uploads.s3.amazonaws.com/i/g52yaybjwslpsbmusw7h.png)

## Turn it into a PWA

A Progressive Web App is an application that expands the functionality of a regular website adding features that previously were exclusive for native applications. Such as **offline capabilities,** access through an **icon on the home screen,** or **push notifications** (except maybe for ios [https://www.pushpro.io/blog/web-push-notifications-for-ios](https://www.pushpro.io/blog/web-push-notifications-for-ios))**.**

The installation process of a PWA doesn't involve an app store. It's installed directly through the browser.

The two very essential features that a Progressive Web App should have is a **Service Worker** and a **manifest.**

### **Service Worker**

They enable native features like an **offline experience** or **push notifications.**

Service Workers allow JavaScript code to be run in the background, they keep working when the tab is closed and can intercept network request, important for offline capabilities.

### Web App Manifest

We still need to give the feel of a native application. Here is where the Web App Manifest enters. In a file named `manifest.json`, we will be adding a **splash screen, name, icons** and more to out app.

Let's have a look at what are the essential fields for a PWA:

- **name** and **short_name**

    The short name is what will be displayed on the home screen below your icon. The full name will be used in the android splash screen.

- **start_url**

    The entry point of the installed app.

- **display**

    Possible values are **`fullscreen`**, **`standalone`**, **`minimal-ui`**, and **`browser`**. You probably want to use **`fullscreen`**, which will make the URL-bar disappear.

- **icons**

    These will be used for the app icon and the generated splash screen.

- **theme_color**

    This affects how the operating system displays the application. For example, this color can be used in the task switcher.

- **background_color**

    This color will be shown while the application's styles are loading.

More resources about PWA:

- [https://felixgerschau.com/how-to-make-your-react-app-a-progressive-web-app-pwa/](https://felixgerschau.com/how-to-make-your-react-app-a-progressive-web-app-pwa/)
- [https://web.dev/pwa-checklist/](https://web.dev/pwa-checklist/)
- [https://web.dev/add-manifest/](https://web.dev/add-manifest/)

## Let's start to add the config

- In the `public` folder create a file named `worker.js` and paste:

```javascript
let CACHE_NAME = "codepen-clone";
let urlsToCache = ["/", "/completed"];
let self = this;

// Install a service worker
self.addEventListener("install", (event) => {
  // Perform install steps
  event.waitUntil(
    caches.open(CACHE_NAME).then(function (cache) {
      console.log("Opened cache");
      return cache.addAll(urlsToCache);
    })
  );
});

// Cache and return requests
self.addEventListener("fetch", (event) => {
  event.respondWith(
    caches.match(event.request).then(function (response) {
      // Cache hit - return response
      if (response) {
        return response;
      }
      return fetch(event.request);
    })
  );
});

// Update a service worker
self.addEventListener("activate", (event) => {
  let cacheWhitelist = ["codepen-clone"];
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return Promise.all(
        cacheNames.map((cacheName) => {
          if (cacheWhitelist.indexOf(cacheName) === -1) {
            return caches.delete(cacheName);
          }
        })
      );
    })
  );
});
``` 
- Register the service worker in `src/index.js`

```javascript
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./components/App";
import * as serviceWorker from "./serviceWorker";

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById("root")
);

// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.register();
```

- In `public/index.html` paste: below `<div id="root"></div>`:

```javascript
  <script>
      if ("serviceWorker" in navigator) {
        window.addEventListener("load", function () {
          navigator.serviceWorker
            .register("worker.js")
            .then(
              function (registration) {
                console.log(
                  "Worker registration successful",
                  registration.scope
                );
              },
              function (err) {
                console.log("Worker registration failed", err);
              }
            )
            .catch(function (err) {
              console.log(err);
            });
        });
      } else {
        console.log("Service Worker is not supported by browser.");
      }
    </script>
```

- Update with your app data `public/manifest.json`

Restart the server and let's inspect the site with Google Lighthouse. Press **Generate Report**.

![Screen_Shot_2020-09-28_at_15.49.42](https://dev-to-uploads.s3.amazonaws.com/i/gm1dfamk204sd38pw2us.png)

If everything goes well you should see something like this.

![Screen_Shot_2020-09-28_at_15.51.00](https://dev-to-uploads.s3.amazonaws.com/i/b44a3wys9tgqqmtjabcn.png)

## Deploy PWA to GitHub Pages

- In the project folder: `$ npm i gh-pages`
- In `package.json`
    - Add below `"private"`:
        - `"homepage": "http://<username>.github.io/<projectname>"`
    - Add a pre-deploy script: `"predeploy": "npm run build"` to build the project before upload it to gh-pages.
    - Add a deploy script: `"deploy": "gh-pages -d build"` to tell gh-pages where is the build directory.

`package.json`

```javascript
    {
      "name": "codepen-clone",
      "version": "0.1.0",
      "private": true,
      "homepage": "http://<username>.github.io/codepen-clone",
      "dependencies": {
        "@fortawesome/fontawesome-svg-core": "^1.2.30",
        "@fortawesome/free-solid-svg-icons": "^5.14.0",
        "@fortawesome/react-fontawesome": "^0.1.11",
        "@testing-library/jest-dom": "^4.2.4",
        "@testing-library/react": "^9.3.2",
        "@testing-library/user-event": "^7.1.2",
        "codemirror": "^5.58.1",
        "gh-pages": "^3.1.0",
        "react": "^16.13.1",
        "react-codemirror2": "^7.2.1",
        "react-dom": "^16.13.1",
        "react-scripts": "3.4.3"
      },
      "scripts": {
        "predeploy": "npm run build",
        "deploy": "gh-pages -d build",
        "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"
        ]
      }
    }
```

- Upload the changes to github like always.
- `$ npm run deploy` -> This is going to publish the site to GitHub Pages.

Now if you go to the site on your cellphone, you should have the option of adding the application to your home screen.