├── .babelrc ├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── src ├── App.js ├── Config.js ├── blog │ ├── Blog.js │ ├── Categories.js │ ├── CategoryList.js │ ├── Excerpt.js │ ├── List.js │ └── Post.js ├── commons │ ├── Dates.js │ └── HtmlSanitizer.js ├── index.html ├── index.js └── page │ ├── Nav.js │ └── Page.js ├── styles ├── _blog.scss ├── _nav.scss ├── _page.scss ├── _type.scss ├── _variables.scss ├── _work.scss └── index.scss └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | 'presets': ['babel-preset-env', 'babel-preset-react'] 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Headless WordPress App with GraphQL 2 | 3 | This repository shows how to create a WordPress Single Page Application with React & Apollo Client. In order to 4 | connect to WP data, it uses the [WPGraphQL plugin](https://github.com/wp-graphql/wp-graphql). 5 | 6 | ## Setup 7 | 8 | Change the configuration file in `src/Config.js` to poing to your WordPress instance using the property: `wordpressUrl`. 9 | 10 | ``` 11 | const Config = { 12 | wordpressUrl: 'http://localhost:5010/', 13 | graphqlEndpoint: 'graphql' 14 | }; 15 | ``` 16 | 17 | Make sure your WordPress instance has the lastest version of the **WPGraphQL plugin installed and activated**. 18 | 19 | Run the front-end app: 20 | 21 | ``` 22 | npm install 23 | npm run serve 24 | ``` 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "website-spa-poc", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "clean": "rimraf dist", 9 | "build": "npm run clean && webpack", 10 | "serve": "webpack-dev-server" 11 | }, 12 | "author": "", 13 | "license": "ISC", 14 | "dependencies": { 15 | "apollo-boost": "^0.1.1", 16 | "graphql": "^0.13.1", 17 | "react": "^16.2.0", 18 | "react-apollo": "^2.0.4", 19 | "react-dom": "^16.2.0", 20 | "react-router-dom": "^4.2.2", 21 | "reset-scss": "^1.0.0" 22 | }, 23 | "devDependencies": { 24 | "babel-core": "^6.26.0", 25 | "babel-loader": "^7.1.2", 26 | "babel-plugin-transform-object-rest-spread": "^6.26.0", 27 | "babel-preset-env": "^1.6.1", 28 | "babel-preset-react": "^6.24.1", 29 | "css-loader": "^0.28.9", 30 | "extract-text-webpack-plugin": "^3.0.2", 31 | "file-loader": "^1.1.6", 32 | "html-webpack-plugin": "^2.30.1", 33 | "image-webpack-loader": "^4.1.0", 34 | "node-sass": "^4.7.2", 35 | "rimraf": "^2.6.2", 36 | "sass-loader": "^6.0.6", 37 | "webpack": "^3.11.0", 38 | "webpack-dev-server": "^2.11.1" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link, Route, Redirect, Switch } from 'react-router-dom'; 3 | import Nav from './page/Nav'; 4 | import Page from './page/Page'; 5 | import Blog from './blog/Blog'; 6 | 7 | const App = () => ( 8 |
9 |
16 | ); 17 | 18 | export default App; 19 | -------------------------------------------------------------------------------- /src/Config.js: -------------------------------------------------------------------------------- 1 | const Config = { 2 | wordpressUrl: 'http://localhost:5010/', 3 | graphqlEndpoint: 'graphql' 4 | }; 5 | 6 | export default Config; -------------------------------------------------------------------------------- /src/blog/Blog.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Switch, Route } from 'react-router-dom'; 3 | import Categories from './Categories'; 4 | import Post from './Post'; 5 | import List from './List'; 6 | import CategoryList from './CategoryList'; 7 | 8 | const Blog = ({ match }) => ( 9 |
10 |
11 |

Categories

12 | 13 |
14 | 15 | 16 | 17 | 18 | 19 |
20 | ); 21 | 22 | export default Blog; 23 | -------------------------------------------------------------------------------- /src/blog/Categories.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { graphql } from 'react-apollo'; 3 | import gql from 'graphql-tag'; 4 | import { Link, withRouter } from 'react-router-dom'; 5 | 6 | const Categories = (props) => { 7 | 8 | const categories = (props.data.categories && props.data.categories.items) ? props.data.categories.items : []; 9 | 10 | return ( 11 | 23 | ); 24 | }; 25 | 26 | export default graphql(gql` 27 | query GetBlogCategories { 28 | categories { 29 | items: edges { 30 | category: node { 31 | id 32 | name 33 | link 34 | slug 35 | } 36 | } 37 | } 38 | } 39 | `)(withRouter(Categories)); -------------------------------------------------------------------------------- /src/blog/CategoryList.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { graphql } from 'react-apollo'; 3 | import gql from 'graphql-tag'; 4 | import Excerpt from './Excerpt'; 5 | 6 | class CategoryList extends Component { 7 | 8 | render() { 9 | let categories = this.props.data.categories; 10 | let posts = []; 11 | let category = null; 12 | 13 | if (categories && categories.edges && categories.edges.length > 0) { 14 | category = categories.edges[0].node; 15 | posts = category.posts.edges; 16 | } 17 | 18 | return ( 19 |
20 | { posts.map(post => ) } 21 |
22 | ); 23 | } 24 | 25 | } 26 | 27 | const GetPostsByCategory = gql` 28 | query GetCategoryPosts($first: Int, $where: RootCategoriesTermArgs!) { 29 | categories(first: $first, where: $where) { 30 | edges { 31 | node { 32 | name 33 | slug 34 | count 35 | posts { 36 | edges { 37 | node { 38 | title 39 | date 40 | excerpt 41 | slug 42 | featuredImage { 43 | sourceUrl 44 | } 45 | } 46 | } 47 | } 48 | } 49 | } 50 | } 51 | }`; 52 | 53 | export default graphql(GetPostsByCategory, { 54 | options: (props) => { 55 | let { slug } = props.match.params; 56 | return { 57 | variables: { 58 | first: 1, 59 | where: { 60 | slug: slug 61 | } 62 | } 63 | } 64 | } 65 | })(CategoryList); -------------------------------------------------------------------------------- /src/blog/Excerpt.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import { toDate } from '../commons/Dates'; 4 | 5 | class Excerpt extends Component { 6 | 7 | render() { 8 | 9 | return ( 10 |
11 | 14 | { this.props.post.featuredImage && } 15 |
16 |

{this.props.post.title}

17 | 18 |
19 |
20 | 21 |
22 | ); 23 | } 24 | 25 | } 26 | 27 | export default Excerpt; 28 | -------------------------------------------------------------------------------- /src/blog/List.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { graphql } from 'react-apollo'; 3 | import gql from 'graphql-tag'; 4 | import Excerpt from './Excerpt'; 5 | 6 | class List extends Component { 7 | 8 | render() { 9 | let { loading, error, posts, fetchMore, loadMoreEntries } = this.props; 10 | 11 | if (loading) { 12 | return ( 13 |
Loading...
14 | ); 15 | } 16 | 17 | if (error) { 18 | console.error(error); 19 | } 20 | 21 | return ( 22 |
23 |
24 | { posts.edges.map(post => ) } 25 | { posts.pageInfo.hasNextPage && } 26 |
27 |
28 | ); 29 | } 30 | 31 | } 32 | 33 | // For pagination reference go here: 34 | // https://www.apollographql.com/docs/react/recipes/pagination.html#relay-cursors 35 | 36 | const PaginatedPostsQuery = gql` 37 | query PaginatedPosts($cursor: String) { 38 | posts(first: 6, after: $cursor) { 39 | edges { 40 | cursor 41 | node { 42 | title 43 | date 44 | excerpt 45 | slug 46 | featuredImage { 47 | sourceUrl 48 | } 49 | } 50 | } 51 | pageInfo { 52 | hasNextPage 53 | hasPreviousPage 54 | endCursor 55 | } 56 | } 57 | } 58 | `; 59 | 60 | export default graphql(PaginatedPostsQuery, { 61 | // This function re-runs every time `data` changes, including after `updateQuery`, 62 | // meaning our loadMoreEntries function will always have the right cursor 63 | props({ data: { loading, posts, fetchMore } }) { 64 | return { 65 | loading, 66 | posts, 67 | loadMoreEntries: () => { 68 | return fetchMore({ 69 | query: PaginatedPostsQuery, 70 | variables: { 71 | cursor: posts.pageInfo.endCursor, 72 | }, 73 | updateQuery: (previousResult, { fetchMoreResult }) => { 74 | const newEdges = fetchMoreResult.posts.edges; 75 | const pageInfo = fetchMoreResult.posts.pageInfo; 76 | 77 | return { 78 | // Put the new posts at the end of the list and update `pageInfo` 79 | // so we have the new `endCursor` and `hasNextPage` values 80 | posts: { 81 | __typename: previousResult.posts.__typename, 82 | edges: [...previousResult.posts.edges, ...newEdges], 83 | pageInfo, 84 | } 85 | }; 86 | 87 | }, 88 | }); 89 | }, 90 | }; 91 | }, 92 | })(List); -------------------------------------------------------------------------------- /src/blog/Post.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { graphql } from 'react-apollo'; 3 | import gql from 'graphql-tag'; 4 | import { sanitize } from '../commons/HtmlSanitizer'; 5 | 6 | const Post = (props) => { 7 | const { post, loading } = props.data; 8 | if (loading) { 9 | return ( 10 |
Loading...
11 | ); 12 | } 13 | return ( 14 |
15 | { post.featuredImage && } 16 |
17 |

{post.title}

18 |
19 |
20 |
21 | ); 22 | } 23 | 24 | const GetPostBySlug = gql` 25 | query GetPostBySlug($slug: String) { 26 | post: postBy(slug: $slug) { 27 | id 28 | title 29 | date 30 | slug 31 | uri 32 | featuredImage { 33 | sourceUrl 34 | } 35 | author { 36 | name 37 | } 38 | excerpt 39 | content 40 | } 41 | }`; 42 | 43 | export default graphql(GetPostBySlug, { 44 | options: (props) => { 45 | return { 46 | variables: { 47 | slug: props.match.params.slug 48 | } 49 | } 50 | } 51 | // options: ({ match }) => ({ variables: { slug: match.params.slug } }) 52 | })(Post); -------------------------------------------------------------------------------- /src/commons/Dates.js: -------------------------------------------------------------------------------- 1 | 2 | export const toDate = (dateString) => { 3 | let date = new Date(Date.parse(dateString)); 4 | return date.toLocaleDateString("en-US"); 5 | } -------------------------------------------------------------------------------- /src/commons/HtmlSanitizer.js: -------------------------------------------------------------------------------- 1 | 2 | import Config from '../Config'; 3 | 4 | export const sanitize = (html) => { 5 | return html.replace(/=\"\/wp-content\//g, '="' + Config.wordpressUrl + 'wp-content/'); 6 | } -------------------------------------------------------------------------------- /src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | WP React GraphQL 4 | 5 | 6 |
7 | 8 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { BrowserRouter } from 'react-router-dom'; 4 | import { ApolloProvider } from 'react-apollo'; 5 | import { ApolloClient } from 'apollo-client'; 6 | import { HttpLink } from 'apollo-link-http'; 7 | import { InMemoryCache } from 'apollo-cache-inmemory'; 8 | import Config from './Config'; 9 | import gql from 'graphql-tag'; 10 | 11 | import App from './App'; 12 | import '../styles/index.scss'; 13 | 14 | const client = new ApolloClient({ 15 | link: new HttpLink({ uri: Config.wordpressUrl + Config.graphqlEndpoint }), 16 | cache: new InMemoryCache() 17 | }); 18 | 19 | ReactDOM.render( 20 | 21 | 22 | 23 | 24 | , 25 | document.getElementById('root') 26 | ); 27 | -------------------------------------------------------------------------------- /src/page/Nav.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link, withRouter } from 'react-router-dom'; 3 | import { graphql } from 'react-apollo'; 4 | import gql from 'graphql-tag'; 5 | 6 | const Nav = (props) => { 7 | 8 | const { loading, pages } = props.data; 9 | 10 | if (loading) { 11 | return ( 12 |
Loading...
13 | ); 14 | } 15 | 16 | if (pages.items) { 17 | return ( 18 | 29 | ); 30 | } 31 | 32 | return (
No pages
); 33 | 34 | }; 35 | 36 | export default graphql(gql` 37 | query GetFirstLevelPages { 38 | pages(first: 100, where: { 39 | orderby: { 40 | field: DATE, 41 | order: ASC 42 | } 43 | }) { 44 | items: edges { 45 | page: node { 46 | title 47 | slug 48 | } 49 | } 50 | } 51 | } 52 | `)(withRouter(Nav)); -------------------------------------------------------------------------------- /src/page/Page.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Redirect } from 'react-router-dom'; 3 | import { graphql } from 'react-apollo'; 4 | import gql from 'graphql-tag'; 5 | import { sanitize } from '../commons/HtmlSanitizer'; 6 | 7 | class Page extends Component { 8 | 9 | render() { 10 | const props = this.props; 11 | 12 | return ( 13 |
14 |

{(props.data.page) ? props.data.page.title : '-'}

15 |
18 |
19 | ); 20 | } 21 | 22 | componentDidUpdate() { 23 | let pageContent = document.getElementById('page-content'); 24 | let links = Array.from(pageContent.querySelectorAll('a')); 25 | links.map( (node) => node.onclick = this.onLinkClicked.bind(this) ); 26 | } 27 | 28 | onLinkClicked(event) { 29 | event.preventDefault(); 30 | this.props.history.push(event.currentTarget.pathname); 31 | } 32 | } 33 | 34 | const GetPageBySlug = gql` 35 | query GetPageBySlug($slug: String) { 36 | page: pageBy(uri: $slug) { 37 | id 38 | title 39 | slug 40 | date 41 | content 42 | } 43 | }`; 44 | 45 | export default graphql(GetPageBySlug, { 46 | options: (props) => { 47 | 48 | let { slug, parent } = props.match.params; 49 | if (parent) { 50 | slug = `${parent}/${slug}` 51 | } 52 | 53 | return { 54 | variables: { 55 | slug 56 | } 57 | } 58 | } 59 | })(Page); -------------------------------------------------------------------------------- /styles/_blog.scss: -------------------------------------------------------------------------------- 1 | 2 | .blog { 3 | h2 { 4 | font-size: $font-size-md; 5 | text-transform: uppercase; 6 | } 7 | 8 | .post-list { 9 | display: flex; 10 | flex-wrap: wrap; 11 | button { 12 | margin: 0 auto; 13 | } 14 | } 15 | 16 | .excerpt { 17 | width: calc(100% / 3); 18 | margin: $gutter-width-lg 0; 19 | padding: 0 $gutter-width-lg; 20 | 21 | .thumbnail { 22 | width: 100%; 23 | } 24 | 25 | .excerpt-meta { 26 | 27 | background: #f6f6f6; 28 | padding: $gutter-width-md; 29 | 30 | 31 | h2 { 32 | margin-bottom: $gutter-width-md; 33 | } 34 | 35 | time { 36 | font-weight: $font-weight-bold; 37 | color: gray; 38 | } 39 | 40 | p { 41 | font-weight: $font-weight-light; 42 | margin-top: $gutter-width-sm; 43 | } 44 | 45 | } 46 | 47 | &:hover { 48 | .excerpt-meta { 49 | background: #eaeaea; 50 | } 51 | } 52 | 53 | } 54 | 55 | .post { 56 | margin: $gutter-width-lg; 57 | 58 | .featured-img { 59 | position: relative; 60 | width: 100%; 61 | } 62 | 63 | .post-content { 64 | position: relative; 65 | background-color: white; 66 | width: 80%; 67 | margin: -300px auto; 68 | padding: $gutter-width-lg; 69 | 70 | h1 { 71 | text-align: center; 72 | } 73 | 74 | img { 75 | width: 100%; 76 | margin: $gutter-width-lg 0; 77 | } 78 | } 79 | } 80 | } -------------------------------------------------------------------------------- /styles/_nav.scss: -------------------------------------------------------------------------------- 1 | nav { 2 | display: flex; 3 | flex-direction: row; 4 | flex-wrap: wrap; 5 | margin-bottom: $gutter-width-md; 6 | background: $brand-primary-color; 7 | a { 8 | background: $brand-primary-lighter-color; 9 | color: $brand-pale-color; 10 | margin: $gutter-width-xs; 11 | padding: $gutter-width-xs; 12 | white-space: nowrap; 13 | &:hover, 14 | &.active { 15 | background: $brand-pale-color; 16 | color: $brand-primary-lighter-color; 17 | } 18 | } 19 | &.inverse { 20 | flex-wrap: wrap-reverse; 21 | flex-direction: row-reverse; 22 | justify-content: flex-end; 23 | a { 24 | margin: $gutter-width-xs; 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /styles/_page.scss: -------------------------------------------------------------------------------- 1 | 2 | .page { 3 | img { 4 | max-width: 100px; 5 | max-height: 100px; 6 | } 7 | } -------------------------------------------------------------------------------- /styles/_type.scss: -------------------------------------------------------------------------------- 1 | h1 { 2 | text-transform: uppercase; 3 | margin-top: $gutter-width-md; 4 | margin-bottom: $gutter-width-xl; 5 | font-size: $font-size-xl; 6 | } 7 | 8 | h2 { 9 | margin-top: $gutter-width-xl; 10 | margin-bottom: $gutter-width-lg; 11 | font-size: $font-size-lg; 12 | } 13 | 14 | h3 { 15 | margin-top: $gutter-width-lg; 16 | margin-bottom: $gutter-width-md; 17 | font-size: $font-size-md; 18 | } 19 | 20 | p { 21 | font-weight: $font-weight-light; 22 | margin: $gutter-width-md 0; 23 | b, strong { 24 | font-weight: $font-weight-base; 25 | } 26 | } -------------------------------------------------------------------------------- /styles/_variables.scss: -------------------------------------------------------------------------------- 1 | $font-family-base: 'Roboto', sans-serif; 2 | $font-weight-bold: 500; 3 | $font-weight-base: 400; 4 | $font-weight-light: 200; 5 | 6 | $font-size-xs: 14px; 7 | $font-size-sm: 16px; 8 | $font-size-md: 18px; 9 | $font-size-lg: 22px; 10 | $font-size-xl: 26px; 11 | 12 | $gutter-width-xs: 5px; 13 | $gutter-width-sm: 10px; 14 | $gutter-width-md: 15px; 15 | $gutter-width-lg: 30px; 16 | $gutter-width-xl: 45px; 17 | 18 | $brand-primary-color: darkslateblue; 19 | $brand-primary-lighter-color: lighten($brand-primary-color, 20); 20 | $brand-secondary-color: #02E8E4; 21 | $brand-pale-color: #dadada; 22 | -------------------------------------------------------------------------------- /styles/_work.scss: -------------------------------------------------------------------------------- 1 | 2 | .work--projects-section { 3 | 4 | .our-work { 5 | display: flex; 6 | flex-wrap: wrap; 7 | 8 | a { 9 | width: calc(100% / 4); 10 | margin: $gutter-width-lg 0; 11 | padding: 0 $gutter-width-lg; 12 | } 13 | 14 | & > p { 15 | display: none; 16 | } 17 | 18 | img { 19 | max-width: 100%; 20 | max-height: initial; 21 | width: 100%; 22 | } 23 | } 24 | 25 | } -------------------------------------------------------------------------------- /styles/index.scss: -------------------------------------------------------------------------------- 1 | @import '../node_modules/reset-scss/reset.scss'; 2 | @import url('https://fonts.googleapis.com/css?family=Roboto:300,400,500'); 3 | @import 'variables'; 4 | @import 'type'; 5 | @import 'nav'; 6 | @import 'page'; 7 | @import 'blog'; 8 | @import 'work'; 9 | 10 | body { 11 | font-family: $font-family-base; 12 | font-weight: $font-weight-base; 13 | } 14 | 15 | .container { 16 | margin: $gutter-width-md; 17 | } 18 | 19 | button { 20 | text-transform: uppercase; 21 | padding: $gutter-width-xs; 22 | border: 1px solid $brand-primary-color; 23 | color: $brand-primary-color; 24 | 25 | &:hover, 26 | &.inverted { 27 | background-color: $brand-primary-color; 28 | color: $brand-pale-color; 29 | } 30 | } 31 | 32 | .search-box { 33 | input { 34 | height: 30px; 35 | padding: 5px; 36 | border: 1px solid $brand-primary-color; 37 | border-right: none; 38 | } 39 | } -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require('path'); 2 | var HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | var ExtractTextPlugin = require('extract-text-webpack-plugin'); 4 | var BabelPluginObjectSpread = require('babel-plugin-transform-object-rest-spread'); 5 | 6 | module.exports = { 7 | entry: './src/index.js', 8 | output: { 9 | path: path.resolve(__dirname, 'dist'), 10 | publicPath: '/', 11 | filename: 'bundle.js' 12 | }, 13 | devServer: { 14 | port: 5001, 15 | historyApiFallback: true 16 | }, 17 | devtool: 'source-map', 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.(js|jsx)$/, 22 | exclude: /(node_modules|bower_components)/, 23 | use: [ 24 | { 25 | loader: 'babel-loader', 26 | options: { 27 | presets: ['env'], 28 | plugins: [BabelPluginObjectSpread] 29 | } 30 | } 31 | ] 32 | }, 33 | { 34 | test: /\.scss$/, 35 | use: ExtractTextPlugin.extract({ 36 | use: ['css-loader', 'sass-loader'] 37 | }) 38 | }, 39 | { 40 | test: /\.(jpe?g|png|gif|svg)$/i, 41 | use: [ 42 | 'file-loader?name=[name].[hash].[ext]', 43 | 'image-webpack-loader' 44 | ] 45 | } 46 | ] 47 | }, 48 | plugins: [ 49 | new HtmlWebpackPlugin({ 50 | template: 'src/index.html' 51 | }), 52 | new ExtractTextPlugin('styles.css') 53 | ] 54 | }; 55 | --------------------------------------------------------------------------------