├── .editorconfig ├── .env.example ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .graphqlrc.yml ├── .husky ├── .gitignore └── pre-commit ├── .vscode ├── extensions.json └── settings.json ├── README.md ├── app ├── .env.example ├── .eslintrc.json ├── .gitignore ├── README.md ├── gql │ └── .gitignore ├── lib │ ├── active-link.tsx │ ├── comment-item.tsx │ ├── container.tsx │ ├── feed-item.tsx │ ├── footer.tsx │ ├── icons.tsx │ ├── loading.tsx │ ├── main-section.tsx │ ├── navigation.tsx │ ├── noop-uuid.ts │ ├── supabase.tsx │ ├── time-ago.ts │ ├── urql.tsx │ └── use-paginated-query.ts ├── next-env.d.ts ├── next.config.js ├── package.json ├── pages │ ├── _app.tsx │ ├── about.tsx │ ├── account.tsx │ ├── api │ │ └── graphiql.ts │ ├── comments.tsx │ ├── index.tsx │ ├── item │ │ └── [postId].tsx │ ├── login.tsx │ ├── logout.tsx │ ├── newest.tsx │ ├── profile │ │ └── [profileId].tsx │ └── submit.tsx ├── postcss.config.js ├── public │ ├── favicon.ico │ ├── supabase-logo-icon.png │ ├── supabase-logo-icon.svg │ ├── supabase-logo-wordmark--dark.png │ ├── supabase-logo-wordmark--dark.svg │ ├── supabase-logo-wordmark--light.png │ ├── supabase-logo-wordmark--light.svg │ ├── the-guild-full-dark.svg │ └── vercel.svg ├── styles │ └── globals.css ├── tailwind.config.js └── tsconfig.json ├── data ├── .env.example ├── db │ ├── backup.sql │ ├── row_level_security_polices.csv │ └── schema.sql ├── seed │ ├── blog.xml │ ├── blog_posts.csv │ └── comments.csv └── supabase │ ├── 00-initial-schema.sql │ ├── 01-auth-schema.sql │ ├── 02-storage-schema.sql │ ├── 03-post-setup.sql │ ├── 04-public-profiles.sql │ ├── 05-setup-total-counts.sql │ ├── 05-setup-user-profile-trigger.sql │ ├── 06-update-post-vote-counts.sql │ ├── 07-add-post-title-url-constraints.sql │ ├── 08-update-post-cascahe-delete-constraints.sql │ ├── 09-update-all-gravatars.sql │ └── rls-policies.md ├── graphql ├── queries │ ├── feed.graphql │ ├── hasProfileVotes.graphql │ └── rankedFeed.graphql └── schema │ └── schema.graphql ├── misc ├── banner_image.png └── gh-repo-social-og-image.png ├── package.json ├── renovate.json ├── scripts └── fetchGraphQLSchema.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | SUPABASE_URL= 2 | SUPABASE_ANON_KEY= 3 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | pull_request: 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v3 11 | - uses: the-guild-org/shared-config/setup@main 12 | with: 13 | nodeVersion: 16 14 | - run: yarn codegen 15 | - run: yarn workspace app run build 16 | lint: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/checkout@v3 20 | - uses: the-guild-org/shared-config/setup@main 21 | with: 22 | nodeVersion: 16 23 | - run: yarn workspace app run lint 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .DS_Store 3 | .env 4 | .env.production 5 | yarn-error.log 6 | node_modules 7 | -------------------------------------------------------------------------------- /.graphqlrc.yml: -------------------------------------------------------------------------------- 1 | schema: ./graphql/schema/schema.graphql 2 | documents: ./app/**/*.{graphql,js,ts,jsx,tsx} 3 | extensions: 4 | codegen: 5 | generates: 6 | ./app/gql: 7 | preset: gql-tag-operations-preset 8 | hooks: 9 | afterOneFileWrite: 10 | - prettier --write 11 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "esbenp.prettier-vscode", 4 | "GraphQL.vscode-graphql", 5 | "bradlc.vscode-tailwindcss" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode" 4 | } 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Supabase GraphQL Example 2 | 3 | A basic HackerNews-like clone where posts can be submitted with url links and then up and down voted. 4 | 5 | graphql-hn 6 | 7 | - Example: [supabase-graphql-example.vercel.app](https://supabase-graphql-example.vercel.app/) 8 | - Features: [supabase-graphql-example.vercel.app/about](https://supabase-graphql-example.vercel.app/about) 9 | 10 | ## Showcase 11 | 12 | ### Backend 13 | 14 | - CRUD (Query + Mutation Operations) 15 | - Cursor Based Pagination 16 | - Authorization / Postgres Row Level Security 17 | - [Supabase](https://supabase.com) - Create a backend in less than 2 minutes. Start your project with a Postgres Database, Authentication, instant APIs, Realtime subscriptions and Storage. 18 | - [pg_graphql](https://supabase.com/blog/2021/12/03/pg-graphql) - A native [PostgreSQL extension](https://supabase.github.io/pg_graphql/) adding [GraphQL support](https://graphql.org). The extension keeps schema generation, query parsing, and resolvers all neatly contained on your database server requiring no external services. 19 | - [Postgres Triggers](https://supabase.com/blog/2021/07/30/supabase-functions-updates) and [Postgres Functions](https://supabase.com/docs/guides/database/functions) - When votes are in, use triggers to invoke a Postgres function that calculates a post score to rank the feed 20 | - [Postgres Enumerated Types](https://www.postgresql.org/docs/14/datatype-enum.html) - Enums help defined the direction of a vote: UP or DOWN. 21 | 22 | ### Frontend 23 | 24 | - [Next.js](https://nextjs.org) - React Framework 25 | - [TypeScript](https://www.typescriptlang.org) - TypeScript is a strongly typed programming language that builds on JavaScript, giving you better tooling at any scale. 26 | - [graphql-code-generator](https://www.graphql-code-generator.com) - Generate code from your GraphQL schema and operations with a simple CLI 27 | - [gql-tag-operations-preset](https://www.graphql-code-generator.com/plugins/gql-tag-operations-preset) - This code gen preset generates typings for your inline gql function usages, without having to manually specify import statements for the documents 28 | - [urql](https://formidable.com/open-source/urql/) - A highly customizable and versatile GraphQL client 29 | - [Gravatar](https://en.gravatar.com) - Default avatar profile images from Gravatar 30 | 31 | ### Functionality 32 | 33 | - Registration 34 | - Get a ranked feed of posts 35 | - Create Post 36 | - Delete Post 37 | - Create Comment 38 | - Delete Comment 39 | - Upvote/Downvote Post 40 | - View Profile (Account) 41 | - View Profile (Public) 42 | - Pagination (Posts, Comments) 43 | 44 | ## QuickStart 45 | 46 | ### Setup env vars 47 | 48 | - `cp app/.env.example app/.env` 49 | - Fill in your url and anon key from the Supabase Dashboard: https://app.supabase.io/project/_/settings/api 50 | 51 | ### Install dependencies, GraphQL codegen, run app 52 | 53 | ```bash 54 | yarn 55 | yarn codegen 56 | yarn workspace app dev 57 | ``` 58 | 59 | ### Deploy to Vercel 60 | 61 | Provide the following settings to deploy a production build to Vercel: 62 | 63 | - BUILD COMMAND: `yarn codegen && yarn workspace app build` 64 | - OUTPUT DIRECTORY: `./app/.next` 65 | - INSTALL COMMAND: `yarn` 66 | - DEVELOPMENT COMMAND: `yarn codegen && yarn workspace app dev --port $PORT` 67 | 68 | ## Development 69 | 70 | 1. Fetch latest GraphQL Schema 71 | 72 | ```bash 73 | yarn codegen:fetch 74 | ``` 75 | 76 | 2. Generate Types and Watch for Changes 77 | 78 | ```bash 79 | yarn codegen:watch 80 | ``` 81 | 82 | 3. Run server 83 | 84 | ```bash 85 | yarn workspace app dev 86 | ``` 87 | 88 | ### Synchronize the GraphQL schema 89 | 90 | Note: You need to call `select graphql.rebuild_schema()` manually to synchronize the GraphQL schema with the SQL schema after altering the SQL schema. 91 | 92 | #### Manage Schema with dbmate 93 | 94 | 1. `brew install dbmate` 95 | 2. Setup `.env` with `DATABASE_URL` 96 | 3. Dump Schema 97 | 98 | ``` 99 | cd data 100 | dbmate dump 101 | ``` 102 | 103 | > Note: If `pgdump` fails due to row locks, a workaround is to grant the `postgres` role superuser permissions with `ALTER USER postgres WITH SUPERUSER`. After dumping the schema, you should reset the permissions using `ALTER USER postgres WITH NOSUPERUSER`. You can run these statements in the Superbase Dashboard SQL Editors. 104 | 105 | ## Schema (Public) 106 | 107 | - Profile belongs to auth.users 108 | - Post 109 | - Comment belongs to Post and Profile 110 | - Vote belongs to Post (can have a direction of UP/DOWN) 111 | 112 | - direction enum is "UP" or "DOWN" 113 | 114 | ### Constraints 115 | 116 | - Post `url` is unique 117 | - Vote is unique per Profile, Post (ie, you cannot vote more than once -- up or down) 118 | 119 | See: [`./data/db/schema.sql`](./data/db/schema.sql) 120 | 121 | > Note: The schema includes the entire Supabase schema with auth, storage, functions, etc. 122 | 123 | ## Seed Data 124 | 125 | A data file for all Supabase Blog posts from the RSS feed can be found in `./data/seed/blog_posts.csv` and can be loaded. Another file for `comments` is available as well. 126 | 127 | Note: Assumes a known `profileId` currently. 128 | 129 | ## GraphQL Schema 130 | 131 | See: [`./graphql/schema/schema.graphql`](./graphql/schema/schema.graphql) 132 | 133 | ## Example Query 134 | 135 | See: [`./graphql/queries/`](./graphql/queries/) 136 | 137 | Use: `https://mvrfvzcivgabojxddwtk.supabase.co/graphql/v1` 138 | 139 | > Note: Needs headers 140 | 141 | ``` 142 | 143 | Content-Type: application/json 144 | apiKey: 145 | 146 | ``` 147 | 148 | ## GraphiQL 149 | 150 | GraphiQL is an in-browser IDE for writing, validating, and testing GraphQL queries. 151 | 152 | Visit `http://localhost:3000/api/graphiql` for the [Yoga GraphiQL Playground](https://www.graphql-yoga.com/docs/features/graphiql) where you can experiment with queries and mutations. 153 | 154 | > Note: Needs headers 155 | 156 | ``` 157 | 158 | Content-Type: application/json 159 | apiKey: 160 | 161 | ``` 162 | 163 | > Note: In order for the RLS policies authenticate you, you have to pass an authorization header ([see example](https://github.com/supabase-community/supabase-graphql-example/blob/main/app/lib/urql.tsx#L15)): 164 | 165 | ``` 166 | authorization: Bearer 167 | 168 | ``` 169 | 170 | ### Ranked Feed 171 | 172 | ```gql 173 | query { 174 | rankedFeed: postCollection(orderBy: [{ voteRank: AscNullsFirst }]) { 175 | edges { 176 | post: node { 177 | id 178 | title 179 | url 180 | upVoteTotal 181 | downVoteTotal 182 | voteTotal 183 | voteDelta 184 | score 185 | voteRank 186 | comments: commentCollection { 187 | edges { 188 | node { 189 | id 190 | message 191 | profile { 192 | id 193 | username 194 | avatarUrl 195 | } 196 | } 197 | } 198 | commentCount: totalCount 199 | } 200 | } 201 | } 202 | } 203 | } 204 | ``` 205 | 206 | # Row Level Security Matrix (RLS) 207 | 208 | You can query all policies via: `select * from pg_policies`. 209 | 210 | See: [Row Level Security Matrix (RLS)](./data/supabase/rls-policies.md) 211 | 212 | ## Read More 213 | 214 | - [pg_graphql](https://supabase.github.io/pg_graphql) 215 | - [pg_graphql Configuration](https://supabase.github.io/pg_graphql/configuration) 216 | 217 | ## Troubleshooting 218 | 219 | 1. `dbmate` can create `schema_migrations` tables in schemas. To make sure they are not included in your GraphQL Schema: 220 | 221 | ```sql 222 | revoke select on table public.schema_migrations from anon, authenticated; 223 | ``` 224 | 225 | 2. To [enable inflection](https://supabase.github.io/pg_graphql/configuration/#inflection) 226 | 227 | ```sql 228 | comment on schema public is e'@graphql({"inflect_names": true})'; 229 | ``` 230 | 231 | 3. Try the heartbeat to see if pg_graphql can access requests 232 | 233 | ``` 234 | select graphql_public.graphql( 235 | null, 236 | $$ { heartbeat }$$ 237 | ) 238 | ``` 239 | 240 | Returns: 241 | 242 | ```json 243 | { "data": { "heartbeat": "2022-07-28T17:07:07.90513" } } 244 | ``` 245 | 246 | 4. Is the `public_graphql` schema not exposed properly? 247 | 248 | Getting an 406 status or error message like: 249 | 250 | ``` 251 | { 252 | "message": "The schema must be one of the following: public, storage" 253 | } 254 | ``` 255 | 256 | Then be sure to expose the `graphql_public` in `Settings` > `Project settings` > `API`. 257 | 258 | > The schema to expose in your API. Tables, views and stored procedures in this schema will get API endpoints. 259 | 260 | ![image](https://user-images.githubusercontent.com/1051633/181597157-f9a47a5b-bc6a-49d4-b41e-9c1324b5e2a7.png) 261 | -------------------------------------------------------------------------------- /app/.env.example: -------------------------------------------------------------------------------- 1 | # Get these from your Supabase Dashboard: https://app.supabase.io/project/_/settings/api 2 | NEXT_PUBLIC_SUPABASE_URL= 3 | NEXT_PUBLIC_SUPABASE_ANON_KEY= -------------------------------------------------------------------------------- /app/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env.local 30 | .env.development.local 31 | .env.test.local 32 | .env.production.local 33 | 34 | # vercel 35 | .vercel 36 | 37 | # typescript 38 | *.tsbuildinfo 39 | -------------------------------------------------------------------------------- /app/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | ``` 12 | 13 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 14 | 15 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. 16 | 17 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. 18 | 19 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 20 | 21 | ## Learn More 22 | 23 | To learn more about Next.js, take a look at the following resources: 24 | 25 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 26 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 27 | 28 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 29 | 30 | ## Deploy on Vercel 31 | 32 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 33 | 34 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 35 | -------------------------------------------------------------------------------- /app/gql/.gitignore: -------------------------------------------------------------------------------- 1 | *.ts 2 | *.tsx 3 | -------------------------------------------------------------------------------- /app/lib/active-link.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useRouter } from "next/router"; 3 | import Link from "next/link"; 4 | 5 | /** 6 | * @source https://nextjs.org/docs/api-reference/next/link 7 | * @source https://github.com/vercel/next.js/tree/canary/examples/active-class-name 8 | */ 9 | export function ActiveLink({ 10 | children, 11 | activeClassName, 12 | ...props 13 | }: { 14 | children: React.ReactNode; 15 | activeClassName: string; 16 | href: string; 17 | as?: string; 18 | }) { 19 | const { asPath, isReady } = useRouter(); 20 | 21 | const child = React.Children.only(children) as React.DetailedReactHTMLElement< 22 | any, 23 | HTMLElement 24 | >; 25 | const childClassName = child.props?.className || ""; 26 | const [className, setClassName] = React.useState(childClassName); 27 | 28 | React.useEffect(() => { 29 | // Check if the router fields are updated client-side 30 | if (isReady) { 31 | // Dynamic route will be matched via props.as 32 | // Static route will be matched via props.href 33 | const linkPathname = new URL(props.as || props.href, location.href) 34 | .pathname; 35 | 36 | // Using URL().pathname to get rid of query and hash 37 | const activePathname = new URL(asPath, location.href).pathname; 38 | 39 | const newClassName = 40 | linkPathname === activePathname 41 | ? `${childClassName} ${activeClassName}`.trim() 42 | : childClassName; 43 | 44 | if (newClassName !== className) { 45 | setClassName(newClassName); 46 | } 47 | } 48 | }, [ 49 | asPath, 50 | isReady, 51 | props.as, 52 | props.href, 53 | childClassName, 54 | activeClassName, 55 | setClassName, 56 | className, 57 | ]); 58 | 59 | return ( 60 | 61 | {React.cloneElement(child, { 62 | className: className || null, 63 | })} 64 | 65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /app/lib/comment-item.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Link from "next/link"; 3 | import { useRouter } from "next/router"; 4 | 5 | import { useMutation } from "urql"; 6 | 7 | import { Auth } from "@supabase/ui"; 8 | import { TrashIcon } from "@heroicons/react/outline"; 9 | import { CalendarIcon, UserIcon } from "./icons"; 10 | import { DocumentType, gql } from "../gql"; 11 | import { timeAgo } from "./time-ago"; 12 | 13 | const CommentItem_CommentFragment = gql(/* GraphQL */ ` 14 | fragment CommentItem_CommentFragment on Comment { 15 | id 16 | message 17 | createdAt 18 | post { 19 | id 20 | title 21 | } 22 | profile { 23 | id 24 | username 25 | avatarUrl 26 | } 27 | } 28 | `); 29 | 30 | const CommentItem_DeleteCommentFragment = gql(/* GraphQL */ ` 31 | mutation CommentItem_DeleteComment($commentId: BigInt!) { 32 | deleteFromCommentCollection(atMost: 1, filter: { id: { eq: $commentId } }) { 33 | affectedCount 34 | } 35 | } 36 | `); 37 | 38 | export function CommentItem(props: { 39 | comment: DocumentType; 40 | }) { 41 | const router = useRouter(); 42 | const { user } = Auth.useUser(); 43 | const [deleteCommentMutation, deleteComment] = useMutation( 44 | CommentItem_DeleteCommentFragment 45 | ); 46 | const createdAt = React.useMemo( 47 | () => timeAgo.format(new Date(props.comment.createdAt)), 48 | [props.comment.createdAt] 49 | ); 50 | 51 | React.useEffect(() => { 52 | if (deleteCommentMutation.data) { 53 | router.reload(); 54 | } 55 | }, [deleteCommentMutation.data]); 56 | return ( 57 |
58 | 63 |
64 |
65 |

