├── .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 | 
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 | 
35 |
36 | 
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 | [](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 |
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 |
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 |
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 |
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 |
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 | { void handleGoogleSignin(event) }}>
30 |
31 | Sign in with Google
32 |
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* User | */ any | null | undefined>(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 |
30 |
31 |
32 |
33 | )
34 | }
35 |
--------------------------------------------------------------------------------
/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Document, { Html, Head, Main, NextScript } from 'next/document'
3 |
4 | import { config } from '../config/config'
5 |
6 | export default class MyDocument extends Document {
7 | // this.props (Server only): __NEXT_DATA__, ampPath, assetPrefix, bodyTags, canonicalBase, dangerousAsPath, dataOnly, devFiles, dynamicImports, files, hasCssMode, head, headTags, html, htmlProps, hybridAmp, inAmpMode, isDevelopment, polyfillFiles, staticMarkup, styles
8 | // Page props in: this.props.__NEXT_DATA__.props.pageProps
9 | render (): React.ReactElement {
10 | const { locale } = this.props.__NEXT_DATA__
11 | return (
12 |
13 |
14 | `family=${`${fontName.replace(/ /g, '+')}${fontWeight !== undefined ? ':' + fontWeight : ''}`}`).join('&') ?? ''}&display=swap`} />
15 | {/* Global Site Tag (gtag.js) - Google Analytics */}
16 | {config.googleAnalyticsId !== undefined
17 | ? (
18 | <>
19 |
20 |
28 | >
29 | )
30 | : null}
31 |
32 |
33 |
34 |
35 |
36 |
37 | )
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/pages/about.tsx:
--------------------------------------------------------------------------------
1 | /*
2 | Don't forget to add to next.config.js:
3 |
4 | images: {
5 | domains: ['www.tomsoderlund.com']
6 | },
7 | */
8 | import React from 'react'
9 | import type { GetStaticPropsContext, GetStaticPropsResult } from 'next'
10 | import Image from 'next/image'
11 |
12 | import { PageProps } from 'components/page/PageHead'
13 | import { config } from 'config/config'
14 |
15 | const AboutPage: React.FC = ({ title }) => {
16 | return (
17 |
18 |
19 |
26 |
27 |
{title}
28 |
29 | I created {config.appName} because I’ve always been passionate about building fast,
30 | modern web apps using the best technology out there. This project combines the power of GraphQL,
31 | Postgres as a backend, and the flexibility of Next.js for SSG or SSR—all to make lightning-fast apps
32 | entirely in JavaScript.
33 |
34 |
35 | As a compulsive maker and someone constantly exploring new ways to build impactful projects,
36 | I wanted a streamlined, flexible template that could handle everything from e-commerce sites to
37 | digital media platforms. It’s a solution born from my experience working as a CTO and full-stack
38 | developer at sustainability-focused startups.
39 |
40 |
41 | You might find my other projects interesting too.
42 | For example, my startup and AI blog ,
43 | or my web and mobile apps at Tomorroworld .
44 |
45 |
46 | If you’re curious to learn more or collaborate,
47 | feel free to email me or check out Tomorroworld for a deeper
48 | dive into my world of projects, ranging from AI to sustainability tech.
49 |
50 |
51 |
52 |
81 |
82 | )
83 | }
84 | export default AboutPage
85 |
86 | export async function getStaticProps ({ params }: GetStaticPropsContext): Promise> {
87 | return {
88 | props: {
89 | title: 'About',
90 | description: `I created ${config.appName} because I’ve always been passionate about building fast, modern web apps using the best technology out there.`
91 | }
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/pages/api/graphiql.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from 'next'
2 |
3 | import postgraphile from 'graphql/server/postgraphile'
4 | import runMiddleware from 'graphql/server/runMiddleware'
5 |
6 | // Graphiql route that handles rendering graphiql
7 | // https://github.com/graphql/graphiql
8 | // An interactive in-browser GraphQL IDE
9 | const graphiql = async (req: NextApiRequest, res: NextApiResponse): Promise => {
10 | res.statusCode = 200
11 | await runMiddleware(req, res, postgraphile)
12 | res.end()
13 | }
14 | export default graphiql
15 |
--------------------------------------------------------------------------------
/pages/api/graphql/index.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from 'next'
2 |
3 | import postgraphile from 'graphql/server/postgraphile'
4 | import runMiddleware from 'graphql/server/runMiddleware'
5 |
6 | // GraphQL route that handles queries
7 | const graphql = async (req: NextApiRequest, res: NextApiResponse): Promise => {
8 | res.statusCode = 200
9 | await runMiddleware(req, res, postgraphile)
10 | res.end()
11 | }
12 | export default graphql
13 |
14 | export const config = {
15 | api: {
16 | bodyParser: false
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/pages/api/graphql/stream.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from 'next'
2 |
3 | import postgraphile from 'graphql/server/postgraphile'
4 | import runMiddleware from 'graphql/server/runMiddleware'
5 |
6 | // Endpoint needed for graphiql
7 | const graphiqlStream = async (req: NextApiRequest, res: NextApiResponse): Promise => {
8 | res.statusCode = 200
9 | await runMiddleware(req, res, postgraphile)
10 | res.end()
11 | }
12 | export default graphiqlStream
13 |
14 | export const config = {
15 | api: {
16 | bodyParser: false
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/pages/articles/[articleSlug].tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import Link from 'next/link'
3 | import type { GetStaticPropsContext, GetStaticPropsResult, GetStaticPathsContext, GetStaticPathsResult } from 'next'
4 | import { ParsedUrlQuery } from 'querystring'
5 |
6 | import { Article } from 'graphql/__generated__/graphql'
7 | import { useGetArticle } from '../../graphql/collections/article/hooks'
8 | import ArticleDetails from '../../components/articles/ArticleDetails'
9 |
10 | interface ArticlePageParams extends ParsedUrlQuery {
11 | articleId: string
12 | }
13 |
14 | interface ArticlePageProps {
15 | articleId: number | null
16 | title: string
17 | }
18 |
19 | const ArticlePage = ({ articleId }: ArticlePageProps): React.ReactElement => {
20 | const { data, loading, error } = useGetArticle(articleId ?? 0)
21 | if (error != null || (data !== undefined && (data.articleById === undefined || data.articleById === null))) {
22 | throw new Error(`Error: ${error?.message as string}`)
23 | }
24 |
25 | return (
26 | <>
27 | {loading
28 | ? (
29 | Loading...
30 | )
31 | : (
32 |
33 | )}
34 |
35 |
36 |
37 |
38 | Home
39 |
40 |
41 |
42 | >
43 | )
44 | }
45 |
46 | export default ArticlePage
47 |
48 | export async function getStaticProps (context: GetStaticPropsContext): Promise> {
49 | const articleId = (context.params?.articleSlug as string)?.split('-')?.pop() ?? null
50 | return {
51 | props: {
52 | title: `Article ${articleId as string}`,
53 | articleId: articleId !== null ? parseInt(articleId) : null
54 | }
55 | }
56 | }
57 |
58 | export async function getStaticPaths (context: GetStaticPathsContext): Promise> {
59 | // const locales = context.locales ?? ['en']
60 | return {
61 | paths: [],
62 | fallback: true // false → 404, true: Next.js tries to generate page
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import type { GetStaticPropsResult } from 'next'
3 |
4 | import { config } from '../config/config'
5 |
6 | import ArticleList from '../components/articles/ArticleList'
7 | import CreateArticleForm from '../components/articles/CreateArticleForm'
8 |
9 | interface StartPageProps {
10 | title: string
11 | }
12 |
13 | function StartPage ({ title }: StartPageProps): React.ReactElement {
14 | // Note: 'query' contains both /:params and ?query=value from url
15 | return (
16 | <>
17 | {config.appName}: {title}
18 | {config.appTagline}
19 |
20 |
21 |
22 |
23 | GraphQL
24 | Try the GraphQL Explorer
25 |
26 | Source code
27 | Get the source code for nextjs-pwa-graphql-sql-boilerplate
28 | >
29 | )
30 | }
31 |
32 | export default StartPage
33 |
34 | export const getStaticProps = async (): Promise> => ({
35 | props: {
36 | title: 'Welcome'
37 | }
38 | })
39 |
--------------------------------------------------------------------------------
/pages/robots.txt.tsx:
--------------------------------------------------------------------------------
1 | import { NextPage, GetServerSidePropsContext } from 'next'
2 | import { config } from '../config/config'
3 |
4 | const robotsTxt = `# robotstxt.org
5 |
6 | User-agent: *
7 |
8 | Sitemap: ${config.appUrl as string}sitemap.xml`
9 |
10 | const RobotsTxt: NextPage = () => null
11 |
12 | export async function getServerSideProps ({ res }: GetServerSidePropsContext): Promise<{ props: any }> {
13 | if (res !== undefined) {
14 | res.setHeader('Content-Type', 'text/plain')
15 | res.write(robotsTxt)
16 | res.end()
17 | }
18 | return { props: {} }
19 | }
20 |
21 | export default RobotsTxt
22 |
--------------------------------------------------------------------------------
/pages/signin/authenticate.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react'
2 | import type { GetStaticPropsResult } from 'next'
3 | import Router from 'next/router'
4 | import { getAuth, signInWithEmailLink } from 'firebase/auth'
5 | import querystring from 'querystring'
6 |
7 | import { config } from 'config/config'
8 | import { firebaseApp } from 'lib/firebase'
9 |
10 | // const titleCase = str => str.replace(/(?:^|\s|[-"'([{])+\S/g, (c) => c.toUpperCase())
11 | // const emailToName = (email) => titleCase(email.split('@')[0].replace(/\./g, ' '))
12 |
13 | function EmailAuthenticatePage ({ query }: { query: any }): React.ReactElement {
14 | const auth = getAuth(firebaseApp)
15 | useEffect(() => {
16 | async function signinUserAndRedirect (): Promise {
17 | // Confirm the link is a sign-in with email link.
18 | let email = window.localStorage.getItem('emailForSignIn')
19 | if (email === undefined) {
20 | // User opened the link on a different device. To prevent session fixation attacks, ask the user to provide the associated email again. For example:
21 | email = window.prompt('Please provide your email again for confirmation (the email was opened in a new window):')
22 | }
23 | try {
24 | const { user } = await signInWithEmailLink(auth, email ?? '', window.location.href)
25 | // Add user.displayName if missing
26 | if (user.displayName !== undefined) {
27 | // // user.updateProfile({ displayName: emailToName(user.email) })
28 | }
29 | // Clear email from storage
30 | window.localStorage.removeItem('emailForSignIn')
31 | // Redirect browser
32 | const { redirectTo } = querystring.parse(window.location.href.split('?')[1])
33 | void Router.push(redirectTo !== undefined ? decodeURIComponent(redirectTo as string) : config.startPagePath ?? '/')
34 | // You can access the new user via result.user
35 | // Additional user info profile not available via: result.additionalUserInfo.profile == null
36 | // You can check if the user is new or existing: result.additionalUserInfo.isNewUser
37 | } catch (error: any) {
38 | console.warn(`Warning: ${error.message as string}`, error)
39 | }
40 | }
41 | void signinUserAndRedirect()
42 | }, [query, auth])
43 |
44 | return (
45 | <>
46 | Logging in to {config.appName}...
47 | >
48 | )
49 | }
50 |
51 | export default EmailAuthenticatePage
52 |
53 | export const getStaticProps = async (): Promise> => ({
54 | props: {
55 | title: 'Signing in',
56 | layout: 'signin'
57 | }
58 | })
59 |
--------------------------------------------------------------------------------
/pages/signin/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import type { GetStaticPropsResult } from 'next'
3 |
4 | import { config } from 'config/config'
5 | import { useUser } from 'graphql/collections/user/hooks'
6 | import SigninWithEmailForm from 'components/user/SigninWithEmailForm'
7 | import SigninWithGoogleButton from 'components/user/SigninWithGoogleButton'
8 |
9 | function SigninPage (): React.ReactElement {
10 | const { user, signOut } = useUser()
11 | return user === null
12 | ? (
13 | <>
14 | Welcome to {config.appName}
15 |
16 | or sign in with email:
17 |
18 | >
19 | )
20 | : (
21 | { void signOut?.() }}
23 | >
24 | Sign out
25 |
26 | )
27 | }
28 |
29 | export default SigninPage
30 |
31 | export const getStaticProps = async (): Promise> => ({
32 | props: {
33 | title: 'Sign in'
34 | }
35 | })
36 |
--------------------------------------------------------------------------------
/pages/sitemap.xml.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { GetServerSidePropsContext, GetServerSidePropsResult } from 'next'
3 | import { ParsedUrlQuery } from 'querystring'
4 |
5 | import { config } from 'config/config'
6 | import { formatDate } from 'lib/formatDate'
7 |
8 | interface SiteUrlProps {
9 | path: string
10 | }
11 |
12 | const SiteUrl = ({ path }: SiteUrlProps): string => {
13 | const getDate = (): string => formatDate(new Date())
14 | return (
15 | `
16 | ${config.appUrl as string}${path.substring(1)}
17 | ${getDate()}
18 | `
19 | )
20 | }
21 |
22 | interface SitemapProps {
23 | pagePaths: string[]
24 | }
25 |
26 | const Sitemap = ({ pagePaths }: SitemapProps): string => {
27 | return (
28 | `
29 | ${pagePaths.map((path, index) => ).join('\n')}
30 | `
31 | )
32 | }
33 |
34 | const getPagePaths = async (): Promise => {
35 | return ['/']
36 | }
37 |
38 | interface SitemapPageParams extends ParsedUrlQuery {
39 | }
40 |
41 | export async function getServerSideProps ({ res }: GetServerSidePropsContext): Promise> {
42 | if (res !== undefined) {
43 | const pagePaths = await getPagePaths()
44 | res.setHeader('Content-Type', 'text/xml')
45 | res.write(
46 |
49 | )
50 | res.end()
51 | }
52 | return { props: {} }
53 | }
54 |
55 | export default Sitemap
56 |
--------------------------------------------------------------------------------
/public/favicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/tomsoderlund/nextjs-pwa-graphql-sql-boilerplate/5313c3679e00ea64e841480ac2e5f53747637002/public/favicon.png
--------------------------------------------------------------------------------
/public/icons/feedback.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/public/icons/help.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/public/icons/home.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/public/icons/menu.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/public/icons/person.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/public/images/google_g.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Next.js GraphQL PWA",
3 | "description": "Next.js serverless PWA with TypeScript + GraphQL (Postgraphile, Apollo) and Postgres SQL",
4 | "icons": [
5 | {
6 | "src": "favicon.png",
7 | "sizes": "256x256",
8 | "type": "image/png"
9 | }
10 | ],
11 | "display": "browser",
12 | "orientation": "portrait",
13 | "scope": "/",
14 | "start_url": "/"
15 | }
--------------------------------------------------------------------------------
/styles/globals.css:
--------------------------------------------------------------------------------
1 | html,
2 | body {
3 | padding: 0;
4 | margin: 0;
5 | font-family: Inter, sans-serif;
6 | }
7 |
8 | a {
9 | color: inherit;
10 | text-decoration: none;
11 | }
12 |
13 | * {
14 | box-sizing: border-box;
15 | }
16 |
17 | @media (prefers-color-scheme: dark) {
18 | html {
19 | color-scheme: dark;
20 | }
21 | body {
22 | color: white;
23 | background: black;
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "strict": true,
12 | "forceConsistentCasingInFileNames": true,
13 | "noEmit": true,
14 | "esModuleInterop": true,
15 | "module": "esnext",
16 | "moduleResolution": "node",
17 | "resolveJsonModule": true,
18 | "isolatedModules": true,
19 | "jsx": "preserve",
20 | "incremental": true,
21 | "baseUrl": ".",
22 | "plugins": [
23 | {
24 | "name": "next"
25 | }
26 | ]
27 | },
28 | "include": [
29 | "types/**/*.d.ts",
30 | "next-env.d.ts",
31 | "**/*.ts",
32 | "**/*.tsx",
33 | ".next/types/**/*.ts"
34 | ],
35 | "exclude": [
36 | "node_modules"
37 | ]
38 | }
39 |
--------------------------------------------------------------------------------
/types/global.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'postgraphile-plugin-nested-mutations'
2 | declare module 'react-toastify'
3 |
4 | declare global {
5 | interface Window {
6 | gtag: (event: string, action: string, options: any) => void
7 | }
8 | }
9 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "crons": [
3 | ]
4 | }
5 |
--------------------------------------------------------------------------------