├── .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 |
10 |
11 |
12 |
13 |
14 |
15 |
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 |
--------------------------------------------------------------------------------