├── .env ├── .eslintrc.json ├── .github └── workflows │ └── codeql-analysis.yml ├── .gitignore ├── .graphqlconfig ├── README.md ├── logo.png ├── package.json ├── public ├── favicon.ico ├── index.html └── manifest.json ├── schema.graphql ├── src ├── apolloClient.js ├── apolloHelpers.js ├── components │ ├── ApolloInfiniteScroll.js │ ├── App.js │ ├── Article │ │ ├── Article.js │ │ ├── AuthorActions.js │ │ ├── Comments │ │ │ ├── Comment.js │ │ │ ├── Comments.js │ │ │ ├── Form.js │ │ │ ├── NewComment.js │ │ │ └── index.js │ │ ├── Meta.js │ │ ├── TagList.js │ │ ├── UserActions.js │ │ └── index.js │ ├── ArticlePreview.js │ ├── Avatar.js │ ├── EditArticle.js │ ├── Editor │ │ ├── Editor.js │ │ ├── TagsInput.js │ │ └── index.js │ ├── FavoriteButton.js │ ├── FollowButton.js │ ├── FormErrors.js │ ├── Home │ │ ├── Feed.js │ │ ├── FeedTabs.js │ │ ├── Home.js │ │ ├── PopularTags.js │ │ ├── Tag.js │ │ ├── feedTypes.js │ │ └── index.js │ ├── Login │ │ ├── Form.js │ │ ├── Login.js │ │ └── index.js │ ├── NewArticle.js │ ├── Page │ │ ├── Footer.js │ │ ├── Header │ │ │ ├── GuestMenuItems.js │ │ │ ├── Header.js │ │ │ ├── Menu.js │ │ │ ├── UserMenuItems.js │ │ │ └── index.js │ │ ├── Page.js │ │ └── index.js │ ├── Profile │ │ ├── ArticleTabs.js │ │ ├── Profile.js │ │ ├── UserArticles.js │ │ └── index.js │ ├── Register │ │ ├── Form.js │ │ ├── Register.js │ │ └── index.js │ ├── Settings │ │ ├── Form.js │ │ ├── Settings.js │ │ └── index.js │ ├── Tab.js │ └── useViewer.js ├── index.js └── tokenStorage.js ├── static.json └── yarn.lock /.env: -------------------------------------------------------------------------------- 1 | # REACT_APP_GRAPHQL_URL=http://localhost:3000/graphql -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "parser": "babel-eslint", 4 | "env": { 5 | "browser": true 6 | }, 7 | "plugins": ["graphql"], 8 | "rules": { 9 | "comma-dangle": ["error", "never"], 10 | "import/prefer-default-export": ["off"], 11 | "jsx-a11y/click-events-have-key-events": ["off"], 12 | "jsx-a11y/anchor-is-valid": ["off"], 13 | "graphql/template-strings": ["error", { "env": "apollo" }], 14 | "object-curly-newline": ["off"], 15 | "react/jsx-filename-extension": ["error", { "extensions": [".js"] }], 16 | "react/jsx-one-expression-per-line": ["off"], 17 | "semi": ["error", "never"] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '31 12 * * 6' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 37 | # Learn more: 38 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v2 43 | 44 | # Initializes the CodeQL tools for scanning. 45 | - name: Initialize CodeQL 46 | uses: github/codeql-action/init@v1 47 | with: 48 | languages: ${{ matrix.language }} 49 | # If you wish to specify custom queries, you can do so here or in a config file. 50 | # By default, queries listed here will override any specified in a config file. 51 | # Prefix the list here with "+" to use these queries and those in the config file. 52 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 53 | 54 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 55 | # If this step fails, then you should remove it and run the build manually (see below) 56 | - name: Autobuild 57 | uses: github/codeql-action/autobuild@v1 58 | 59 | # ℹ️ Command-line programs to run using the OS shell. 60 | # 📚 https://git.io/JvXDl 61 | 62 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 63 | # and modify them (or add more) to build your code if your project 64 | # uses a compiled language 65 | 66 | #- run: | 67 | # make bootstrap 68 | # make release 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@v1 72 | -------------------------------------------------------------------------------- /.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 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /.graphqlconfig: -------------------------------------------------------------------------------- 1 | { 2 | "projects": { 3 | "Realworld": { 4 | "schemaPath": "schema.graphql", 5 | "extensions": { 6 | "endpoints": { 7 | "default": "http://localhost:3000/graphql" 8 | } 9 | } 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![RealWorld Example App](logo.png) 2 | 3 | > ### React + Apollo codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld) spec and GraphQL API. 4 | 5 | ### [Demo](https://realworld-react-apollo.herokuapp.com/)    [RealWorld](https://github.com/gothinkster/realworld) 6 | 7 | This codebase was created to demonstrate a fully fledged fullstack application built with React + Apollo including CRUD operations, authentication, routing, pagination, and more. 8 | 9 | # Getting started 10 | 11 | You can view a live demo over at https://realworld-react-apollo.herokuapp.com/ 12 | 13 | To get the frontend running locally: 14 | 15 | 1. Clone this repo 16 | 2. `npm install` to install required dependencies 17 | 3. `npm start` to start the development server 18 | 19 | ### Making requests to the backend API 20 | 21 | For convenience, we have a live GraphQL API server running at https://realworld-graphql.herokuapp.com/graphql for the application to make requests against. You can view [the GraphQL API spec here](https://github.com/dostu/rails-graphql-realworld-example-app/blob/master/GRAPHQL_API_SPEC.md) which contains the schema for the server. 22 | 23 | The source code for the backend server (available for Ruby on Rails) can be found in the [this repo](https://github.com/dostu/rails-graphql-realworld-example-app). 24 | 25 | If you want to change the API URL to a local server, simply set `REACT_APP_GRAPHQL_URL` env variable to another URL. 26 | `REACT_APP_GRAPHQL_URL=http://localhost:3000/graphql yarn start` 27 | -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dostu/react-apollo-realworld-example-app/13d15bbcafbe2ac7ec501d7ff6bf482e4b15c84d/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-apollo-realworld-example-app", 3 | "version": "0.1.0", 4 | "description": "Exemplary real world application built with React + Apollo", 5 | "author": "Donatas Stundys ", 6 | "private": true, 7 | "dependencies": { 8 | "@apollo/react-components": "^4.0.0", 9 | "@apollo/react-hoc": "^4.0.0", 10 | "@apollo/react-hooks": "^4.0.0", 11 | "apollo-boost": "^0.4.9", 12 | "apollo-client": "^2.6.3", 13 | "classnames": "^2.2.6", 14 | "cross-env": "^7.0.3", 15 | "date-fns": "^2.23.0", 16 | "formik": "^2.2.9", 17 | "graphql": "^15.5.3", 18 | "graphql-tag": "^2.9.2", 19 | "immutability-helper": "^3.1.1", 20 | "lodash": "^4.17.10", 21 | "prop-types": "^15.6.2", 22 | "raven-js": "^3.26.3", 23 | "react": "^17.0.2", 24 | "react-apollo": "3.1.5", 25 | "react-dom": "^17.0.2", 26 | "react-helmet": "^6.1.0", 27 | "react-infinite-scroller": "^1.2.0", 28 | "react-markdown": "^7.0.1", 29 | "react-router-dom": "^5.3.0", 30 | "react-scripts": "4.0.3" 31 | }, 32 | "scripts": { 33 | "start": "cross-env PORT=4000 react-scripts start", 34 | "build": "react-scripts build", 35 | "test": "react-scripts test --env=jsdom", 36 | "eject": "react-scripts eject", 37 | "lint": "eslint ." 38 | }, 39 | "devDependencies": { 40 | "babel-eslint": "^10.1.0", 41 | "cross-env": "^5.2.0", 42 | "eslint": "^7.32.0", 43 | "eslint-config-airbnb": "^18.2.1", 44 | "eslint-plugin-graphql": "^4.0.0", 45 | "eslint-plugin-import": "^2.13.0", 46 | "eslint-plugin-jsx-a11y": "^6.1.1", 47 | "eslint-plugin-react": "^7.10.0", 48 | "graphql-cli": "^4.1.0" 49 | }, 50 | "browserslist": { 51 | "production": [ 52 | ">0.2%", 53 | "not dead", 54 | "not op_mini all" 55 | ], 56 | "development": [ 57 | "last 1 chrome version", 58 | "last 1 firefox version", 59 | "last 1 safari version" 60 | ] 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dostu/react-apollo-realworld-example-app/13d15bbcafbe2ac7ec501d7ff6bf482e4b15c84d/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | Conduit 30 | 31 | 32 | 35 |
36 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /schema.graphql: -------------------------------------------------------------------------------- 1 | # source: https://realworld-graphql.herokuapp.com/graphql 2 | # timestamp: Sun Mar 08 2020 08:45:38 GMT+0000 (Greenwich Mean Time) 3 | 4 | """Autogenerated input type of AddComment""" 5 | input AddCommentInput { 6 | articleId: ID! 7 | body: String! 8 | 9 | """A unique identifier for the client performing the mutation.""" 10 | clientMutationId: String 11 | } 12 | 13 | """Autogenerated return type of AddComment""" 14 | type AddCommentPayload { 15 | """A unique identifier for the client performing the mutation.""" 16 | clientMutationId: String 17 | comment: Comment 18 | errors: [UserError!]! 19 | } 20 | 21 | type Article { 22 | author: User 23 | body: String! 24 | comments: [Comment!]! 25 | createdAt: ISO8601DateTime! 26 | description: String! 27 | favoritesCount: Int! 28 | id: ID! 29 | slug: String! 30 | tagList: [String!]! 31 | title: String! 32 | updatedAt: ISO8601DateTime! 33 | viewerHasFavorited: Boolean! 34 | } 35 | 36 | """The connection type for Article.""" 37 | type ArticleConnection { 38 | """A list of edges.""" 39 | edges: [ArticleEdge] 40 | 41 | """Information to aid in pagination.""" 42 | pageInfo: PageInfo! 43 | } 44 | 45 | """An edge in a connection.""" 46 | type ArticleEdge { 47 | """A cursor for use in pagination.""" 48 | cursor: String! 49 | 50 | """The item at the end of the edge.""" 51 | node: Article 52 | } 53 | 54 | type Comment { 55 | article: Article 56 | author: User 57 | body: String! 58 | createdAt: ISO8601DateTime! 59 | id: ID! 60 | updatedAt: ISO8601DateTime! 61 | } 62 | 63 | """Autogenerated input type of CreateArticle""" 64 | input CreateArticleInput { 65 | title: String! 66 | description: String! 67 | body: String! 68 | tagList: [String!]! 69 | 70 | """A unique identifier for the client performing the mutation.""" 71 | clientMutationId: String 72 | } 73 | 74 | """Autogenerated return type of CreateArticle""" 75 | type CreateArticlePayload { 76 | article: Article 77 | 78 | """A unique identifier for the client performing the mutation.""" 79 | clientMutationId: String 80 | errors: [UserError!]! 81 | } 82 | 83 | """Autogenerated input type of CreateUser""" 84 | input CreateUserInput { 85 | username: String! 86 | email: String! 87 | password: String! 88 | 89 | """A unique identifier for the client performing the mutation.""" 90 | clientMutationId: String 91 | } 92 | 93 | """Autogenerated return type of CreateUser""" 94 | type CreateUserPayload { 95 | """A unique identifier for the client performing the mutation.""" 96 | clientMutationId: String 97 | errors: [UserError!]! 98 | user: User 99 | } 100 | 101 | """Autogenerated input type of DeleteArticle""" 102 | input DeleteArticleInput { 103 | id: ID! 104 | 105 | """A unique identifier for the client performing the mutation.""" 106 | clientMutationId: String 107 | } 108 | 109 | """Autogenerated return type of DeleteArticle""" 110 | type DeleteArticlePayload { 111 | article: Article! 112 | 113 | """A unique identifier for the client performing the mutation.""" 114 | clientMutationId: String 115 | } 116 | 117 | """Autogenerated input type of DeleteComment""" 118 | input DeleteCommentInput { 119 | id: ID! 120 | 121 | """A unique identifier for the client performing the mutation.""" 122 | clientMutationId: String 123 | } 124 | 125 | """Autogenerated return type of DeleteComment""" 126 | type DeleteCommentPayload { 127 | """A unique identifier for the client performing the mutation.""" 128 | clientMutationId: String 129 | comment: Comment 130 | } 131 | 132 | """Autogenerated input type of FavoriteArticle""" 133 | input FavoriteArticleInput { 134 | id: ID! 135 | 136 | """A unique identifier for the client performing the mutation.""" 137 | clientMutationId: String 138 | } 139 | 140 | """Autogenerated return type of FavoriteArticle""" 141 | type FavoriteArticlePayload { 142 | article: Article 143 | 144 | """A unique identifier for the client performing the mutation.""" 145 | clientMutationId: String 146 | } 147 | 148 | """The connection type for User.""" 149 | type FollowersConnection { 150 | """A list of edges.""" 151 | edges: [UserEdge] 152 | 153 | """A list of nodes.""" 154 | nodes: [User] 155 | 156 | """Information to aid in pagination.""" 157 | pageInfo: PageInfo! 158 | totalCount: Int! 159 | } 160 | 161 | """Autogenerated input type of FollowUser""" 162 | input FollowUserInput { 163 | id: ID! 164 | 165 | """A unique identifier for the client performing the mutation.""" 166 | clientMutationId: String 167 | } 168 | 169 | """Autogenerated return type of FollowUser""" 170 | type FollowUserPayload { 171 | """A unique identifier for the client performing the mutation.""" 172 | clientMutationId: String 173 | user: User 174 | } 175 | 176 | """An ISO 8601-encoded datetime""" 177 | scalar ISO8601DateTime 178 | 179 | type Mutation { 180 | addComment(input: AddCommentInput!): AddCommentPayload 181 | createArticle(input: CreateArticleInput!): CreateArticlePayload 182 | createUser(input: CreateUserInput!): CreateUserPayload 183 | deleteArticle(input: DeleteArticleInput!): DeleteArticlePayload 184 | deleteComment(input: DeleteCommentInput!): DeleteCommentPayload 185 | favoriteArticle(input: FavoriteArticleInput!): FavoriteArticlePayload 186 | followUser(input: FollowUserInput!): FollowUserPayload 187 | signInUser(input: SignInUserInput!): SignInUserPayload 188 | unfavoriteArticle(input: UnfavoriteArticleInput!): UnfavoriteArticlePayload 189 | unfollowUser(input: UnfollowUserInput!): UnfollowUserPayload 190 | updateArticle(input: UpdateArticleInput!): UpdateArticlePayload 191 | updateUser(input: UpdateUserInput!): UpdateUserPayload 192 | } 193 | 194 | """Information about pagination in a connection.""" 195 | type PageInfo { 196 | """When paginating forwards, the cursor to continue.""" 197 | endCursor: String 198 | 199 | """When paginating forwards, are there more items?""" 200 | hasNextPage: Boolean! 201 | 202 | """When paginating backwards, are there more items?""" 203 | hasPreviousPage: Boolean! 204 | 205 | """When paginating backwards, the cursor to continue.""" 206 | startCursor: String 207 | } 208 | 209 | type Query { 210 | article(slug: String!): Article 211 | articles( 212 | """Returns the first _n_ elements from the list.""" 213 | first: Int 214 | 215 | """Returns the elements in the list that come after the specified cursor.""" 216 | after: String 217 | 218 | """Returns the last _n_ elements from the list.""" 219 | last: Int 220 | 221 | """ 222 | Returns the elements in the list that come before the specified cursor. 223 | """ 224 | before: String 225 | tag: String 226 | ): ArticleConnection! 227 | tags: [String!]! 228 | user(username: String!): User 229 | viewer: Viewer 230 | } 231 | 232 | """Autogenerated input type of SignInUser""" 233 | input SignInUserInput { 234 | email: String! 235 | password: String! 236 | 237 | """A unique identifier for the client performing the mutation.""" 238 | clientMutationId: String 239 | } 240 | 241 | """Autogenerated return type of SignInUser""" 242 | type SignInUserPayload { 243 | """A unique identifier for the client performing the mutation.""" 244 | clientMutationId: String 245 | errors: [UserError!]! 246 | token: String 247 | viewer: Viewer 248 | } 249 | 250 | """Autogenerated input type of UnfavoriteArticle""" 251 | input UnfavoriteArticleInput { 252 | id: ID! 253 | 254 | """A unique identifier for the client performing the mutation.""" 255 | clientMutationId: String 256 | } 257 | 258 | """Autogenerated return type of UnfavoriteArticle""" 259 | type UnfavoriteArticlePayload { 260 | article: Article 261 | 262 | """A unique identifier for the client performing the mutation.""" 263 | clientMutationId: String 264 | } 265 | 266 | """Autogenerated input type of UnfollowUser""" 267 | input UnfollowUserInput { 268 | id: ID! 269 | 270 | """A unique identifier for the client performing the mutation.""" 271 | clientMutationId: String 272 | } 273 | 274 | """Autogenerated return type of UnfollowUser""" 275 | type UnfollowUserPayload { 276 | """A unique identifier for the client performing the mutation.""" 277 | clientMutationId: String 278 | user: User 279 | } 280 | 281 | """Autogenerated input type of UpdateArticle""" 282 | input UpdateArticleInput { 283 | id: ID! 284 | title: String! 285 | description: String! 286 | body: String! 287 | tagList: [String!]! 288 | 289 | """A unique identifier for the client performing the mutation.""" 290 | clientMutationId: String 291 | } 292 | 293 | """Autogenerated return type of UpdateArticle""" 294 | type UpdateArticlePayload { 295 | article: Article 296 | 297 | """A unique identifier for the client performing the mutation.""" 298 | clientMutationId: String 299 | errors: [UserError!]! 300 | } 301 | 302 | """Autogenerated input type of UpdateUser""" 303 | input UpdateUserInput { 304 | email: String! 305 | username: String! 306 | bio: String 307 | image: String 308 | password: String 309 | 310 | """A unique identifier for the client performing the mutation.""" 311 | clientMutationId: String 312 | } 313 | 314 | """Autogenerated return type of UpdateUser""" 315 | type UpdateUserPayload { 316 | """A unique identifier for the client performing the mutation.""" 317 | clientMutationId: String 318 | errors: [UserError!]! 319 | user: User 320 | } 321 | 322 | type User { 323 | articles( 324 | """Returns the first _n_ elements from the list.""" 325 | first: Int 326 | 327 | """Returns the elements in the list that come after the specified cursor.""" 328 | after: String 329 | 330 | """Returns the last _n_ elements from the list.""" 331 | last: Int 332 | 333 | """ 334 | Returns the elements in the list that come before the specified cursor. 335 | """ 336 | before: String 337 | ): ArticleConnection! 338 | bio: String 339 | email: String! 340 | favoriteArticles( 341 | """Returns the first _n_ elements from the list.""" 342 | first: Int 343 | 344 | """Returns the elements in the list that come after the specified cursor.""" 345 | after: String 346 | 347 | """Returns the last _n_ elements from the list.""" 348 | last: Int 349 | 350 | """ 351 | Returns the elements in the list that come before the specified cursor. 352 | """ 353 | before: String 354 | ): ArticleConnection! 355 | followedByViewer: Boolean! 356 | followers( 357 | """Returns the first _n_ elements from the list.""" 358 | first: Int 359 | 360 | """Returns the elements in the list that come after the specified cursor.""" 361 | after: String 362 | 363 | """Returns the last _n_ elements from the list.""" 364 | last: Int 365 | 366 | """ 367 | Returns the elements in the list that come before the specified cursor. 368 | """ 369 | before: String 370 | ): FollowersConnection! 371 | id: ID! 372 | image: String 373 | username: String! 374 | } 375 | 376 | """An edge in a connection.""" 377 | type UserEdge { 378 | """A cursor for use in pagination.""" 379 | cursor: String! 380 | 381 | """The item at the end of the edge.""" 382 | node: User 383 | } 384 | 385 | type UserError { 386 | message: String! 387 | path: String 388 | } 389 | 390 | type Viewer { 391 | feed( 392 | """Returns the first _n_ elements from the list.""" 393 | first: Int 394 | 395 | """Returns the elements in the list that come after the specified cursor.""" 396 | after: String 397 | 398 | """Returns the last _n_ elements from the list.""" 399 | last: Int 400 | 401 | """ 402 | Returns the elements in the list that come before the specified cursor. 403 | """ 404 | before: String 405 | ): ArticleConnection! 406 | user: User! 407 | } 408 | -------------------------------------------------------------------------------- /src/apolloClient.js: -------------------------------------------------------------------------------- 1 | import ApolloClient from 'apollo-boost' 2 | import tokenStorage from './tokenStorage' 3 | 4 | const GRAPHQL_URL = process.env.REACT_APP_GRAPHQL_URL || 'https://realworld-graphql.herokuapp.com/graphql' 5 | 6 | const clientState = { 7 | defaults: { 8 | feedFilter: { 9 | __typename: 'FeedFilter', 10 | type: null, 11 | tag: null 12 | } 13 | }, 14 | resolvers: { 15 | Mutation: { 16 | changeFeedFilter: (_, { type, tag = null }, { cache }) => { 17 | const feedFilter = { __typename: 'FeedFilter', type, tag } 18 | cache.writeData({ data: { feedFilter } }) 19 | return feedFilter 20 | } 21 | } 22 | } 23 | } 24 | 25 | const client = new ApolloClient({ 26 | uri: GRAPHQL_URL, 27 | request: (operation) => { 28 | const token = tokenStorage.read() 29 | let headers = {} 30 | if (token) { 31 | headers = { authorization: `Token ${token}` } 32 | } 33 | operation.setContext({ headers }) 34 | }, 35 | clientState 36 | }) 37 | 38 | export default client 39 | -------------------------------------------------------------------------------- /src/apolloHelpers.js: -------------------------------------------------------------------------------- 1 | import update from 'immutability-helper' 2 | import _ from 'lodash' 3 | 4 | export const mergePaginatedData = (connectionPath, previousData, newData) => ( 5 | update( 6 | previousData, 7 | _.set({}, connectionPath, { 8 | $merge: _.get(newData, connectionPath), 9 | edges: { 10 | $push: _.get(newData, connectionPath).edges 11 | } 12 | }) 13 | ) 14 | ) 15 | 16 | export const transformGraphQLErrors = (userErrors) => ( 17 | _.chain(userErrors).map('message').toPlainObject().value() 18 | ) 19 | -------------------------------------------------------------------------------- /src/components/ApolloInfiniteScroll.js: -------------------------------------------------------------------------------- 1 | import update from 'immutability-helper' 2 | import _ from 'lodash' 3 | import PropTypes from 'prop-types' 4 | import React from 'react' 5 | import InfiniteScroll from 'react-infinite-scroller' 6 | 7 | export const mergePaginatedData = (connectionPath, previousData, newData) => ( 8 | update( 9 | previousData, 10 | _.set({}, connectionPath, { 11 | $merge: _.get(newData, connectionPath), 12 | edges: { 13 | $push: _.get(newData, connectionPath).edges 14 | } 15 | }) 16 | ) 17 | ) 18 | 19 | const ApolloInfiniteScroll = ({ 20 | data, connectionPath, loading, fetchMore, children, threshold 21 | }) => { 22 | const connection = _.get(data, connectionPath) 23 | const nodes = _.map(connection.edges, 'node') 24 | const { pageInfo } = connection 25 | 26 | if (!pageInfo || pageInfo.hasNextPage === undefined || pageInfo.endCursor === undefined) { 27 | throw new Error('ApolloInfiniteScroll connection must include pageInfo { hasNextPage endCursor }') 28 | } 29 | 30 | return ( 31 | { 36 | if (loading) { return } 37 | 38 | fetchMore({ 39 | variables: { 40 | cursor: pageInfo.endCursor 41 | }, 42 | updateQuery: (previousResult, { fetchMoreResult }) => ( 43 | mergePaginatedData(connectionPath, previousResult, fetchMoreResult) 44 | ) 45 | }) 46 | }} 47 | > 48 | {nodes.map(children)} 49 | 50 | ) 51 | } 52 | 53 | ApolloInfiniteScroll.propTypes = { 54 | data: PropTypes.object.isRequired, // eslint-disable-line react/forbid-prop-types 55 | connectionPath: PropTypes.string.isRequired, 56 | loading: PropTypes.bool.isRequired, 57 | fetchMore: PropTypes.func.isRequired, 58 | children: PropTypes.func.isRequired, 59 | threshold: PropTypes.number 60 | } 61 | 62 | ApolloInfiniteScroll.defaultProps = { 63 | threshold: 0 64 | } 65 | 66 | export default ApolloInfiniteScroll 67 | -------------------------------------------------------------------------------- /src/components/App.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react' 2 | import { ApolloProvider } from '@apollo/react-components' 3 | import { BrowserRouter, Route, Switch } from 'react-router-dom' 4 | import apolloClient from '../apolloClient' 5 | import Article from './Article' 6 | import EditArticle from './EditArticle' 7 | import Home from './Home' 8 | import Login from './Login' 9 | import NewArticle from './NewArticle' 10 | import Profile from './Profile' 11 | import Register from './Register' 12 | import Settings from './Settings' 13 | 14 | const App = () => ( 15 | 16 | 17 | <> 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | ) 32 | 33 | export default App 34 | -------------------------------------------------------------------------------- /src/components/Article/Article.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | import PropTypes from 'prop-types' 3 | import React from 'react' 4 | import { useQuery } from '@apollo/react-hooks' 5 | import Helmet from 'react-helmet' 6 | import ReactMarkdown from 'react-markdown' 7 | import Page from '../Page' 8 | import ArticleComments from './Comments' 9 | import ArticleMeta from './Meta' 10 | import TagList from './TagList' 11 | 12 | const GET_ARTICLE = gql` 13 | query Article($slug: String!) { 14 | article(slug: $slug) { 15 | id 16 | slug 17 | title 18 | body 19 | tagList 20 | ...ArticleMeta 21 | } 22 | } 23 | ${ArticleMeta.fragments.article} 24 | ` 25 | 26 | const Article = ({ 27 | match: { 28 | params: { slug } 29 | } 30 | }) => { 31 | const { loading, data, error } = useQuery(GET_ARTICLE, { 32 | variables: { slug } 33 | }) 34 | if (loading || error) return null 35 | 36 | const { article, viewer } = data 37 | 38 | return ( 39 | 40 | 41 | 42 |
43 |
44 |

