├── .gitignore ├── LICENSE ├── README.md ├── blog-preview.PNG ├── client ├── .babelrc ├── .gitignore ├── api │ ├── constants.js │ ├── mutations │ │ ├── comment │ │ │ └── commentPost.js │ │ ├── like │ │ │ └── likePost.js │ │ ├── post │ │ │ ├── deletePost.js │ │ │ └── writePost.js │ │ └── user │ │ │ ├── login.js │ │ │ ├── signup.js │ │ │ └── updateUser.js │ ├── queries │ │ ├── post │ │ │ └── allPosts.js │ │ └── user │ │ │ └── getCurrentUser.js │ └── subscriptions │ │ ├── newComment.js │ │ ├── newLike.js │ │ └── newPost.js ├── components │ ├── CommentList.js │ ├── FeedList.js │ ├── FeedLoader.js │ ├── GoogleLoginButton.js │ ├── HeadData.js │ ├── LoadPendingButton.js │ ├── PostCard.js │ ├── SearchForm.js │ ├── StyledSignForm.js │ └── nav.js ├── context.js ├── index.js ├── lib │ ├── alerts.js │ ├── gtag.js │ ├── init-apollo.js │ ├── isAuth.js │ ├── parseError.js │ ├── privatePage.js │ ├── sitemapAndRobots.js │ ├── with-apollo-client.js │ └── withUser.js ├── next.config.js ├── now.json ├── package.json ├── pages │ ├── _app.js │ ├── _document.js │ ├── callback.js │ ├── index.js │ ├── login.js │ ├── new-post.js │ ├── profile.js │ └── signup.js ├── static │ ├── alertdefault.css │ ├── alertjelly.css │ ├── blog-placeholder.jpg │ ├── favicon.ico │ ├── icons │ │ ├── icon-128x128.png │ │ ├── icon-144x144.png │ │ ├── icon-152x152.png │ │ ├── icon-192x192.png │ │ ├── icon-384x384.png │ │ ├── icon-512x512.png │ │ ├── icon-72x72.png │ │ └── icon-96x96.png │ ├── manifest.json │ ├── nprogress.css │ └── robots.txt └── yarn.lock └── server ├── .babelrc ├── .gitignore ├── auth └── google.js ├── backpack.config.js ├── db ├── index.js └── models │ ├── Comment.js │ ├── Like.js │ ├── Post.js │ └── User.js ├── index.js ├── loaders ├── index.js ├── multiple.js ├── queryLoader.js └── single.js ├── now.json ├── package.json ├── resolvers ├── index.js ├── mutations │ ├── auth │ │ ├── index.js │ │ ├── login.js │ │ └── signup.js │ ├── comment │ │ ├── commentPost.js │ │ └── index.js │ ├── index.js │ ├── like │ │ ├── index.js │ │ └── likePost.js │ ├── post │ │ ├── deletePost.js │ │ ├── index.js │ │ └── writePost.js │ └── user │ │ ├── index.js │ │ └── updateUser.js ├── queries │ ├── auth │ │ ├── index.js │ │ └── user.js │ ├── comment │ │ ├── commentedBy.js │ │ ├── index.js │ │ └── post.js │ ├── index.js │ ├── like │ │ ├── index.js │ │ ├── likedBy.js │ │ └── post.js │ ├── post │ │ ├── allPosts.js │ │ ├── comments.js │ │ ├── index.js │ │ ├── likes.js │ │ ├── postById.js │ │ └── postedBy.js │ └── user │ │ ├── allUsers.js │ │ ├── comments.js │ │ ├── currentUser.js │ │ ├── index.js │ │ ├── likes.js │ │ └── posts.js └── subscriptions │ ├── index.js │ ├── newComment.js │ ├── newLike.js │ └── newPost.js ├── types ├── Auth.js ├── Comment.js ├── Like.js ├── Post.js ├── User.js └── index.js ├── utils └── index.js └── yarn.lock /.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 | /dist 12 | /.next 13 | 14 | # misc 15 | .DS_Store 16 | .env 17 | npm-debug.log* 18 | yarn-debug.log* 19 | yarn-error.log* 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Alex Lööf 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Next GraphQL Blog 2 | 3 | A Blog including a server and a client. 4 | Server is built with Node, Express & a customized GraphQL-yoga server. 5 | Client is built with React, Next js & Apollo client. 6 | 7 | The idea behind this app was that I wanted to learn all basic and advanced concepts when it comes to GraphQL together with Next js/React. This resulted in a great app that can fit very well as a boilerplate and as a inpiration source for upcoming projects. 8 | 9 | Live at: https://next-graphql.now.sh 10 | 11 | Preview: 12 | ![Blog](https://github.com/Alexloof/Next-GraphQL-Blog/blob/master/blog-preview.PNG 'Blog') 13 | 14 | Run the app locally: 15 | 16 | \*server 17 | 18 | ``` 19 | yarn install 20 | yarn dev 21 | ``` 22 | 23 | \*client 24 | 25 | ``` 26 | yarn install 27 | yarn dev 28 | 29 | Visit http://localhost:3000 30 | ``` 31 | 32 | Remeber to include a .env file with variables 33 | -------------------------------------------------------------------------------- /blog-preview.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/awesomedev08/Next-GraphQL-Blog/6e2042af334ee5f323f3c5d3d7fad41d31575058/blog-preview.PNG -------------------------------------------------------------------------------- /client/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["next/babel"], 3 | "plugins": [ 4 | [ 5 | "styled-components", 6 | { "ssr": true, "displayName": true, "preprocess": false } 7 | ] 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /client/.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 | /dist 12 | /.next 13 | 14 | # misc 15 | .DS_Store 16 | .env 17 | npm-debug.log* 18 | yarn-debug.log* 19 | yarn-error.log* 20 | -------------------------------------------------------------------------------- /client/api/constants.js: -------------------------------------------------------------------------------- 1 | export const POSTS_LIMIT = 12 2 | 3 | export const COMMENTS_LIMIT = 3 4 | -------------------------------------------------------------------------------- /client/api/mutations/comment/commentPost.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | 3 | import ALL_POSTS from '../../queries/post/allPosts' 4 | 5 | import { POSTS_LIMIT } from '../../constants' 6 | 7 | export const COMMENT_POST = gql` 8 | mutation commentPost($postId: ID!, $text: String) { 9 | commentPost(postId: $postId, text: $text) { 10 | _id 11 | createdAt 12 | text 13 | commentedBy { 14 | _id 15 | name 16 | } 17 | post { 18 | _id 19 | } 20 | } 21 | } 22 | ` 23 | 24 | const fakeId = Math.round(Math.random() * -1000000) 25 | 26 | export const commentPostOptions = (props, input) => { 27 | const { postId, user } = props 28 | return { 29 | optimisticResponse: { 30 | __typename: 'Mutation', 31 | commentPost: { 32 | __typename: 'Comment', 33 | _id: fakeId, 34 | createdAt: new Date(), 35 | text: input, 36 | commentedBy: { 37 | _id: user._id, 38 | __typename: 'User', 39 | name: user.name 40 | }, 41 | post: { 42 | _id: postId, 43 | __typename: 'Post' 44 | } 45 | } 46 | }, 47 | update: (cache, { data: { commentPost } }) => { 48 | const { allPosts } = cache.readQuery({ 49 | query: ALL_POSTS, 50 | variables: { offset: 0, limit: POSTS_LIMIT, sort: '-createdAt' } 51 | }) 52 | 53 | // takes a reference of the post we want 54 | const updatedPost = allPosts.posts.find(post => post._id === postId) 55 | 56 | const commentExist = updatedPost.comments.filter( 57 | comment => comment._id === commentPost._id 58 | ) 59 | 60 | if (commentExist.length === 0) { 61 | // mutate the newly created post with en user_id so it matches the query 62 | commentPost.commentedBy._id = user._id 63 | 64 | // mutates the reference 65 | updatedPost.comments = [...updatedPost.comments, commentPost] 66 | } 67 | 68 | cache.writeQuery({ 69 | query: ALL_POSTS, 70 | data: { 71 | allPosts: { 72 | __typename: 'PostFeed', 73 | count: allPosts.count, 74 | posts: [...allPosts.posts] 75 | } 76 | } 77 | }) 78 | } 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /client/api/mutations/like/likePost.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | 3 | import ALL_POSTS from '../../queries/post/allPosts' 4 | 5 | import { POSTS_LIMIT } from '../../constants' 6 | 7 | export const LIKE_POST = gql` 8 | mutation likePost($postId: ID!) { 9 | likePost(postId: $postId) { 10 | _id 11 | post { 12 | _id 13 | } 14 | likedBy { 15 | _id 16 | name 17 | } 18 | } 19 | } 20 | ` 21 | 22 | const fakeId = Math.round(Math.random() * -1000000) 23 | 24 | export const likePostOptions = props => { 25 | const { _id, user } = props 26 | return { 27 | optimisticResponse: { 28 | __typename: 'Mutation', 29 | likePost: { 30 | __typename: 'Like', 31 | _id: fakeId, 32 | post: { 33 | __typename: 'Post', 34 | _id: _id 35 | }, 36 | likedBy: { 37 | __typename: 'User', 38 | _id: user._id, 39 | name: user.name 40 | } 41 | } 42 | }, 43 | update: (cache, { data: { likePost } }) => { 44 | const { allPosts } = cache.readQuery({ 45 | query: ALL_POSTS, 46 | variables: { offset: 0, limit: POSTS_LIMIT, sort: '-createdAt' } 47 | }) 48 | 49 | // takes a reference of the post we want 50 | const updatedPost = allPosts.posts.find(post => post._id === _id) 51 | 52 | const likeExist = updatedPost.likes.filter( 53 | like => like._id === likePost._id 54 | ) 55 | 56 | // if same like already exist in the cache - mutation update vs sub problem 57 | if (likeExist.length === 0) { 58 | // mutates the reference 59 | updatedPost.likes = [...updatedPost.likes, likePost] 60 | } 61 | 62 | cache.writeQuery({ 63 | query: ALL_POSTS, 64 | data: { 65 | allPosts: { 66 | __typename: 'PostFeed', 67 | count: allPosts.count, 68 | posts: [...allPosts.posts] 69 | } 70 | } 71 | }) 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /client/api/mutations/post/deletePost.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | 3 | import { POSTS_LIMIT } from '../../constants' 4 | 5 | import ALL_POSTS from '../../queries/post/allPosts' 6 | 7 | export const DELETE_POST = gql` 8 | mutation deletePost($_id: String!) { 9 | deletePost(_id: $_id) { 10 | _id 11 | } 12 | } 13 | ` 14 | 15 | export const deletePostOptions = props => { 16 | const { _id } = props 17 | return { 18 | update: (cache, { data: { deletePost } }) => { 19 | const { allPosts } = cache.readQuery({ 20 | query: ALL_POSTS, 21 | variables: { offset: 0, limit: POSTS_LIMIT, sort: '-createdAt' } 22 | }) 23 | 24 | const updatedPosts = allPosts.posts.filter(post => post._id !== _id) 25 | 26 | cache.writeQuery({ 27 | query: ALL_POSTS, 28 | variables: { offset: 0, limit: POSTS_LIMIT, sort: '-createdAt' }, 29 | data: { 30 | allPosts: { 31 | __typename: 'PostFeed', 32 | count: allPosts.count - 1, 33 | posts: [...updatedPosts] 34 | } 35 | } 36 | }) 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /client/api/mutations/post/writePost.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | 3 | import ALL_POSTS from '../../queries/post/allPosts' 4 | 5 | import { POSTS_LIMIT } from '../../constants' 6 | 7 | export const WRITE_POST = gql` 8 | mutation writePost($name: String!, $content: String!, $image: String) { 9 | writePost(name: $name, content: $content, image: $image) { 10 | _id 11 | name 12 | createdAt 13 | content 14 | image 15 | postedBy { 16 | _id 17 | name 18 | } 19 | } 20 | } 21 | ` 22 | 23 | export const writePostOptions = props => { 24 | return { 25 | update: (cache, { data: { writePost } }) => { 26 | const { allPosts } = cache.readQuery({ 27 | query: ALL_POSTS, 28 | variables: { offset: 0, limit: POSTS_LIMIT, sort: '-createdAt' } 29 | }) 30 | 31 | writePost.likes = [] 32 | writePost.comments = [] 33 | 34 | allPosts.posts.unshift(writePost) 35 | if (allPosts.posts.length === POSTS_LIMIT) { 36 | allPosts.posts.pop() 37 | } 38 | 39 | cache.writeQuery({ 40 | query: ALL_POSTS, 41 | variables: { offset: 0, limit: POSTS_LIMIT, sort: '-createdAt' }, 42 | data: { 43 | allPosts: { 44 | __typename: 'PostFeed', 45 | count: allPosts.count++, 46 | posts: [...allPosts.posts] 47 | } 48 | } 49 | }) 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /client/api/mutations/user/login.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | 3 | export const LOGIN_MUTATION = gql` 4 | mutation login($email: String!, $password: String!) { 5 | login(email: $email, password: $password) { 6 | token 7 | user { 8 | _id 9 | name 10 | email 11 | } 12 | } 13 | } 14 | ` 15 | -------------------------------------------------------------------------------- /client/api/mutations/user/signup.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | 3 | export const SIGNUP_MUTATION = gql` 4 | mutation signup($email: String!, $password: String!, $name: String!) { 5 | signup(email: $email, password: $password, name: $name) { 6 | token 7 | user { 8 | _id 9 | name 10 | email 11 | } 12 | } 13 | } 14 | ` 15 | -------------------------------------------------------------------------------- /client/api/mutations/user/updateUser.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | 3 | export const UPDATE_USER = gql` 4 | mutation updateUser( 5 | $name: String 6 | $email: String 7 | $password: String 8 | $newPassword: String 9 | ) { 10 | updateUser( 11 | name: $name 12 | email: $email 13 | password: $password 14 | newPassword: $newPassword 15 | ) { 16 | _id 17 | name 18 | email 19 | googleId 20 | } 21 | } 22 | ` 23 | -------------------------------------------------------------------------------- /client/api/queries/post/allPosts.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | 3 | export default gql` 4 | query allPosts($filter: String, $offset: Int, $limit: Int, $sort: String) { 5 | allPosts(filter: $filter, offset: $offset, limit: $limit, sort: $sort) { 6 | count 7 | posts { 8 | _id 9 | createdAt 10 | name 11 | content 12 | image 13 | postedBy { 14 | _id 15 | name 16 | } 17 | likes { 18 | _id 19 | } 20 | comments { 21 | _id 22 | createdAt 23 | text 24 | commentedBy { 25 | _id 26 | name 27 | } 28 | } 29 | } 30 | } 31 | } 32 | ` 33 | -------------------------------------------------------------------------------- /client/api/queries/user/getCurrentUser.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | 3 | export const GET_CURRENT_USER = gql` 4 | query currentUser { 5 | currentUser { 6 | _id 7 | name 8 | email 9 | googleId 10 | } 11 | } 12 | ` 13 | -------------------------------------------------------------------------------- /client/api/subscriptions/newComment.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | import { showSuccessAlert } from '../../lib/alerts' 3 | 4 | export const NEW_COMMENT_SUB = gql` 5 | subscription newComment { 6 | newComment { 7 | _id 8 | createdAt 9 | text 10 | post { 11 | _id 12 | } 13 | commentedBy { 14 | _id 15 | name 16 | } 17 | } 18 | } 19 | ` 20 | 21 | export const newCommentUpdate = (prev, { subscriptionData }) => { 22 | if (!subscriptionData.data) return prev 23 | const newComment = subscriptionData.data.newComment 24 | 25 | const posts = [...prev.allPosts.posts] 26 | 27 | let commentExist = false 28 | posts.map(post => { 29 | if (post._id === newComment.post._id) { 30 | post.comments.map(comment => { 31 | if (comment._id === newComment._id) { 32 | commentExist = true 33 | } 34 | }) 35 | } 36 | }) 37 | 38 | if (!commentExist) { 39 | const newPosts = posts.map( 40 | post => 41 | post._id === newComment.post._id 42 | ? { ...post, comments: [...post.comments, newComment] } 43 | : post 44 | ) 45 | 46 | const user = JSON.parse(localStorage.getItem('user')) 47 | 48 | if (user) { 49 | if (newComment.commentedBy._id != user._id) { 50 | showSuccessAlert(`New comment from ${newComment.commentedBy.name}`) 51 | } 52 | } else { 53 | showSuccessAlert(`New comment from ${newComment.commentedBy.name}`) 54 | } 55 | 56 | return { 57 | ...prev, 58 | allPosts: { 59 | __typename: 'PostFeed', 60 | count: prev.allPosts.count, 61 | posts: newPosts 62 | } 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /client/api/subscriptions/newLike.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | 3 | export const NEW_LIKE_SUB = gql` 4 | subscription newLike { 5 | newLike { 6 | _id 7 | post { 8 | _id 9 | } 10 | } 11 | } 12 | ` 13 | 14 | export const newLikeUpdate = (prev, { subscriptionData }) => { 15 | if (!subscriptionData.data) return prev 16 | const newLike = subscriptionData.data.newLike 17 | 18 | const posts = [...prev.allPosts.posts] 19 | 20 | let likeExist = false 21 | posts.map(post => { 22 | if (post._id === newLike.post._id) { 23 | post.likes.map(like => { 24 | if (like._id === newLike._id) { 25 | likeExist = true 26 | } 27 | }) 28 | } 29 | }) 30 | 31 | if (!likeExist) { 32 | const newPosts = posts.map( 33 | post => 34 | post._id === newLike.post._id 35 | ? { ...post, likes: [...post.likes, newLike] } 36 | : post 37 | ) 38 | 39 | return { 40 | ...prev, 41 | allPosts: { 42 | __typename: 'PostFeed', 43 | count: prev.allPosts.count, 44 | posts: newPosts 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /client/api/subscriptions/newPost.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | 3 | export const NEW_POST_SUB = gql` 4 | subscription newPost { 5 | newPost { 6 | _id 7 | createdAt 8 | name 9 | content 10 | image 11 | postedBy { 12 | _id 13 | name 14 | } 15 | } 16 | } 17 | ` 18 | -------------------------------------------------------------------------------- /client/components/CommentList.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Comment, Input } from 'semantic-ui-react' 3 | import styled from 'styled-components' 4 | import moment from 'moment' 5 | import { Mutation, Query } from 'react-apollo' 6 | import ReactDOM from 'react-dom' 7 | 8 | import withUser from '../lib/withUser' 9 | import { showErrorAlert } from '../lib/alerts' 10 | import parseError from '../lib/parseError' 11 | 12 | import ALL_POSTS from '../api/queries/post/allPosts' 13 | import { 14 | COMMENT_POST, 15 | commentPostOptions 16 | } from '../api/mutations/comment/commentPost' 17 | 18 | class CommentList extends Component { 19 | state = { 20 | input: '' 21 | } 22 | 23 | componentDidMount() { 24 | this.scrollToBottom() 25 | } 26 | 27 | componentDidUpdate() { 28 | this.scrollToBottom() 29 | } 30 | 31 | writeComment = (e, commentPost) => { 32 | e.preventDefault() 33 | const { input } = this.state 34 | if (!input) return 35 | 36 | commentPost(commentPostOptions(this.props, input)).catch(e => 37 | showErrorAlert(parseError(e.message)) 38 | ) 39 | 40 | this.setState({ 41 | input: '' 42 | }) 43 | } 44 | 45 | handleChange = e => { 46 | this.setState({ 47 | input: e.target.value 48 | }) 49 | } 50 | 51 | scrollToBottom = () => { 52 | const messagesContainer = ReactDOM.findDOMNode(this.messageList) 53 | messagesContainer.scrollTop = messagesContainer.scrollHeight 54 | } 55 | 56 | render() { 57 | const { postId, user, comments } = this.props 58 | return ( 59 | 63 | {(commentPost, { loading, error, data }) => ( 64 | 65 | { 67 | this.messageList = node 68 | }} 69 | > 70 | {comments.map(comment => { 71 | return ( 72 | 73 | 74 | 75 | {comment.commentedBy.name} 76 | 77 | 78 |
79 | {moment(new Date(comment.createdAt)).fromNow()} 80 |
81 |
82 | {comment.text} 83 |
84 |
85 | ) 86 | })} 87 |
{ 90 | this.messagesEnd = el 91 | }} 92 | /> 93 | 94 | 95 |
this.writeComment(e, commentPost)}> 96 | 102 |
103 | 104 | )} 105 | 106 | ) 107 | } 108 | } 109 | 110 | export default withUser(CommentList) 111 | 112 | const CommentContainer = styled(Comment.Group)` 113 | &&& { 114 | padding: 14px; 115 | margin: 0; 116 | position: absolute; 117 | background: #ffffff; 118 | border-radius: 5px; 119 | border-radius: 5px; 120 | box-shadow: 0px 7px 8px 0px #00000047; 121 | top: 100%; 122 | z-index: 10; 123 | } 124 | ` 125 | 126 | const StyledList = styled.div` 127 | overflow-y: auto; 128 | max-height: 180px; 129 | ` 130 | -------------------------------------------------------------------------------- /client/components/FeedList.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import styled from 'styled-components' 3 | import InfiniteScroll from 'react-infinite-scroll-component' 4 | import FlipMove from 'react-flip-move' 5 | 6 | import PostCard from './PostCard' 7 | 8 | class FeedList extends Component { 9 | componentDidMount() { 10 | this.props.subscribeToNewLikes() 11 | this.props.subscribeToNewComments() 12 | this.props.subscribeToNewPosts() 13 | } 14 | 15 | render() { 16 | const { posts, fetchMore, hasMorePosts, isFromServer } = this.props 17 | return ( 18 | 19 | 25 | 40 | {!!posts.length ? ( 41 | posts.map((post, index) => ( 42 |
  • 43 | 44 |
  • 45 | )) 46 | ) : ( 47 |
    No Posts here... :(
    48 | )} 49 |
    50 |
    51 |
    52 | ) 53 | } 54 | } 55 | 56 | const Container = styled.div` 57 | display: flex; 58 | flex-wrap: wrap; 59 | flex: 1; 60 | .ui.card { 61 | margin: 15px; 62 | width: 310px; 63 | } 64 | .ui.card:first-child { 65 | margin-top: 15px; 66 | } 67 | .ui.card:last-child { 68 | margin-bottom: 15px; 69 | } 70 | ` 71 | export default FeedList 72 | -------------------------------------------------------------------------------- /client/components/FeedLoader.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { Dimmer, Loader, Segment } from 'semantic-ui-react' 3 | 4 | export default () => ( 5 | 6 | 7 | 8 | ) 9 | 10 | const StyledSegment = styled(Segment)` 11 | &&& { 12 | position: fixed; 13 | bottom: 30px; 14 | right: 30px; 15 | box-shadow: none; 16 | border: 0; 17 | .ui.inverted.loader:before { 18 | border-color: rgb(255, 209, 152); 19 | } 20 | } 21 | ` 22 | -------------------------------------------------------------------------------- /client/components/GoogleLoginButton.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | const dev = process.env.NODE_ENV !== 'production' 4 | 5 | export default ({ text }) => ( 6 | 13 | {text} 14 | 15 | ) 16 | 17 | const GoogleLoginButton = styled.a` 18 | background: #dd4b39; 19 | color: white; 20 | height: 40px; 21 | display: flex; 22 | justify-content: center; 23 | align-items: center; 24 | font-size: 14px; 25 | width: 60%; 26 | margin: 0 auto; 27 | border-radius: 3px; 28 | margin-top: 15px; 29 | letter-spacing: 1px; 30 | font-weight: bold; 31 | ` 32 | -------------------------------------------------------------------------------- /client/components/HeadData.js: -------------------------------------------------------------------------------- 1 | import NextHead from 'next/head' 2 | import { string } from 'prop-types' 3 | 4 | const defaultDescription = 5 | 'A Blog including a server and a client. Server is built with Node, Express & a customized GraphQL-yoga server. Client is built with React, Next js & Apollo client. ' 6 | const defaultOGURL = 'https://next-graphql-client.now.sh/' 7 | const defaultOGImage = '' 8 | const defaultTitle = 'Next GraphQL Blog' 9 | 10 | const Head = props => ( 11 | 12 | 13 | {props.title || defaultTitle} 14 | 18 | 19 | 20 | 21 | 22 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | ) 34 | 35 | Head.propTypes = { 36 | title: string, 37 | description: string, 38 | url: string, 39 | ogImage: string 40 | } 41 | 42 | export default Head 43 | -------------------------------------------------------------------------------- /client/components/LoadPendingButton.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { Transition, animated } from 'react-spring' 3 | 4 | export default styled(animated.div)` 5 | width: 250px; 6 | height: 50px; 7 | background: #fdd474cf; 8 | display: flex; 9 | justify-content: center; 10 | align-items: center; 11 | position: fixed; 12 | top: 55px; 13 | border-radius: 3px; 14 | left: calc(50% - 125px); 15 | box-shadow: 0px 4px 9px 1px #0000001f; 16 | color: #3c3c3c; 17 | font-weight: bold; 18 | font-size: 15px; 19 | cursor: pointer; 20 | z-index: 99999999; 21 | ` 22 | -------------------------------------------------------------------------------- /client/components/PostCard.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Card, Icon, Image, Divider } from 'semantic-ui-react' 3 | import styled from 'styled-components' 4 | import { Query, Mutation } from 'react-apollo' 5 | import gql from 'graphql-tag' 6 | 7 | import ALL_POSTS from '../api/queries/post/allPosts' 8 | import { LIKE_POST, likePostOptions } from '../api/mutations/like/likePost' 9 | import { 10 | DELETE_POST, 11 | deletePostOptions 12 | } from '../api/mutations/post/deletePost' 13 | 14 | import withUser from '../lib/withUser' 15 | import parseError from '../lib/parseError' 16 | import { showSuccessAlert, showErrorAlert } from '../lib/alerts' 17 | 18 | import CommentList from './CommentList' 19 | 20 | class PostCard extends Component { 21 | state = { 22 | showComments: false 23 | } 24 | 25 | toggleComments = () => { 26 | this.setState({ 27 | showComments: !this.state.showComments 28 | }) 29 | } 30 | 31 | render() { 32 | const { showComments } = this.state 33 | const { 34 | _id, 35 | name, 36 | content, 37 | postedBy, 38 | likes, 39 | comments, 40 | createdAt, 41 | image, 42 | user 43 | } = this.props 44 | 45 | return ( 46 | 47 | {likePost => ( 48 | 49 | {deletePost => ( 50 | 51 | {user && 52 | user._id === postedBy._id && ( 53 | { 56 | deletePost(deletePostOptions(this.props)).then(() => 57 | showSuccessAlert('Post was deleted!') 58 | ) 59 | }} 60 | /> 61 | )} 62 | 63 | 66 | 67 | 68 | {name} 69 | 70 | By {postedBy.name} 71 | 72 | 73 | {content} 74 | 75 | 76 | 77 | 78 | 79 | 80 | {comments.length} Comments 81 | 82 | { 84 | likePost(likePostOptions(this.props)) 85 | .then(() => showSuccessAlert('You liked a Post!')) 86 | .catch(e => showErrorAlert(parseError(e.message))) 87 | }} 88 | > 89 | 90 | {likes.length} Likes 91 | 92 | 93 | 94 | {showComments && ( 95 | 96 | )} 97 | 98 | )} 99 | 100 | )} 101 | 102 | ) 103 | } 104 | } 105 | 106 | const StyledCard = styled(Card)` 107 | &&& { 108 | height: ${props => (props.props.showComments ? 'auto' : '325px')}; 109 | box-shadow: 0px 3px 25px 2px #00000014; 110 | border-bottom-left-radius: 0px; 111 | border-bottom-right-radius: 0px; 112 | } 113 | ` 114 | 115 | const BottomSection = styled(Card.Content)` 116 | display: flex; 117 | justify-content: space-between; 118 | ` 119 | 120 | const RemoveIcon = styled(Icon)` 121 | &&& { 122 | font-size: 18px; 123 | position: absolute; 124 | top: 5px; 125 | z-index: 9; 126 | right: 0; 127 | cursor: pointer; 128 | transition: 0.2s all ease; 129 | &:hover { 130 | font-size: 22px; 131 | } 132 | } 133 | ` 134 | 135 | const StyledImage = styled(Image)` 136 | &&& { 137 | height: 191px; 138 | object-fit: cover; 139 | } 140 | ` 141 | 142 | const ellipsisStyle = { 143 | whiteSpace: 'nowrap', 144 | textOverflow: 'ellipsis', 145 | overflow: 'hidden' 146 | } 147 | 148 | export default withUser(PostCard) 149 | -------------------------------------------------------------------------------- /client/components/SearchForm.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Input, Form, Icon } from 'semantic-ui-react' 3 | import styled from 'styled-components' 4 | 5 | import ALL_POSTS from '../api/queries/post/allPosts' 6 | import { POSTS_LIMIT } from '../api/constants' 7 | 8 | class SearchForm extends Component { 9 | state = { 10 | searchTerm: '' 11 | } 12 | 13 | searchPosts = async e => { 14 | e.preventDefault() 15 | const { data } = await this.props.client.query({ 16 | query: ALL_POSTS, 17 | variables: { 18 | filter: this.state.searchTerm, 19 | sort: '-createdAt', 20 | limit: POSTS_LIMIT, 21 | offset: 0 22 | } 23 | }) 24 | 25 | const cacheData = this.props.client.readQuery({ 26 | query: ALL_POSTS, 27 | variables: { offset: 0, limit: POSTS_LIMIT, sort: '-createdAt' } 28 | }) 29 | 30 | this.props.client.writeQuery({ 31 | query: ALL_POSTS, 32 | variables: { offset: 0, limit: POSTS_LIMIT, sort: '-createdAt' }, 33 | data: { 34 | allPosts: { 35 | __typename: 'PostFeed', 36 | count: cacheData.allPosts.count, 37 | posts: [...data.allPosts.posts] 38 | } 39 | } 40 | }) 41 | } 42 | 43 | render() { 44 | return ( 45 |
    46 | 47 | this.setState({ searchTerm: e.target.value })} 49 | value={this.state.searchTerm} 50 | /> 51 | {!!this.state.searchTerm.length ? ( 52 | this.setState({ searchTerm: '' })} 55 | /> 56 | ) : ( 57 | 58 | )} 59 | 60 |
    61 | ) 62 | } 63 | } 64 | 65 | const StyledInput = styled(Input)` 66 | &&& { 67 | display: flex; 68 | justify-content: center; 69 | width: 400px; 70 | height: 50px; 71 | margin: 0 auto; 72 | margin-top: 30px; 73 | max-width: 100%; 74 | } 75 | ` 76 | 77 | const StyledIcon = styled(Icon)` 78 | &&& { 79 | pointer-events: auto !important; 80 | cursor: pointer !important; 81 | } 82 | ` 83 | 84 | export default SearchForm 85 | -------------------------------------------------------------------------------- /client/components/StyledSignForm.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | import { Form } from 'semantic-ui-react' 3 | 4 | export default styled(Form)` 5 | &&& { 6 | background-color: #fdfdfd; 7 | border-radius: 3px; 8 | box-shadow: 0px 0px 0px 1px #75757533; 9 | padding: 40px; 10 | width: 550px; 11 | min-height: 300px; 12 | display: flex; 13 | flex-direction: column; 14 | justify-content: space-around; 15 | & .field label, 16 | .header.large { 17 | color: #7d7d7d; 18 | } 19 | } 20 | ` 21 | -------------------------------------------------------------------------------- /client/components/nav.js: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import Router from 'next/router' 3 | import React, { Component } from 'react' 4 | import { Menu } from 'semantic-ui-react' 5 | import styled from 'styled-components' 6 | 7 | class Nav extends Component { 8 | state = { activeItem: 'home' } 9 | 10 | handleItemClick = (e, { name }) => this.setState({ activeItem: name }) 11 | 12 | logout = () => { 13 | this.props.client.resetStore().then(() => { 14 | localStorage.removeItem('user') 15 | localStorage.removeItem('token') 16 | Router.push('/logout') 17 | }) 18 | } 19 | 20 | render() { 21 | const { isAuth } = this.props 22 | const { activeItem } = this.state 23 | 24 | return ( 25 | 55 | ) 56 | } 57 | } 58 | 59 | const StyledMenu = styled(Menu)` 60 | &&& { 61 | border-bottom: 2px solid #ffd045; 62 | padding: 0 15px; 63 | } 64 | ` 65 | 66 | const StyledLink = styled.a` 67 | color: #5f5f5f; 68 | line-height: 70px; 69 | padding: 0 2vw; 70 | cursor: pointer; 71 | text-transform: uppercase; 72 | letter-spacing: 1px; 73 | white-space: nowrap; 74 | ` 75 | 76 | export default Nav 77 | -------------------------------------------------------------------------------- /client/context.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | 3 | export const Context = React.createContext() 4 | 5 | class ContextProvider extends Component { 6 | state = { 7 | user: { 8 | _id: '', 9 | email: '', 10 | name: '', 11 | googleId: '' 12 | } 13 | } 14 | 15 | componentDidMount() { 16 | const user = JSON.parse(localStorage.getItem('user')) 17 | this.setState(prevState => ({ 18 | user: { ...prevState.user, ...user } 19 | })) 20 | } 21 | 22 | setUser = user => { 23 | this.setState(prevState => ({ 24 | user: { ...prevState.user, ...user } 25 | })) 26 | } 27 | 28 | clearUser = () => { 29 | this.setState({ 30 | user: { 31 | _id: '', 32 | email: '', 33 | name: '', 34 | googleId: '' 35 | } 36 | }) 37 | } 38 | 39 | render() { 40 | return ( 41 | 47 | {this.props.children} 48 | 49 | ) 50 | } 51 | } 52 | 53 | export default ContextProvider 54 | -------------------------------------------------------------------------------- /client/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const next = require('next') 3 | const cookie = require('cookie') 4 | const compression = require('compression') 5 | const cookieParser = require('cookie-parser') 6 | const sitemapAndRobots = require('./lib/sitemapAndRobots') 7 | const { join } = require('path') 8 | const { parse } = require('url') 9 | 10 | const ONE_YEAR = 31556952000 11 | 12 | const port = parseInt(process.env.PORT, 10) || 3000 13 | 14 | const dev = process.env.NODE_ENV !== 'production' 15 | 16 | const ROOT_URL = dev 17 | ? `http://localhost:${port}` 18 | : 'https://next-graphql.now.sh' 19 | 20 | const app = next({ dev }) 21 | const handle = app.getRequestHandler() 22 | 23 | app.prepare().then(() => { 24 | const server = express() 25 | 26 | server.use(compression()) 27 | 28 | if (!dev) { 29 | server.set('trust proxy', 1) 30 | } 31 | 32 | server.use(cookieParser()) 33 | 34 | server.get('/logout', (req, res) => { 35 | res.clearCookie('next-graphql.sid') 36 | return res.redirect('/') 37 | }) 38 | 39 | server.get('/authcallback', (req, res) => { 40 | const token = req.query.token 41 | if (token) { 42 | res.setHeader( 43 | 'Set-Cookie', 44 | cookie.serialize('next-graphql.sid', String(token), { 45 | httpOnly: true, 46 | secure: dev ? false : true, 47 | maxAge: ONE_YEAR 48 | }) 49 | ) 50 | } 51 | return res.redirect(`/callback?token=${token}`) 52 | }) 53 | 54 | sitemapAndRobots({ server }) 55 | 56 | server.get('*', (req, res) => { 57 | const parsedUrl = parse(req.url, true) 58 | const { pathname } = parsedUrl 59 | if (pathname === '/service-worker.js') { 60 | const filePath = join(__dirname, '.next', pathname) 61 | 62 | app.serveStatic(req, res, filePath) 63 | } else { 64 | return handle(req, res) 65 | } 66 | }) 67 | 68 | server.listen(port, err => { 69 | if (err) throw err 70 | console.log(`> Ready on ${ROOT_URL}. [${process.env.NODE_ENV}]`) 71 | }) 72 | }) 73 | -------------------------------------------------------------------------------- /client/lib/alerts.js: -------------------------------------------------------------------------------- 1 | import Alert from 'react-s-alert' 2 | 3 | export const showSuccessAlert = text => { 4 | Alert.success(text, { 5 | position: 'top-right', 6 | effect: 'jelly', 7 | beep: false, 8 | timeout: 4000, 9 | offset: 30 10 | }) 11 | } 12 | 13 | export const showErrorAlert = text => { 14 | Alert.error(text, { 15 | position: 'top-right', 16 | effect: 'jelly', 17 | beep: false, 18 | timeout: 4000, 19 | offset: 30 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /client/lib/gtag.js: -------------------------------------------------------------------------------- 1 | export const GA_TRACKING_ID = process.env.GA_TRACKING_ID 2 | 3 | // https://developers.google.com/analytics/devguides/collection/gtagjs/pages 4 | export const pageview = url => { 5 | window.gtag('config', GA_TRACKING_ID, { 6 | page_location: url 7 | }) 8 | } 9 | 10 | // https://developers.google.com/analytics/devguides/collection/gtagjs/events 11 | export const event = ({ action, category, label, value }) => { 12 | window.gtag('event', action, { 13 | event_category: category, 14 | event_label: label, 15 | value: value 16 | }) 17 | } 18 | -------------------------------------------------------------------------------- /client/lib/init-apollo.js: -------------------------------------------------------------------------------- 1 | import { ApolloClient, InMemoryCache, HttpLink } from 'apollo-boost' 2 | import { setContext } from 'apollo-link-context' 3 | import { WebSocketLink } from 'apollo-link-ws' 4 | import { getMainDefinition } from 'apollo-utilities' 5 | import { split } from 'apollo-link' 6 | import fetch from 'isomorphic-unfetch' 7 | 8 | let apolloClient = null 9 | 10 | // Polyfill fetch() on the server (used by apollo-client) 11 | if (!process.browser) { 12 | global.fetch = fetch 13 | } 14 | 15 | const dev = process.env.NODE_ENV !== 'production' 16 | 17 | function create(initialState, { getToken }) { 18 | const httpLink = new HttpLink({ 19 | uri: dev ? process.env.API_URL_DEV : process.env.API_URL_PROD, // Server URL (must be absolute) 20 | credentials: 'include' // Additional fetch() options like `credentials` or `headers` 21 | }) 22 | 23 | const authLink = setContext((_, { headers }) => { 24 | const tokenObj = getToken() 25 | 26 | let token 27 | if (tokenObj && tokenObj['next-graphql.sid']) { 28 | token = tokenObj['next-graphql.sid'] 29 | } else { 30 | token = tokenObj 31 | } 32 | 33 | return { 34 | headers: { 35 | ...headers, 36 | authorization: `Bearer ${token ? token : 'fan'}` 37 | } 38 | } 39 | }) 40 | 41 | const httpLinkWithAuthToken = authLink.concat(httpLink) 42 | 43 | const wsLink = process.browser 44 | ? new WebSocketLink({ 45 | uri: dev ? process.env.API_WS_URL_DEV : process.env.API_WS_URL_PROD, 46 | options: { 47 | reconnect: true 48 | } 49 | }) 50 | : null 51 | 52 | const link = process.browser 53 | ? split( 54 | ({ query }) => { 55 | const { kind, operation } = getMainDefinition(query) 56 | return kind === 'OperationDefinition' && operation === 'subscription' 57 | }, 58 | wsLink, 59 | httpLinkWithAuthToken 60 | ) 61 | : httpLinkWithAuthToken 62 | 63 | return new ApolloClient({ 64 | connectToDevTools: process.browser, 65 | ssrMode: !process.browser, // Disables forceFetch on the server (so queries are only run once) 66 | link: link, 67 | cache: new InMemoryCache().restore(initialState || {}) 68 | }) 69 | } 70 | 71 | export default function initApollo(initialState, options) { 72 | // Make sure to create a new client for every server-side request so that data 73 | // isn't shared between connections (which would be bad) 74 | if (!process.browser) { 75 | return create(initialState, options) 76 | } 77 | 78 | // Reuse client on the client-side 79 | if (!apolloClient) { 80 | apolloClient = create(initialState, options) 81 | } 82 | 83 | return apolloClient 84 | } 85 | -------------------------------------------------------------------------------- /client/lib/isAuth.js: -------------------------------------------------------------------------------- 1 | import cookie from 'cookie' 2 | 3 | export default ctx => { 4 | const isFromServer = !!ctx.req 5 | 6 | let isAuth = false 7 | if (isFromServer) { 8 | if (ctx.req.cookies['next-graphql.sid']) { 9 | isAuth = true 10 | } else { 11 | const cookies = 12 | ctx.req.headers && ctx.req.headers.cookie 13 | ? cookie.parse(ctx.req.headers.cookie, {}) 14 | : null 15 | if (cookies && cookies['next-graphql.sid']) { 16 | isAuth = true 17 | } 18 | } 19 | } else { 20 | if (localStorage.getItem('user')) isAuth = true 21 | } 22 | 23 | return isAuth 24 | } 25 | -------------------------------------------------------------------------------- /client/lib/parseError.js: -------------------------------------------------------------------------------- 1 | export default error => error.slice(14) 2 | -------------------------------------------------------------------------------- /client/lib/privatePage.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import Router from 'next/router' 4 | 5 | export default Page => 6 | class BaseComponent extends React.Component { 7 | static async getInitialProps(ctx) { 8 | const props = {} 9 | 10 | if (Page.getInitialProps) { 11 | Object.assign(props, (await Page.getInitialProps(ctx)) || {}) 12 | } 13 | return props 14 | } 15 | 16 | componentDidMount() { 17 | const { isAuth } = this.props 18 | 19 | if (!isAuth) { 20 | Router.push('/login') 21 | return 22 | } 23 | } 24 | 25 | render() { 26 | const { isAuth } = this.props 27 | if (!isAuth) { 28 | return null 29 | } 30 | 31 | return 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /client/lib/sitemapAndRobots.js: -------------------------------------------------------------------------------- 1 | const sm = require('sitemap') 2 | const path = require('path') 3 | 4 | const sitemap = sm.createSitemap({ 5 | hostname: 'https://next-graphql.now.sh', 6 | cacheTime: 600000 // 600 sec - cache purge period 7 | }) 8 | 9 | const setup = ({ server }) => { 10 | // add all pages that you want to be indexed by google 11 | sitemap.add({ 12 | url: '/', 13 | changefreq: 'daily', 14 | priority: 1 15 | }) 16 | 17 | sitemap.add({ 18 | url: '/signup', 19 | changefreq: 'daily', 20 | priority: 1 21 | }) 22 | 23 | sitemap.add({ 24 | url: '/login', 25 | changefreq: 'daily', 26 | priority: 1 27 | }) 28 | 29 | server.get('/sitemap.xml', (req, res) => { 30 | sitemap.toXML((err, xml) => { 31 | if (err) { 32 | res.status(500).end() 33 | return 34 | } 35 | 36 | res.header('Content-Type', 'application/xml') 37 | res.send(xml) 38 | }) 39 | }) 40 | 41 | server.get('/robots.txt', (req, res) => { 42 | res.sendFile(path.join(__dirname, '../static', 'robots.txt')) 43 | }) 44 | } 45 | 46 | module.exports = setup 47 | -------------------------------------------------------------------------------- /client/lib/with-apollo-client.js: -------------------------------------------------------------------------------- 1 | import initApollo from './init-apollo' 2 | import Head from 'next/head' 3 | import { getDataFromTree } from 'react-apollo' 4 | import propTypes from 'prop-types' 5 | import cookie from 'cookie' 6 | 7 | function parseCookies(req, options = {}) { 8 | if (req) { 9 | return cookie.parse(req.headers.cookie || '') 10 | } else { 11 | return localStorage.getItem('token') 12 | } 13 | } 14 | 15 | export default App => { 16 | return class Apollo extends React.Component { 17 | static displayName = 'withApolloClient(App)' 18 | static async getInitialProps(ctx) { 19 | const { 20 | Component, 21 | router, 22 | ctx: { req, res } 23 | } = ctx 24 | const apolloState = {} 25 | const apollo = initApollo( 26 | {}, 27 | { 28 | getToken: () => parseCookies(req) 29 | } 30 | ) 31 | 32 | ctx.ctx.apolloClient = apollo 33 | 34 | let appProps = {} 35 | if (App.getInitialProps) { 36 | appProps = await App.getInitialProps(ctx) 37 | } 38 | 39 | if (res && res.finished) { 40 | return {} 41 | } 42 | 43 | try { 44 | // Run all GraphQL queries 45 | await getDataFromTree( 46 | 53 | ) 54 | } catch (error) { 55 | // Prevent Apollo Client GraphQL errors from crashing SSR. 56 | // Handle them in components via the data.error prop: 57 | // http://dev.apollodata.com/react/api-queries.html#graphql-query-data-error 58 | console.error('Error while running `getDataFromTree`', error) 59 | } 60 | 61 | if (!process.browser) { 62 | // getDataFromTree does not call componentWillUnmount 63 | // head side effect therefore need to be cleared manually 64 | Head.rewind() 65 | } 66 | 67 | // Extract query data from the Apollo store 68 | apolloState.data = apollo.cache.extract() 69 | 70 | return { 71 | ...appProps, 72 | apolloState 73 | } 74 | } 75 | 76 | constructor(props) { 77 | super(props) 78 | // `getDataFromTree` renders the component first, the client is passed off as a property. 79 | // After that rendering is done using Next's normal rendering pipeline 80 | this.apolloClient = 81 | props.apolloClient || 82 | initApollo(props.apolloState.data, { 83 | getToken: () => parseCookies() 84 | }) 85 | } 86 | 87 | render() { 88 | return 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /client/lib/withUser.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { Context } from '../context' 4 | 5 | export default Page => 6 | class BaseComponent extends React.Component { 7 | static async getInitialProps(ctx) { 8 | const props = {} 9 | 10 | if (Page.getInitialProps) { 11 | Object.assign(props, (await Page.getInitialProps(ctx)) || {}) 12 | } 13 | return props 14 | } 15 | 16 | render() { 17 | return ( 18 | 19 | {context => { 20 | return ( 21 | 27 | ) 28 | }} 29 | 30 | ) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /client/next.config.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config() 2 | const webpack = require('webpack') 3 | const withCss = require('@zeit/next-css') 4 | const withOffline = require('next-offline') 5 | 6 | module.exports = withOffline( 7 | withCss({ 8 | webpack: config => { 9 | // Fixes npm packages that depend on `fs` module 10 | config.node = { 11 | fs: 'empty' 12 | } 13 | config.plugins.push(new webpack.EnvironmentPlugin(process.env)) 14 | 15 | config.module.rules.push({ 16 | test: /\.(png|svg|eot|otf|ttf|woff|woff2)$/, 17 | use: { 18 | loader: 'url-loader', 19 | options: { 20 | limit: 100000, 21 | publicPath: './', 22 | outputPath: 'static/', 23 | name: '[name].[ext]' 24 | } 25 | } 26 | }) 27 | 28 | return config 29 | } 30 | }) 31 | ) 32 | -------------------------------------------------------------------------------- /client/now.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "NODE_ENV": "production" 4 | }, 5 | "dotenv": true, 6 | "alias": "https://next-graphql.now.sh" 7 | } 8 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-graphql-client", 3 | "scripts": { 4 | "dev": "node index.js", 5 | "build": "next build", 6 | "start": "set NODE_ENV=production&& node index.js", 7 | "deploy": "now && now alias" 8 | }, 9 | "dependencies": { 10 | "@zeit/next-css": "^0.2.0", 11 | "apollo-boost": "^0.1.6", 12 | "apollo-link": "^1.2.2", 13 | "apollo-link-context": "^1.0.8", 14 | "apollo-link-http": "^1.5.4", 15 | "apollo-link-ws": "^1.0.8", 16 | "apollo-utilities": "^1.0.13", 17 | "axios": "^0.18.0", 18 | "babel-plugin-styled-components": "^1.5.0", 19 | "compression": "^1.7.2", 20 | "cookie": "^0.3.1", 21 | "cookie-parser": "^1.4.3", 22 | "dotenv": "^5.0.1", 23 | "express": "^4.16.3", 24 | "file-loader": "^1.1.11", 25 | "graphql": "^0.13.2", 26 | "graphql-tag": "^2.9.2", 27 | "isomorphic-unfetch": "^2.0.0", 28 | "moment": "^2.22.1", 29 | "next": "^6.0.3", 30 | "next-offline": "^2.7.1", 31 | "nprogress": "^0.2.0", 32 | "react": "^16.3.2", 33 | "react-apollo": "^2.1.4", 34 | "react-chatview": "^0.2.5", 35 | "react-cookie": "^2.1.6", 36 | "react-dom": "^16.3.2", 37 | "react-dropzone": "^4.2.11", 38 | "react-flip-move": "^3.0.2", 39 | "react-infinite-scroll-component": "^4.1.0", 40 | "react-s-alert": "^1.4.1", 41 | "react-spring": "^5.3.8", 42 | "recompose": "^0.27.1", 43 | "semantic-ui-css": "^2.3.1", 44 | "semantic-ui-react": "^0.80.2", 45 | "sitemap": "^1.13.0", 46 | "styled-components": "^3.2.5", 47 | "subscriptions-transport-ws": "^0.9.9", 48 | "url-loader": "^1.0.1" 49 | }, 50 | "devDependencies": { 51 | "nodemon": "^1.17.5" 52 | }, 53 | "author": "alexloof", 54 | "license": "ISC" 55 | } 56 | -------------------------------------------------------------------------------- /client/pages/_app.js: -------------------------------------------------------------------------------- 1 | import App, { Container as NextContainer } from 'next/app' 2 | import React, { Component } from 'react' 3 | import withApolloClient from '../lib/with-apollo-client' 4 | import { ApolloProvider } from 'react-apollo' 5 | import Router from 'next/router' 6 | import NProgress from 'nprogress' 7 | import { Container as SContainer } from 'semantic-ui-react' 8 | import Alert from 'react-s-alert' 9 | import styled from 'styled-components' 10 | 11 | import * as gtag from '../lib/gtag' 12 | 13 | import isAuth from '../lib/isAuth' 14 | 15 | import Head from '../components/HeadData' 16 | import Nav from '../components/Nav' 17 | 18 | import ContextProvider from '../context' 19 | 20 | Router.onRouteChangeStart = () => { 21 | NProgress.start() 22 | } 23 | Router.onRouteChangeComplete = url => { 24 | gtag.pageview(url) 25 | NProgress.done() 26 | } 27 | Router.onRouteChangeError = () => NProgress.done() 28 | 29 | class NextApp extends App { 30 | static async getInitialProps({ Component, router, ctx }) { 31 | const isAuthenticated = isAuth(ctx) 32 | 33 | let pageProps = {} 34 | 35 | if (Component.getInitialProps) { 36 | pageProps = await Component.getInitialProps(ctx) 37 | } 38 | 39 | return { pageProps: { ...pageProps, isAuth: isAuthenticated } } 40 | } 41 | 42 | render() { 43 | const { Component, pageProps, apolloClient } = this.props 44 | const propsWithClient = { 45 | ...pageProps, 46 | client: apolloClient 47 | } 48 | 49 | return ( 50 | 51 | 52 | 53 | 54 | 55 |