├── .env.example ├── .github └── pull_request_template.md ├── .gitignore ├── LICENSE.md ├── README.md ├── codegen.ts ├── components ├── articles │ ├── ArticleDetails.tsx │ ├── ArticleList.tsx │ ├── ArticleListItem.tsx │ └── CreateArticleForm.tsx ├── page │ ├── Footer.tsx │ ├── GoogleAnalytics.ts │ ├── Header.tsx │ ├── Notifications.tsx │ └── PageHead.tsx └── user │ ├── SigninWithEmailForm.tsx │ └── SigninWithGoogleButton.tsx ├── config └── config.ts ├── docs ├── demo.jpg ├── github_preview.jpg └── graphiql.png ├── graphql ├── __generated__ │ ├── fragment-masking.ts │ ├── gql.ts │ ├── graphql.ts │ └── index.ts ├── apollo.tsx ├── collections │ ├── _TEMPLATE │ │ ├── hooks.ts │ │ ├── queries.ts │ │ └── schema.sql │ ├── all_tables.sql │ ├── article │ │ ├── hooks.ts │ │ ├── queries.ts │ │ ├── resolvers.ts │ │ ├── schema.sql │ │ └── serverQueries.ts │ └── user │ │ ├── hooks.tsx │ │ ├── queries.ts │ │ └── schema.sql └── server │ ├── postgraphile.ts │ ├── postgres.ts │ ├── resolverExtensions.ts │ └── runMiddleware.ts ├── hooks ├── useDebounce.ts ├── useFormValidation.ts └── useLocalStorage.ts ├── lib ├── firebase.ts ├── formatDate.ts ├── handleRestRequest.ts ├── isClientSide.ts ├── isDevelopment.ts ├── lodashy.ts ├── makeRestRequest.ts ├── showNotification.ts └── toSlug.ts ├── next-env.d.ts ├── next.config.js ├── package.json ├── pages ├── _app.tsx ├── _document.tsx ├── about.tsx ├── api │ ├── graphiql.ts │ └── graphql │ │ ├── index.ts │ │ └── stream.ts ├── articles │ └── [articleSlug].tsx ├── index.tsx ├── robots.txt.tsx ├── signin │ ├── authenticate.tsx │ └── index.tsx └── sitemap.xml.tsx ├── public ├── favicon.png ├── icons │ ├── feedback.svg │ ├── help.svg │ ├── home.svg │ ├── menu.svg │ └── person.svg ├── images │ └── google_g.svg └── manifest.json ├── styles └── globals.css ├── tsconfig.json ├── types └── global.d.ts ├── vercel.json └── yarn.lock /.env.example: -------------------------------------------------------------------------------- 1 | # Make sure you have ?sslmode=require on the URL 2 | DATABASE_URL=postgres://[YOUR URL] 3 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | **Related ticket:** https://trello.com/c/... 2 | 3 | ### What this PR includes 4 | 5 | - [e.g. Added a new component that does...] 6 | - [Mark PR as 🚧 DRAFT if you don’t want it to be merged] 7 | 8 | ### Checklist 9 | 10 | - [ ] I have compared design with Figma sketches 11 | - [ ] I have run `npm run lint:fix` and solved any linting issues 12 | - [ ] There are no merge conflicts -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Optional REPL history 57 | .node_repl_history 58 | 59 | # Output of 'npm pack' 60 | *.tgz 61 | 62 | # Yarn Integrity file 63 | .yarn-integrity 64 | 65 | # dotenv environment variables file 66 | .env 67 | .env.test 68 | 69 | # parcel-bundler cache (https://parceljs.org/) 70 | .cache 71 | 72 | # next.js build output 73 | .next 74 | 75 | # nuxt.js build output 76 | .nuxt 77 | 78 | # vuepress build output 79 | .vuepress/dist 80 | 81 | # Serverless directories 82 | .serverless/ 83 | 84 | # FuseBox cache 85 | .fusebox/ 86 | 87 | # DynamoDB Local files 88 | .dynamodb/ 89 | 90 | .now 91 | .vercel 92 | .DS_Store 93 | .env.local 94 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2019, Tom Söderlund 2 | 3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. 4 | 5 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 6 | 7 | Source: http://opensource.org/licenses/ISC 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Next.js serverless PWA with TypeScript + GraphQL (Postgraphile, Apollo) and Postgres SQL boilerplate 2 | 3 | **Note:** this is a new version using Postgraphile. See branch `old-graphql-server` if you want the old version. 4 | 5 | ![nextjs-pwa-graphql-sql-boilerplate demo on phone](docs/github_preview.jpg) 6 | 7 | _Note: this is my v3 boilerplate for React web apps. See also my [Firebase and React Hooks boilerplate](https://github.com/tomsoderlund/nextjs-pwa-firebase-boilerplate), [Redux + REST + Postgres SQL boilerplate](https://github.com/tomsoderlund/nextjs-sql-rest-api-boilerplate) and [REST + MongoDB boilerplate](https://github.com/tomsoderlund/nextjs-express-mongoose-crudify-boilerplate)._ 8 | 9 | ## Why is this awesome? 10 | 11 | This is a great template for a any project where you want **React (with Hooks)** (with server-side rendering, powered by [Next.js](https://github.com/vercel/next.js)) as frontend and **GraphQL and Postgres SQL** as backend. 12 | _Lightning fast, all JavaScript._ 13 | 14 | - Great starting point for a [PWA (Progressive Web App)](https://en.wikipedia.org/wiki/Progressive_web_applications). 15 | - Data objects defined in Postgres database, and then propagated all the way to React frontend (with full TypeScript support). 16 | - Both front-end client and GraphQL/SQL server in one project. 17 | - Can be deployed as [serverless functions on Vercel](#deploying-serverless-on-vercel). 18 | - A fast Postgres SQL database server. 19 | - [GraphQL API](#graphql-client-and-server) with Apollo. 20 | - React Hooks for business logic. 21 | - PWA features such as `manifest.json` and offline support (`next-offline`). 22 | - Easy to style the visual theme using CSS (e.g. using [Design Profile Generator](https://tomsoderlund.github.io/design-profile-generator/)). 23 | - `sitemap.xml` and `robots.txt` support. 24 | - Google Analytics and `google-site-verification` support (see `config/config.js`). 25 | - Flexible configuration with `config/config.js` and `.env` file. 26 | - Unit testing with Jasmine (`yarn unit`). 27 | - Code linting and formatting with StandardJS (`yarn lint`/`yarn fix`). 28 | 29 | 30 | ## Demo 31 | 32 | See [**nextjs-pwa-graphql-sql-boilerplate** running on Vercel here](https://nextjs-pwa-graphql-sql-boilerplate.vercel.app/). 33 | 34 | ![nextjs-pwa-graphql-sql-boilerplate demo on phone](docs/demo.jpg) 35 | 36 | ![GraphQL Explorer (/api/graphiql)](docs/graphiql.png) 37 | 38 | ## How to use 39 | 40 | Clone this repository: 41 | 42 | git clone https://github.com/tomsoderlund/nextjs-pwa-graphql-sql-boilerplate.git [MY_APP] 43 | 44 | Remove the .git folder since you want to create a new repository 45 | 46 | rm -rf .git 47 | 48 | Install dependencies: 49 | 50 | cd [MY_APP] 51 | yarn 52 | 53 | Set up Postgres database, either: 54 | 55 | 1. Get a cloud Postgres database from [Vercel](https://vercel.com/docs/storage/vercel-postgres), AWS or similar. 56 | 2. Local Postgres: Install Postgres and set up the database: 57 | 58 | ``` 59 | psql postgres # Start the Postgres command-line client 60 | 61 | CREATE DATABASE "nextjs-pwa-graphql-sql"; -- You can also use \connect to connect to existing database 62 | \connect "nextjs-pwa-graphql-sql"; 63 | CREATE TABLE article (id serial, title varchar(200), content text); -- Create a blank table 64 | INSERT INTO article (title) VALUES ('The first article'); -- Add example data 65 | SELECT * FROM article; -- Check data exists 66 | \q 67 | ``` 68 | 69 | Create the `.env` file, then add `DATABASE_URL`: 70 | 71 | cp .env.example .env.local 72 | 73 | Start it by doing the following: 74 | 75 | yarn dev 76 | 77 | In production: 78 | 79 | yarn build 80 | yarn start 81 | 82 | If you navigate to `http://localhost:3003/` you will see a web page with a list of articles (or an empty list if you haven’t added one). 83 | 84 | ### GraphQL client and server 85 | 86 | Your GraphQL API server is running at `http://localhost:3003/api/graphql`. 87 | Your GraphQL Explorer is running at `http://localhost:3003/api/graphiql`. 88 | [Try the GraphQL Explorer](https://nextjs-pwa-graphql-sql-boilerplate.vercel.app/api/graphiql) on the demo server. 89 | 90 | 91 | ## Modifying the app to your needs 92 | 93 | ### Change app name 94 | 95 | Do search/replace for "nextjs-pwa-graphql-sql-boilerplate" AND "nextjs-pwa-graphql-sql" to something else. 96 | 97 | Change name in `public/manifest.json` 98 | 99 | ### Change port number 100 | 101 | Do search/replace for `3003` to something else. 102 | 103 | ### Renaming “Article” to something else 104 | 105 | The database item is called “Article”, but you probably want something else in your app. 106 | 107 | Rename the files: 108 | 109 | mkdir graphql/collections/{newName} 110 | git mv graphql/collections/article/hooks.ts graphql/collections/{newName} 111 | git mv graphql/collections/article/queries.ts graphql/collections/{newName} 112 | git mv graphql/collections/article/resolvers.ts graphql/collections/{newName} 113 | git mv graphql/collections/article/schema.sql graphql/collections/{newName} 114 | git mv graphql/collections/article/serverQueries.ts graphql/collections/{newName} 115 | rm -r graphql/collections/article 116 | 117 | mkdir -p components/{newName}s 118 | git mv components/articles/CreateArticleForm.tsx components/{newName}s/Create{NewName}Form.tsx 119 | git mv components/articles/ArticleList.tsx components/{newName}s/{NewName}List.tsx 120 | git mv components/articles/ArticleListItem.tsx components/{newName}s/{NewName}ListItem.tsx 121 | git mv components/articles/ArticleDetails.tsx components/{newName}s/{NewName}Details.tsx 122 | rm -r components/articles 123 | 124 | mkdir pages/{newName}s 125 | git mv "pages/articles/[articleSlug].tsx" "pages/{newName}s/[{newName}Slug].tsx" 126 | rm -r pages/articles 127 | 128 | Then, do search/replace inside the files for different casing: article, Article, ARTICLE 129 | 130 | ### Create a new data model/object type 131 | 132 | yarn new:collection # Creates a new folder graphql/newObject with 4 empty JS files inside 133 | 134 | ### How to remove/replace SQL database 135 | 136 | 1. Remove references to `graphql/postgres` 137 | 2. `graphql/collections/article/resolvers.js`: remove “sql*” references 138 | 139 | ### Change visual theme (CSS) 140 | 141 | 1. Change colors in `public/manifest.json` 142 | 2. Change CSS in `public/app.css` 143 | 3. Change font in `PageHead.js` 144 | 145 | ## Todo 146 | 147 | - SSR support: 148 | - https://www.apollographql.com/docs/react/performance/server-side-rendering 149 | - https://www.apollographql.com/blog/next-js-getting-started 150 | - https://github.com/shshaw/next-apollo-ssr 151 | 152 | 153 | ## Support this project 154 | 155 | Did you or your company find `nextjs-pwa-graphql-sql-boilerplate` useful? Please consider giving a small donation, it helps me spend more time on open-source projects: 156 | 157 | [![Support Tom on Ko-Fi.com](https://www.tomsoderlund.com/ko-fi_tomsoderlund_50.png)](https://ko-fi.com/tomsoderlund) 158 | -------------------------------------------------------------------------------- /codegen.ts: -------------------------------------------------------------------------------- 1 | import { CodegenConfig } from '@graphql-codegen/cli' 2 | 3 | const config: CodegenConfig = { 4 | schema: 'http://localhost:3003/api/graphql', 5 | documents: ['graphql/**/*.ts*'], 6 | generates: { 7 | './graphql/__generated__/': { 8 | preset: 'client', 9 | presetConfig: { 10 | gqlTagName: 'gql' 11 | }, 12 | plugins: [ 13 | // 'typescript', 14 | // 'typescript-operations' 15 | ], 16 | config: { 17 | // flattenGeneratedTypes: true, 18 | // flattenGeneratedTypesIncludeFragments: true, 19 | // exportFragmentSpreadSubTypes: true 20 | } 21 | } 22 | }, 23 | ignoreNoDocuments: true 24 | } 25 | 26 | export default config 27 | -------------------------------------------------------------------------------- /components/articles/ArticleDetails.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { Article } from 'graphql/__generated__/graphql' 4 | 5 | const ArticleDetails = ({ article }: { article: Article }): React.ReactElement => { 6 | return ( 7 | <> 8 |

{article.title}

9 |

{article.content}