{article.title}

45 | 46 |
47 |
48 | 49 |
50 |
51 |
52 | 53 | 54 |
55 |
56 | 57 |
58 | 59 |
60 | 61 |
62 | 63 |
64 |
65 | 66 |
67 |
68 |
69 |
70 | ) 71 | } 72 | 73 | Article.propTypes = { 74 | match: PropTypes.shape({ 75 | params: PropTypes.shape({ 76 | slug: PropTypes.string.isRequired 77 | }).isRequired 78 | }).isRequired 79 | } 80 | 81 | export default Article 82 | -------------------------------------------------------------------------------- /src/components/Article/AuthorActions.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | import PropTypes from 'prop-types' 3 | import React, { Fragment } from 'react' 4 | import { useMutation } from '@apollo/react-hooks' 5 | import { Link, withRouter } from 'react-router-dom' 6 | 7 | const DELETE_ARTICLE = gql` 8 | mutation DeleteArticle($input: DeleteArticleInput!) { 9 | deleteArticle(input: $input) { 10 | article { 11 | id 12 | } 13 | } 14 | } 15 | ` 16 | 17 | const AuthorActions = ({ history, article }) => { 18 | const [deleteArticle] = useMutation(DELETE_ARTICLE, { 19 | onCompleted: () => history.push('/') 20 | }) 21 | 22 | return ( 23 | <> 24 | 28 | 29 |   Edit Article 30 | 31 |   32 | 40 | 41 | ) 42 | } 43 | 44 | AuthorActions.propTypes = { 45 | article: PropTypes.shape({ 46 | id: PropTypes.string.isRequired, 47 | slug: PropTypes.string.isRequired 48 | }).isRequired, 49 | history: PropTypes.shape({ 50 | push: PropTypes.func.isRequired 51 | }).isRequired 52 | } 53 | 54 | export default withRouter(AuthorActions) 55 | -------------------------------------------------------------------------------- /src/components/Article/Comments/Comment.js: -------------------------------------------------------------------------------- 1 | import { format } from 'date-fns' 2 | import gql from 'graphql-tag' 3 | import PropTypes from 'prop-types' 4 | import React from 'react' 5 | import { Link } from 'react-router-dom' 6 | import Avatar from '../../Avatar' 7 | 8 | const Comment = ({ comment, onDelete }) => { 9 | const { author } = comment 10 | return ( 11 |
12 |
13 |

{comment.body}

14 |
15 |
16 | 17 | 18 | 19 |   20 | 21 | {author.username} 22 | 23 | 24 | {format(Date.parse(comment.createdAt), 'MMM do, yyyy')} 25 | 26 | 27 | onDelete()} 33 | /> 34 | 35 |
36 |
37 | ) 38 | } 39 | 40 | Comment.propTypes = { 41 | comment: PropTypes.shape({ 42 | id: PropTypes.string.isRequired, 43 | body: PropTypes.string.isRequired, 44 | createdAt: PropTypes.string.isRequired, 45 | author: PropTypes.shape({ 46 | id: PropTypes.string.isRequired, 47 | username: PropTypes.string.isRequired, 48 | image: PropTypes.string 49 | }).isRequired 50 | }).isRequired, 51 | onDelete: PropTypes.func.isRequired 52 | } 53 | 54 | Comment.fragments = { 55 | comment: gql` 56 | fragment Comment on Comment { 57 | id 58 | body 59 | createdAt 60 | author { 61 | id 62 | username 63 | image 64 | } 65 | } 66 | ` 67 | } 68 | 69 | export default Comment 70 | -------------------------------------------------------------------------------- /src/components/Article/Comments/Comments.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | import PropTypes from 'prop-types' 3 | import React, { Fragment } from 'react' 4 | import { useQuery, useMutation } from '@apollo/react-hooks' 5 | import Comment from './Comment' 6 | import NewComment from './NewComment' 7 | 8 | const GET_ARTICLE_COMMENTS = gql` 9 | query ArticleComments($slug: String!) { 10 | article(slug: $slug) { 11 | id 12 | slug 13 | comments { 14 | ...Comment 15 | } 16 | } 17 | } 18 | ${Comment.fragments.comment} 19 | ` 20 | 21 | const DELETE_COMMENT = gql` 22 | mutation DeleteComment($input: DeleteCommentInput!) { 23 | deleteComment(input: $input) { 24 | comment { 25 | id 26 | } 27 | } 28 | } 29 | ` 30 | 31 | const ArticleComments = ({ slug }) => { 32 | const { loading, data, error } = useQuery(GET_ARTICLE_COMMENTS, { 33 | variables: { slug } 34 | }) 35 | 36 | if (loading || error) return 'Loading comments...' 37 | 38 | const [deleteComment] = useMutation(DELETE_COMMENT, { 39 | update(cache, { data: { deleteComment: { comment } } }) { 40 | const deletedCommentId = comment.id 41 | const cacheData = cache.readQuery({ 42 | query: GET_ARTICLE_COMMENTS, 43 | variables: { slug } 44 | }) 45 | cacheData.article.comments = cacheData.article.comments.filter( 46 | (x) => x.id !== deletedCommentId 47 | ) 48 | cache.writeQuery({ 49 | query: GET_ARTICLE_COMMENTS, 50 | data: cacheData 51 | }) 52 | } 53 | }) 54 | 55 | return ( 56 | <> 57 | 58 | 59 | {data.article.comments.map((comment) => ( 60 | deleteComment({ variables: { input: { id: comment.id } } })} 64 | /> 65 | ))} 66 | 67 | ) 68 | } 69 | 70 | ArticleComments.propTypes = { 71 | slug: PropTypes.string.isRequired 72 | } 73 | 74 | export default ArticleComments 75 | -------------------------------------------------------------------------------- /src/components/Article/Comments/Form.js: -------------------------------------------------------------------------------- 1 | import { Field, Formik } from 'formik' 2 | import PropTypes from 'prop-types' 3 | import React from 'react' 4 | 5 | const CommentForm = ({ onSubmit }) => ( 6 | 12 | {({ isSubmitting, handleSubmit }) => ( 13 |
17 |
18 |
19 | 27 |
28 |
29 | 30 | 33 |
34 |
35 |
36 | )} 37 |
38 | ) 39 | 40 | CommentForm.propTypes = { 41 | onSubmit: PropTypes.func.isRequired 42 | } 43 | 44 | export default CommentForm 45 | -------------------------------------------------------------------------------- /src/components/Article/Comments/NewComment.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | import _ from 'lodash' 3 | import PropTypes from 'prop-types' 4 | import React, { Fragment } from 'react' 5 | import { useMutation } from '@apollo/react-hooks' 6 | import { Link } from 'react-router-dom' 7 | import useViewer from '../../useViewer' 8 | import Comment from './Comment' 9 | import CommentForm from './Form' 10 | 11 | const ADD_COMMENT = gql` 12 | mutation AddComment($input: AddCommentInput!) { 13 | addComment(input: $input) { 14 | comment { 15 | ...Comment 16 | } 17 | errors { 18 | message 19 | } 20 | } 21 | } 22 | ${Comment.fragments.comment} 23 | ` 24 | 25 | const NewComment = ({ article }) => { 26 | const viewer = useViewer() 27 | 28 | if (!viewer) { 29 | return ( 30 | <> 31 | Sign in or sign up{' '} 32 | to add comments on this article. 33 | 34 | ) 35 | } 36 | 37 | const [addComment] = useMutation(ADD_COMMENT, { 38 | refetchQueries: ['ArticleComments'] 39 | }) 40 | 41 | return ( 42 | { 44 | const { data } = await addComment({ 45 | variables: { input: { ...values, articleId: article.id } } 46 | }) 47 | 48 | if (!_.isEmpty(data.addComment.errors)) { 49 | setSubmitting(false) 50 | return 51 | } 52 | 53 | resetForm() 54 | }} 55 | /> 56 | ) 57 | } 58 | 59 | NewComment.propTypes = { 60 | article: PropTypes.shape({ 61 | id: PropTypes.string.isRequired, 62 | slug: PropTypes.string.isRequired 63 | }).isRequired 64 | } 65 | 66 | export default NewComment 67 | -------------------------------------------------------------------------------- /src/components/Article/Comments/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Comments' 2 | -------------------------------------------------------------------------------- /src/components/Article/Meta.js: -------------------------------------------------------------------------------- 1 | import { format } from 'date-fns' 2 | import gql from 'graphql-tag' 3 | import _ from 'lodash' 4 | import PropTypes from 'prop-types' 5 | import React from 'react' 6 | import { Link } from 'react-router-dom' 7 | import useViewer from '../useViewer' 8 | import AuthorActions from './AuthorActions' 9 | import UserActions from './UserActions' 10 | import Avatar from '../Avatar' 11 | 12 | const ArticleMeta = ({ article }) => { 13 | const { author } = article 14 | const viewer = useViewer() 15 | 16 | return ( 17 |
18 | 19 | 20 | 21 |
22 | 23 | {author.username} 24 | 25 | 26 | {format(Date.parse(article.createdAt), 'MMMM do, yyyy')} 27 | 28 |
29 | 30 | {_.get(viewer, 'user.id') === author.id ? ( 31 | 32 | ) : ( 33 | 34 | )} 35 |
36 | ) 37 | } 38 | 39 | ArticleMeta.propTypes = { 40 | article: PropTypes.shape({ 41 | createdAt: PropTypes.string.isRequired, 42 | author: PropTypes.shape({ 43 | id: PropTypes.string.isRequired, 44 | username: PropTypes.string.isRequired, 45 | image: PropTypes.string 46 | }).isRequired 47 | }).isRequired 48 | } 49 | 50 | ArticleMeta.fragments = { 51 | article: gql` 52 | fragment ArticleMeta on Article { 53 | createdAt 54 | author { 55 | id 56 | username 57 | image 58 | } 59 | ...UserActions 60 | } 61 | ${UserActions.fragments.article} 62 | ` 63 | } 64 | 65 | export default ArticleMeta 66 | -------------------------------------------------------------------------------- /src/components/Article/TagList.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | import React from 'react' 3 | 4 | const TagList = ({ tagList }) => ( 5 |
    6 | {tagList.map((tag) => ( 7 |
  • {tag}
  • 8 | ))} 9 |
10 | ) 11 | 12 | TagList.propTypes = { 13 | tagList: PropTypes.arrayOf(PropTypes.string).isRequired 14 | } 15 | 16 | export default TagList 17 | -------------------------------------------------------------------------------- /src/components/Article/UserActions.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | import PropTypes from 'prop-types' 3 | import React, { Fragment } from 'react' 4 | import FavoriteButton from '../FavoriteButton' 5 | import FollowButton from '../FollowButton' 6 | 7 | const UserActions = ({ article }) => ( 8 | <> 9 | 10 |    11 | 12 | 13 |   14 | {article.viewerHasFavorited ? 'Unfavorite' : 'Favorite'} Article ({article.favoritesCount}) 15 | 16 | 17 | ) 18 | 19 | UserActions.propTypes = { 20 | article: PropTypes.shape({ 21 | viewerHasFavorited: PropTypes.bool.isRequired, 22 | favoritesCount: PropTypes.number.isRequired, 23 | author: PropTypes.PropTypes.shape({}).isRequired 24 | }).isRequired 25 | } 26 | 27 | UserActions.fragments = { 28 | article: gql` 29 | fragment UserActions on Article { 30 | viewerHasFavorited 31 | favoritesCount 32 | author { 33 | ...FollowButton 34 | } 35 | ...FavoriteButton 36 | } 37 | ${FavoriteButton.fragments.article} 38 | ${FollowButton.fragments.user} 39 | ` 40 | } 41 | 42 | export default UserActions 43 | -------------------------------------------------------------------------------- /src/components/Article/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Article' 2 | -------------------------------------------------------------------------------- /src/components/ArticlePreview.js: -------------------------------------------------------------------------------- 1 | import { format } from 'date-fns' 2 | import gql from 'graphql-tag' 3 | import PropTypes from 'prop-types' 4 | import React from 'react' 5 | import { Link } from 'react-router-dom' 6 | import Avatar from './Avatar' 7 | import FavoriteButton from './FavoriteButton' 8 | 9 | const ArticlePreview = ({ article }) => { 10 | const { author } = article 11 | 12 | return ( 13 |
14 |
15 | 16 | 17 | 18 |
19 | 20 | {author.username} 21 | 22 | {format(Date.parse(article.createdAt), 'MMMM do, yyyy')} 23 |
24 | 25 | 26 |  {article.favoritesCount} 27 | 28 |
29 | 30 | 31 |

