├── 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 |
64 | 65 | - {author} 66 | 67 |
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 | 36 | 37 | 41 | 45 | 49 | 50 | 51 | 56 | 57 | 58 | 59 | 60 | 64 | 68 | 72 | 76 | 80 | 81 | 82 | 87 | 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 | --------------------------------------------------------------------------------