66 | 67 | 68 | {props.comment.profile?.username} 69 | 70 | 71 |

72 |

73 | 74 | 75 | 76 | {createdAt} 77 | 78 | 79 |

80 |
81 |

82 | {props.comment.message} 83 | {user?.id && user.id === props.comment.profile?.id ? ( 84 | 95 | ) : null} 96 |

97 |
98 |
99 | ); 100 | } 101 | -------------------------------------------------------------------------------- /app/lib/container.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export function Container(props: { children: React.ReactNode }) { 4 | return
{props.children}
; 5 | } 6 | -------------------------------------------------------------------------------- /app/lib/feed-item.tsx: -------------------------------------------------------------------------------- 1 | import { Auth } from "@supabase/ui"; 2 | import Link from "next/link"; 3 | import { useRouter } from "next/router"; 4 | import React from "react"; 5 | import { useMutation } from "urql"; 6 | import { Modal } from "@supabase/ui"; 7 | 8 | import { DocumentType, gql } from "../gql"; 9 | import { 10 | CalendarIcon, 11 | ChevronDownIcon, 12 | ChevronUpIcon, 13 | CommentIcon, 14 | PointIcon, 15 | TrashIcon, 16 | UserIcon, 17 | } from "./icons"; 18 | import { timeAgo } from "./time-ago"; 19 | 20 | const VoteButtons_PostFragment = gql(/* GraphQL */ ` 21 | fragment VoteButtons_PostFragment on Post { 22 | id 23 | upVoteByViewer: voteCollection( 24 | filter: { profileId: { eq: $profileId }, direction: { eq: "UP" } } 25 | ) { 26 | totalCount 27 | } 28 | downVoteByViewer: voteCollection( 29 | filter: { profileId: { eq: $profileId }, direction: { eq: "DOWN" } } 30 | ) { 31 | totalCount 32 | } 33 | } 34 | `); 35 | 36 | const VoteButtons_DeleteVoteMutation = gql(/* GraphQL */ ` 37 | mutation VoteButtons_DeleteVoteMutation($postId: BigInt!, $profileId: UUID!) { 38 | deleteFromVoteCollection( 39 | filter: { postId: { eq: $postId }, profileId: { eq: $profileId } } 40 | ) { 41 | __typename 42 | } 43 | } 44 | `); 45 | 46 | const VoteButtons_VoteMutation = gql(/* GraphQL */ ` 47 | mutation VoteButtons_VoteMutation( 48 | $postId: BigInt! 49 | $profileId: UUID! 50 | $voteDirection: String! 51 | ) { 52 | insertIntoVoteCollection( 53 | objects: [ 54 | { postId: $postId, profileId: $profileId, direction: $voteDirection } 55 | ] 56 | ) { 57 | __typename 58 | affectedCount 59 | records { 60 | id 61 | direction 62 | } 63 | } 64 | } 65 | `); 66 | 67 | function VoteButtons(props: { 68 | post: DocumentType; 69 | }) { 70 | const router = useRouter(); 71 | const { user } = Auth.useUser(); 72 | const [, deleteVote] = useMutation(VoteButtons_DeleteVoteMutation); 73 | const [voteMutation, vote] = useMutation(VoteButtons_VoteMutation); 74 | 75 | React.useEffect(() => { 76 | if (voteMutation.data) { 77 | router.reload(); 78 | } 79 | }, [voteMutation.data]); 80 | 81 | return ( 82 |
83 | 104 | 127 |
128 | ); 129 | } 130 | 131 | const FeedItem_PostFragment = gql(/* GraphQL */ ` 132 | fragment FeedItem_PostFragment on Post { 133 | id 134 | title 135 | url 136 | voteTotal 137 | createdAt 138 | commentCollection { 139 | totalCount 140 | } 141 | profile { 142 | id 143 | username 144 | avatarUrl 145 | } 146 | ...VoteButtons_PostFragment 147 | ...DeleteButton_PostFragment 148 | } 149 | `); 150 | 151 | export function FeedItem(props: { 152 | post: DocumentType; 153 | }) { 154 | const { user } = Auth.useUser(); 155 | const createdAt = React.useMemo( 156 | () => timeAgo.format(new Date(props.post.createdAt)), 157 | [props.post.createdAt] 158 | ); 159 | return ( 160 |
161 | 162 |
163 | 164 | 165 |

166 | {props.post.title} 167 |

168 |
169 | 170 | 171 |
172 | 173 | 174 | {props.post.voteTotal}{" "} 175 | {props.post.voteTotal === 1 ? "point" : "points"} 176 | 177 | 178 | 179 | 180 | {props.post.commentCollection?.totalCount}{" "} 181 | {props.post.commentCollection?.totalCount === 1 182 | ? "comment" 183 | : "comments"} 184 | 185 | 186 | 187 | 188 | 192 | {props.post.profile?.username} 193 | 194 | 195 | 196 | 197 | 198 | {createdAt} 199 | 200 | 201 | {user?.id && props.post.profile?.id === user?.id ? ( 202 | 203 | ) : null} 204 |
205 |
206 |
207 | ); 208 | } 209 | 210 | const DeleteButton_DeletePostMutation = gql(/* GraphQL */ ` 211 | mutation DeleteButton_DeletePostMutation($postId: BigInt!) { 212 | deleteFromPostCollection(atMost: 1, filter: { id: { eq: $postId } }) { 213 | affectedCount 214 | } 215 | } 216 | `); 217 | 218 | const DeleteButton_PostFragment = gql(/* GraphQL */ ` 219 | fragment DeleteButton_PostFragment on Post { 220 | id 221 | } 222 | `); 223 | 224 | const DeleteButton = (props: { 225 | post: DocumentType; 226 | }) => { 227 | const router = useRouter(); 228 | const [show, setShow] = React.useState(false); 229 | const [deletePostMutation, deletePost] = useMutation( 230 | DeleteButton_DeletePostMutation 231 | ); 232 | 233 | React.useEffect(() => { 234 | if (deletePostMutation.data) { 235 | router.push("/"); 236 | } 237 | }, [deletePostMutation.data, deletePostMutation.error]); 238 | 239 | return ( 240 | <> 241 | 248 | setShow(false)} 252 | onConfirm={() => { 253 | deletePost({ 254 | postId: props.post.id, 255 | }); 256 | }} 257 | > 258 | Deleting your post can not be reverted 259 | 260 | 261 | ); 262 | }; 263 | -------------------------------------------------------------------------------- /app/lib/footer.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import Link from "next/link"; 3 | 4 | export function Footer() { 5 | const navigation = { 6 | main: [{ name: "🤔 How did we build this app?", href: "/about" }], 7 | social: [ 8 | { 9 | name: "Twitter", 10 | href: "https://twitter.com/supabase", 11 | icon: (props: any) => ( 12 | 13 | 14 | 15 | ), 16 | }, 17 | { 18 | name: "GitHub", 19 | href: "https://github.com/supabase-community/supabase-graphql-example", 20 | icon: (props: any) => ( 21 | 22 | 27 | 28 | ), 29 | }, 30 | ], 31 | }; 32 | 33 | return ( 34 | 88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /app/lib/icons.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export function CalendarIcon(props: { className?: string }) { 4 | return ( 5 | 14 | 15 | 16 | 17 | ); 18 | } 19 | 20 | export function TrashIcon(props: { className?: string }) { 21 | return ( 22 | 31 | 32 | 33 | ); 34 | } 35 | 36 | export function CommentIcon(props: { className?: string }) { 37 | return ( 38 | 47 | 48 | 49 | ); 50 | } 51 | 52 | export function PointIcon(props: { className?: string }) { 53 | return ( 54 | 55 | 61 | 62 | ); 63 | } 64 | 65 | export function UserIcon(props: { className?: string }) { 66 | return ( 67 | 77 | 78 | 79 | 80 | ); 81 | } 82 | 83 | export function SupabaseIcon(props: { className?: string; height: number }) { 84 | return ( 85 | 86 | 90 | 95 | 99 | 100 | 108 | 109 | 110 | 111 | 119 | 120 | 121 | 122 | 123 | 124 | ); 125 | } 126 | 127 | export function ChevronUpIcon(props: { 128 | className?: string; 129 | strokeWidth?: string; 130 | }) { 131 | return ( 132 | 142 | 143 | 144 | ); 145 | } 146 | 147 | export function ChevronDownIcon(props: { 148 | className?: string; 149 | strokeWidth?: string; 150 | }) { 151 | return ( 152 | 162 | 163 | 164 | ); 165 | } 166 | -------------------------------------------------------------------------------- /app/lib/loading.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { LightningBoltIcon } from "@heroicons/react/solid"; 3 | 4 | export function Loading() { 5 | return ( 6 |
7 | 8 |
9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /app/lib/main-section.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export function MainSection(props: { children: React.ReactNode }) { 4 | return ( 5 |
6 | {props.children} 7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /app/lib/navigation.tsx: -------------------------------------------------------------------------------- 1 | import { Auth } from "@supabase/ui"; 2 | import Link from "next/link"; 3 | import React from "react"; 4 | import { ActiveLink } from "./active-link"; 5 | import { SupabaseIcon } from "./icons"; 6 | 7 | export function Navigation() { 8 | const user = Auth.useUser(); 9 | return ( 10 |
11 |
12 | 13 | 14 | 15 | supanews 16 | 17 | 18 | 35 | {user.user === null ? ( 36 | 37 | 38 | login 39 | 40 | 41 | ) : ( 42 | <> 43 | 44 | 45 | account 46 | 47 | 48 | 49 | 50 | logout 51 | 52 | 53 | 54 | )} 55 |
56 |
57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /app/lib/noop-uuid.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Noop UUID for GraphQL operations that require an UUID 3 | */ 4 | export const noopUUID = "00000000-0000-0000-0000-000000000000"; 5 | -------------------------------------------------------------------------------- /app/lib/supabase.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { createClient, SupabaseClient } from "@supabase/supabase-js"; 3 | import { Auth } from "@supabase/ui"; 4 | 5 | const SupabaseClientContext = React.createContext(null); 6 | 7 | export function SupabaseProvider(props: { children: React.ReactNode }) { 8 | const [client] = React.useState(() => 9 | createClient( 10 | process.env.NEXT_PUBLIC_SUPABASE_URL ?? "http://127.0.0.1:6969", 11 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? "noop" 12 | ) 13 | ); 14 | 15 | return ( 16 | 17 | 18 | {props.children} 19 | 20 | 21 | ); 22 | } 23 | 24 | export function useSupabaseClient(): SupabaseClient { 25 | const client = React.useContext(SupabaseClientContext); 26 | if (client === null) { 27 | throw new Error( 28 | "Supabase client not provided via context.\n" + 29 | "Did you forget to wrap your component tree with SupabaseProvider?" 30 | ); 31 | } 32 | return client; 33 | } 34 | -------------------------------------------------------------------------------- /app/lib/time-ago.ts: -------------------------------------------------------------------------------- 1 | import TimeAgo from "javascript-time-ago"; 2 | import en from "javascript-time-ago/locale/en.json"; 3 | 4 | TimeAgo.addDefaultLocale(en); 5 | 6 | export const timeAgo = new TimeAgo("en-US"); 7 | -------------------------------------------------------------------------------- /app/lib/urql.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { createClient, Provider } from "urql"; 3 | import { useSupabaseClient } from "./supabase"; 4 | 5 | export function UrqlProvider(props: { children: React.ReactNode }) { 6 | const supabaseClient = useSupabaseClient(); 7 | 8 | function getHeaders(): Record { 9 | const headers: Record = { 10 | apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, 11 | }; 12 | const authorization = supabaseClient.auth.session()?.access_token; 13 | 14 | if (authorization) { 15 | headers["authorization"] = `Bearer ${authorization}`; 16 | } 17 | 18 | return headers; 19 | } 20 | 21 | const [client] = React.useState(function createUrqlClient() { 22 | return createClient({ 23 | url: `${process.env.NEXT_PUBLIC_SUPABASE_URL!}/graphql/v1`, 24 | fetchOptions: function createFetchOptions() { 25 | return { headers: getHeaders() }; 26 | }, 27 | }); 28 | }); 29 | return {props.children}; 30 | } 31 | -------------------------------------------------------------------------------- /app/lib/use-paginated-query.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { useQuery, UseQueryArgs, UseQueryResponse } from "urql"; 3 | 4 | /** 5 | * Urql only supports "merge/infinite" pagination by adoptinh the GraphCache (a global normalized cache), 6 | * which certainly is an overkill for this demo. 7 | * 8 | * This hook wraps `useQuery` from urql and adds a light-weight merge previous and current result API. 9 | */ 10 | export function usePaginatedQuery( 11 | args: UseQueryArgs & { 12 | /** 13 | * Merge the old result with the new result. 14 | */ 15 | mergeResult: (oldData: Data, newData: Data) => Data; 16 | } 17 | ): UseQueryResponse { 18 | const [query, queryFn] = useQuery(args); 19 | 20 | const { data, ...rest } = query; 21 | 22 | const mergeRef = React.useRef({ current: data, last: data }); 23 | 24 | if ( 25 | data && 26 | mergeRef.current.current && 27 | query.data !== mergeRef.current.last 28 | ) { 29 | mergeRef.current.current = args.mergeResult(mergeRef.current.current, data); 30 | } 31 | 32 | if (data != null && mergeRef.current.current == null) { 33 | mergeRef.current.current = data; 34 | } 35 | 36 | mergeRef.current.last = query.data; 37 | 38 | return [ 39 | { 40 | ...rest, 41 | data: mergeRef.current.current, 42 | }, 43 | queryFn, 44 | ]; 45 | } 46 | -------------------------------------------------------------------------------- /app/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 | -------------------------------------------------------------------------------- /app/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | }; 5 | 6 | module.exports = nextConfig; 7 | -------------------------------------------------------------------------------- /app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@graphql-yoga/render-graphiql": "2.13.12", 13 | "@heroicons/react": "1.0.6", 14 | "@supabase/supabase-js": "1.35.7", 15 | "@supabase/ui": "0.36.5", 16 | "autoprefixer": "10.4.12", 17 | "graphql": "16.6.0", 18 | "javascript-time-ago": "2.5.7", 19 | "next": "12.3.1", 20 | "postcss": "8.4.17", 21 | "react": "17.0.2", 22 | "react-dom": "17.0.2", 23 | "tailwindcss": "3.1.8", 24 | "urql": "2.2.3" 25 | }, 26 | "devDependencies": { 27 | "@types/javascript-time-ago": "2.0.3", 28 | "@types/node": "17.0.45", 29 | "@types/react": "17.0.50", 30 | "eslint": "8.24.0", 31 | "eslint-config-next": "12.3.1", 32 | "typescript": "4.6.2" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import "../styles/globals.css"; 2 | import type { AppProps } from "next/app"; 3 | import { UrqlProvider } from "../lib/urql"; 4 | import { SupabaseProvider } from "../lib/supabase"; 5 | import { Navigation } from "../lib/navigation"; 6 | import { Footer } from "../lib/footer"; 7 | 8 | function MyApp({ Component, pageProps }: AppProps) { 9 | return ( 10 | 11 | 12 | 13 | 14 |