├── next.config.js
├── .prettierrc
├── public
├── favicon.ico
└── marcus-aurelius.jpg
├── helpers
├── general.js
├── middleware.js
└── api.js
├── .gitignore
├── components
├── error-message.js
├── blockquote.js
├── home-view.js
├── click-icon.js
└── global-style.js
├── pages
├── api
│ ├── quote.js
│ └── quotes.js
├── index.js
└── _app.js
├── .eslintrc
├── package.json
├── LICENCE
├── contexts
└── quote.js
└── README.md
/next.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {};
2 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": false,
3 | "printWidth": 120
4 | }
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/benhoneywill/stoic-quotes/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/marcus-aurelius.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/benhoneywill/stoic-quotes/HEAD/public/marcus-aurelius.jpg
--------------------------------------------------------------------------------
/helpers/general.js:
--------------------------------------------------------------------------------
1 | export const getIntBetween = (min, max) => Math.floor(Math.random() * (max - min + 1) + min);
2 |
3 | export const shuffle = arr => arr.sort(() => 0.5 - Math.random());
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # dependencies
2 | /node_modules
3 |
4 | # testing
5 | /coverage
6 |
7 | # production
8 | /.next
9 |
10 | # misc
11 | .DS_Store
12 | .env
13 | npm-debug.log*
14 | yarn-debug.log*
15 | yarn-error.log*
16 |
--------------------------------------------------------------------------------
/helpers/middleware.js:
--------------------------------------------------------------------------------
1 | export const initMiddleware = middleware => (req, res) =>
2 | new Promise((resolve, reject) => {
3 | middleware(req, res, result => {
4 | if (result instanceof Error) {
5 | return reject(result);
6 | }
7 | return resolve(result);
8 | });
9 | });
10 |
--------------------------------------------------------------------------------
/components/error-message.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import Blockquote from "./blockquote";
4 |
5 | const ErrorMessage = () => (
6 |
7 | );
8 |
9 | ErrorMessage.propTypes = {};
10 |
11 | export default ErrorMessage;
12 |
--------------------------------------------------------------------------------
/helpers/api.js:
--------------------------------------------------------------------------------
1 | import fetch from "isomorphic-fetch";
2 |
3 | const url = process.env.NODE_ENV === "production" ? "https://stoic-quotes.com" : "http://localhost:3000";
4 |
5 | export const fetchQuote = async () => {
6 | const res = await fetch(`${url}/api/quote`);
7 | return res.json();
8 | };
9 |
10 | export const fetchQuotes = async (num = 10) => {
11 | const res = await fetch(`${url}/api/quotes?num=${num}`);
12 | return res.json();
13 | };
14 |
--------------------------------------------------------------------------------
/pages/api/quote.js:
--------------------------------------------------------------------------------
1 | import Cors from "cors";
2 |
3 | import data from "../../data/quotes.json";
4 | import { getIntBetween } from "../../helpers/general";
5 | import { initMiddleware } from "../../helpers/middleware";
6 |
7 | const cors = initMiddleware(
8 | Cors({
9 | methods: ["GET"]
10 | })
11 | );
12 |
13 | export default async (req, res) => {
14 | await cors(req, res);
15 | const randomNum = getIntBetween(0, data.quotes.length - 1);
16 | res.status(200).json(data.quotes[randomNum]);
17 | };
18 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["airbnb", "airbnb/hooks", "prettier"],
3 | "plugins": ["prettier"],
4 | "rules": {
5 | "prettier/prettier": ["error"],
6 | "import/prefer-default-export": "off",
7 | "react/jsx-filename-extension": [
8 | 1,
9 | {
10 | "extensions": [".js", ".jsx"]
11 | }
12 | ],
13 | "react/jsx-props-no-spreading": "off",
14 | "react/forbid-prop-types": "off"
15 | },
16 | "env": {
17 | "node": true,
18 | "browser": true,
19 | "jest": true
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/pages/api/quotes.js:
--------------------------------------------------------------------------------
1 | import Cors from "cors";
2 |
3 | import data from "../../data/quotes.json";
4 | import { shuffle } from "../../helpers/general";
5 | import { initMiddleware } from "../../helpers/middleware";
6 |
7 | const cors = initMiddleware(
8 | Cors({
9 | methods: ["GET"]
10 | })
11 | );
12 |
13 | export default async (req, res) => {
14 | await cors(req, res);
15 |
16 | const { num = 10 } = req.query;
17 | const count = parseInt(num, 10);
18 |
19 | if (Number.isNaN(count) || count <= 0 || count > 100) {
20 | return res.status(422).json({ error: "`num` must be an integer from 1 to 100." });
21 | }
22 |
23 | const quotes = shuffle(data.quotes).slice(0, count);
24 | return res.status(200).json(quotes);
25 | };
26 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "stoic-quotes",
3 | "scripts": {
4 | "dev": "next",
5 | "build": "next build",
6 | "start": "next start",
7 | "lint": "eslint \"**/*.js\""
8 | },
9 | "dependencies": {
10 | "@emotion/core": "^10.0.35",
11 | "@emotion/styled": "^10.0.27",
12 | "cors": "^2.8.5",
13 | "isomorphic-fetch": "^3.0.0",
14 | "next": "^12.1.0",
15 | "prop-types": "^15.7.2",
16 | "react": "^17.0.0",
17 | "react-dom": "^17.0.0"
18 | },
19 | "devDependencies": {
20 | "eslint": "^7.0.0",
21 | "eslint-config-airbnb": "18.2.1",
22 | "eslint-config-prettier": "^8.3.0",
23 | "eslint-plugin-import": "^2.22.0",
24 | "eslint-plugin-jsx-a11y": "^6.3.1",
25 | "eslint-plugin-prettier": "^3.1.4",
26 | "eslint-plugin-react": "^7.20.6",
27 | "eslint-plugin-react-hooks": "^4.0.0",
28 | "prettier": "1.19.1"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/pages/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import PropTypes from "prop-types";
3 |
4 | import { fetchQuote } from "../helpers/api";
5 | import { QuoteProvider } from "../contexts/quote";
6 | import HomeView from "../components/home-view";
7 |
8 | const HomePage = ({ quote, error }) => (
9 |
10 |
11 |
12 | );
13 |
14 | export const getStaticProps = async () => {
15 | let quote = null;
16 | let error = null;
17 | try {
18 | quote = await fetchQuote();
19 | } catch (err) {
20 | error = err;
21 | }
22 |
23 | return { props: { quote, error }, revalidate: 10 };
24 | };
25 |
26 | HomePage.defaultProps = {
27 | error: null,
28 | quote: null
29 | };
30 |
31 | HomePage.propTypes = {
32 | error: PropTypes.any,
33 | quote: PropTypes.shape({
34 | text: PropTypes.string.isRequired,
35 | author: PropTypes.string.isRequired
36 | })
37 | };
38 |
39 | export default HomePage;
40 |
--------------------------------------------------------------------------------
/LICENCE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) [year] [fullname]
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.
--------------------------------------------------------------------------------
/contexts/quote.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import PropTypes from "prop-types";
3 |
4 | import { fetchQuote } from "../helpers/api";
5 |
6 | export const quoteContext = React.createContext({ quote: null });
7 | const { Provider } = quoteContext;
8 |
9 | export const QuoteProvider = ({ children, initialQuote, serverError }) => {
10 | const [quote, setQuote] = React.useState(initialQuote);
11 | const [loading, setLoading] = React.useState(false);
12 | const [error, setError] = React.useState(serverError);
13 |
14 | const fetchNewQuote = async () => {
15 | setLoading(true);
16 | setError(null);
17 |
18 | try {
19 | const data = await fetchQuote();
20 | setQuote(data);
21 | } catch (err) {
22 | setError(err);
23 | }
24 |
25 | setLoading(false);
26 | };
27 |
28 | return {children};
29 | };
30 |
31 | QuoteProvider.defaultProps = {
32 | serverError: null,
33 | initialQuote: null
34 | };
35 |
36 | QuoteProvider.propTypes = {
37 | children: PropTypes.node.isRequired,
38 | serverError: PropTypes.any,
39 | initialQuote: PropTypes.shape({
40 | text: PropTypes.string.isRequired,
41 | author: PropTypes.string.isRequired
42 | })
43 | };
44 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Stoic Quotes
2 |
3 | A simple app that displays random Stoic quotes from Seneca, Marcus Aurelius, and Epictetus.
4 |
5 | [stoic-quotes.com](https://stoic-quotes.com)
6 |
7 | Please feel free to make a pull request if you want to add your favourite Stoic quote to the app 🙂
8 |
9 | # Using the API
10 |
11 | This app exposes a public API which you are free to use to fetch random Stoic quotes for use in your own applications.
12 |
13 | ### Endpoints
14 |
15 | #### Get a random Stoic quote
16 |
17 | ```
18 | GET https://stoic-quotes.com/api/quote
19 | ```
20 |
21 | ##### Example JSON response
22 |
23 | ```json
24 | {
25 | "text": "All that exists is the seed of what will emerge from it.",
26 | "author": "Marcus Aurelius"
27 | }
28 | ```
29 |
30 | #### Get a list of random Stoic quotes
31 |
32 | ```
33 | GET https://stoic-quotes.com/api/quotes
34 | ```
35 |
36 | Optionally pass in a `num` param to change the number of quotes returned.
37 | `num` defaults to `10`, and can not be higher than `100`.
38 |
39 | ```
40 | GET https://stoic-quotes.com/api/quotes?num=10
41 | ```
42 |
43 | ##### Example JSON response
44 |
45 | ```json
46 | [
47 | {
48 | "text": "All that exists is the seed of what will emerge from it.",
49 | "author": "Marcus Aurelius"
50 | },
51 | {
52 | "text": "If it doesn't harm your character, how can it harm your life?",
53 | "author": "Marcus Aurelius"
54 | }
55 | ]
56 | ```
57 |
--------------------------------------------------------------------------------
/components/blockquote.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import PropTypes from "prop-types";
3 | import styled from "@emotion/styled";
4 | import { keyframes, css } from "@emotion/core";
5 |
6 | const fadeIn = keyframes`
7 | 0% {
8 | opacity: 0;
9 | }
10 |
11 | 100% {
12 | opacity: 1;
13 | }
14 | `;
15 |
16 | const fadeOut = keyframes`
17 | 0% {
18 | opacity: 1;
19 | }
20 |
21 | 100% {
22 | opacity: 0;
23 | }
24 | `;
25 |
26 | const Quote = styled.blockquote`
27 | max-width: 620px;
28 | margin: 0;
29 | animation: ${fadeIn} 0.8s ease-in forwards;
30 | cursor: default;
31 |
32 | ${({ animate }) =>
33 | animate &&
34 | css`
35 | animation: ${fadeOut} 0.4s ease-in forwards;
36 | `}
37 | `;
38 |
39 | const Text = styled.p`
40 | margin: 0;
41 | font-size: 36px;
42 | line-height: 1.3;
43 | cursor: text;
44 |
45 | @media (max-width: 620px) {
46 | font-size: 32px;
47 | }
48 | `;
49 |
50 | const Footer = styled.footer`
51 | text-align: right;
52 | padding: 30px 30px 0 30px;
53 | `;
54 |
55 | const Cite = styled.cite`
56 | font-size: 24px;
57 | cursor: text;
58 | `;
59 |
60 | const Blockquote = ({ author, text, animate }) => (
61 | e.stopPropagation()}>
62 | {text}
63 |
68 |
69 | );
70 |
71 | Blockquote.defaultProps = {
72 | animate: false
73 | };
74 |
75 | Blockquote.propTypes = {
76 | text: PropTypes.string.isRequired,
77 | author: PropTypes.string.isRequired,
78 | animate: PropTypes.bool
79 | };
80 |
81 | export default Blockquote;
82 |
--------------------------------------------------------------------------------
/components/home-view.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import styled from "@emotion/styled";
3 |
4 | import { quoteContext } from "../contexts/quote";
5 | import ErrorMessage from "./error-message";
6 | import Blockquote from "./blockquote";
7 | import ClickIcon from "./click-icon";
8 |
9 | const Container = styled.div`
10 | position: relative;
11 | min-height: 100%;
12 | width: 100%;
13 | color: inherit;
14 | display: flex;
15 | flex-direction: column;
16 | justify-content: center;
17 | align-items: center;
18 | padding: 10vw 5vw;
19 | cursor: pointer;
20 | -webkit-tap-highlight-color: transparent;
21 |
22 | &:focus {
23 | outline: none;
24 | }
25 | `;
26 |
27 | const Hint = styled.div`
28 | position: absolute;
29 | bottom: 20px;
30 | right: 20px;
31 |
32 | @media (max-width: 620px) {
33 | right: auto;
34 | left: 20px;
35 | }
36 | `;
37 |
38 | const HomeView = () => {
39 | const { quote, error, loading, fetchNewQuote } = React.useContext(quoteContext);
40 |
41 | const buttonKeyDown = event => {
42 | if (event.keyCode === 32) {
43 | event.preventDefault();
44 | } else if (event.keyCode === 13) {
45 | event.preventDefault();
46 | fetchNewQuote();
47 | }
48 | };
49 |
50 | const buttonKeyUp = event => {
51 | if (event.keyCode === 32) {
52 | event.preventDefault();
53 | fetchNewQuote();
54 | }
55 | };
56 |
57 | return (
58 |
66 | {!error && quote ? : }
67 |
68 |
69 |
70 |
71 | );
72 | };
73 |
74 | export default HomeView;
75 |
--------------------------------------------------------------------------------
/pages/_app.js:
--------------------------------------------------------------------------------
1 | import NextApp from "next/app";
2 | import Head from "next/head";
3 | import React from "react";
4 |
5 | import GlobalStyle from "../components/global-style";
6 |
7 | class App extends NextApp {
8 | render() {
9 | const { Component, pageProps } = this.props;
10 |
11 | const title = "Stoic Quotes | The best quotes from the great Roman Stoics";
12 | const imgSrc = "https://stoic-quotes.com/marcus-aurelius.jpg";
13 | const description =
14 | "The very best Stoic quotes from the three great Roman Stoics: Marcus Aurelius, Seneca, and Epictetus. Presented in bitesize chunks, learn from their wisdom and bring Stoicism into your everyday life.";
15 |
16 | return (
17 | <>
18 |
19 | {title}
20 | {[
21 | {
22 | name: "description",
23 | content: description
24 | },
25 | {
26 | property: "og:title",
27 | content: title
28 | },
29 | {
30 | property: "og:description",
31 | content: description
32 | },
33 | {
34 | property: "og:image",
35 | content: imgSrc
36 | },
37 | {
38 | name: "twitter:image",
39 | content: imgSrc
40 | },
41 | {
42 | property: "og:type",
43 | content: "website"
44 | },
45 | {
46 | name: "twitter:card",
47 | content: "summary_large_image"
48 | },
49 | {
50 | name: "twitter:creator",
51 | content: "benhoneywill"
52 | },
53 | {
54 | name: "twitter:title",
55 | content: title
56 | },
57 | {
58 | name: "twitter:description",
59 | content: description
60 | },
61 | {
62 | name: "keywords",
63 | content: "Stoic, Stoicism, Marcus, Aurelius, Seneca, Epictetus, Roman, Philosophy, Quotes, Inspirational"
64 | },
65 | {
66 | name: "theme-color",
67 | content: "#0c0c0c"
68 | }
69 | ].map(props => (
70 |
71 | ))}
72 |
73 |
74 |
75 |
76 | >
77 | );
78 | }
79 | }
80 |
81 | export default App;
82 |
--------------------------------------------------------------------------------
/components/click-icon.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import PropTypes from "prop-types";
3 | import styled from "@emotion/styled";
4 | import { keyframes } from "@emotion/core";
5 |
6 | const flash = keyframes`
7 | 0% { opacity: 0; }
8 | 50% { opacity: 0.6; }
9 | 100% { opacity: 0; }
10 | `;
11 |
12 | const Wrapper = styled.span`
13 | .mobile {
14 | display: none;
15 | }
16 |
17 | @media (pointer: coarse) {
18 | .mobile {
19 | display: inline;
20 | }
21 | .desktop {
22 | display: none;
23 | }
24 | }
25 | `;
26 |
27 | const G = styled.g`
28 | position: relative;
29 | opacity: 0.2;
30 | animation: ${flash} 3s infinite ease-in-out;
31 | `;
32 |
33 | const ClickIcon = ({ size, color }) => (
34 |
35 |
57 |
58 |
88 |
89 | );
90 |
91 | ClickIcon.defaultProps = {
92 | size: 100,
93 | color: "#111111"
94 | };
95 |
96 | ClickIcon.propTypes = {
97 | size: PropTypes.number,
98 | color: PropTypes.string
99 | };
100 |
101 | export default ClickIcon;
102 |
--------------------------------------------------------------------------------
/components/global-style.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Global, css, keyframes } from "@emotion/core";
3 |
4 | const backgroundShift = keyframes`
5 | 0% { background-position: 100%, 0% 50%; }
6 | 50% { background-position: 100%, 100% 50%; }
7 | 100% { background-position: 100%, 0% 50%; }
8 | `;
9 |
10 | const GlobalStyle = () => (
11 |
179 | );
180 |
181 | export default GlobalStyle;
182 |
--------------------------------------------------------------------------------