├── .gitignore
├── LICENSE
├── README.md
├── netlify.toml
├── package-lock.json
├── package.json
├── public
├── _redirects
├── android-chrome-192x192.png
├── android-chrome-256x256.png
├── apple-touch-icon.png
├── browserconfig.xml
├── favicon-16x16.png
├── favicon-32x32.png
├── favicon.ico
├── index.html
├── manifest.json
├── mstile-150x150.png
├── robots.txt
├── safari-pinned-tab.svg
└── site.webmanifest
└── src
├── App.css
├── App.js
├── App.test.js
├── Routes.js
├── components
├── AppliedRoute.js
├── AuthenticatedRoute.js
├── BillingForm.css
├── BillingForm.js
├── ErrorBoundary.css
├── ErrorBoundary.js
├── LoaderButton.css
├── LoaderButton.js
├── RouteNavItem.js
└── UnauthenticatedRoute.js
├── config.js
├── containers
├── Home.css
├── Home.js
├── Login.css
├── Login.js
├── NewNote.css
├── NewNote.js
├── NotFound.css
├── NotFound.js
├── Notes.css
├── Notes.js
├── Settings.css
├── Settings.js
├── Signup.css
└── Signup.js
├── index.css
├── index.js
├── libs
├── awsLib.js
├── contextLib.js
├── errorLib.js
└── hooksLib.js
├── reportWebVitals.js
└── setupTests.js
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 |
6 | # testing
7 | /coverage
8 |
9 | # production
10 | /build
11 |
12 | # misc
13 | .DS_Store
14 | .env
15 | npm-debug.log*
16 | yarn-debug.log*
17 | yarn-error.log*
18 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 Anomaly Innovations
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Serverless Stack Demo React App [](https://app.netlify.com/sites/serverless-stack-demo-client/deploys)
2 |
3 | The [Serverless Stack Guide](http://serverless-stack.com) is a free comprehensive resource to creating full-stack serverless applications. We create a [note taking app](http://demo2.serverless-stack.com) from scratch.
4 |
5 | The main part of the guide uses [SST](https://github.com/serverless-stack/serverless-stack). We also have an alternative version that uses Serverless Framework. This repo connects to the [Serverless Framework version of the backend](https://github.com/AnomalyInnovations/serverless-stack-demo-api).
6 |
7 | #### Steps
8 |
9 | To support the different chapters and steps of the tutorial; we use branches to represent the project codebase at the various points. Here is an index of the various chapters and branches in order.
10 |
11 | - [Initialize the Frontend Repo](../../tree/initialize-the-frontend-repo)
12 | - [Configure AWS Amplify](../../tree/configure-aws-amplify)
13 | - [Redirect on Login](../../tree/redirect-on-login)
14 | - [Create a Build Script](../../tree/create-a-build-script)
15 |
16 | #### Usage
17 |
18 | This project is created using [Create React App](https://github.com/facebookincubator/create-react-app).
19 |
20 | To use this repo locally, start by cloning it and installing the NPM packages.
21 |
22 | ``` bash
23 | $ git clone https://github.com/AnomalyInnovations/serverless-stack-demo-client
24 | $ npm install
25 | ```
26 |
27 | Run it locally.
28 |
29 | ``` bash
30 | $ npm run start
31 | ```
32 |
33 | ---
34 |
35 | This repo is maintained by [Serverless Stack](https://serverless-stack.com).
36 |
37 | [Email]: mailto:hello@serverless-stack.com
38 |
--------------------------------------------------------------------------------
/netlify.toml:
--------------------------------------------------------------------------------
1 | # Global settings applied to the whole site.
2 | # “base” is directory to change to before starting build, and
3 | # “publish” is the directory to publish (relative to root of your repo).
4 | # “command” is your build command.
5 |
6 | [build]
7 | base = ""
8 | publish = "build"
9 | command = "REACT_APP_STAGE=dev npm run build"
10 |
11 | # Production context: All deploys to the main
12 | # repository branch will inherit these settings.
13 | [context.production]
14 | command = "REACT_APP_STAGE=prod npm run build"
15 |
16 | # Deploy Preview context: All Deploy Previews
17 | # will inherit these settings.
18 | [context.deploy-preview]
19 | command = "REACT_APP_STAGE=dev npm run build"
20 |
21 | # Branch Deploy context: All deploys that are not in
22 | # an active Deploy Preview will inherit these settings.
23 | [context.branch-deploy]
24 | command = "REACT_APP_STAGE=dev npm run build"
25 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "notes-app-client",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@sentry/browser": "^5.27.3",
7 | "@stripe/react-stripe-js": "^1.4.1",
8 | "@stripe/stripe-js": "^1.15.1",
9 | "@testing-library/jest-dom": "^5.11.4",
10 | "@testing-library/react": "^11.1.0",
11 | "@testing-library/user-event": "^12.1.10",
12 | "aws-amplify": "^3.3.7",
13 | "react": "^17.0.1",
14 | "react-bootstrap": "^1.4.0",
15 | "react-dom": "^17.0.1",
16 | "react-icons": "^3.11.0",
17 | "react-router-bootstrap": "^0.25.0",
18 | "react-router-dom": "^5.2.0",
19 | "react-scripts": "4.0.0",
20 | "web-vitals": "^0.2.4"
21 | },
22 | "scripts": {
23 | "start": "react-scripts start",
24 | "build": "react-scripts build",
25 | "test": "react-scripts test",
26 | "eject": "react-scripts eject"
27 | },
28 | "eslintConfig": {
29 | "extends": [
30 | "react-app",
31 | "react-app/jest"
32 | ]
33 | },
34 | "browserslist": {
35 | "production": [
36 | ">0.2%",
37 | "not dead",
38 | "not op_mini all"
39 | ],
40 | "development": [
41 | "last 1 chrome version",
42 | "last 1 firefox version",
43 | "last 1 safari version"
44 | ]
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/public/_redirects:
--------------------------------------------------------------------------------
1 | /* /index.html 200
2 |
--------------------------------------------------------------------------------
/public/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AnomalyInnovations/serverless-stack-demo-client/d37ae1edd2fe81d69f08f35c23d527c72772944d/public/android-chrome-192x192.png
--------------------------------------------------------------------------------
/public/android-chrome-256x256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AnomalyInnovations/serverless-stack-demo-client/d37ae1edd2fe81d69f08f35c23d527c72772944d/public/android-chrome-256x256.png
--------------------------------------------------------------------------------
/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AnomalyInnovations/serverless-stack-demo-client/d37ae1edd2fe81d69f08f35c23d527c72772944d/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/public/browserconfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | #da532c
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AnomalyInnovations/serverless-stack-demo-client/d37ae1edd2fe81d69f08f35c23d527c72772944d/public/favicon-16x16.png
--------------------------------------------------------------------------------
/public/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AnomalyInnovations/serverless-stack-demo-client/d37ae1edd2fe81d69f08f35c23d527c72772944d/public/favicon-32x32.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AnomalyInnovations/serverless-stack-demo-client/d37ae1edd2fe81d69f08f35c23d527c72772944d/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
11 |
17 |
23 |
28 |
29 |
30 |
34 |
35 |
40 |
46 |
55 | Scratch - A simple note taking app
56 |
57 |
58 |
59 |
60 |
70 |
71 |
72 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Scratch",
3 | "name": "Scratch Note Taking App",
4 | "icons": [
5 | {
6 | "src": "android-chrome-192x192.png",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | },
10 | {
11 | "src": "android-chrome-256x256.png",
12 | "sizes": "256x256",
13 | "type": "image/png"
14 | }
15 | ],
16 | "start_url": ".",
17 | "display": "standalone",
18 | "theme_color": "#ffffff",
19 | "background_color": "#ffffff"
20 | }
21 |
--------------------------------------------------------------------------------
/public/mstile-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AnomalyInnovations/serverless-stack-demo-client/d37ae1edd2fe81d69f08f35c23d527c72772944d/public/mstile-150x150.png
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/public/safari-pinned-tab.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
23 |
--------------------------------------------------------------------------------
/public/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "",
3 | "short_name": "",
4 | "icons": [
5 | {
6 | "src": "/android-chrome-192x192.png",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | },
10 | {
11 | "src": "/android-chrome-256x256.png",
12 | "sizes": "256x256",
13 | "type": "image/png"
14 | }
15 | ],
16 | "theme_color": "#ffffff",
17 | "background_color": "#ffffff",
18 | "display": "standalone"
19 | }
20 |
--------------------------------------------------------------------------------
/src/App.css:
--------------------------------------------------------------------------------
1 | .App {
2 | }
3 |
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from "react";
2 | import { Auth } from "aws-amplify";
3 | import Nav from "react-bootstrap/Nav";
4 | import Navbar from "react-bootstrap/Navbar";
5 | import { useHistory } from "react-router-dom";
6 | import { LinkContainer } from "react-router-bootstrap";
7 | import ErrorBoundary from "./components/ErrorBoundary";
8 | import { AppContext } from "./libs/contextLib";
9 | import { onError } from "./libs/errorLib";
10 | import Routes from "./Routes";
11 | import "./App.css";
12 |
13 | function App() {
14 | const history = useHistory();
15 | const [isAuthenticating, setIsAuthenticating] = useState(true);
16 | const [isAuthenticated, userHasAuthenticated] = useState(false);
17 |
18 | useEffect(() => {
19 | onLoad();
20 | }, []);
21 |
22 | async function onLoad() {
23 | try {
24 | await Auth.currentSession();
25 | userHasAuthenticated(true);
26 | }
27 | catch(e) {
28 | if (e !== 'No current user') {
29 | onError(e);
30 | }
31 | }
32 |
33 | setIsAuthenticating(false);
34 | }
35 |
36 | async function handleLogout() {
37 | await Auth.signOut();
38 |
39 | userHasAuthenticated(false);
40 |
41 | history.push("/login");
42 | }
43 |
44 | return (
45 | !isAuthenticating && (
46 |
47 |
48 |
49 |
50 | Scratch
51 |
52 |
53 |
54 |
55 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 | )
83 | );
84 | }
85 |
86 | export default App;
87 |
--------------------------------------------------------------------------------
/src/App.test.js:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import App from './App';
3 |
4 | test('renders learn react link', () => {
5 | render();
6 | const linkElement = screen.getByText(/learn react/i);
7 | expect(linkElement).toBeInTheDocument();
8 | });
9 |
--------------------------------------------------------------------------------
/src/Routes.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Route, Switch } from "react-router-dom";
3 | import Home from "./containers/Home";
4 | import Login from "./containers/Login";
5 | import Notes from "./containers/Notes";
6 | import Signup from "./containers/Signup";
7 | import NewNote from "./containers/NewNote";
8 | import Settings from "./containers/Settings";
9 | import NotFound from "./containers/NotFound";
10 | import AuthenticatedRoute from "./components/AuthenticatedRoute";
11 | import UnauthenticatedRoute from "./components/UnauthenticatedRoute";
12 |
13 | export default function Routes() {
14 | return (
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | {/* Finally, catch all unmatched routes */}
35 |
36 |
37 |
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/src/components/AppliedRoute.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Route } from "react-router-dom";
3 |
4 | export default function AppliedRoute({ component: C, appProps, ...rest }) {
5 | return (
6 | } />
7 | );
8 | }
9 |
--------------------------------------------------------------------------------
/src/components/AuthenticatedRoute.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Route, Redirect, useLocation } from "react-router-dom";
3 | import { useAppContext } from "../libs/contextLib";
4 |
5 | export default function AuthenticatedRoute({ children, ...rest }) {
6 | const { pathname, search } = useLocation();
7 | const { isAuthenticated } = useAppContext();
8 | return (
9 |
10 | {isAuthenticated ? (
11 | children
12 | ) : (
13 |
16 | )}
17 |
18 | );
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/BillingForm.css:
--------------------------------------------------------------------------------
1 | .BillingForm .card-field {
2 | line-height: 1.5;
3 | margin-bottom: 1rem;
4 | border-radius: 0.25rem;
5 | padding: 0.55rem 0.75rem;
6 | background-color: white;
7 | border: 1px solid #ced4da;
8 | transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
9 | }
10 |
11 | .BillingForm .card-field.StripeElement--focus {
12 | outline: 0;
13 | border-color: #80bdff;
14 | box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
15 | }
16 |
--------------------------------------------------------------------------------
/src/components/BillingForm.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import Form from "react-bootstrap/Form";
3 | import { CardElement,useStripe, useElements } from '@stripe/react-stripe-js'
4 | import LoaderButton from "./LoaderButton";
5 | import { useFormFields } from "../libs/hooksLib";
6 | import "./BillingForm.css";
7 |
8 | function BillingForm({ isLoading, onSubmit, ...props }) {
9 | const stripe = useStripe();
10 | const elements = useElements();
11 | const [fields, handleFieldChange] = useFormFields({
12 | name: "",
13 | storage: "",
14 | });
15 | const [isProcessing, setIsProcessing] = useState(false);
16 | const [isCardComplete, setIsCardComplete] = useState(false);
17 |
18 | isLoading = isProcessing || isLoading;
19 |
20 | function validateForm() {
21 | return fields.name !== "" && fields.storage !== "" && isCardComplete;
22 | }
23 |
24 | async function handleSubmitClick(event) {
25 | event.preventDefault();
26 |
27 | setIsProcessing(true);
28 |
29 | const cardElement = elements.getElement(CardElement);
30 |
31 | const { token, error } = await stripe.createToken(cardElement);
32 |
33 | setIsProcessing(false);
34 |
35 | onSubmit(fields.storage, { token, error });
36 | }
37 |
38 | return (
39 |
41 | Storage
42 |
49 |
50 |
51 |
52 | Cardholder's name
53 |
59 |
60 | Credit Card Info
61 | setIsCardComplete(e.complete)}
64 | options={{
65 | style: {
66 | base: {
67 | fontSize: "16px",
68 | color: "#495057",
69 | fontFamily: "'Open Sans', sans-serif",
70 | },
71 | }
72 | }}
73 | />
74 |
81 | Purchase
82 |
83 |
84 | );
85 | }
86 |
87 | export default BillingForm;
88 |
--------------------------------------------------------------------------------
/src/components/ErrorBoundary.css:
--------------------------------------------------------------------------------
1 | .ErrorBoundary {
2 | padding-top: 100px;
3 | }
4 |
--------------------------------------------------------------------------------
/src/components/ErrorBoundary.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { logError } from "../libs/errorLib";
3 | import "./ErrorBoundary.css";
4 |
5 | export default class ErrorBoundary extends React.Component {
6 | state = { hasError: false };
7 |
8 | static getDerivedStateFromError(error) {
9 | return { hasError: true };
10 | }
11 |
12 | componentDidCatch(error, errorInfo) {
13 | logError(error, errorInfo);
14 | }
15 |
16 | render() {
17 | return this.state.hasError ? (
18 |
19 |
Sorry there was a problem loading this page
20 |
21 | ) : (
22 | this.props.children
23 | );
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/components/LoaderButton.css:
--------------------------------------------------------------------------------
1 | .LoaderButton .spinning {
2 | margin-right: 7px;
3 | top: 2px;
4 | animation: spin 1s infinite linear;
5 | }
6 |
7 | @keyframes spin {
8 | from {
9 | transform: scale(1) rotate(0deg);
10 | }
11 | to {
12 | transform: scale(1) rotate(360deg);
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/LoaderButton.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import Button from "react-bootstrap/Button";
3 | import { BsArrowRepeat } from "react-icons/bs";
4 | import "./LoaderButton.css";
5 |
6 | export default function LoaderButton({
7 | isLoading,
8 | className = "",
9 | disabled = false,
10 | ...props
11 | }) {
12 | return (
13 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/RouteNavItem.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Route } from "react-router-dom";
3 | import { NavItem } from "react-bootstrap";
4 |
5 | export default props =>
6 |
10 | history.push(e.currentTarget.getAttribute("href"))}
12 | {...props}
13 | active={match ? true : false}
14 | >
15 | {props.children}
16 | }
17 | />;
18 |
--------------------------------------------------------------------------------
/src/components/UnauthenticatedRoute.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Route, Redirect } from "react-router-dom";
3 | import { useAppContext } from "../libs/contextLib";
4 |
5 | function querystring(name, url = window.location.href) {
6 | name = name.replace(/[[]]/g, "\\$&");
7 |
8 | const regex = new RegExp("[?&]" + name + "(=([^]*)|&|#|$)", "i");
9 | const results = regex.exec(url);
10 |
11 | if (!results) {
12 | return null;
13 | }
14 | if (!results[2]) {
15 | return "";
16 | }
17 |
18 | return decodeURIComponent(results[2].replace(/\+/g, " "));
19 | }
20 |
21 | export default function UnauthenticatedRoute({ children, ...rest }) {
22 | const { isAuthenticated } = useAppContext();
23 | const redirect = querystring("redirect");
24 | return (
25 |
26 | {!isAuthenticated ? (
27 | children
28 | ) : (
29 |
30 | )}
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/src/config.js:
--------------------------------------------------------------------------------
1 | const dev = {
2 | STRIPE_KEY: "pk_test_v1amvR35uoCNduJfkqGB8RLD",
3 | s3: {
4 | REGION: "us-east-1",
5 | BUCKET: "notes-app-2-api-dev-attachmentsbucket-1xboyuq7t4m3b",
6 | },
7 | apiGateway: {
8 | REGION: "us-east-1",
9 | URL: "https://api.serverless-stack.seed-demo.club/dev",
10 | },
11 | cognito: {
12 | REGION: "us-east-1",
13 | USER_POOL_ID: "us-east-1_inr9YMAGu",
14 | APP_CLIENT_ID: "1j8rsat63d6bgrf4a1urfa5fl",
15 | IDENTITY_POOL_ID: "us-east-1:9fbc1763-a1b0-4e44-958d-8d8c155252d5",
16 | },
17 | };
18 |
19 | const prod = {
20 | STRIPE_KEY: "pk_test_v1amvR35uoCNduJfkqGB8RLD",
21 | s3: {
22 | REGION: "us-east-1",
23 | BUCKET: "notes-app-2-api-prod-attachmentsbucket-1i904t99uyi9u",
24 | },
25 | apiGateway: {
26 | REGION: "us-east-1",
27 | URL: "https://api.serverless-stack.seed-demo.club/prod",
28 | },
29 | cognito: {
30 | REGION: "us-east-1",
31 | USER_POOL_ID: "us-east-1_m3dpB46HZ",
32 | APP_CLIENT_ID: "fuindvj7f1ljpa35tp2d7kjrn",
33 | IDENTITY_POOL_ID: "us-east-1:67cb4bb1-d2b2-49ec-b412-5b7ef2404bcc",
34 | },
35 | };
36 |
37 | const config = {
38 | // Add common config values here
39 | MAX_ATTACHMENT_SIZE: 5000000,
40 | // Default to dev if not set
41 | ...(process.env.REACT_APP_STAGE === "prod" ? prod : dev),
42 | };
43 |
44 | export default config;
45 |
--------------------------------------------------------------------------------
/src/containers/Home.css:
--------------------------------------------------------------------------------
1 | .Home .lander {
2 | padding: 80px 0;
3 | text-align: center;
4 | }
5 |
6 | .Home .lander h1 {
7 | font-family: "Open Sans", sans-serif;
8 | font-weight: 600;
9 | }
10 |
--------------------------------------------------------------------------------
/src/containers/Home.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from "react";
2 | import { API } from "aws-amplify";
3 | import { Link } from "react-router-dom";
4 | import { BsPencilSquare } from "react-icons/bs";
5 | import ListGroup from "react-bootstrap/ListGroup";
6 | import { LinkContainer } from "react-router-bootstrap";
7 | import { useAppContext } from "../libs/contextLib";
8 | import { onError } from "../libs/errorLib";
9 | import "./Home.css";
10 |
11 | export default function Home() {
12 | const [notes, setNotes] = useState([]);
13 | const { isAuthenticated } = useAppContext();
14 | const [isLoading, setIsLoading] = useState(true);
15 |
16 | useEffect(() => {
17 | async function onLoad() {
18 | if (!isAuthenticated) {
19 | return;
20 | }
21 |
22 | try {
23 | const notes = await loadNotes();
24 | setNotes(notes);
25 | } catch (e) {
26 | onError(e);
27 | }
28 |
29 | setIsLoading(false);
30 | }
31 |
32 | onLoad();
33 | }, [isAuthenticated]);
34 |
35 | function loadNotes() {
36 | return API.get("notes", "/notes");
37 | }
38 |
39 | function renderNotesList(notes) {
40 | return (
41 | <>
42 |
43 |
44 |
45 | Create a new note
46 |
47 |
48 | {notes.map(({ noteId, content, createdAt }) => (
49 |
50 |
51 |
52 | {content.trim().split("\n")[0]}
53 |
54 |
55 |
56 | Created: {new Date(createdAt).toLocaleString()}
57 |
58 |
59 |
60 | ))}
61 | >
62 | );
63 | }
64 |
65 | function renderLander() {
66 | return (
67 |
68 |
Scratch
69 |
A simple note taking app
70 |
71 |
72 | Login
73 |
74 |
75 | Signup
76 |
77 |
78 |
79 | );
80 | }
81 |
82 | function renderNotes() {
83 | return (
84 |
85 |
Your Notes
86 | {!isLoading && renderNotesList(notes)}
87 |
88 | );
89 | }
90 |
91 | return (
92 |
93 | {isAuthenticated ? renderNotes() : renderLander()}
94 |
95 | );
96 | }
97 |
--------------------------------------------------------------------------------
/src/containers/Login.css:
--------------------------------------------------------------------------------
1 | @media all and (min-width: 480px) {
2 | .Login {
3 | padding: 60px 0;
4 | }
5 |
6 | .Login form {
7 | margin: 0 auto;
8 | max-width: 320px;
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/src/containers/Login.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { Auth } from "aws-amplify";
3 | import Form from "react-bootstrap/Form";
4 | import LoaderButton from "../components/LoaderButton";
5 | import { useAppContext } from "../libs/contextLib";
6 | import { useFormFields } from "../libs/hooksLib";
7 | import { onError } from "../libs/errorLib";
8 | import "./Login.css";
9 |
10 | export default function Login() {
11 | const { userHasAuthenticated } = useAppContext();
12 | const [isLoading, setIsLoading] = useState(false);
13 | const [fields, handleFieldChange] = useFormFields({
14 | email: "",
15 | password: ""
16 | });
17 |
18 | function validateForm() {
19 | return fields.email.length > 0 && fields.password.length > 0;
20 | }
21 |
22 | async function handleSubmit(event) {
23 | event.preventDefault();
24 |
25 | setIsLoading(true);
26 |
27 | try {
28 | await Auth.signIn(fields.email, fields.password);
29 | userHasAuthenticated(true);
30 | } catch (e) {
31 | onError(e);
32 | setIsLoading(false);
33 | }
34 | }
35 |
36 | return (
37 |
38 |
40 | Email
41 |
47 |
48 |
49 | Password
50 |
55 |
56 |
63 | Login
64 |
65 |
66 |
67 | );
68 | }
69 |
--------------------------------------------------------------------------------
/src/containers/NewNote.css:
--------------------------------------------------------------------------------
1 | .NewNote form textarea {
2 | height: 300px;
3 | font-size: 1.5rem;
4 | }
5 |
--------------------------------------------------------------------------------
/src/containers/NewNote.js:
--------------------------------------------------------------------------------
1 | import React, { useRef, useState } from "react";
2 | import { API } from "aws-amplify";
3 | import Form from "react-bootstrap/Form";
4 | import { useHistory } from "react-router-dom";
5 | import LoaderButton from "../components/LoaderButton";
6 | import { onError } from "../libs/errorLib";
7 | import { s3Upload } from "../libs/awsLib";
8 | import config from "../config";
9 | import "./NewNote.css";
10 |
11 | export default function NewNote() {
12 | const file = useRef(null);
13 | const history = useHistory();
14 | const [content, setContent] = useState("");
15 | const [isLoading, setIsLoading] = useState(false);
16 |
17 | function validateForm() {
18 | return content.length > 0;
19 | }
20 |
21 | function handleFileChange(event) {
22 | file.current = event.target.files[0];
23 | }
24 |
25 | async function handleSubmit(event) {
26 | event.preventDefault();
27 |
28 | if (file.current && file.current.size > config.MAX_ATTACHMENT_SIZE) {
29 | alert(
30 | `Please pick a file smaller than ${
31 | config.MAX_ATTACHMENT_SIZE / 1000000
32 | } MB.`
33 | );
34 | return;
35 | }
36 |
37 | setIsLoading(true);
38 |
39 | try {
40 | const attachment = file.current ? await s3Upload(file.current) : null;
41 |
42 | await createNote({ content, attachment });
43 | history.push("/");
44 | } catch (e) {
45 | onError(e);
46 | setIsLoading(false);
47 | }
48 | }
49 |
50 | function createNote(note) {
51 | return API.post("notes", "/notes", {
52 | body: note
53 | });
54 | }
55 |
56 | return (
57 |
58 |
60 | setContent(e.target.value)}
64 | />
65 |
66 |
67 | Attachment
68 |
69 |
70 |
78 | Create
79 |
80 |
81 |
82 | );
83 | }
84 |
--------------------------------------------------------------------------------
/src/containers/NotFound.css:
--------------------------------------------------------------------------------
1 | .NotFound {
2 | padding-top: 100px;
3 | }
4 |
--------------------------------------------------------------------------------
/src/containers/NotFound.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import "./NotFound.css";
3 |
4 | export default function NotFound() {
5 | return (
6 |
7 |
Sorry, page not found!
8 |
9 | );
10 | }
11 |
--------------------------------------------------------------------------------
/src/containers/Notes.css:
--------------------------------------------------------------------------------
1 | .Notes form textarea {
2 | height: 300px;
3 | font-size: 1.5rem;
4 | }
5 |
--------------------------------------------------------------------------------
/src/containers/Notes.js:
--------------------------------------------------------------------------------
1 | import React, { useRef, useState, useEffect } from "react";
2 | import Form from "react-bootstrap/Form";
3 | import { API, Storage } from "aws-amplify";
4 | import { useParams, useHistory } from "react-router-dom";
5 | import LoaderButton from "../components/LoaderButton";
6 | import { onError } from "../libs/errorLib";
7 | import { s3Upload } from "../libs/awsLib";
8 | import config from "../config";
9 | import "./Notes.css";
10 |
11 | export default function Notes() {
12 | const file = useRef(null);
13 | const { id } = useParams();
14 | const history = useHistory();
15 | const [note, setNote] = useState(null);
16 | const [content, setContent] = useState("");
17 | const [isLoading, setIsLoading] = useState(false);
18 | const [isDeleting, setIsDeleting] = useState(false);
19 |
20 | useEffect(() => {
21 | function loadNote() {
22 | return API.get("notes", `/notes/${id}`);
23 | }
24 |
25 | async function onLoad() {
26 | try {
27 | const note = await loadNote();
28 | const { content, attachment } = note;
29 |
30 | if (attachment) {
31 | note.attachmentURL = await Storage.vault.get(attachment);
32 | }
33 |
34 | setContent(content);
35 | setNote(note);
36 | } catch (e) {
37 | onError(e);
38 | }
39 | }
40 |
41 | onLoad();
42 | }, [id]);
43 |
44 | function validateForm() {
45 | return content.length > 0;
46 | }
47 |
48 | function formatFilename(str) {
49 | return str.replace(/^\w+-/, "");
50 | }
51 |
52 | function handleFileChange(event) {
53 | file.current = event.target.files[0];
54 | }
55 |
56 | function saveNote(note) {
57 | return API.put("notes", `/notes/${id}`, {
58 | body: note
59 | });
60 | }
61 |
62 | async function handleSubmit(event) {
63 | let attachment;
64 |
65 | event.preventDefault();
66 |
67 | if (file.current && file.current.size > config.MAX_ATTACHMENT_SIZE) {
68 | alert(
69 | `Please pick a file smaller than ${
70 | config.MAX_ATTACHMENT_SIZE / 1000000
71 | } MB.`
72 | );
73 | return;
74 | }
75 |
76 | setIsLoading(true);
77 |
78 | try {
79 | if (file.current) {
80 | attachment = await s3Upload(file.current);
81 | }
82 |
83 | await saveNote({
84 | content,
85 | attachment: attachment || note.attachment
86 | });
87 | history.push("/");
88 | } catch (e) {
89 | onError(e);
90 | setIsLoading(false);
91 | }
92 | }
93 |
94 | function deleteNote() {
95 | return API.del("notes", `/notes/${id}`);
96 | }
97 |
98 | async function handleDelete(event) {
99 | event.preventDefault();
100 |
101 | const confirmed = window.confirm(
102 | "Are you sure you want to delete this note?"
103 | );
104 |
105 | if (!confirmed) {
106 | return;
107 | }
108 |
109 | setIsDeleting(true);
110 |
111 | try {
112 | await deleteNote();
113 | history.push("/");
114 | } catch (e) {
115 | onError(e);
116 | setIsDeleting(false);
117 | }
118 | }
119 |
120 | return (
121 |
122 | {note && (
123 |
125 | setContent(e.target.value)}
129 | />
130 |
131 |
132 | Attachment
133 | {note.attachment && (
134 |
135 |
140 | {formatFilename(note.attachment)}
141 |
142 |
143 | )}
144 |
145 |
146 |
153 | Save
154 |
155 |
162 | Delete
163 |
164 |
165 | )}
166 |
167 | );
168 | }
169 |
--------------------------------------------------------------------------------
/src/containers/Settings.css:
--------------------------------------------------------------------------------
1 | @media all and (min-width: 480px) {
2 | .Settings {
3 | padding: 60px 0;
4 | }
5 |
6 | .Settings form {
7 | margin: 0 auto;
8 | max-width: 480px;
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/src/containers/Settings.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { API } from "aws-amplify";
3 | import { useHistory } from "react-router-dom";
4 | import { Elements } from "@stripe/react-stripe-js";
5 | import { loadStripe } from '@stripe/stripe-js';
6 | import BillingForm from "../components/BillingForm";
7 | import { onError } from "../libs/errorLib";
8 | import config from "../config";
9 | import "./Settings.css";
10 |
11 | export default function Settings() {
12 | const history = useHistory();
13 | const [isLoading, setIsLoading] = useState(false);
14 | const stripePromise = loadStripe(config.STRIPE_KEY);
15 |
16 | function billUser(details) {
17 | return API.post("notes", "/billing", {
18 | body: details
19 | });
20 | }
21 |
22 | async function handleFormSubmit(storage, { token, error }) {
23 | if (error) {
24 | onError(error);
25 | return;
26 | }
27 |
28 | setIsLoading(true);
29 |
30 | try {
31 | await billUser({
32 | storage,
33 | source: token.id
34 | });
35 |
36 | alert("Your card has been charged successfully!");
37 | history.push("/");
38 | } catch (e) {
39 | onError(e);
40 | setIsLoading(false);
41 | }
42 | }
43 |
44 | return (
45 |
46 |
55 |
56 |
57 |
58 | );
59 | }
60 |
--------------------------------------------------------------------------------
/src/containers/Signup.css:
--------------------------------------------------------------------------------
1 | @media all and (min-width: 480px) {
2 | .Signup {
3 | padding: 60px 0;
4 | }
5 |
6 | .Signup form {
7 | margin: 0 auto;
8 | max-width: 320px;
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/src/containers/Signup.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { Auth } from "aws-amplify";
3 | import Form from "react-bootstrap/Form";
4 | import { useHistory } from "react-router-dom";
5 | import LoaderButton from "../components/LoaderButton";
6 | import { useAppContext } from "../libs/contextLib";
7 | import { useFormFields } from "../libs/hooksLib";
8 | import { onError } from "../libs/errorLib";
9 | import "./Signup.css";
10 |
11 | export default function Signup() {
12 | const [fields, handleFieldChange] = useFormFields({
13 | email: "",
14 | password: "",
15 | confirmPassword: "",
16 | confirmationCode: "",
17 | });
18 | const history = useHistory();
19 | const [newUser, setNewUser] = useState(null);
20 | const { userHasAuthenticated } = useAppContext();
21 | const [isLoading, setIsLoading] = useState(false);
22 |
23 | function validateForm() {
24 | return (
25 | fields.email.length > 0 &&
26 | fields.password.length > 0 &&
27 | fields.password === fields.confirmPassword
28 | );
29 | }
30 |
31 | function validateConfirmationForm() {
32 | return fields.confirmationCode.length > 0;
33 | }
34 |
35 | async function handleSubmit(event) {
36 | event.preventDefault();
37 |
38 | setIsLoading(true);
39 |
40 | try {
41 | const newUser = await Auth.signUp({
42 | username: fields.email,
43 | password: fields.password,
44 | });
45 | setIsLoading(false);
46 | setNewUser(newUser);
47 | } catch (e) {
48 | onError(e);
49 | setIsLoading(false);
50 | }
51 | }
52 |
53 | async function handleConfirmationSubmit(event) {
54 | event.preventDefault();
55 |
56 | setIsLoading(true);
57 |
58 | try {
59 | await Auth.confirmSignUp(fields.email, fields.confirmationCode);
60 | await Auth.signIn(fields.email, fields.password);
61 |
62 | userHasAuthenticated(true);
63 | history.push("/");
64 | } catch (e) {
65 | onError(e);
66 | setIsLoading(false);
67 | }
68 | }
69 |
70 | function renderConfirmationForm() {
71 | return (
72 |
74 | Confirmation Code
75 |
81 | Please check your email for the code.
82 |
83 |
91 | Verify
92 |
93 |
94 | );
95 | }
96 |
97 | function renderForm() {
98 | return (
99 |
101 | Email
102 |
108 |
109 |
110 | Password
111 |
116 |
117 |
118 | Confirm Password
119 |
124 |
125 |
133 | Signup
134 |
135 |
136 | );
137 | }
138 |
139 | return (
140 |
141 | {newUser === null ? renderForm() : renderConfirmationForm()}
142 |
143 | );
144 | }
145 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | padding: 0;
4 | color: #333;
5 | font-size: 16px;
6 | -moz-osx-font-smoothing: grayscale;
7 | -webkit-font-smoothing: antialiased;
8 | font-family: "Open Sans", sans-serif;
9 | }
10 | h1, h2, h3, h4, h5, h6 {
11 | font-family: "PT Serif", serif;
12 | }
13 |
14 | h1, h2, h3, h4, h5, h6 {
15 | font-family: "PT Serif", serif;
16 | }
17 |
18 | h1, h2, h3, h4, h5, h6 {
19 | font-family: "PT Serif", serif;
20 | }
21 |
22 | select.form-control,
23 | textarea.form-control,
24 | input.form-control {
25 | font-size: 1rem;
26 | }
27 | input[type=file] {
28 | width: 100%;
29 | }
30 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { Amplify } from 'aws-amplify';
4 | import { BrowserRouter as Router } from 'react-router-dom';
5 | import './index.css';
6 | import App from './App';
7 | import config from './config';
8 | import { initSentry } from './libs/errorLib';
9 | import reportWebVitals from './reportWebVitals';
10 |
11 | initSentry();
12 |
13 | Amplify.configure({
14 | Auth: {
15 | mandatorySignIn: true,
16 | region: config.cognito.REGION,
17 | userPoolId: config.cognito.USER_POOL_ID,
18 | identityPoolId: config.cognito.IDENTITY_POOL_ID,
19 | userPoolWebClientId: config.cognito.APP_CLIENT_ID
20 | },
21 | Storage: {
22 | region: config.s3.REGION,
23 | bucket: config.s3.BUCKET,
24 | identityPoolId: config.cognito.IDENTITY_POOL_ID
25 | },
26 | API: {
27 | endpoints: [
28 | {
29 | name: "notes",
30 | endpoint: config.apiGateway.URL,
31 | region: config.apiGateway.REGION
32 | },
33 | ]
34 | }
35 | });
36 |
37 | ReactDOM.render(
38 |
39 |
40 |
41 |
42 | ,
43 | document.getElementById('root')
44 | );
45 |
46 | // If you want to start measuring performance in your app, pass a function
47 | // to log results (for example: reportWebVitals(console.log))
48 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
49 | reportWebVitals();
50 |
--------------------------------------------------------------------------------
/src/libs/awsLib.js:
--------------------------------------------------------------------------------
1 | import { Storage } from "aws-amplify";
2 |
3 | export async function s3Upload(file) {
4 | const filename = `${Date.now()}-${file.name}`;
5 |
6 | const stored = await Storage.vault.put(filename, file, {
7 | contentType: file.type,
8 | });
9 |
10 | return stored.key;
11 | }
12 |
--------------------------------------------------------------------------------
/src/libs/contextLib.js:
--------------------------------------------------------------------------------
1 | import { useContext, createContext } from "react";
2 |
3 | export const AppContext = createContext(null);
4 |
5 | export function useAppContext() {
6 | return useContext(AppContext);
7 | }
8 |
--------------------------------------------------------------------------------
/src/libs/errorLib.js:
--------------------------------------------------------------------------------
1 | import * as Sentry from "@sentry/browser";
2 |
3 | const isLocal = process.env.NODE_ENV === "development";
4 |
5 | export function initSentry() {
6 | if (isLocal) {
7 | return;
8 | }
9 |
10 | Sentry.init({ dsn: "https://5f83aa2e21064e47bab8a1f308f940eb@sentry.io/5185720" });
11 | }
12 |
13 | export function logError(error, errorInfo = null) {
14 | if (isLocal) {
15 | return;
16 | }
17 |
18 | Sentry.withScope((scope) => {
19 | errorInfo && scope.setExtras(errorInfo);
20 | Sentry.captureException(error);
21 | });
22 | }
23 |
24 | export function onError(error) {
25 | let errorInfo = {};
26 | let message = error.toString();
27 |
28 | // Auth errors
29 | if (!(error instanceof Error) && error.message) {
30 | errorInfo = error;
31 | message = error.message;
32 | error = new Error(message);
33 | // API errors
34 | } else if (error.config && error.config.url) {
35 | errorInfo.url = error.config.url;
36 | }
37 |
38 | logError(error, errorInfo);
39 |
40 | alert(message);
41 | }
42 |
--------------------------------------------------------------------------------
/src/libs/hooksLib.js:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 |
3 | export function useFormFields(initialState) {
4 | const [fields, setValues] = useState(initialState);
5 |
6 | return [
7 | fields,
8 | function(event) {
9 | setValues({
10 | ...fields,
11 | [event.target.id]: event.target.value
12 | });
13 | }
14 | ];
15 | }
16 |
--------------------------------------------------------------------------------
/src/reportWebVitals.js:
--------------------------------------------------------------------------------
1 | const reportWebVitals = onPerfEntry => {
2 | if (onPerfEntry && onPerfEntry instanceof Function) {
3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
4 | getCLS(onPerfEntry);
5 | getFID(onPerfEntry);
6 | getFCP(onPerfEntry);
7 | getLCP(onPerfEntry);
8 | getTTFB(onPerfEntry);
9 | });
10 | }
11 | };
12 |
13 | export default reportWebVitals;
14 |
--------------------------------------------------------------------------------
/src/setupTests.js:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom';
6 |
--------------------------------------------------------------------------------