{article.title}

32 |

{article.description}

33 | Read more... 34 | 35 |
36 | ) 37 | } 38 | 39 | ArticlePreview.propTypes = { 40 | article: PropTypes.shape({ 41 | slug: PropTypes.string.isRequired, 42 | title: PropTypes.string.isRequired, 43 | description: PropTypes.string.isRequired, 44 | favoritesCount: PropTypes.number.isRequired, 45 | createdAt: PropTypes.string.isRequired, 46 | author: PropTypes.shape({ 47 | username: PropTypes.string.isRequired, 48 | image: PropTypes.string 49 | }).isRequired 50 | }).isRequired 51 | } 52 | 53 | ArticlePreview.fragments = { 54 | article: gql` 55 | fragment ArticlePreview on Article { 56 | id 57 | slug 58 | title 59 | description 60 | favoritesCount 61 | createdAt 62 | author { 63 | id 64 | username 65 | image 66 | } 67 | ...FavoriteButton 68 | } 69 | ${FavoriteButton.fragments.article} 70 | ` 71 | } 72 | 73 | export default ArticlePreview 74 | -------------------------------------------------------------------------------- /src/components/Avatar.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | const placeholderImageUrl = 'https://static.productionready.io/images/smiley-cyrus.jpg' 5 | 6 | // eslint-disable-next-line jsx-a11y/alt-text 7 | const Avatar = ({ src, alt, className }) => ( 8 | {alt} 9 | ) 10 | 11 | Avatar.propTypes = { 12 | src: PropTypes.string, // eslint-disable-line react/require-default-props 13 | alt: PropTypes.string, // eslint-disable-line react/require-default-props 14 | className: PropTypes.string // eslint-disable-line react/require-default-props 15 | } 16 | 17 | export default Avatar 18 | -------------------------------------------------------------------------------- /src/components/EditArticle.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | import _ from 'lodash' 3 | import PropTypes from 'prop-types' 4 | import React from 'react' 5 | import { useMutation, useQuery } from '@apollo/react-hooks' 6 | import { transformGraphQLErrors } from '../apolloHelpers' 7 | import Editor from './Editor' 8 | import Page from './Page' 9 | 10 | const GET_ARTICLE = gql` 11 | query Article($slug: String!) { 12 | article(slug: $slug) { 13 | id 14 | slug 15 | title 16 | description 17 | body 18 | tagList 19 | } 20 | } 21 | ` 22 | 23 | const UPDATE_ARTICLE = gql` 24 | mutation UpdateArticle($input: UpdateArticleInput!) { 25 | updateArticle(input: $input) { 26 | article { 27 | id 28 | slug 29 | title 30 | description 31 | body 32 | tagList 33 | } 34 | errors { 35 | message 36 | } 37 | } 38 | } 39 | ` 40 | 41 | const EditArticle = ({ 42 | history, 43 | match: { 44 | params: { slug } 45 | } 46 | }) => { 47 | const { loading, data, error } = useQuery(GET_ARTICLE, { 48 | variables: { slug } 49 | }) 50 | const [updateArticle] = useMutation(UPDATE_ARTICLE) 51 | 52 | if (loading || error) return null 53 | 54 | return ( 55 | 56 | { 59 | const { data: mutationData } = await updateArticle({ 60 | variables: { input: { ...values, id: data.article.id } } 61 | }) 62 | 63 | setSubmitting(false) 64 | setErrors(transformGraphQLErrors(mutationData.updateArticle.errors)) 65 | 66 | if (!_.isEmpty(mutationData.updateArticle.errors)) return 67 | 68 | const updatedSlug = _.get(mutationData, 'updateArticle.article.slug') 69 | history.push(`/article/${updatedSlug}`) 70 | }} 71 | /> 72 | 73 | ) 74 | } 75 | 76 | EditArticle.propTypes = { 77 | history: PropTypes.shape({ 78 | push: PropTypes.func.isRequired 79 | }).isRequired, 80 | match: PropTypes.shape({ 81 | params: PropTypes.shape({ 82 | slug: PropTypes.string.isRequired 83 | }).isRequired 84 | }).isRequired 85 | } 86 | 87 | export default EditArticle 88 | -------------------------------------------------------------------------------- /src/components/Editor/Editor.js: -------------------------------------------------------------------------------- 1 | import { Field, Formik } from 'formik' 2 | import PropTypes from 'prop-types' 3 | import React, { Fragment } from 'react' 4 | import FormErrors from '../FormErrors' 5 | import TagsInput from './TagsInput' 6 | 7 | const Editor = ({ article, onSubmit }) => ( 8 |
9 |
10 |
11 | 20 | {({ values, isSubmitting, handleSubmit, setFieldValue, errors }) => ( 21 | <> 22 | 23 | 24 |
25 |
26 |
27 | 34 |
35 |
36 | 43 |
44 |
45 | 53 |
54 |
55 | setFieldValue('tagList', tagList)} 59 | className="form-control" 60 | placeholder="Enter tags" 61 | /> 62 |
63 | 66 |
67 |
68 | 69 | )} 70 |
71 |
72 |
73 |
74 | ) 75 | 76 | Editor.propTypes = { 77 | article: PropTypes.shape({ 78 | title: PropTypes.string, 79 | description: PropTypes.string, 80 | body: PropTypes.string, 81 | tagList: PropTypes.arrayOf(PropTypes.string) 82 | }), 83 | onSubmit: PropTypes.func.isRequired 84 | } 85 | 86 | Editor.defaultProps = { 87 | article: {} 88 | } 89 | 90 | export default Editor 91 | -------------------------------------------------------------------------------- /src/components/Editor/TagsInput.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | import React, { Component, Fragment } from 'react' 3 | 4 | class TagsInput extends Component { 5 | handleKeyPress = (event) => { 6 | if (event.key === 'Enter') { 7 | event.preventDefault() 8 | const value = event.target.value.trim() 9 | const { tagList, onChange } = this.props 10 | 11 | if (value === '') return 12 | if (tagList.includes(value)) return 13 | 14 | onChange([...tagList, value]) 15 | 16 | // eslint-disable-next-line no-param-reassign 17 | event.target.value = '' 18 | } 19 | } 20 | 21 | handleDeleteClick = (deletedTag) => { 22 | const { tagList, onChange } = this.props 23 | onChange(tagList.filter((tag) => tag !== deletedTag)) 24 | } 25 | 26 | render() { 27 | const { tagList, name, className, placeholder } = this.props 28 | 29 | return ( 30 | <> 31 | 38 |
39 | {tagList.map((tag) => ( 40 | 41 | this.handleDeleteClick(tag)} 47 | /> 48 | {tag} 49 | 50 | ))} 51 |
52 | 53 | ) 54 | } 55 | } 56 | 57 | TagsInput.propTypes = { 58 | tagList: PropTypes.arrayOf(PropTypes.string).isRequired, 59 | onChange: PropTypes.func.isRequired, 60 | name: PropTypes.string.isRequired, 61 | className: PropTypes.string.isRequired, 62 | placeholder: PropTypes.string.isRequired 63 | } 64 | 65 | export default TagsInput 66 | -------------------------------------------------------------------------------- /src/components/Editor/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Editor' 2 | -------------------------------------------------------------------------------- /src/components/FavoriteButton.js: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames' 2 | import gql from 'graphql-tag' 3 | import PropTypes from 'prop-types' 4 | import React from 'react' 5 | import { useMutation } from '@apollo/react-hooks' 6 | import { withRouter } from 'react-router-dom' 7 | import useViewer from './useViewer' 8 | 9 | const FAVORITE_ARTICLE = gql` 10 | mutation FavoriteArticle($input: FavoriteArticleInput!) { 11 | favoriteArticle(input: $input) { 12 | article { 13 | id 14 | viewerHasFavorited 15 | favoritesCount 16 | } 17 | } 18 | } 19 | ` 20 | 21 | const UNFAVORITE_ARTICLE = gql` 22 | mutation UnfavoriteArticle($input: UnfavoriteArticleInput!) { 23 | unfavoriteArticle(input: $input) { 24 | article { 25 | id 26 | viewerHasFavorited 27 | favoritesCount 28 | } 29 | } 30 | } 31 | ` 32 | 33 | const FavoriteButton = ({ article, history, children, className }) => { 34 | const viewer = useViewer() 35 | const [togglFavorite] = useMutation( 36 | article.viewerHasFavorited ? UNFAVORITE_ARTICLE : FAVORITE_ARTICLE 37 | ) 38 | return ( 39 | 56 | ) 57 | } 58 | 59 | FavoriteButton.propTypes = { 60 | article: PropTypes.shape({ 61 | id: PropTypes.string.isRequired, 62 | viewerHasFavorited: PropTypes.bool.isRequired, 63 | favoritesCount: PropTypes.number.isRequired 64 | }).isRequired, 65 | history: PropTypes.shape({ 66 | push: PropTypes.func.isRequired 67 | }).isRequired, 68 | children: PropTypes.node.isRequired, 69 | className: PropTypes.string 70 | } 71 | 72 | FavoriteButton.defaultProps = { 73 | className: null 74 | } 75 | 76 | FavoriteButton.fragments = { 77 | article: gql` 78 | fragment FavoriteButton on Article { 79 | id 80 | viewerHasFavorited 81 | favoritesCount 82 | } 83 | ` 84 | } 85 | 86 | export default withRouter(FavoriteButton) 87 | -------------------------------------------------------------------------------- /src/components/FollowButton.js: -------------------------------------------------------------------------------- 1 | import classNames from 'classnames' 2 | import gql from 'graphql-tag' 3 | import PropTypes from 'prop-types' 4 | import React from 'react' 5 | import { useMutation } from '@apollo/react-hooks' 6 | import { withRouter } from 'react-router-dom' 7 | import useViewer from './useViewer' 8 | 9 | const FOLLOW_USER = gql` 10 | mutation FollowUser($input: FollowUserInput!) { 11 | followUser(input: $input) { 12 | user { 13 | id 14 | followedByViewer 15 | followers { 16 | totalCount 17 | } 18 | } 19 | } 20 | } 21 | ` 22 | 23 | const UNFOLLOW_USER = gql` 24 | mutation UnfollowUser($input: UnfollowUserInput!) { 25 | unfollowUser(input: $input) { 26 | user { 27 | id 28 | followedByViewer 29 | followers { 30 | totalCount 31 | } 32 | } 33 | } 34 | } 35 | ` 36 | 37 | const FollowButton = ({ user, className, history }) => { 38 | const viewer = useViewer() 39 | const [toggleFollow] = useMutation( 40 | user.followedByViewer ? UNFOLLOW_USER : FOLLOW_USER 41 | ) 42 | 43 | return ( 44 | 65 | ) 66 | } 67 | 68 | FollowButton.propTypes = { 69 | user: PropTypes.shape({ 70 | id: PropTypes.string.isRequired, 71 | username: PropTypes.string.isRequired, 72 | followedByViewer: PropTypes.bool.isRequired, 73 | followers: PropTypes.shape({ 74 | totalCount: PropTypes.number.isRequired 75 | }).isRequired 76 | }).isRequired, 77 | history: PropTypes.shape({ 78 | push: PropTypes.func.isRequired 79 | }).isRequired, 80 | className: PropTypes.string 81 | } 82 | 83 | FollowButton.defaultProps = { 84 | className: null 85 | } 86 | 87 | FollowButton.fragments = { 88 | user: gql` 89 | fragment FollowButton on User { 90 | id 91 | username 92 | followedByViewer 93 | followers { 94 | totalCount 95 | } 96 | } 97 | ` 98 | } 99 | 100 | export default withRouter(FollowButton) 101 | -------------------------------------------------------------------------------- /src/components/FormErrors.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | import PropTypes from 'prop-types' 3 | import React from 'react' 4 | 5 | const FormErrors = ({ errors }) => { 6 | if (_.isEmpty(errors)) return null 7 | 8 | return ( 9 |
    10 | {Object.values(errors).map((error) =>
  • {error}
  • )} 11 |
12 | ) 13 | } 14 | 15 | FormErrors.propTypes = { 16 | // eslint-disable-next-line react/forbid-prop-types 17 | errors: PropTypes.object.isRequired 18 | } 19 | 20 | export default FormErrors 21 | -------------------------------------------------------------------------------- /src/components/Home/Feed.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | import _ from 'lodash' 3 | import PropTypes from 'prop-types' 4 | import React from 'react' 5 | import { useQuery } from '@apollo/react-hooks' 6 | import ApolloInfiniteScroll from '../ApolloInfiniteScroll' 7 | import ArticlePreview from '../ArticlePreview' 8 | import { GLOBAL_FEED, TAG_FEED, YOUR_FEED } from './feedTypes' 9 | 10 | const ARTICLES_CONNECTION_FRAGMENT = gql` 11 | fragment Articles on ArticleConnection { 12 | edges { 13 | node { 14 | id 15 | ...ArticlePreview 16 | } 17 | } 18 | pageInfo { 19 | endCursor 20 | hasNextPage 21 | } 22 | } 23 | ${ArticlePreview.fragments.article} 24 | ` 25 | 26 | const GET_FEED = gql` 27 | query Feed($cursor: String) { 28 | viewer { 29 | feed(first: 10, after: $cursor) { 30 | ...Articles 31 | } 32 | } 33 | } 34 | ${ARTICLES_CONNECTION_FRAGMENT} 35 | ` 36 | 37 | const GET_ARTICLES = gql` 38 | query Articles($tag: String, $cursor: String) { 39 | articles(tag: $tag, first: 10, after: $cursor) { 40 | ...Articles 41 | } 42 | } 43 | ${ARTICLES_CONNECTION_FRAGMENT} 44 | ` 45 | 46 | const Feed = ({ feedType, tag }) => { 47 | const { loading, data, error, fetchMore } = useQuery( 48 | feedType === YOUR_FEED ? GET_FEED : GET_ARTICLES, 49 | { 50 | variables: feedType === TAG_FEED ? { tag } : {}, 51 | fetchPolicy: 'cache-and-network' 52 | } 53 | ) 54 | 55 | const connectionPath = feedType === YOUR_FEED ? 'viewer.feed' : 'articles' 56 | const articles = _.get(data, connectionPath) 57 | 58 | if (error || !articles) { 59 | return
Loading feed...
60 | } 61 | 62 | if (articles.edges.length === 0) { 63 | return
No articles are here... yet.
64 | } 65 | 66 | return ( 67 | 74 | {(article) => } 75 | 76 | ) 77 | } 78 | 79 | Feed.propTypes = { 80 | feedType: PropTypes.oneOf([YOUR_FEED, GLOBAL_FEED, TAG_FEED]).isRequired, 81 | tag: PropTypes.string 82 | } 83 | 84 | Feed.defaultProps = { 85 | tag: null 86 | } 87 | 88 | export default Feed 89 | -------------------------------------------------------------------------------- /src/components/Home/FeedTabs.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | import React, { Fragment } from 'react' 3 | import { useQuery, useMutation } from '@apollo/react-hooks' 4 | import Tab from '../Tab' 5 | import useViewer from '../useViewer' 6 | import Feed from './Feed' 7 | import { GLOBAL_FEED, TAG_FEED, YOUR_FEED } from './feedTypes' 8 | 9 | /* eslint-disable graphql/template-strings */ 10 | const GET_FEED_FILTER = gql` 11 | query FeedFilter { 12 | feedFilter @client { 13 | type 14 | tag 15 | } 16 | } 17 | ` 18 | /* eslint-enable */ 19 | 20 | /* eslint-disable graphql/template-strings */ 21 | const CHANGE_FEED_FILTER = gql` 22 | mutation ChangeFeedFilter($type: String) { 23 | changeFeedFilter(type: $type) @client 24 | } 25 | ` 26 | /* eslint-enable */ 27 | 28 | const FeedTabs = () => { 29 | const { loading, data, error } = useQuery(GET_FEED_FILTER) 30 | const viewer = useViewer() 31 | const [changeFeedFilter] = useMutation(CHANGE_FEED_FILTER) 32 | 33 | if (error || loading) return null 34 | 35 | const feedType = data.feedFilter.type || (viewer ? YOUR_FEED : GLOBAL_FEED) 36 | const { tag } = data.feedFilter 37 | 38 | return ( 39 | <> 40 |
41 |
    42 | {viewer && ( 43 | changeFeedFilter({ variables: { type: YOUR_FEED } })} 46 | > 47 | Your Feed 48 | 49 | )} 50 | 51 | changeFeedFilter({ variables: { type: GLOBAL_FEED } })} 54 | > 55 | Global Feed 56 | 57 | 58 | {feedType === TAG_FEED && #{tag}} 59 |
60 |
61 | 62 | 63 | 64 | ) 65 | } 66 | 67 | export default FeedTabs 68 | -------------------------------------------------------------------------------- /src/components/Home/Home.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | import PropTypes from 'prop-types' 3 | import React, { Component } from 'react' 4 | import { withApollo } from '@apollo/react-hoc' 5 | import Page from '../Page' 6 | import FeedTabs from './FeedTabs' 7 | import PopularTags from './PopularTags' 8 | 9 | /* eslint-disable graphql/template-strings */ 10 | const CHANGE_FEED_FILTER = gql` 11 | mutation ChangeFeedFilter($type: String) { 12 | changeFeedFilter(type: $type) @client 13 | } 14 | ` 15 | /* eslint-enable */ 16 | 17 | class Home extends Component { 18 | componentWillUnmount() { 19 | const { client } = this.props 20 | 21 | client.mutate({ 22 | mutation: CHANGE_FEED_FILTER, 23 | variables: { type: null } 24 | }) 25 | } 26 | 27 | render() { 28 | return ( 29 | 30 |
31 |
32 |
33 | 34 |
35 | 36 |
37 |
38 | 39 |
40 |
41 |
42 |
43 |
44 | ) 45 | } 46 | } 47 | 48 | Home.propTypes = { 49 | client: PropTypes.shape({ 50 | mutate: PropTypes.func.isRequired 51 | }).isRequired 52 | } 53 | 54 | export default withApollo(Home) 55 | -------------------------------------------------------------------------------- /src/components/Home/PopularTags.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | import React, { Fragment } from 'react' 3 | import { useQuery, useMutation } from '@apollo/react-hooks' 4 | import Tag from './Tag' 5 | import { TAG_FEED } from './feedTypes' 6 | 7 | const GET_TAGS = gql` 8 | query Tags { 9 | tags 10 | } 11 | ` 12 | 13 | /* eslint-disable graphql/template-strings */ 14 | const CHANGE_FEED_FILTER = gql` 15 | mutation ChangeFeedFilter($type: String, $tag: String) { 16 | changeFeedFilter(type: $type, tag: $tag) @client 17 | } 18 | ` 19 | /* eslint-enable */ 20 | 21 | const PopularTags = () => { 22 | const { loading, data, error } = useQuery(GET_TAGS, { 23 | fetchPolicy: 'cache-and-network' 24 | }) 25 | const [changeFeedFilter] = useMutation(CHANGE_FEED_FILTER) 26 | 27 | if (loading || error) return 'Loading tags...' 28 | return ( 29 | <> 30 |

Popular Tags

31 |
32 | {data.tags.map((tag) => ( 33 | changeFeedFilter({ variables: { type: TAG_FEED, tag } })} 36 | > 37 | {tag} 38 | 39 | ))} 40 |
41 | 42 | ) 43 | } 44 | 45 | export default PopularTags 46 | -------------------------------------------------------------------------------- /src/components/Home/Tag.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | import React from 'react' 3 | 4 | const Tag = ({ children, onClick }) => ( 5 | { 9 | event.preventDefault() 10 | onClick() 11 | }} 12 | > 13 | {children} 14 | 15 | ) 16 | 17 | Tag.propTypes = { 18 | children: PropTypes.node.isRequired, 19 | onClick: PropTypes.func.isRequired 20 | } 21 | 22 | export default Tag 23 | -------------------------------------------------------------------------------- /src/components/Home/feedTypes.js: -------------------------------------------------------------------------------- 1 | export const YOUR_FEED = 'YOUR_FEED' 2 | export const GLOBAL_FEED = 'GLOBAL_FEED' 3 | export const TAG_FEED = 'TAG_FEED' 4 | -------------------------------------------------------------------------------- /src/components/Home/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Home' 2 | -------------------------------------------------------------------------------- /src/components/Login/Form.js: -------------------------------------------------------------------------------- 1 | import { Field, Formik } from 'formik' 2 | import PropTypes from 'prop-types' 3 | import React, { Fragment } from 'react' 4 | import FormErrors from '../FormErrors' 5 | 6 | const LoginForm = ({ onSubmit }) => ( 7 | 14 | {({ handleSubmit, isSubmitting, errors }) => ( 15 | <> 16 | 17 | 18 |
19 |
20 |
21 | 29 |
30 |
31 | 39 |
40 | 43 |
44 |
45 | 46 | )} 47 |
48 | ) 49 | 50 | LoginForm.propTypes = { 51 | onSubmit: PropTypes.func.isRequired 52 | } 53 | 54 | export default LoginForm 55 | -------------------------------------------------------------------------------- /src/components/Login/Login.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | import _ from 'lodash' 3 | import PropTypes from 'prop-types' 4 | import React from 'react' 5 | import { useMutation } from '@apollo/react-hooks' 6 | import { Link } from 'react-router-dom' 7 | import { transformGraphQLErrors } from '../../apolloHelpers' 8 | import tokenStorage from '../../tokenStorage' 9 | import Page from '../Page' 10 | import { WithViewer } from '../useViewer' 11 | import LoginForm from './Form' 12 | 13 | const SIGN_IN_USER = gql` 14 | mutation SignInUser($input: SignInUserInput!) { 15 | signInUser(input: $input) { 16 | token 17 | viewer { 18 | ...WithViewer 19 | } 20 | errors { 21 | message 22 | } 23 | } 24 | } 25 | ${WithViewer.fragments.viewer} 26 | ` 27 | 28 | const GET_VIEWER = gql` 29 | query Viewer { 30 | viewer { 31 | ...WithViewer 32 | } 33 | } 34 | ${WithViewer.fragments.viewer} 35 | ` 36 | 37 | const Login = ({ history }) => { 38 | const [signInUser] = useMutation(SIGN_IN_USER, { 39 | update: (cache, { data: mutationData }) => { 40 | cache.writeQuery({ 41 | query: GET_VIEWER, 42 | data: { viewer: mutationData.signInUser.viewer } 43 | }) 44 | } 45 | }) 46 | return ( 47 | 48 |
49 |
50 |
51 |

Sign in

52 |

53 | Need an account? 54 |

55 | 56 | { 58 | const { data: mutationData } = await signInUser({ 59 | variables: { input: values } 60 | }) 61 | 62 | setSubmitting(false) 63 | setErrors( 64 | transformGraphQLErrors(mutationData.signInUser.errors) 65 | ) 66 | 67 | if (!_.isEmpty(mutationData.signInUser.errors)) return 68 | 69 | tokenStorage.write(mutationData.signInUser.token) 70 | history.push('/') 71 | }} 72 | /> 73 |
74 |
75 |
76 |
77 | ) 78 | } 79 | 80 | Login.propTypes = { 81 | history: PropTypes.shape({ 82 | push: PropTypes.func.isRequired 83 | }).isRequired 84 | } 85 | 86 | export default Login 87 | -------------------------------------------------------------------------------- /src/components/Login/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Login' 2 | -------------------------------------------------------------------------------- /src/components/NewArticle.js: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag' 2 | import _ from 'lodash' 3 | import PropTypes from 'prop-types' 4 | import React from 'react' 5 | import { useMutation } from '@apollo/react-hooks' 6 | import { transformGraphQLErrors } from '../apolloHelpers' 7 | import Editor from './Editor' 8 | import Page from './Page' 9 | 10 | const CREATE_ARTICLE = gql` 11 | mutation CreateArticle($input: CreateArticleInput!) { 12 | createArticle(input: $input) { 13 | article { 14 | id 15 | slug 16 | title 17 | description 18 | body 19 | tagList 20 | } 21 | errors { 22 | message 23 | } 24 | } 25 | } 26 | ` 27 | 28 | const NewArticle = ({ history }) => { 29 | const [createArticle] = useMutation(CREATE_ARTICLE) 30 | return ( 31 | 32 | { 34 | const { data } = await createArticle({ 35 | variables: { input: values } 36 | }) 37 | 38 | setSubmitting(false) 39 | setErrors(transformGraphQLErrors(data.createArticle.errors)) 40 | 41 | if (!_.isEmpty(data.createArticle.errors)) return 42 | 43 | const slug = _.get(data, 'createArticle.article.slug') 44 | history.push(`/article/${slug}`) 45 | }} 46 | /> 47 | 48 | ) 49 | } 50 | 51 | NewArticle.propTypes = { 52 | history: PropTypes.shape({ 53 | push: PropTypes.func.isRequired 54 | }).isRequired 55 | } 56 | 57 | export default NewArticle 58 | -------------------------------------------------------------------------------- /src/components/Page/Footer.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link } from 'react-router-dom' 3 | 4 | const Footer = () => ( 5 |
6 |
7 | conduit 8 | 9 | An interactive learning project from Thinkster. Code & design licensed under MIT. 10 | 11 |
12 |
13 | ) 14 | 15 | export default Footer 16 | -------------------------------------------------------------------------------- /src/components/Page/Header/GuestMenuItems.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react' 2 | import { NavLink } from 'react-router-dom' 3 | 4 | const GuestMenuItems = () => ( 5 | <> 6 |
  • 7 | Home 8 |
  • 9 |
  • 10 | Sign in 11 |
  • 12 |
  • 13 | Sign up 14 |
  • 15 | 16 | ) 17 | 18 | export default GuestMenuItems 19 | -------------------------------------------------------------------------------- /src/components/Page/Header/Header.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { NavLink } from 'react-router-dom' 3 | import Menu from './Menu' 4 | 5 | const Header = () => ( 6 | 12 | ) 13 | 14 | export default Header 15 | -------------------------------------------------------------------------------- /src/components/Page/Header/Menu.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import useViewer from '../../useViewer' 3 | import GuestMenuItems from './GuestMenuItems' 4 | import UserMenuItems from './UserMenuItems' 5 | 6 | const Menu = () => { 7 | const viewer = useViewer() 8 | return ( 9 |
      10 | {viewer ? : } 11 |
    12 | ) 13 | } 14 | 15 | export default Menu 16 | -------------------------------------------------------------------------------- /src/components/Page/Header/UserMenuItems.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | import React, { Fragment } from 'react' 3 | import { NavLink } from 'react-router-dom' 4 | import Avatar from '../../Avatar' 5 | 6 | const UserMenuItems = ({ user }) => ( 7 | <> 8 |
  • 9 | Home 10 |
  • 11 |
  • 12 | 13 |  New Post 14 | 15 |
  • 16 |
  • 17 | 18 |  Settings 19 | 20 |
  • 21 |
  • 22 | 23 | 24 | {user.username} 25 | 26 |
  • 27 | 28 | ) 29 | 30 | UserMenuItems.propTypes = { 31 | user: PropTypes.shape({ 32 | username: PropTypes.string.isRequired, 33 | image: PropTypes.string 34 | }).isRequired 35 | } 36 | 37 | export default UserMenuItems 38 | -------------------------------------------------------------------------------- /src/components/Page/Header/index.js: -------------------------------------------------------------------------------- 1 | export { default } from './Header' 2 | -------------------------------------------------------------------------------- /src/components/Page/Page.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types' 2 | import React, { Fragment } from 'react' 3 | import { Helmet } from 'react-helmet' 4 | import Footer from './Footer' 5 | import Header from './Header' 6 | 7 | const Page = ({ children, title, className }) => ( 8 | <> 9 | 10 |
    11 |
    12 | {children} 13 |
    14 |