10 | 11 | ) 12 | } 13 | export default ArticleDetails 14 | -------------------------------------------------------------------------------- /components/articles/ArticleList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { useListArticles } from '../../graphql/collections/article/hooks' 4 | import ArticleListItem from './ArticleListItem' 5 | 6 | const ArticleList = (): React.ReactElement | string => { 7 | const { data, loading, error } = useListArticles() 8 | if (loading) return 'Loading...' 9 | if (error != null) return `Error! ${error.message}` 10 | 11 | return ( 12 | <> 13 | {data?.allArticlesList?.map((article) => ( 14 | 18 | ))} 19 | 20 | ) 21 | } 22 | export default ArticleList 23 | -------------------------------------------------------------------------------- /components/articles/ArticleListItem.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | 3 | import { Article } from 'graphql/__generated__/graphql' 4 | import { articlePath, useUpdateArticle, useDeleteArticle } from '../../graphql/collections/article/hooks' 5 | 6 | type VoidFunction = () => Promise 7 | 8 | const usePromptAndUpdateArticle = (article: Article, fieldName: keyof Article): VoidFunction => { 9 | const updateArticle = useUpdateArticle() 10 | 11 | const handleUpdate = async (): Promise => { 12 | const newValue = window.prompt(`New value for ${fieldName}?`, article[fieldName]) 13 | if (newValue !== null) { 14 | const articlePatch = { 15 | [fieldName]: newValue 16 | } 17 | await updateArticle({ variables: { id: article.id, articlePatch } }) 18 | } 19 | } 20 | 21 | return handleUpdate 22 | } 23 | 24 | const usePromptAndDeleteArticle = (article: Article): VoidFunction => { 25 | const deleteArticle = useDeleteArticle() 26 | 27 | const handleDelete = async (): Promise => { 28 | if (window.confirm(`Delete ${article.title as string}?`)) { 29 | const variables = { 30 | id: article.id 31 | } 32 | await deleteArticle({ variables }) 33 | } 34 | } 35 | 36 | return handleDelete 37 | } 38 | 39 | interface ArticleListItemProps { 40 | article: any 41 | inProgress?: boolean 42 | } 43 | 44 | const ArticleListItem = ({ article, inProgress = false }: ArticleListItemProps): React.ReactElement => { 45 | const promptAndUpdateArticle = usePromptAndUpdateArticle(article, 'title') 46 | const promptAndDeleteArticle = usePromptAndDeleteArticle(article) 47 | 48 | return ( 49 |
50 | {article.title} 51 | { void promptAndUpdateArticle() }}>Update 52 | { void promptAndDeleteArticle() }}>Delete 53 | 73 |
74 | ) 75 | } 76 | export default ArticleListItem 77 | -------------------------------------------------------------------------------- /components/articles/CreateArticleForm.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { ArticleInput } from 'graphql/__generated__/graphql' 4 | import { useCreateArticle } from '../../graphql/collections/article/hooks' 5 | 6 | interface CreateArticleFormProps { 7 | inputs: ArticleInput 8 | handleInputChange: (event: React.ChangeEvent) => void 9 | handleSubmit: (event: React.FormEvent) => Promise 10 | } 11 | 12 | const useCreateArticleForm = (): CreateArticleFormProps => { 13 | const [inputs, setInputs] = React.useState({ title: '' }) 14 | const createArticle = useCreateArticle() 15 | 16 | const handleSubmit = async (event: React.FormEvent): Promise => { 17 | event?.preventDefault() 18 | if (inputs.title === '') { 19 | window.alert('No title provided') 20 | return 21 | } 22 | await createArticle({ variables: { input: { article: inputs } } }) 23 | // Clear input form when done 24 | setInputs({ title: '' }) 25 | } 26 | 27 | const handleInputChange = (event: React.ChangeEvent): void => { 28 | event.persist() 29 | setInputs(inputs => ({ ...inputs, [event.target.name]: event.target.value })) 30 | } 31 | 32 | return { inputs, handleInputChange, handleSubmit } 33 | } 34 | 35 | const CreateArticleForm = (): React.ReactElement => { 36 | const { inputs, handleInputChange, handleSubmit } = useCreateArticleForm() 37 | return ( 38 |
{ void handleSubmit(event) }}> 39 | 47 | 48 | 54 |
55 | ) 56 | } 57 | export default CreateArticleForm 58 | -------------------------------------------------------------------------------- /components/page/Footer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Link from 'next/link' 3 | 4 | import { config } from 'config/config' 5 | 6 | const Footer = (): React.ReactElement => ( 7 |
8 | 9 | About {config.appName} 10 | {' | '} 11 | By Tomorroworld 12 | 13 | {' | '} 14 | Contact 15 | 16 | 34 |
35 | ) 36 | export default Footer 37 | 38 | const TomorroworldLogo = (): React.ReactElement => ( 39 | 40 | Tomorroworld 45 | 57 | 58 | ) 59 | -------------------------------------------------------------------------------- /components/page/GoogleAnalytics.ts: -------------------------------------------------------------------------------- 1 | import { config } from '../../config/config' 2 | import isDevelopment from '../../lib/isDevelopment' 3 | 4 | declare global { 5 | interface Window { 6 | gtag: (event: string, action: string, options: any) => void 7 | } 8 | } 9 | 10 | /* options: { 'page_title' : 'homepage' } */ 11 | // See https://developers.google.com/analytics/devguides/collection/gtagjs 12 | export const googlePageview = (path: string, options?: any): void => { 13 | const completeOptions = Object.assign({}, options, { page_path: path }) // 'page_title' : 'homepage' 14 | if (config.googleAnalyticsId !== undefined && config.googleAnalyticsId !== null) window.gtag('config', config.googleAnalyticsId, completeOptions) 15 | if (isDevelopment()) console.log('Google pageview:', { path, options: completeOptions }) 16 | } 17 | 18 | // options: { 'event_category' : 'bbb', 'event_label' : 'ccc' } 19 | // See https://developers.google.com/analytics/devguides/collection/gtagjs/events 20 | export const googleEvent = (action: string, options?: any): void => { 21 | if (config.googleAnalyticsId !== undefined) window.gtag('event', action, options) 22 | if (isDevelopment()) console.log('Google event:', { action, options }) 23 | } 24 | -------------------------------------------------------------------------------- /components/page/Header.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Link from 'next/link' 3 | import { config } from '../../config/config' 4 | 5 | interface HeaderProps { 6 | title?: string 7 | children?: React.ReactNode 8 | } 9 | 10 | const Header: React.FC = ({ title = config.appName, children }) => ( 11 |
12 | 13 | {title} 14 | {children} 15 | 33 |
34 | ) 35 | 36 | export default Header 37 | 38 | const AppIcon: React.FC = () => ( 39 | 40 | 41 | {config.appName} 42 | 57 | 58 | 59 | ) 60 | -------------------------------------------------------------------------------- /components/page/Notifications.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import { ToastContainer } from 'react-toastify' 4 | import 'react-toastify/dist/ReactToastify.min.css' 5 | 6 | const Notifications = (): React.ReactElement => { 7 | return ( 8 | <> 9 | 10 | 11 | 33 | 34 | ) 35 | } 36 | export default Notifications 37 | -------------------------------------------------------------------------------- /components/page/PageHead.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Head from 'next/head' 3 | 4 | import { config } from 'config/config' 5 | 6 | export interface PageProps { 7 | title?: string 8 | description?: string 9 | imageUrl?: string 10 | iconUrl?: string 11 | path?: string 12 | } 13 | 14 | const PageHead = ({ title, description, imageUrl, iconUrl = '/favicon.png', path = '/' }: PageProps): React.ReactElement | null => { 15 | const pageTitle = (title !== undefined && title !== null) 16 | ? `${(title)} – ${config.appName as string}` 17 | : `${config.appName as string} – ${(config.appTagline as string)}` 18 | 19 | const pageDescription = description ?? config.appDescription ?? '' 20 | 21 | // SEO: title 60 characters, description 160 characters 22 | // if (isDevelopment()) console.log(`PageHead (dev):\n• title (${60 - pageTitle.length}): “${pageTitle}”\n• description (${160 - pageDescription.length}): “${pageDescription}”`) 23 | 24 | const thumbnailUrl = imageUrl ?? `${config.appUrl as string}images/preview_default.png` // ?? `https://screens.myscreenshooterserver.com/?url=${config.appUrl}${path.slice(1)}${(path.includes('?') ? '&' : '?')}thumbnail=true` 25 | 26 | return ( 27 | 28 | {pageTitle} 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | {(thumbnailUrl !== undefined && thumbnailUrl !== null) && ( 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 | export default PageHead 73 | -------------------------------------------------------------------------------- /components/user/SigninWithEmailForm.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | // import { getAuth, sendSignInLinkToEmail } from 'firebase/auth' 3 | 4 | // import { firebaseApp } from 'lib/firebase' 5 | // import showNotification from 'lib/showNotification' 6 | // import makeRestRequest from 'lib/makeRestRequest' 7 | // import { googleEvent } from 'components/page/GoogleAnalytics' 8 | 9 | // const anonymizeEmail = email => email.split('@').map((part, isDomain) => isDomain ? part : part[0] + new Array(part.length).join('•')).join('@') 10 | 11 | interface SimpleEvent { 12 | target: { 13 | name: string 14 | value: string 15 | } 16 | } 17 | 18 | interface SigninWithEmailFormProps { 19 | buttonText?: string 20 | thankyouText?: string 21 | googleEventName?: string 22 | redirectTo?: string 23 | onCompleted?: (error: Error | null, inputs: { email: string }) => void 24 | } 25 | 26 | const SigninWithEmailForm = ({ buttonText = 'Sign in', thankyouText = 'Check your email for a sign-in link!', googleEventName = 'user_login', redirectTo, onCompleted }: SigninWithEmailFormProps): React.ReactElement => { 27 | const [inProgress, setInProgress] = useState(false) 28 | const [isSubmitted, setIsSubmitted] = useState(false) 29 | // const auth = getAuth(firebaseApp) 30 | 31 | const [inputs, setInputs] = useState({ email: '' }) 32 | const handleInputChange = ({ target }: SimpleEvent): void => setInputs({ ...inputs, [target.name]: target.value }) 33 | 34 | const handleSubmit = async (event: React.FormEvent): Promise => { 35 | event.preventDefault() 36 | setInProgress(true) 37 | 38 | try { 39 | // Firebase sign-in with just email link, no password 40 | // const actionCodeSettings = { 41 | // url: `${window.location.origin}/signin/authenticate${redirectTo !== undefined ? `?redirectTo=${encodeURIComponent(redirectTo)}` : ''}`, 42 | // handleCodeInApp: true 43 | // } 44 | // await sendSignInLinkToEmail(auth, inputs.email, actionCodeSettings) 45 | window.localStorage.setItem('emailForSignIn', inputs.email) 46 | // makeRestRequest('/api/notifications', { email: anonymizeEmail(inputs.email) }, { method: 'POST' }) 47 | handleInputChange({ target: { name: 'email', value: '' } }) 48 | setIsSubmitted(true) 49 | // if (googleEventName) googleEvent(googleEventName) 50 | onCompleted?.(null, inputs) 51 | } catch (error: any) { 52 | console.warn(error.message as string) 53 | // showNotification(`Could not sign in: ${error.message}`, 'error') 54 | onCompleted?.(null, inputs) 55 | } finally { 56 | setInProgress(false) 57 | } 58 | } 59 | 60 | return ( 61 |
62 | {!isSubmitted 63 | ? ( 64 | <> 65 |
{ void handleSubmit(event) }}> 66 | 77 | 84 |
85 |
86 | (No password necessary, we will send a sign-in link to your email inbox) 87 |
88 | 89 | ) 90 | : ( 91 |
{thankyouText}
92 | )} 93 |
94 | ) 95 | } 96 | 97 | export default SigninWithEmailForm 98 | -------------------------------------------------------------------------------- /components/user/SigninWithGoogleButton.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useRouter } from 'next/router' 3 | import Image from 'next/image' 4 | // import { getAuth, GoogleAuthProvider, signInWithPopup } from 'firebase/auth' 5 | 6 | import { config } from 'config/config' 7 | // import { firebaseApp } from 'lib/firebase' 8 | 9 | interface SigninWithGoogleButtonProps { 10 | googleEventName?: string 11 | redirectTo?: string 12 | } 13 | 14 | const SigninWithGoogleButton = ({ redirectTo = config.startPagePath ?? '/' }: SigninWithGoogleButtonProps): React.ReactElement => { 15 | const router = useRouter() 16 | // const auth = getAuth(firebaseApp) 17 | // const provider = new GoogleAuthProvider() 18 | 19 | const handleGoogleSignin = async (event: React.MouseEvent): Promise => { 20 | event.preventDefault() 21 | // See https://firebase.google.com/docs/auth/web/google-signin 22 | // await signInWithPopup(auth, provider) 23 | // const result = await signInWithPopup(auth, provider) 24 | // const user = result.user 25 | void router.push(redirectTo) 26 | } 27 | return ( 28 |
29 | 33 |
34 | ) 35 | } 36 | export default SigninWithGoogleButton 37 | -------------------------------------------------------------------------------- /config/config.ts: -------------------------------------------------------------------------------- 1 | import packageJson from '../package.json' 2 | import manifest from '../public/manifest.json' 3 | 4 | export const environment = process.env.NODE_ENV 5 | export const isDevelopment = process.env.NODE_ENV === 'development' 6 | const appSlug = 'nextjs-pwa-graphql-sql' // packageJson.name 7 | const serverPort = parseInt(process.env.PORT ?? '3003') 8 | 9 | interface EnvironmentConfiguration { 10 | appSlug: string 11 | appVersion: string 12 | appUrl: string 13 | appName: string 14 | appTagline?: string 15 | appDescription?: string 16 | serverPort: number 17 | locale?: string 18 | googleAnalyticsId?: string | null 19 | fonts?: string[][] 20 | 21 | startPagePath?: string 22 | graphqlPath?: string 23 | databaseUrl?: string 24 | allowedHostsList?: string[] 25 | 26 | isProduction?: boolean 27 | sendRealMessages?: boolean 28 | } 29 | 30 | interface AllConfigurations { 31 | default?: EnvironmentConfiguration 32 | development?: Partial 33 | production?: Partial 34 | test?: Partial 35 | } 36 | 37 | const completeConfig: AllConfigurations = { 38 | 39 | default: { 40 | serverPort, 41 | appSlug, 42 | appVersion: packageJson.version, 43 | appUrl: process.env.APP_URL ?? 'https://nextjs-pwa-graphql-sql-boilerplate.vercel.app/', 44 | appName: manifest.name, 45 | appTagline: manifest.description, 46 | appDescription: manifest.description, 47 | locale: 'en_US', 48 | fonts: [ 49 | ['Inter', 'wght@300;400;500;700'] 50 | ], 51 | googleAnalyticsId: 'G-XXXXXXXXXX', 52 | databaseUrl: process.env.DATABASE_URL, 53 | graphqlPath: '/api/graphql' 54 | }, 55 | 56 | development: { 57 | appUrl: `http://localhost:${serverPort}/`, 58 | googleAnalyticsId: null 59 | }, 60 | 61 | production: { 62 | } 63 | 64 | } 65 | 66 | // Public API 67 | export const config = { ...completeConfig.default, ...completeConfig[environment] } 68 | export default config 69 | -------------------------------------------------------------------------------- /docs/demo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomsoderlund/nextjs-pwa-graphql-sql-boilerplate/5313c3679e00ea64e841480ac2e5f53747637002/docs/demo.jpg -------------------------------------------------------------------------------- /docs/github_preview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomsoderlund/nextjs-pwa-graphql-sql-boilerplate/5313c3679e00ea64e841480ac2e5f53747637002/docs/github_preview.jpg -------------------------------------------------------------------------------- /docs/graphiql.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomsoderlund/nextjs-pwa-graphql-sql-boilerplate/5313c3679e00ea64e841480ac2e5f53747637002/docs/graphiql.png -------------------------------------------------------------------------------- /graphql/__generated__/fragment-masking.ts: -------------------------------------------------------------------------------- 1 | import { ResultOf, DocumentTypeDecoration, TypedDocumentNode } from '@graphql-typed-document-node/core'; 2 | import { FragmentDefinitionNode } from 'graphql'; 3 | import { Incremental } from './graphql'; 4 | 5 | 6 | export type FragmentType> = TDocumentType extends DocumentTypeDecoration< 7 | infer TType, 8 | any 9 | > 10 | ? [TType] extends [{ ' $fragmentName'?: infer TKey }] 11 | ? TKey extends string 12 | ? { ' $fragmentRefs'?: { [key in TKey]: TType } } 13 | : never 14 | : never 15 | : never; 16 | 17 | // return non-nullable if `fragmentType` is non-nullable 18 | export function useFragment( 19 | _documentNode: DocumentTypeDecoration, 20 | fragmentType: FragmentType> 21 | ): TType; 22 | // return nullable if `fragmentType` is nullable 23 | export function useFragment( 24 | _documentNode: DocumentTypeDecoration, 25 | fragmentType: FragmentType> | null | undefined 26 | ): TType | null | undefined; 27 | // return array of non-nullable if `fragmentType` is array of non-nullable 28 | export function useFragment( 29 | _documentNode: DocumentTypeDecoration, 30 | fragmentType: ReadonlyArray>> 31 | ): ReadonlyArray; 32 | // return array of nullable if `fragmentType` is array of nullable 33 | export function useFragment( 34 | _documentNode: DocumentTypeDecoration, 35 | fragmentType: ReadonlyArray>> | null | undefined 36 | ): ReadonlyArray | null | undefined; 37 | export function useFragment( 38 | _documentNode: DocumentTypeDecoration, 39 | fragmentType: FragmentType> | ReadonlyArray>> | null | undefined 40 | ): TType | ReadonlyArray | null | undefined { 41 | return fragmentType as any; 42 | } 43 | 44 | 45 | export function makeFragmentData< 46 | F extends DocumentTypeDecoration, 47 | FT extends ResultOf 48 | >(data: FT, _fragment: F): FragmentType { 49 | return data as FragmentType; 50 | } 51 | export function isFragmentReady( 52 | queryNode: DocumentTypeDecoration, 53 | fragmentNode: TypedDocumentNode, 54 | data: FragmentType, any>> | null | undefined 55 | ): data is FragmentType { 56 | const deferredFields = (queryNode as { __meta__?: { deferredFields: Record } }).__meta__ 57 | ?.deferredFields; 58 | 59 | if (!deferredFields) return true; 60 | 61 | const fragDef = fragmentNode.definitions[0] as FragmentDefinitionNode | undefined; 62 | const fragName = fragDef?.name?.value; 63 | 64 | const fields = (fragName && deferredFields[fragName]) || []; 65 | return fields.length > 0 && fields.every(field => data && field in data); 66 | } 67 | -------------------------------------------------------------------------------- /graphql/__generated__/gql.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import * as types from './graphql'; 3 | import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; 4 | 5 | /** 6 | * Map of all GraphQL operations in the project. 7 | * 8 | * This map has several performance disadvantages: 9 | * 1. It is not tree-shakeable, so it will include all operations in the project. 10 | * 2. It is not minifiable, so the string of a GraphQL query will be multiple times inside the bundle. 11 | * 3. It does not support dead code elimination, so it will add unused operations. 12 | * 13 | * Therefore it is highly recommended to use the babel or swc plugin for production. 14 | */ 15 | const documents = { 16 | "\n fragment ArticleShortInfo on Article {\n id\n title\n createdDate\n }\n": types.ArticleShortInfoFragmentDoc, 17 | "\n query ListArticles {\n allArticlesList (orderBy: CREATED_DATE_DESC) {\n id\n ...ArticleShortInfo\n }\n }\n": types.ListArticlesDocument, 18 | "\n query GetArticle ($id: Int!) {\n articleById(id: $id) {\n id\n ...ArticleShortInfo\n }\n }\n": types.GetArticleDocument, 19 | "\n mutation CreateArticle ($input: CreateArticleInput!) {\n createArticle(input: $input) {\n article {\n id\n ...ArticleShortInfo\n }\n }\n }\n": types.CreateArticleDocument, 20 | "\n mutation UpdateArticle ($id: Int!, $articlePatch: ArticlePatch!) {\n updateArticleById(input: {id: $id, articlePatch: $articlePatch}) {\n article {\n ...ArticleShortInfo\n }\n }\n }\n": types.UpdateArticleDocument, 21 | "\n mutation DeleteArticle ($id: Int!) {\n deleteArticleById(input: {id: $id}) {\n article {\n ...ArticleShortInfo\n }\n }\n }\n": types.DeleteArticleDocument, 22 | "\n fragment UserShortInfo on User {\n id\n firebaseUid\n }\n": types.UserShortInfoFragmentDoc, 23 | "\n query GetUser ($firebaseUid: String!) {\n userByFirebaseUid(firebaseUid: $firebaseUid) {\n id\n }\n }\n": types.GetUserDocument, 24 | "\n mutation CreateUser ($input: CreateUserInput!) {\n createUser(input: $input) {\n user {\n ...UserShortInfo\n }\n }\n }\n": types.CreateUserDocument, 25 | }; 26 | 27 | /** 28 | * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. 29 | * 30 | * 31 | * @example 32 | * ```ts 33 | * const query = gql(`query GetUser($id: ID!) { user(id: $id) { name } }`); 34 | * ``` 35 | * 36 | * The query argument is unknown! 37 | * Please regenerate the types. 38 | */ 39 | export function gql(source: string): unknown; 40 | 41 | /** 42 | * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. 43 | */ 44 | export function gql(source: "\n fragment ArticleShortInfo on Article {\n id\n title\n createdDate\n }\n"): (typeof documents)["\n fragment ArticleShortInfo on Article {\n id\n title\n createdDate\n }\n"]; 45 | /** 46 | * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. 47 | */ 48 | export function gql(source: "\n query ListArticles {\n allArticlesList (orderBy: CREATED_DATE_DESC) {\n id\n ...ArticleShortInfo\n }\n }\n"): (typeof documents)["\n query ListArticles {\n allArticlesList (orderBy: CREATED_DATE_DESC) {\n id\n ...ArticleShortInfo\n }\n }\n"]; 49 | /** 50 | * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. 51 | */ 52 | export function gql(source: "\n query GetArticle ($id: Int!) {\n articleById(id: $id) {\n id\n ...ArticleShortInfo\n }\n }\n"): (typeof documents)["\n query GetArticle ($id: Int!) {\n articleById(id: $id) {\n id\n ...ArticleShortInfo\n }\n }\n"]; 53 | /** 54 | * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. 55 | */ 56 | export function gql(source: "\n mutation CreateArticle ($input: CreateArticleInput!) {\n createArticle(input: $input) {\n article {\n id\n ...ArticleShortInfo\n }\n }\n }\n"): (typeof documents)["\n mutation CreateArticle ($input: CreateArticleInput!) {\n createArticle(input: $input) {\n article {\n id\n ...ArticleShortInfo\n }\n }\n }\n"]; 57 | /** 58 | * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. 59 | */ 60 | export function gql(source: "\n mutation UpdateArticle ($id: Int!, $articlePatch: ArticlePatch!) {\n updateArticleById(input: {id: $id, articlePatch: $articlePatch}) {\n article {\n ...ArticleShortInfo\n }\n }\n }\n"): (typeof documents)["\n mutation UpdateArticle ($id: Int!, $articlePatch: ArticlePatch!) {\n updateArticleById(input: {id: $id, articlePatch: $articlePatch}) {\n article {\n ...ArticleShortInfo\n }\n }\n }\n"]; 61 | /** 62 | * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. 63 | */ 64 | export function gql(source: "\n mutation DeleteArticle ($id: Int!) {\n deleteArticleById(input: {id: $id}) {\n article {\n ...ArticleShortInfo\n }\n }\n }\n"): (typeof documents)["\n mutation DeleteArticle ($id: Int!) {\n deleteArticleById(input: {id: $id}) {\n article {\n ...ArticleShortInfo\n }\n }\n }\n"]; 65 | /** 66 | * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. 67 | */ 68 | export function gql(source: "\n fragment UserShortInfo on User {\n id\n firebaseUid\n }\n"): (typeof documents)["\n fragment UserShortInfo on User {\n id\n firebaseUid\n }\n"]; 69 | /** 70 | * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. 71 | */ 72 | export function gql(source: "\n query GetUser ($firebaseUid: String!) {\n userByFirebaseUid(firebaseUid: $firebaseUid) {\n id\n }\n }\n"): (typeof documents)["\n query GetUser ($firebaseUid: String!) {\n userByFirebaseUid(firebaseUid: $firebaseUid) {\n id\n }\n }\n"]; 73 | /** 74 | * The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. 75 | */ 76 | export function gql(source: "\n mutation CreateUser ($input: CreateUserInput!) {\n createUser(input: $input) {\n user {\n ...UserShortInfo\n }\n }\n }\n"): (typeof documents)["\n mutation CreateUser ($input: CreateUserInput!) {\n createUser(input: $input) {\n user {\n ...UserShortInfo\n }\n }\n }\n"]; 77 | 78 | export function gql(source: string) { 79 | return (documents as any)[source] ?? {}; 80 | } 81 | 82 | export type DocumentType> = TDocumentNode extends DocumentNode< infer TType, any> ? TType : never; -------------------------------------------------------------------------------- /graphql/__generated__/graphql.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; 3 | export type Maybe = T | null; 4 | export type InputMaybe = Maybe; 5 | export type Exact = { [K in keyof T]: T[K] }; 6 | export type MakeOptional = Omit & { [SubKey in K]?: Maybe }; 7 | export type MakeMaybe = Omit & { [SubKey in K]: Maybe }; 8 | export type MakeEmpty = { [_ in K]?: never }; 9 | export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; 10 | /** All built-in and custom scalars, mapped to their actual values */ 11 | export type Scalars = { 12 | ID: { input: string; output: string; } 13 | String: { input: string; output: string; } 14 | Boolean: { input: boolean; output: boolean; } 15 | Int: { input: number; output: number; } 16 | Float: { input: number; output: number; } 17 | /** 18 | * A point in time as described by the [ISO 19 | * 8601](https://en.wikipedia.org/wiki/ISO_8601) standard. May or may not include a timezone. 20 | */ 21 | Datetime: { input: any; output: any; } 22 | }; 23 | 24 | export type Article = Node & { 25 | __typename?: 'Article'; 26 | content?: Maybe; 27 | createdDate?: Maybe; 28 | id: Scalars['Int']['output']; 29 | /** A globally unique identifier. Can be used in various places throughout the system to identify this single value. */ 30 | nodeId: Scalars['ID']['output']; 31 | title?: Maybe; 32 | }; 33 | 34 | /** A condition to be used against `Article` object types. All fields are tested for equality and combined with a logical ‘and.’ */ 35 | export type ArticleCondition = { 36 | /** Checks for equality with the object’s `content` field. */ 37 | content?: InputMaybe; 38 | /** Checks for equality with the object’s `createdDate` field. */ 39 | createdDate?: InputMaybe; 40 | /** Checks for equality with the object’s `id` field. */ 41 | id?: InputMaybe; 42 | /** Checks for equality with the object’s `title` field. */ 43 | title?: InputMaybe; 44 | }; 45 | 46 | /** An input for mutations affecting `Article` */ 47 | export type ArticleInput = { 48 | content?: InputMaybe; 49 | createdDate?: InputMaybe; 50 | id?: InputMaybe; 51 | title?: InputMaybe; 52 | }; 53 | 54 | /** Represents an update to a `Article`. Fields that are set will be updated. */ 55 | export type ArticlePatch = { 56 | content?: InputMaybe; 57 | createdDate?: InputMaybe; 58 | id?: InputMaybe; 59 | title?: InputMaybe; 60 | }; 61 | 62 | /** Methods to use when ordering `Article`. */ 63 | export enum ArticlesOrderBy { 64 | ContentAsc = 'CONTENT_ASC', 65 | ContentDesc = 'CONTENT_DESC', 66 | CreatedDateAsc = 'CREATED_DATE_ASC', 67 | CreatedDateDesc = 'CREATED_DATE_DESC', 68 | IdAsc = 'ID_ASC', 69 | IdDesc = 'ID_DESC', 70 | Natural = 'NATURAL', 71 | PrimaryKeyAsc = 'PRIMARY_KEY_ASC', 72 | PrimaryKeyDesc = 'PRIMARY_KEY_DESC', 73 | TitleAsc = 'TITLE_ASC', 74 | TitleDesc = 'TITLE_DESC' 75 | } 76 | 77 | /** All input for the create `Article` mutation. */ 78 | export type CreateArticleInput = { 79 | /** The `Article` to be created by this mutation. */ 80 | article: ArticleInput; 81 | /** 82 | * An arbitrary string value with no semantic meaning. Will be included in the 83 | * payload verbatim. May be used to track mutations by the client. 84 | */ 85 | clientMutationId?: InputMaybe; 86 | }; 87 | 88 | /** The output of our create `Article` mutation. */ 89 | export type CreateArticlePayload = { 90 | __typename?: 'CreateArticlePayload'; 91 | /** The `Article` that was created by this mutation. */ 92 | article?: Maybe
; 93 | /** 94 | * The exact same `clientMutationId` that was provided in the mutation input, 95 | * unchanged and unused. May be used by a client to track mutations. 96 | */ 97 | clientMutationId?: Maybe; 98 | /** Our root query field type. Allows us to run any query from our mutation payload. */ 99 | query?: Maybe; 100 | }; 101 | 102 | /** All input for the create `User` mutation. */ 103 | export type CreateUserInput = { 104 | /** 105 | * An arbitrary string value with no semantic meaning. Will be included in the 106 | * payload verbatim. May be used to track mutations by the client. 107 | */ 108 | clientMutationId?: InputMaybe; 109 | /** The `User` to be created by this mutation. */ 110 | user: UserInput; 111 | }; 112 | 113 | /** The output of our create `User` mutation. */ 114 | export type CreateUserPayload = { 115 | __typename?: 'CreateUserPayload'; 116 | /** 117 | * The exact same `clientMutationId` that was provided in the mutation input, 118 | * unchanged and unused. May be used by a client to track mutations. 119 | */ 120 | clientMutationId?: Maybe; 121 | /** Our root query field type. Allows us to run any query from our mutation payload. */ 122 | query?: Maybe; 123 | /** The `User` that was created by this mutation. */ 124 | user?: Maybe; 125 | }; 126 | 127 | /** All input for the `deleteArticleById` mutation. */ 128 | export type DeleteArticleByIdInput = { 129 | /** 130 | * An arbitrary string value with no semantic meaning. Will be included in the 131 | * payload verbatim. May be used to track mutations by the client. 132 | */ 133 | clientMutationId?: InputMaybe; 134 | id: Scalars['Int']['input']; 135 | }; 136 | 137 | /** All input for the `deleteArticle` mutation. */ 138 | export type DeleteArticleInput = { 139 | /** 140 | * An arbitrary string value with no semantic meaning. Will be included in the 141 | * payload verbatim. May be used to track mutations by the client. 142 | */ 143 | clientMutationId?: InputMaybe; 144 | /** The globally unique `ID` which will identify a single `Article` to be deleted. */ 145 | nodeId: Scalars['ID']['input']; 146 | }; 147 | 148 | /** The output of our delete `Article` mutation. */ 149 | export type DeleteArticlePayload = { 150 | __typename?: 'DeleteArticlePayload'; 151 | /** The `Article` that was deleted by this mutation. */ 152 | article?: Maybe
; 153 | /** 154 | * The exact same `clientMutationId` that was provided in the mutation input, 155 | * unchanged and unused. May be used by a client to track mutations. 156 | */ 157 | clientMutationId?: Maybe; 158 | deletedArticleId?: Maybe; 159 | /** Our root query field type. Allows us to run any query from our mutation payload. */ 160 | query?: Maybe; 161 | }; 162 | 163 | /** All input for the `deleteUserByFirebaseUid` mutation. */ 164 | export type DeleteUserByFirebaseUidInput = { 165 | /** 166 | * An arbitrary string value with no semantic meaning. Will be included in the 167 | * payload verbatim. May be used to track mutations by the client. 168 | */ 169 | clientMutationId?: InputMaybe; 170 | firebaseUid: Scalars['String']['input']; 171 | }; 172 | 173 | /** All input for the `deleteUserById` mutation. */ 174 | export type DeleteUserByIdInput = { 175 | /** 176 | * An arbitrary string value with no semantic meaning. Will be included in the 177 | * payload verbatim. May be used to track mutations by the client. 178 | */ 179 | clientMutationId?: InputMaybe; 180 | id: Scalars['Int']['input']; 181 | }; 182 | 183 | /** All input for the `deleteUser` mutation. */ 184 | export type DeleteUserInput = { 185 | /** 186 | * An arbitrary string value with no semantic meaning. Will be included in the 187 | * payload verbatim. May be used to track mutations by the client. 188 | */ 189 | clientMutationId?: InputMaybe; 190 | /** The globally unique `ID` which will identify a single `User` to be deleted. */ 191 | nodeId: Scalars['ID']['input']; 192 | }; 193 | 194 | /** The output of our delete `User` mutation. */ 195 | export type DeleteUserPayload = { 196 | __typename?: 'DeleteUserPayload'; 197 | /** 198 | * The exact same `clientMutationId` that was provided in the mutation input, 199 | * unchanged and unused. May be used by a client to track mutations. 200 | */ 201 | clientMutationId?: Maybe; 202 | deletedUserId?: Maybe; 203 | /** Our root query field type. Allows us to run any query from our mutation payload. */ 204 | query?: Maybe; 205 | /** The `User` that was deleted by this mutation. */ 206 | user?: Maybe; 207 | }; 208 | 209 | /** The root mutation type which contains root level fields which mutate data. */ 210 | export type Mutation = { 211 | __typename?: 'Mutation'; 212 | /** Creates a single `Article`. */ 213 | createArticle?: Maybe; 214 | /** Creates a single `User`. */ 215 | createUser?: Maybe; 216 | /** Deletes a single `Article` using its globally unique id. */ 217 | deleteArticle?: Maybe; 218 | /** Deletes a single `Article` using a unique key. */ 219 | deleteArticleById?: Maybe; 220 | /** Deletes a single `User` using its globally unique id. */ 221 | deleteUser?: Maybe; 222 | /** Deletes a single `User` using a unique key. */ 223 | deleteUserByFirebaseUid?: Maybe; 224 | /** Deletes a single `User` using a unique key. */ 225 | deleteUserById?: Maybe; 226 | /** Updates a single `Article` using its globally unique id and a patch. */ 227 | updateArticle?: Maybe; 228 | /** Updates a single `Article` using a unique key and a patch. */ 229 | updateArticleById?: Maybe; 230 | /** Updates a single `User` using its globally unique id and a patch. */ 231 | updateUser?: Maybe; 232 | /** Updates a single `User` using a unique key and a patch. */ 233 | updateUserByFirebaseUid?: Maybe; 234 | /** Updates a single `User` using a unique key and a patch. */ 235 | updateUserById?: Maybe; 236 | }; 237 | 238 | 239 | /** The root mutation type which contains root level fields which mutate data. */ 240 | export type MutationCreateArticleArgs = { 241 | input: CreateArticleInput; 242 | }; 243 | 244 | 245 | /** The root mutation type which contains root level fields which mutate data. */ 246 | export type MutationCreateUserArgs = { 247 | input: CreateUserInput; 248 | }; 249 | 250 | 251 | /** The root mutation type which contains root level fields which mutate data. */ 252 | export type MutationDeleteArticleArgs = { 253 | input: DeleteArticleInput; 254 | }; 255 | 256 | 257 | /** The root mutation type which contains root level fields which mutate data. */ 258 | export type MutationDeleteArticleByIdArgs = { 259 | input: DeleteArticleByIdInput; 260 | }; 261 | 262 | 263 | /** The root mutation type which contains root level fields which mutate data. */ 264 | export type MutationDeleteUserArgs = { 265 | input: DeleteUserInput; 266 | }; 267 | 268 | 269 | /** The root mutation type which contains root level fields which mutate data. */ 270 | export type MutationDeleteUserByFirebaseUidArgs = { 271 | input: DeleteUserByFirebaseUidInput; 272 | }; 273 | 274 | 275 | /** The root mutation type which contains root level fields which mutate data. */ 276 | export type MutationDeleteUserByIdArgs = { 277 | input: DeleteUserByIdInput; 278 | }; 279 | 280 | 281 | /** The root mutation type which contains root level fields which mutate data. */ 282 | export type MutationUpdateArticleArgs = { 283 | input: UpdateArticleInput; 284 | }; 285 | 286 | 287 | /** The root mutation type which contains root level fields which mutate data. */ 288 | export type MutationUpdateArticleByIdArgs = { 289 | input: UpdateArticleByIdInput; 290 | }; 291 | 292 | 293 | /** The root mutation type which contains root level fields which mutate data. */ 294 | export type MutationUpdateUserArgs = { 295 | input: UpdateUserInput; 296 | }; 297 | 298 | 299 | /** The root mutation type which contains root level fields which mutate data. */ 300 | export type MutationUpdateUserByFirebaseUidArgs = { 301 | input: UpdateUserByFirebaseUidInput; 302 | }; 303 | 304 | 305 | /** The root mutation type which contains root level fields which mutate data. */ 306 | export type MutationUpdateUserByIdArgs = { 307 | input: UpdateUserByIdInput; 308 | }; 309 | 310 | /** An object with a globally unique `ID`. */ 311 | export type Node = { 312 | /** A globally unique identifier. Can be used in various places throughout the system to identify this single value. */ 313 | nodeId: Scalars['ID']['output']; 314 | }; 315 | 316 | /** The root query type which gives access points into the data universe. */ 317 | export type Query = Node & { 318 | __typename?: 'Query'; 319 | /** Reads a set of `Article`. */ 320 | allArticlesList?: Maybe>; 321 | /** Reads a set of `User`. */ 322 | allUsersList?: Maybe>; 323 | /** Reads a single `Article` using its globally unique `ID`. */ 324 | article?: Maybe
; 325 | articleById?: Maybe
; 326 | /** Fetches an object given its globally unique `ID`. */ 327 | node?: Maybe; 328 | /** The root query type must be a `Node` to work well with Relay 1 mutations. This just resolves to `query`. */ 329 | nodeId: Scalars['ID']['output']; 330 | /** 331 | * Exposes the root query type nested one level down. This is helpful for Relay 1 332 | * which can only query top level fields if they are in a particular form. 333 | */ 334 | query: Query; 335 | /** Reads a single `User` using its globally unique `ID`. */ 336 | user?: Maybe; 337 | userByFirebaseUid?: Maybe; 338 | userById?: Maybe; 339 | }; 340 | 341 | 342 | /** The root query type which gives access points into the data universe. */ 343 | export type QueryAllArticlesListArgs = { 344 | condition?: InputMaybe; 345 | first?: InputMaybe; 346 | offset?: InputMaybe; 347 | orderBy?: InputMaybe>; 348 | }; 349 | 350 | 351 | /** The root query type which gives access points into the data universe. */ 352 | export type QueryAllUsersListArgs = { 353 | condition?: InputMaybe; 354 | first?: InputMaybe; 355 | offset?: InputMaybe; 356 | orderBy?: InputMaybe>; 357 | }; 358 | 359 | 360 | /** The root query type which gives access points into the data universe. */ 361 | export type QueryArticleArgs = { 362 | nodeId: Scalars['ID']['input']; 363 | }; 364 | 365 | 366 | /** The root query type which gives access points into the data universe. */ 367 | export type QueryArticleByIdArgs = { 368 | id: Scalars['Int']['input']; 369 | }; 370 | 371 | 372 | /** The root query type which gives access points into the data universe. */ 373 | export type QueryNodeArgs = { 374 | nodeId: Scalars['ID']['input']; 375 | }; 376 | 377 | 378 | /** The root query type which gives access points into the data universe. */ 379 | export type QueryUserArgs = { 380 | nodeId: Scalars['ID']['input']; 381 | }; 382 | 383 | 384 | /** The root query type which gives access points into the data universe. */ 385 | export type QueryUserByFirebaseUidArgs = { 386 | firebaseUid: Scalars['String']['input']; 387 | }; 388 | 389 | 390 | /** The root query type which gives access points into the data universe. */ 391 | export type QueryUserByIdArgs = { 392 | id: Scalars['Int']['input']; 393 | }; 394 | 395 | /** All input for the `updateArticleById` mutation. */ 396 | export type UpdateArticleByIdInput = { 397 | /** An object where the defined keys will be set on the `Article` being updated. */ 398 | articlePatch: ArticlePatch; 399 | /** 400 | * An arbitrary string value with no semantic meaning. Will be included in the 401 | * payload verbatim. May be used to track mutations by the client. 402 | */ 403 | clientMutationId?: InputMaybe; 404 | id: Scalars['Int']['input']; 405 | }; 406 | 407 | /** All input for the `updateArticle` mutation. */ 408 | export type UpdateArticleInput = { 409 | /** An object where the defined keys will be set on the `Article` being updated. */ 410 | articlePatch: ArticlePatch; 411 | /** 412 | * An arbitrary string value with no semantic meaning. Will be included in the 413 | * payload verbatim. May be used to track mutations by the client. 414 | */ 415 | clientMutationId?: InputMaybe; 416 | /** The globally unique `ID` which will identify a single `Article` to be updated. */ 417 | nodeId: Scalars['ID']['input']; 418 | }; 419 | 420 | /** The output of our update `Article` mutation. */ 421 | export type UpdateArticlePayload = { 422 | __typename?: 'UpdateArticlePayload'; 423 | /** The `Article` that was updated by this mutation. */ 424 | article?: Maybe
; 425 | /** 426 | * The exact same `clientMutationId` that was provided in the mutation input, 427 | * unchanged and unused. May be used by a client to track mutations. 428 | */ 429 | clientMutationId?: Maybe; 430 | /** Our root query field type. Allows us to run any query from our mutation payload. */ 431 | query?: Maybe; 432 | }; 433 | 434 | /** All input for the `updateUserByFirebaseUid` mutation. */ 435 | export type UpdateUserByFirebaseUidInput = { 436 | /** 437 | * An arbitrary string value with no semantic meaning. Will be included in the 438 | * payload verbatim. May be used to track mutations by the client. 439 | */ 440 | clientMutationId?: InputMaybe; 441 | firebaseUid: Scalars['String']['input']; 442 | /** An object where the defined keys will be set on the `User` being updated. */ 443 | userPatch: UserPatch; 444 | }; 445 | 446 | /** All input for the `updateUserById` mutation. */ 447 | export type UpdateUserByIdInput = { 448 | /** 449 | * An arbitrary string value with no semantic meaning. Will be included in the 450 | * payload verbatim. May be used to track mutations by the client. 451 | */ 452 | clientMutationId?: InputMaybe; 453 | id: Scalars['Int']['input']; 454 | /** An object where the defined keys will be set on the `User` being updated. */ 455 | userPatch: UserPatch; 456 | }; 457 | 458 | /** All input for the `updateUser` mutation. */ 459 | export type UpdateUserInput = { 460 | /** 461 | * An arbitrary string value with no semantic meaning. Will be included in the 462 | * payload verbatim. May be used to track mutations by the client. 463 | */ 464 | clientMutationId?: InputMaybe; 465 | /** The globally unique `ID` which will identify a single `User` to be updated. */ 466 | nodeId: Scalars['ID']['input']; 467 | /** An object where the defined keys will be set on the `User` being updated. */ 468 | userPatch: UserPatch; 469 | }; 470 | 471 | /** The output of our update `User` mutation. */ 472 | export type UpdateUserPayload = { 473 | __typename?: 'UpdateUserPayload'; 474 | /** 475 | * The exact same `clientMutationId` that was provided in the mutation input, 476 | * unchanged and unused. May be used by a client to track mutations. 477 | */ 478 | clientMutationId?: Maybe; 479 | /** Our root query field type. Allows us to run any query from our mutation payload. */ 480 | query?: Maybe; 481 | /** The `User` that was updated by this mutation. */ 482 | user?: Maybe; 483 | }; 484 | 485 | export type User = Node & { 486 | __typename?: 'User'; 487 | createdDate: Scalars['Datetime']['output']; 488 | firebaseUid: Scalars['String']['output']; 489 | id: Scalars['Int']['output']; 490 | isSystemAdmin: Scalars['Boolean']['output']; 491 | name?: Maybe; 492 | /** A globally unique identifier. Can be used in various places throughout the system to identify this single value. */ 493 | nodeId: Scalars['ID']['output']; 494 | }; 495 | 496 | /** A condition to be used against `User` object types. All fields are tested for equality and combined with a logical ‘and.’ */ 497 | export type UserCondition = { 498 | /** Checks for equality with the object’s `createdDate` field. */ 499 | createdDate?: InputMaybe; 500 | /** Checks for equality with the object’s `firebaseUid` field. */ 501 | firebaseUid?: InputMaybe; 502 | /** Checks for equality with the object’s `id` field. */ 503 | id?: InputMaybe; 504 | /** Checks for equality with the object’s `isSystemAdmin` field. */ 505 | isSystemAdmin?: InputMaybe; 506 | /** Checks for equality with the object’s `name` field. */ 507 | name?: InputMaybe; 508 | }; 509 | 510 | /** An input for mutations affecting `User` */ 511 | export type UserInput = { 512 | createdDate?: InputMaybe; 513 | firebaseUid: Scalars['String']['input']; 514 | id?: InputMaybe; 515 | isSystemAdmin?: InputMaybe; 516 | name?: InputMaybe; 517 | }; 518 | 519 | /** Represents an update to a `User`. Fields that are set will be updated. */ 520 | export type UserPatch = { 521 | createdDate?: InputMaybe; 522 | firebaseUid?: InputMaybe; 523 | id?: InputMaybe; 524 | isSystemAdmin?: InputMaybe; 525 | name?: InputMaybe; 526 | }; 527 | 528 | /** Methods to use when ordering `User`. */ 529 | export enum UsersOrderBy { 530 | CreatedDateAsc = 'CREATED_DATE_ASC', 531 | CreatedDateDesc = 'CREATED_DATE_DESC', 532 | FirebaseUidAsc = 'FIREBASE_UID_ASC', 533 | FirebaseUidDesc = 'FIREBASE_UID_DESC', 534 | IdAsc = 'ID_ASC', 535 | IdDesc = 'ID_DESC', 536 | IsSystemAdminAsc = 'IS_SYSTEM_ADMIN_ASC', 537 | IsSystemAdminDesc = 'IS_SYSTEM_ADMIN_DESC', 538 | NameAsc = 'NAME_ASC', 539 | NameDesc = 'NAME_DESC', 540 | Natural = 'NATURAL', 541 | PrimaryKeyAsc = 'PRIMARY_KEY_ASC', 542 | PrimaryKeyDesc = 'PRIMARY_KEY_DESC' 543 | } 544 | 545 | export type ArticleShortInfoFragment = { __typename?: 'Article', id: number, title?: string | null, createdDate?: any | null } & { ' $fragmentName'?: 'ArticleShortInfoFragment' }; 546 | 547 | export type ListArticlesQueryVariables = Exact<{ [key: string]: never; }>; 548 | 549 | 550 | export type ListArticlesQuery = { __typename?: 'Query', allArticlesList?: Array<( 551 | { __typename?: 'Article', id: number } 552 | & { ' $fragmentRefs'?: { 'ArticleShortInfoFragment': ArticleShortInfoFragment } } 553 | )> | null }; 554 | 555 | export type GetArticleQueryVariables = Exact<{ 556 | id: Scalars['Int']['input']; 557 | }>; 558 | 559 | 560 | export type GetArticleQuery = { __typename?: 'Query', articleById?: ( 561 | { __typename?: 'Article', id: number } 562 | & { ' $fragmentRefs'?: { 'ArticleShortInfoFragment': ArticleShortInfoFragment } } 563 | ) | null }; 564 | 565 | export type CreateArticleMutationVariables = Exact<{ 566 | input: CreateArticleInput; 567 | }>; 568 | 569 | 570 | export type CreateArticleMutation = { __typename?: 'Mutation', createArticle?: { __typename?: 'CreateArticlePayload', article?: ( 571 | { __typename?: 'Article', id: number } 572 | & { ' $fragmentRefs'?: { 'ArticleShortInfoFragment': ArticleShortInfoFragment } } 573 | ) | null } | null }; 574 | 575 | export type UpdateArticleMutationVariables = Exact<{ 576 | id: Scalars['Int']['input']; 577 | articlePatch: ArticlePatch; 578 | }>; 579 | 580 | 581 | export type UpdateArticleMutation = { __typename?: 'Mutation', updateArticleById?: { __typename?: 'UpdateArticlePayload', article?: ( 582 | { __typename?: 'Article' } 583 | & { ' $fragmentRefs'?: { 'ArticleShortInfoFragment': ArticleShortInfoFragment } } 584 | ) | null } | null }; 585 | 586 | export type DeleteArticleMutationVariables = Exact<{ 587 | id: Scalars['Int']['input']; 588 | }>; 589 | 590 | 591 | export type DeleteArticleMutation = { __typename?: 'Mutation', deleteArticleById?: { __typename?: 'DeleteArticlePayload', article?: ( 592 | { __typename?: 'Article' } 593 | & { ' $fragmentRefs'?: { 'ArticleShortInfoFragment': ArticleShortInfoFragment } } 594 | ) | null } | null }; 595 | 596 | export type UserShortInfoFragment = { __typename?: 'User', id: number, firebaseUid: string } & { ' $fragmentName'?: 'UserShortInfoFragment' }; 597 | 598 | export type GetUserQueryVariables = Exact<{ 599 | firebaseUid: Scalars['String']['input']; 600 | }>; 601 | 602 | 603 | export type GetUserQuery = { __typename?: 'Query', userByFirebaseUid?: { __typename?: 'User', id: number } | null }; 604 | 605 | export type CreateUserMutationVariables = Exact<{ 606 | input: CreateUserInput; 607 | }>; 608 | 609 | 610 | export type CreateUserMutation = { __typename?: 'Mutation', createUser?: { __typename?: 'CreateUserPayload', user?: ( 611 | { __typename?: 'User' } 612 | & { ' $fragmentRefs'?: { 'UserShortInfoFragment': UserShortInfoFragment } } 613 | ) | null } | null }; 614 | 615 | export const ArticleShortInfoFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ArticleShortInfo"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Article"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"createdDate"}}]}}]} as unknown as DocumentNode; 616 | export const UserShortInfoFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"UserShortInfo"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"User"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"firebaseUid"}}]}}]} as unknown as DocumentNode; 617 | export const ListArticlesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ListArticles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"allArticlesList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"orderBy"},"value":{"kind":"EnumValue","value":"CREATED_DATE_DESC"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ArticleShortInfo"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ArticleShortInfo"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Article"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"createdDate"}}]}}]} as unknown as DocumentNode; 618 | export const GetArticleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetArticle"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"articleById"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ArticleShortInfo"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ArticleShortInfo"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Article"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"createdDate"}}]}}]} as unknown as DocumentNode; 619 | export const CreateArticleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateArticle"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateArticleInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createArticle"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"article"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"FragmentSpread","name":{"kind":"Name","value":"ArticleShortInfo"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ArticleShortInfo"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Article"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"createdDate"}}]}}]} as unknown as DocumentNode; 620 | export const UpdateArticleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateArticle"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"articlePatch"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ArticlePatch"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateArticleById"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}},{"kind":"ObjectField","name":{"kind":"Name","value":"articlePatch"},"value":{"kind":"Variable","name":{"kind":"Name","value":"articlePatch"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"article"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ArticleShortInfo"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ArticleShortInfo"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Article"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"createdDate"}}]}}]} as unknown as DocumentNode; 621 | export const DeleteArticleDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteArticle"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteArticleById"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"article"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ArticleShortInfo"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ArticleShortInfo"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Article"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"createdDate"}}]}}]} as unknown as DocumentNode; 622 | export const GetUserDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetUser"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"firebaseUid"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userByFirebaseUid"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"firebaseUid"},"value":{"kind":"Variable","name":{"kind":"Name","value":"firebaseUid"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; 623 | export const CreateUserDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateUser"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateUserInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createUser"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"UserShortInfo"}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"UserShortInfo"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"User"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"firebaseUid"}}]}}]} as unknown as DocumentNode; -------------------------------------------------------------------------------- /graphql/__generated__/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./fragment-masking"; 2 | export * from "./gql"; -------------------------------------------------------------------------------- /graphql/apollo.tsx: -------------------------------------------------------------------------------- 1 | import { ApolloClient, InMemoryCache } from '@apollo/client' 2 | 3 | import { config } from '../config/config' 4 | 5 | const client = new ApolloClient({ 6 | uri: `${(config.appUrl ?? '').slice(0, -1)}${config.graphqlPath as string}`, 7 | cache: new InMemoryCache() 8 | }) 9 | 10 | export default client 11 | -------------------------------------------------------------------------------- /graphql/collections/_TEMPLATE/hooks.ts: -------------------------------------------------------------------------------- 1 | /* 2 | CRUD actions for Collections: 3 | 4 | import { useListCollections, useGetCollection, useCreateCollection, useUpdateCollection, useDeleteCollection } from 'graphql/collections/[collection]/hooks' 5 | const { data } = useListCollections() 6 | 7 | const createCollection = useCreateCollection() 8 | const { data } = await createCollection({ 9 | variables: { 10 | input: {...} 11 | } 12 | }) 13 | 14 | Using 'Collection' type in your code: 15 | 16 | import { Collection } from 'graphql/__generated__/graphql' 17 | 18 | */ 19 | 20 | /* 21 | import { useQuery, QueryResult, useMutation, MutationFunction } from '@apollo/client' 22 | 23 | import { 24 | GetCollectionQuery, 25 | GetCollectionQueryVariables, 26 | ListCollectionsQuery, 27 | ListCollectionsQueryVariables, 28 | CreateCollectionMutation, 29 | CreateCollectionMutationVariables, 30 | UpdateCollectionMutation, 31 | UpdateCollectionMutationVariables, 32 | DeleteCollectionMutation, 33 | DeleteCollectionMutationVariables 34 | } from 'graphql/__generated__/graphql' 35 | import { 36 | LIST_COLLECTIONS, 37 | GET_COLLECTION, 38 | CREATE_COLLECTION, 39 | UPDATE_COLLECTION, 40 | DELETE_COLLECTION 41 | } from './queries' 42 | 43 | export const useListCollections = (): QueryResult => { 44 | return useQuery(LIST_COLLECTIONS) 45 | } 46 | 47 | export const useGetCollection = (id: number): QueryResult => 48 | useQuery(GET_COLLECTION, { variables: { id } }) 49 | 50 | export const useCreateCollection = (): MutationFunction => { 51 | const [createCollectionMutation] = useMutation( 52 | CREATE_COLLECTION, 53 | { 54 | refetchQueries: [{ query: LIST_COLLECTIONS }] 55 | } 56 | ) 57 | return createCollectionMutation 58 | } 59 | 60 | export const useUpdateCollection = (): MutationFunction => { 61 | const [updateCollectionMutation] = useMutation(UPDATE_COLLECTION) 62 | return updateCollectionMutation 63 | } 64 | 65 | export const useDeleteCollection = (): MutationFunction => { 66 | const [deleteCollectionMutation] = useMutation( 67 | DELETE_COLLECTION, 68 | { 69 | refetchQueries: [{ query: LIST_COLLECTIONS }] 70 | } 71 | ) 72 | return deleteCollectionMutation 73 | } 74 | */ 75 | -------------------------------------------------------------------------------- /graphql/collections/_TEMPLATE/queries.ts: -------------------------------------------------------------------------------- 1 | /* 2 | import { gql } from 'graphql/__generated__' 3 | 4 | // ----- Fragments ----- 5 | 6 | gql(` 7 | fragment CollectionShortInfo on Collection { 8 | id 9 | } 10 | `) 11 | 12 | // ----- Queries ----- 13 | 14 | export const LIST_COLLECTIONS = gql(` 15 | query ListCollections { 16 | allCollectionsList (orderBy: NAME_ASC) { 17 | id 18 | ...CollectionShortInfo 19 | } 20 | } 21 | `) 22 | 23 | // export const LIST_COLLECTIONS_VARS = { 24 | // skip: 0, 25 | // first: 10 26 | // } 27 | 28 | export const GET_COLLECTION = gql(` 29 | query GetCollection ($id: Int!) { 30 | collectionById(id: $id) { 31 | id 32 | ...CollectionShortInfo 33 | } 34 | } 35 | `) 36 | 37 | // ----- Mutations ----- 38 | 39 | export const CREATE_COLLECTION = gql(` 40 | mutation CreateCollection ($input: CreateCollectionInput!) { 41 | createCollection(input: $input) { 42 | collection { 43 | id 44 | ...CollectionShortInfo 45 | } 46 | } 47 | } 48 | `) 49 | 50 | export const UPDATE_COLLECTION = gql(` 51 | mutation UpdateCollection ($id: Int!, $collectionPatch: CollectionPatch!) { 52 | updateCollectionById(input: {id: $id, collectionPatch: $collectionPatch}) { 53 | collection { 54 | ...CollectionShortInfo 55 | } 56 | } 57 | } 58 | `) 59 | 60 | export const DELETE_COLLECTION = gql(` 61 | mutation DeleteCollection ($id: Int!) { 62 | deleteCollectionById(input: {id: $id}) { 63 | collection { 64 | ...CollectionShortInfo 65 | } 66 | } 67 | } 68 | `) 69 | 70 | */ 71 | -------------------------------------------------------------------------------- /graphql/collections/_TEMPLATE/schema.sql: -------------------------------------------------------------------------------- 1 | -- Table: collection 2 | -- [description of this table] 3 | 4 | DROP TABLE IF EXISTS "collection" CASCADE; 5 | 6 | -- CREATE TABLE "collection" ( 7 | -- id SERIAL PRIMARY KEY, 8 | -- created_date timestamp with time zone NOT NULL DEFAULT now(), 9 | 10 | -- -- [add columns here] 11 | -- ); 12 | -------------------------------------------------------------------------------- /graphql/collections/all_tables.sql: -------------------------------------------------------------------------------- 1 | -- Roles 2 | -- \i graphql/collections/roles/roles.sql 3 | 4 | -- Tables 5 | \i graphql/collections/user/schema.sql 6 | \i graphql/collections/article/schema.sql 7 | 8 | -- Functions (see also each schema.sql file) 9 | 10 | -- Data (see also exampleData.csv in each table folder) 11 | -------------------------------------------------------------------------------- /graphql/collections/article/hooks.ts: -------------------------------------------------------------------------------- 1 | /* 2 | CRUD actions for Articles: 3 | 4 | import { useListArticles, useGetArticle, useCreateArticle, useUpdateArticle, useDeleteArticle } from 'graphql/collections/article/hooks' 5 | const { data } = useListArticles() 6 | 7 | const createArticle = useCreateArticle() 8 | const { data } = await createArticle({ 9 | variables: { 10 | input: {...} 11 | } 12 | }) 13 | 14 | Using 'Article' type in your code: 15 | 16 | import { Article } from 'graphql/__generated__/graphql' 17 | 18 | */ 19 | 20 | import { useQuery, QueryResult, useMutation, MutationFunction } from '@apollo/client' 21 | 22 | import toSlug from 'lib/toSlug' 23 | import { 24 | Article, 25 | GetArticleQuery, 26 | GetArticleQueryVariables, 27 | ListArticlesQuery, 28 | ListArticlesQueryVariables, 29 | CreateArticleMutation, 30 | CreateArticleMutationVariables, 31 | UpdateArticleMutation, 32 | UpdateArticleMutationVariables, 33 | DeleteArticleMutation, 34 | DeleteArticleMutationVariables 35 | } from 'graphql/__generated__/graphql' 36 | import { 37 | LIST_ARTICLES, 38 | GET_ARTICLE, 39 | CREATE_ARTICLE, 40 | UPDATE_ARTICLE, 41 | DELETE_ARTICLE 42 | } from './queries' 43 | 44 | export const articlePath = (article: Article): string => `/articles/${toSlug(article.title as string)}-${article.id}` 45 | 46 | export const useListArticles = (): QueryResult => { 47 | return useQuery(LIST_ARTICLES) 48 | } 49 | 50 | export const useGetArticle = (id: number): QueryResult => 51 | useQuery(GET_ARTICLE, { variables: { id } }) 52 | 53 | export const useCreateArticle = (): MutationFunction => { 54 | const [createArticleMutation] = useMutation( 55 | CREATE_ARTICLE, 56 | { 57 | refetchQueries: [{ query: LIST_ARTICLES }] 58 | } 59 | ) 60 | return createArticleMutation 61 | } 62 | 63 | export const useUpdateArticle = (): MutationFunction => { 64 | const [updateArticleMutation] = useMutation(UPDATE_ARTICLE) 65 | return updateArticleMutation 66 | } 67 | 68 | export const useDeleteArticle = (): MutationFunction => { 69 | const [deleteArticleMutation] = useMutation( 70 | DELETE_ARTICLE, 71 | { 72 | refetchQueries: [{ query: LIST_ARTICLES }] 73 | } 74 | ) 75 | return deleteArticleMutation 76 | } 77 | -------------------------------------------------------------------------------- /graphql/collections/article/queries.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql/__generated__' 2 | 3 | // ----- Fragments ----- 4 | 5 | gql(` 6 | fragment ArticleShortInfo on Article { 7 | id 8 | title 9 | createdDate 10 | } 11 | `) 12 | 13 | // ----- Queries ----- 14 | 15 | export const LIST_ARTICLES = gql(` 16 | query ListArticles { 17 | allArticlesList (orderBy: CREATED_DATE_DESC) { 18 | id 19 | ...ArticleShortInfo 20 | } 21 | } 22 | `) 23 | 24 | // export const LIST_ARTICLES_VARS = { 25 | // skip: 0, 26 | // first: 10 27 | // } 28 | 29 | export const GET_ARTICLE = gql(` 30 | query GetArticle ($id: Int!) { 31 | articleById(id: $id) { 32 | id 33 | ...ArticleShortInfo 34 | } 35 | } 36 | `) 37 | 38 | // ----- Mutations ----- 39 | 40 | export const CREATE_ARTICLE = gql(` 41 | mutation CreateArticle ($input: CreateArticleInput!) { 42 | createArticle(input: $input) { 43 | article { 44 | id 45 | ...ArticleShortInfo 46 | } 47 | } 48 | } 49 | `) 50 | 51 | export const UPDATE_ARTICLE = gql(` 52 | mutation UpdateArticle ($id: Int!, $articlePatch: ArticlePatch!) { 53 | updateArticleById(input: {id: $id, articlePatch: $articlePatch}) { 54 | article { 55 | ...ArticleShortInfo 56 | } 57 | } 58 | } 59 | `) 60 | 61 | export const DELETE_ARTICLE = gql(` 62 | mutation DeleteArticle ($id: Int!) { 63 | deleteArticleById(input: {id: $id}) { 64 | article { 65 | ...ArticleShortInfo 66 | } 67 | } 68 | } 69 | `) 70 | -------------------------------------------------------------------------------- /graphql/collections/article/resolvers.ts: -------------------------------------------------------------------------------- 1 | // For Postgraphile makeWrapResolversPlugin; see graphql/server/resolverExtensions.ts 2 | 3 | // import { ArticleInput } from 'graphql/__generated__/graphql' 4 | 5 | export const createArticle = async (resolve: any, source: any, args: any, context: any, resolveInfo: any): Promise => { 6 | // const articleInput: ArticleInput = args.input.article 7 | // ...do something before save to local database 8 | // Continue with the original resolver 9 | const result = await resolve(source, args, context, resolveInfo) 10 | return result 11 | } 12 | -------------------------------------------------------------------------------- /graphql/collections/article/schema.sql: -------------------------------------------------------------------------------- 1 | -- Table: article 2 | -- An article or blog post 3 | 4 | DROP TABLE IF EXISTS "article" CASCADE; 5 | 6 | CREATE TABLE "article" ( 7 | id SERIAL PRIMARY KEY, 8 | created_date timestamp with time zone NOT NULL DEFAULT now(), 9 | 10 | title text NOT NULL, 11 | content text 12 | ); 13 | -------------------------------------------------------------------------------- /graphql/collections/article/serverQueries.ts: -------------------------------------------------------------------------------- 1 | import { Article } from 'graphql/__generated__/graphql' 2 | import { query } from 'graphql/server/postgres' 3 | 4 | export const articleById = async (id: number): Promise
=> { 5 | if (id === undefined) throw new Error('articleById: id is undefined') 6 | const sqlString = `SELECT * FROM article WHERE id = ${id};` 7 | const rows = await query(sqlString) 8 | const article: Article = rows?.[0] 9 | return article 10 | } 11 | -------------------------------------------------------------------------------- /graphql/collections/user/hooks.tsx: -------------------------------------------------------------------------------- 1 | /* 2 | Usage: 3 | import { UserContextProvider } from 'graphql/collections/user/hooks' 4 | 5 | 6 | 7 | 8 | and inside ComponentThatUsesUser: 9 | import { useUser } from 'graphql/collections/user/hooks' 10 | const { user, signOut } = useUser() 11 | */ 12 | 13 | import React, { createContext, useContext, useState, useEffect } from 'react' 14 | // import { User, getAuth, onAuthStateChanged, signOut } from 'firebase/auth' 15 | import { useQuery, useMutation } from '@apollo/client' 16 | import { useRouter } from 'next/router' 17 | 18 | // import { firebaseApp } from 'lib/firebase' 19 | import { GET_USER, CREATE_USER } from './queries' 20 | 21 | interface UserInputProps { 22 | children: React.ReactNode 23 | } 24 | 25 | interface UserReturnProps { 26 | user: any | null 27 | 28 | signOut: () => Promise 29 | } 30 | 31 | const UserContext = createContext>({}) 32 | 33 | export const UserContextProvider = (props: UserInputProps): React.ReactElement => { 34 | const [user] = useState(undefined) 35 | // const auth = getAuth(firebaseApp) 36 | const [createUser] = useMutation(CREATE_USER) 37 | const router = useRouter() 38 | 39 | // Get user profile from Postgres 40 | const { data } = useQuery(GET_USER, { variables: { firebaseUid: user?.uid as string } }) 41 | 42 | useEffect(() => { 43 | try { 44 | // onAuthStateChanged(auth, (firebaseUser) => { 45 | // setUser(firebaseUser) 46 | // }) 47 | } catch (error: any) { 48 | console.warn(`Warning: ${error.message as string}`) 49 | } 50 | }, []) 51 | 52 | useEffect(() => { 53 | if (user?.uid !== undefined && data?.userByFirebaseUid === null) { 54 | // Create in Postgres if user doesn’t exist 55 | console.log('Creating new user:', user?.uid) 56 | void createUser({ 57 | variables: { 58 | input: { 59 | user: { 60 | firebaseUid: user?.uid 61 | } 62 | } 63 | } 64 | }) 65 | } 66 | }, [user?.uid, data?.userByFirebaseUid]) 67 | 68 | const signOutFunction = async (): Promise => { 69 | // Sign out from Firebase 70 | // await signOut(auth) 71 | // Redirect to start page 72 | void router.push('/') 73 | } 74 | 75 | // Make the context object (i.e. the “API” for User) 76 | const userContext: UserReturnProps = { 77 | user, 78 | 79 | signOut: signOutFunction 80 | } 81 | // Pass the value in Provider and return 82 | return ( 83 | 84 | {props.children} 85 | 86 | ) 87 | } 88 | 89 | export const { Consumer: UserContextConsumer } = UserContext 90 | 91 | export const useUser = (): Partial => useContext(UserContext) 92 | -------------------------------------------------------------------------------- /graphql/collections/user/queries.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql/__generated__' 2 | 3 | // ----- Fragments ----- 4 | 5 | gql(` 6 | fragment UserShortInfo on User { 7 | id 8 | firebaseUid 9 | } 10 | `) 11 | 12 | // ----- Queries ----- 13 | 14 | export const GET_USER = gql(` 15 | query GetUser ($firebaseUid: String!) { 16 | userByFirebaseUid(firebaseUid: $firebaseUid) { 17 | id 18 | } 19 | } 20 | `) 21 | 22 | // ----- Mutations ----- 23 | 24 | export const CREATE_USER = gql(` 25 | mutation CreateUser ($input: CreateUserInput!) { 26 | createUser(input: $input) { 27 | user { 28 | ...UserShortInfo 29 | } 30 | } 31 | } 32 | `) 33 | -------------------------------------------------------------------------------- /graphql/collections/user/schema.sql: -------------------------------------------------------------------------------- 1 | -- Table: user 2 | -- Represents a user who can log in, perform actions, and own data. 3 | 4 | DROP TABLE IF EXISTS "user" CASCADE; 5 | 6 | CREATE TABLE "user" ( 7 | id SERIAL PRIMARY KEY, 8 | created_date timestamp with time zone NOT NULL DEFAULT now(), 9 | 10 | name text, 11 | firebase_uid varchar(128) NOT NULL UNIQUE, 12 | 13 | is_system_admin boolean NOT NULL DEFAULT false 14 | ); 15 | -------------------------------------------------------------------------------- /graphql/server/postgraphile.ts: -------------------------------------------------------------------------------- 1 | import { postgraphile } from 'postgraphile' 2 | import PostGraphileNestedMutations from 'postgraphile-plugin-nested-mutations' 3 | 4 | import { isDevelopment } from '../../config/config' 5 | import { pool } from './postgres' 6 | import resolverExtensions from './resolverExtensions' 7 | 8 | export default postgraphile( 9 | pool, 10 | 'public', 11 | { 12 | // watchPg: true, // Need extension for this to work properly 13 | graphiql: isDevelopment, 14 | enhanceGraphiql: isDevelopment, 15 | // externalUrlBase: "/api", // Don't use this since graphql route is incorrect w/ it 16 | graphqlRoute: '/api/graphql', 17 | graphiqlRoute: '/api/graphiql', 18 | retryOnInitFail: true, 19 | simpleCollections: 'only', 20 | appendPlugins: [ 21 | PostGraphileNestedMutations, 22 | resolverExtensions 23 | ], 24 | graphileBuildOptions: { 25 | // nestedMutationsSimpleFieldNames: true 26 | }, 27 | extendedErrors: ['severity', 'code', 'detail', 'hint', 'position', 'internalPosition', 'internalQuery', 'where', 'schema', 'table', 'column', 'dataType', 'constraint', 'file', 'line', 'routine'], 28 | // showErrorStack: isDevelopment, 29 | allowExplain: () => { return isDevelopment } 30 | // exportGqlSchemaPath: './graphql/__postgraphile__/schema.graphql' 31 | // retryOnInitFail is mainly so that going to /api/graphiql 32 | // doesn't crash entire app if config is incorrect. Fix config. 33 | } 34 | ) 35 | -------------------------------------------------------------------------------- /graphql/server/postgres.ts: -------------------------------------------------------------------------------- 1 | import { Pool, Client, PoolConfig } from 'pg' 2 | import { config } from 'config/config' 3 | import { dateAsISO } from 'lib/formatDate' 4 | 5 | // Postgres (pg) 6 | if (config.databaseUrl === undefined) throw new Error('.env DATABASE_URL not set') 7 | const postgresOptions: PoolConfig = { 8 | connectionString: config.databaseUrl, 9 | max: 10 10 | // idleTimeoutMillis: 1000, 11 | // connectionTimeoutMillis: 1000 12 | // ssl: { rejectUnauthorized: false }, 13 | } 14 | export const pool = new Pool(postgresOptions) 15 | 16 | // Connect to database, do query, then release database connection 17 | export const query = async (sqlString: string): Promise => { 18 | try { 19 | // Connect db 20 | const client = new Client(postgresOptions) 21 | await client.connect() 22 | // Run function 23 | const { rows } = await client.query(sqlString) 24 | // Release db 25 | await client.end() 26 | // await client.release() 27 | const rowsCamelCase = rows.map(mapKeysToCamelCase) 28 | return rowsCamelCase 29 | } catch (error: any) { 30 | console.error('Database query error:', error, '\nError:', error.message, '\n\nSQL:\n', sqlString, '\n') 31 | throw new Error('Database query failed') 32 | } 33 | } 34 | 35 | const snakeToCamel = (str: string): string => str.toLowerCase().replace(/([-_][a-z])/g, (group: string) => 36 | group 37 | .toUpperCase() 38 | .replace('-', '') 39 | .replace('_', '') 40 | ) 41 | 42 | export const camelToSnake = (str: string): string => str.replace(/([A-Z0-9])/g, (match) => `_${match.toLowerCase()}`) 43 | 44 | const isDate = (obj: any): boolean => obj.constructor.toString().includes('Date') 45 | 46 | export const formatSqlValue = (obj: any): any => typeof obj === 'string' 47 | ? `'${obj.replace(/'/g, "''")}'` 48 | : isDate(obj) 49 | ? `'${dateAsISO(obj) as string}'` 50 | : obj 51 | 52 | const mapKeysToCamelCase = (obj: any): any => { 53 | if (Array.isArray(obj)) { 54 | return obj.map(v => mapKeysToCamelCase(v)) 55 | } else if (obj !== null && obj.constructor === Object) { 56 | return Object.keys(obj).reduce( 57 | (result, key) => ({ 58 | ...result, 59 | [snakeToCamel(key)]: mapKeysToCamelCase(obj[key]) 60 | }), 61 | {} 62 | ) 63 | } 64 | return obj 65 | } 66 | 67 | // const sqlString = createUpsertQuery('customer', { customerNumber: 123 }, customerFieldsWithNoCustomerNumber) 68 | export const createUpsertQuery = (tableName: string, fieldsRequired: Record, fieldsNotRequired: Record = {}): string => { 69 | const fieldsRequiredNames = Object.keys(fieldsRequired).map(camelToSnake) 70 | const fieldsRequiredValues = Object.values(fieldsRequired).map(formatSqlValue) 71 | const fieldsNotRequiredNames = Object.keys(fieldsNotRequired).map(camelToSnake) 72 | const allFieldsNames = [...fieldsRequiredNames, ...fieldsNotRequiredNames] 73 | const allFieldsValues = [...fieldsRequiredValues, ...Object.values(fieldsNotRequired).map(formatSqlValue)] 74 | const sqlString = `INSERT INTO "${tableName}" 75 | (${allFieldsNames.join(', ').replace(/'/g, '')}) 76 | VALUES (${allFieldsValues.join(', ')}) 77 | ON CONFLICT (${fieldsRequiredNames.join(', ')}) 78 | ` + (fieldsNotRequiredNames.length > 0 79 | ? `DO UPDATE SET ${fieldsNotRequiredNames.map(fieldName => `${fieldName} = EXCLUDED.${fieldName}`).join(', ')};` 80 | : 'DO NOTHING;') 81 | return sqlString 82 | } 83 | -------------------------------------------------------------------------------- /graphql/server/resolverExtensions.ts: -------------------------------------------------------------------------------- 1 | import { makeWrapResolversPlugin } from 'graphile-utils' 2 | 3 | // import { createArticle, updateArticle } from 'graphql/collections/article/resolvers' 4 | 5 | const wrapResolversPlugin = makeWrapResolversPlugin({ 6 | Mutation: { 7 | // createArticle: { resolve: createArticle }, 8 | // updateArticleById: { resolve: updateArticle }, 9 | } 10 | }) 11 | 12 | export default wrapResolversPlugin 13 | -------------------------------------------------------------------------------- /graphql/server/runMiddleware.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next' 2 | 3 | export default async function runMiddleware ( 4 | req: NextApiRequest, 5 | res: NextApiResponse, 6 | fn: any 7 | ): Promise { 8 | return await new Promise((resolve, reject) => { 9 | fn(req, res, (result: any) => { 10 | if (result instanceof Error) { 11 | return reject(result) 12 | } 13 | return resolve(result) 14 | }) 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /hooks/useDebounce.ts: -------------------------------------------------------------------------------- 1 | // E.g. const debouncedSearchTerm = useDebounce(searchTerm, 500) 2 | // https://dev.to/gabe_ragland/debouncing-with-react-hooks-jci 3 | import { useState, useEffect } from 'react' 4 | 5 | type DebounceValue = string 6 | 7 | export default function useDebounce (value: DebounceValue, delay: number, onChange?: (value: DebounceValue) => void): DebounceValue { 8 | const [debouncedValue, setDebouncedValue] = useState(value) 9 | 10 | useEffect( 11 | () => { 12 | const handler = setTimeout(() => { 13 | setDebouncedValue(value) 14 | onChange?.(value) 15 | }, delay) 16 | return () => clearTimeout(handler) 17 | }, 18 | [value] 19 | ) 20 | 21 | return debouncedValue 22 | } 23 | -------------------------------------------------------------------------------- /hooks/useFormValidation.ts: -------------------------------------------------------------------------------- 1 | /** 2 | WIP: not used yet 3 | 4 | import useFormValidation from 'hooks/useFormValidation' 5 | 6 | const formValidationErrors = useFormValidation(formData, { 7 | customerId: 'Customer is required', 8 | transporterId: 'Transporter is required' 9 | }) 10 | 11 | {formValidationErrors !== null && ( 12 |
{formValidationErrors}
13 | )} 14 | */ 15 | 16 | import { useMemo } from 'react' 17 | 18 | export default function useFormValidation (formData: any, fieldNames: Record): string | null { 19 | const formValuesToWatch = Object.keys(fieldNames).map((fieldName: string) => formData[fieldName]) 20 | const formValidationErrors: string | null = useMemo( 21 | () => { 22 | console.log('formValidationErrors:', formData) 23 | Object.keys(fieldNames).forEach((fieldName: string) => { 24 | if (formData[fieldName] === undefined) return fieldNames[fieldName] 25 | }) 26 | return null 27 | }, 28 | formValuesToWatch 29 | ) 30 | 31 | return formValidationErrors 32 | } 33 | -------------------------------------------------------------------------------- /hooks/useLocalStorage.ts: -------------------------------------------------------------------------------- 1 | /* 2 | import useLocalStorage from 'hooks/useLocalStorage' 3 | const [value, setValue] = useLocalStorage(propertyName, defaultValue) 4 | */ 5 | 6 | import { useState, useEffect } from 'react' 7 | 8 | type LocalStorageReturnProps = [ 9 | T | undefined, 10 | (propertyValue: T) => void 11 | ] 12 | 13 | export default function useLocalStorage (propertyName: string, defaultValue?: T): LocalStorageReturnProps { 14 | const [value, setValueInState] = useState(defaultValue) 15 | 16 | const setValueInLocalStorage = (propertyValue: any): void => { 17 | setValueInState(propertyValue) 18 | const propertyValueObject = typeof propertyValue === 'object' 19 | ? JSON.stringify(propertyValue) 20 | : propertyValue 21 | window.localStorage.setItem(propertyName, propertyValueObject) 22 | } 23 | 24 | useEffect(() => { 25 | const propertyValue = window.localStorage.getItem(propertyName) 26 | const propertyValueObject = ( 27 | propertyValue !== null && ( 28 | propertyValue?.startsWith('{') || 29 | propertyValue?.startsWith('[') || 30 | Boolean(propertyValue.match(/\d/)) 31 | ) 32 | ) 33 | ? JSON.parse(propertyValue) 34 | : propertyValue 35 | setValueInState(propertyValueObject ?? defaultValue) 36 | }, [propertyName]) 37 | 38 | return [value, setValueInLocalStorage] 39 | } 40 | -------------------------------------------------------------------------------- /lib/firebase.ts: -------------------------------------------------------------------------------- 1 | import { initializeApp } from 'firebase/app' 2 | // import { getAnalytics } from 'firebase/analytics' 3 | 4 | import { config } from 'config/config' 5 | 6 | // Your web app's Firebase configuration 7 | // For Firebase JS SDK v7.20.0 and later, measurementId is optional 8 | const firebaseConfig = { 9 | apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY, 10 | authDomain: `${config.appSlug as string}.firebaseapp.com`, 11 | databaseURL: `https://${config.appSlug as string}.firebaseio.com`, 12 | projectId: config.appSlug, 13 | storageBucket: `${config.appSlug as string}.appspot.com`, 14 | messagingSenderId: '952284368443', 15 | appId: '1:952284368443:web:b88f8bdda98e5fd8ce161e', 16 | measurementId: 'G-4BH0K0P06S' 17 | } 18 | 19 | // Initialize Firebase 20 | const firebaseApp = initializeApp(firebaseConfig) 21 | // const analytics = getAnalytics(firebaseApp) 22 | 23 | export { firebaseApp } 24 | -------------------------------------------------------------------------------- /lib/formatDate.ts: -------------------------------------------------------------------------------- 1 | // import dayjs from 'dayjs' 2 | // import relativeTime from 'dayjs/plugin/relativeTime' 3 | // dayjs.extend(relativeTime) 4 | 5 | export const formatDate = (dateObj: Date): string => `${dateObj.getFullYear()}-${('0' + (dateObj.getMonth() + 1).toString()).slice(-2)}-${('0' + dateObj.getDate().toString()).slice(-2)}` 6 | 7 | export const dateAsISO = (date: Date | string): string | undefined => (date !== null && date !== undefined) ? (new Date(date)).toISOString() : undefined 8 | -------------------------------------------------------------------------------- /lib/handleRestRequest.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from 'next' 2 | 3 | type ActionFunction = (req: NextApiRequest, res: NextApiResponse) => Promise 4 | 5 | interface RequestAndResponse { 6 | req: NextApiRequest 7 | res: NextApiResponse 8 | } 9 | 10 | /** handleRestRequest(async () => {...}, { req, res }) */ 11 | export const handleRestRequest = async function handleRestRequest (actionFunction: ActionFunction, { req, res }: RequestAndResponse): Promise { 12 | try { 13 | await actionFunction(req, res) 14 | } catch (error: any) { 15 | const reference = `E${Math.round(1000 * Math.random())}` 16 | const { message, status = 400 }: CustomError = error 17 | console.error(`[${reference}] Error ${status ?? ''}: “${message ?? ''}” –`, error) 18 | if (!isNaN(status)) res.status(status) 19 | res.json({ status, message, reference }) 20 | } 21 | } 22 | 23 | // From: https://levelup.gitconnected.com/the-definite-guide-to-handling-errors-gracefully-in-javascript-58424d9c60e6 24 | /** throw new CustomError(`Account not found`, 404) */ 25 | export class CustomError extends Error { 26 | status?: number 27 | 28 | constructor (message: string, status: number) { 29 | super(message) 30 | Object.setPrototypeOf(this, CustomError.prototype) 31 | Error.captureStackTrace?.(this, CustomError) 32 | this.status = status 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /lib/isClientSide.ts: -------------------------------------------------------------------------------- 1 | const isClientSide = (): boolean => typeof window !== 'undefined' 2 | export default isClientSide 3 | -------------------------------------------------------------------------------- /lib/isDevelopment.ts: -------------------------------------------------------------------------------- 1 | const isDevelopment = (): boolean => process.env.NODE_ENV === 'development' 2 | export default isDevelopment 3 | -------------------------------------------------------------------------------- /lib/lodashy.ts: -------------------------------------------------------------------------------- 1 | export function get (value: any, path: string, defaultValue?: any, allowNull = false): any { 2 | return String(path).split('.').reduce((acc, v) => { 3 | try { 4 | acc = acc[v] 5 | } catch (e) { 6 | return defaultValue 7 | } 8 | if (acc === undefined || (!allowNull && acc === null)) { 9 | return defaultValue 10 | } 11 | return acc 12 | }, value) 13 | } 14 | 15 | export function removeUndefinedProps (obj: T): Partial { 16 | const result: Partial = {} // Create a new object to hold the result 17 | for (const key in obj) { 18 | if (Object.prototype.hasOwnProperty.call(obj, key)) { 19 | // Check if the property exists in the original object 20 | const value = obj[key] 21 | if (value !== undefined) { 22 | result[key] = value 23 | } 24 | } 25 | } 26 | return result 27 | } 28 | 29 | export function removeUndefinedOrNullProps (obj: T): Partial { 30 | const result: Partial = {} // Create a new object to hold the result 31 | for (const key in obj) { 32 | if (Object.prototype.hasOwnProperty.call(obj, key)) { 33 | // Check if the property exists in the original object 34 | const value = obj[key] 35 | if (value !== undefined && value !== null) { 36 | result[key] = value 37 | } 38 | } 39 | } 40 | return result 41 | } 42 | -------------------------------------------------------------------------------- /lib/makeRestRequest.ts: -------------------------------------------------------------------------------- 1 | import { config } from 'config/config' 2 | 3 | export const makeRestRequest = async (method = 'GET', path: string, data?: object, sessionAccessToken?: string): Promise => { 4 | const completeUrl = config.appUrl as string + path.substring(1) 5 | // console.log('makeRestRequest:', { completeUrl, method, path, data, sessionAccessToken }) 6 | return await fetch( 7 | completeUrl, 8 | { 9 | method, 10 | mode: 'cors', 11 | headers: { 12 | Accept: 'application/json', 13 | 'Content-Type': 'application/json', 14 | // Auth token 15 | ...(sessionAccessToken !== undefined && { 16 | Authorization: `Bearer ${sessionAccessToken ?? ''}` 17 | }) 18 | }, 19 | body: (data !== undefined) ? JSON.stringify(data) : null 20 | } 21 | ) 22 | .then(async (res: Response) => { 23 | if (!res.ok) { 24 | const jsonData = await res.json() 25 | throwError(jsonData, res) 26 | } else { 27 | let jsonData: any = {} 28 | try { 29 | jsonData = await res.json() 30 | } catch (error: any) { 31 | console.warn('Parse JSON:', error?.message ?? error) 32 | } 33 | // console.log('*apiResponses.json:\n', JSON.stringify({ method, path, response: jsonData }, null, 2)) 34 | if (jsonData.code?.toLowerCase?.()?.includes('error') === true) throwError(jsonData, res) 35 | return jsonData 36 | } 37 | }) 38 | } 39 | 40 | export default makeRestRequest 41 | 42 | const throwError = (jsonData: any, res: Response): void => { 43 | const errorMessage = jsonData?.message?.includes('{') === true 44 | // Variant 1: { message: '{ message }' } 45 | ? JSON.parse(jsonData?.message)?.message ?? jsonData?.message 46 | // Variant 2: { errors: [{ message }] } 47 | : jsonData?.errors?.[0]?.message ?? 48 | // Variant 3: { message } 49 | jsonData?.message ?? 50 | // Fallback: use HTTP status text 51 | res.statusText 52 | throw new Error(errorMessage) 53 | } 54 | -------------------------------------------------------------------------------- /lib/showNotification.ts: -------------------------------------------------------------------------------- 1 | import { toast } from 'react-toastify' 2 | 3 | const defaultOptions = { 4 | autoClose: 3 * 1000, 5 | hideProgressBar: true, 6 | closeButton: false, 7 | position: toast.POSITION.BOTTOM_LEFT 8 | } 9 | 10 | // Type: info, success, warning, error. Returns a notificationId that you can use for follow-up notifications. 11 | const showNotification = (message: string, type = 'info', options?: any): any => { 12 | if (options?.notificationId !== undefined) { 13 | // Update existing notification 14 | return toast.update(options?.notificationId, { ...defaultOptions, render: message, type, hideProgressBar: true, ...options }) 15 | } else { 16 | // Create new notification 17 | toast.dismiss() 18 | return toast[type](message, { ...defaultOptions, ...options }) 19 | } 20 | } 21 | 22 | export default showNotification 23 | 24 | // Commonly used notifications 25 | export const showErrorNotification = (error: any): void => { 26 | console.error(error) 27 | showNotification(error.message, 'error') 28 | } 29 | -------------------------------------------------------------------------------- /lib/toSlug.ts: -------------------------------------------------------------------------------- 1 | const toSlug = (str: string): string => str?.replace(/ /g, '-')?.replace(/[^\w-]+/g, '')?.toLowerCase() 2 | export default toSlug 3 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | swcMinify: true, 5 | images: { 6 | domains: ['www.tomsoderlund.com'] 7 | } 8 | } 9 | 10 | module.exports = nextConfig 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs-pwa-graphql-sql-boilerplate", 3 | "description": "Next.js serverless PWA with TypeScript + GraphQL (Postgraphile, Apollo) and Postgres SQL", 4 | "version": "2.0.0", 5 | "author": "Tom Söderlund ", 6 | "license": "ISC", 7 | "scripts": { 8 | "dev": "yarn api-types:watch & yarn dev:next", 9 | "dev:next": "next dev -p 3003", 10 | "build": "next build", 11 | "start": "next start", 12 | "lint": "next lint", 13 | "lint:standard": "ts-standard", 14 | "fix": "ts-standard --fix", 15 | "api-types": "graphql-codegen", 16 | "api-types:watch": "graphql-codegen --watch", 17 | "api-types:EXTERNALAPI": "npx openapi-typescript@5.4.0 https://EXTERNALAPI.com/REST/v1/openapi.json --output types/EXTERNALAPI.ts", 18 | "new:collection": "mkdir graphql/collections/_NEW && cp graphql/collections/_TEMPLATE/* graphql/collections/_NEW && echo '\\i graphql/collections/_NEW/schema.sql' >> graphql/collections/all_tables.sql", 19 | "database:reset": "eval $(grep '^DATABASE_URL' .env.local) && psql ${DATABASE_URL} -a -f graphql/collections/all_tables.sql" 20 | }, 21 | "ts-standard": { 22 | "ignore": [ 23 | "next-env.d.ts", 24 | "graphql/__generated__" 25 | ] 26 | }, 27 | "dependencies": { 28 | "@apollo/client": "^3.8.3", 29 | "@apollo/react-hooks": "^4.0.0", 30 | "@apollo/react-ssr": "^4.0.0", 31 | "aether-css-framework": "^1.7.1", 32 | "eslint": "8.48.0", 33 | "eslint-config-next": "13.4.19", 34 | "firebase": "^10.5.0", 35 | "graphile-utils": "^4.13.0", 36 | "graphql": "^15.8.0", 37 | "next": "13.4.19", 38 | "pg": "^8.11.3", 39 | "postgraphile": "^4.13.0", 40 | "postgraphile-plugin-nested-mutations": "^1.1.0", 41 | "react": "18.2.0", 42 | "react-dom": "18.2.0", 43 | "react-toastify": "^9.1.3", 44 | "typescript": "^5.2.2" 45 | }, 46 | "devDependencies": { 47 | "@graphql-codegen/cli": "^5.0.0", 48 | "@graphql-codegen/client-preset": "^4.1.0", 49 | "@graphql-codegen/typescript-operations": "^4.0.1", 50 | "@parcel/watcher": "^2.3.0", 51 | "@types/node": "20.5.9", 52 | "@types/pg": "^8.10.2", 53 | "@types/react": "18.2.21", 54 | "@types/react-dom": "18.2.7", 55 | "ts-standard": "^12.0.2" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import type { AppProps } from 'next/app' 3 | import Link from 'next/link' 4 | import { ApolloProvider } from '@apollo/client' 5 | import Router from 'next/router' 6 | 7 | import client from '../graphql/apollo' 8 | import { googlePageview } from '../components/page/GoogleAnalytics' 9 | import PageHead from '../components/page/PageHead' 10 | import Footer from '../components/page/Footer' 11 | import Notifications from '../components/page/Notifications' 12 | 13 | import 'aether-css-framework/dist/aether.min.css' 14 | import '../styles/globals.css' 15 | 16 | Router.events.on('routeChangeComplete', path => googlePageview(path)) 17 | 18 | export default function App ({ Component, pageProps, router }: AppProps): React.ReactElement { 19 | // this.props (Server + Client): Component, err, pageProps, router 20 | return ( 21 | 22 | 23 |
24 | 28 |
29 | Home 30 |