├── .env.example ├── .gitignore ├── README.md ├── components ├── AwesomeLink.tsx └── Layout │ ├── Header.tsx │ └── index.tsx ├── data └── links.ts ├── graphql ├── builder.ts ├── context.ts ├── resolvers.ts ├── schema.ts └── types │ ├── Link.ts │ └── User.ts ├── lib ├── apollo.ts └── prisma.ts ├── next-env.d.ts ├── package.json ├── pages ├── _app.tsx ├── about.tsx ├── admin.tsx ├── api │ ├── auth │ │ ├── [...auth0].ts │ │ └── hook.ts │ ├── graphql.ts │ └── upload-image.ts ├── favorites.tsx ├── index.tsx └── link │ └── [id].tsx ├── postcss.config.js ├── prisma ├── schema.prisma └── seed.ts ├── public ├── favicon.ico └── vercel.svg ├── styles └── tailwind.css ├── tailwind.config.js └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | PP# You can get a hosted PostgreSQL Database using Heroku, Digital Ocean 2 | DATABASE_URL="" 3 | # can be generated by running `openssl rand -hex 32` 4 | AUTH0_SECRET ='' 5 | 6 | # Can be found in the App settings 7 | AUTH0_BASE_URL='http://localhost:3000' 8 | AUTH0_ISSUER_BASE_URL='' 9 | AUTH0_CLIENT_ID = '' 10 | AUTH0_CLIENT_SECRET = '' 11 | 12 | # change `http://localhost:3000` to the domain of your app in production 13 | AUTH0_CALLBACK_URL = 'http://localhost:3000/api/auth/callback' 14 | # To be sent to the /api/auth/hook endpoint to add users to the database 15 | AUTH0_HOOK_SECRET = '' 16 | 17 | APP_AWS_ACCESS_KEY = '' 18 | APP_AWS_SECRET_KEY = '' 19 | APP_AWS_REGION = '' 20 | AWS_S3_BUCKET_NAME = '' 21 | NEXT_PUBLIC_AWS_S3_BUCKET_NAME = '' 22 | -------------------------------------------------------------------------------- /.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 | 27 | # local env files 28 | .env 29 | .env.local 30 | .env.development.local 31 | .env.test.local 32 | .env.production.local 33 | 34 | # vercel 35 | .vercel 36 | 37 | package-lock.json -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Awesome Links 2 | 3 | This project is the source code for the tutorial series of three parts about building a Fullstack Web application With TypeScript, PostgreSQL, Next.js, Prisma & GraphQL. 4 | 5 | Here is what you will learn: 6 | 7 | ## Part 1: Data Modeling 8 | 9 | * Set up Prisma on a Next.js project to connect to PostgreSQL database 10 | * Define database models with Prisma 11 | * Define relationship with Prisma (Many-to-Many) 12 | * Execute migration and seed your database with Prisma 13 | * Explore your database with Prisma Studio 14 | 15 | [Link to the article](https://www.prisma.io/blog/fullstack-nextjs-graphql-prisma-oklidw1rhw) 16 | 17 | ## Part 2: GraphQL API 18 | 19 | * Create a GraphQL schema 20 | * Set up a GraphQL server on Next.js 21 | * Inject Prisma client in the GraphQL context 22 | * Use GraphQL code-first approach using Pothos 23 | * Set up Apollo client on Next.js to consume a GraphQL API 24 | * Implement the pagination on a GraphQL API 25 | 26 | [Link to the article](https://www.prisma.io/blog/fullstack-nextjs-graphql-prisma-2-fwpc6ds155) 27 | 28 | ## Part 3: Authentication 29 | 30 | [Link to article](https://www.prisma.io/blog/fullstack-nextjs-graphql-prisma-3-clxbrcqppv) 31 | 32 | ## Part 4: Image Upload 33 | 34 | [Link to article](https://www.prisma.io/blog/fullstack-nextjs-graphql-prisma-4-1k1kc83x3v) 35 | 36 | ## Part 5: Deployment 37 | 38 | [Link to article](https://www.prisma.io/blog/fullstack-nextjs-graphql-prisma-5-m2fna60h7c) 39 | -------------------------------------------------------------------------------- /components/AwesomeLink.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface Props { 4 | imageUrl: string; 5 | url: string; 6 | title: string; 7 | category: string; 8 | description: string; 9 | id: number; 10 | } 11 | 12 | export const AwesomeLink: React.FC = ({ 13 | imageUrl, 14 | url, 15 | title, 16 | category, 17 | description, 18 | id, 19 | }) => { 20 | return ( 21 |
22 | 23 |
24 |

{category}

25 |

{title}

26 |

{description}

27 | 28 | {/* removes https from url */} 29 | {url.replace(/(^\w+:|^)\/\//, '')} 30 | 36 | 37 | 38 | 39 | 40 |
41 |
42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /components/Layout/Header.tsx: -------------------------------------------------------------------------------- 1 | // components/Layout/Header.tsx 2 | import React from 'react' 3 | import Link from 'next/link' 4 | import { useUser } from '@auth0/nextjs-auth0/client' 5 | 6 | const Header = () => { 7 | const { user } = useUser() 8 | return ( 9 |
10 |
11 | 12 | 19 | 25 | 26 | 27 | 48 |
49 |
50 | ) 51 | } 52 | 53 | export default Header 54 | -------------------------------------------------------------------------------- /components/Layout/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Header from "./Header"; 3 | 4 | interface Props { 5 | children: React.ReactNode 6 | } 7 | const Layout: React.FC = ({ children }) => { 8 | return ( 9 |
10 |
11 | {children} 12 |
13 | ); 14 | }; 15 | 16 | export default Layout; 17 | -------------------------------------------------------------------------------- /data/links.ts: -------------------------------------------------------------------------------- 1 | export const links = [ 2 | { 3 | category: "Open Source", 4 | description: "Fullstack React framework", 5 | id: 1, 6 | imageUrl: "https://nextjs.org/static/twitter-cards/home.jpg", 7 | title: "Next.js", 8 | url: "https://nextjs.org", 9 | }, 10 | { 11 | category: "Open Source", 12 | description: "Next Generation ORM for TypeScript and JavaScript", 13 | id: 2, 14 | imageUrl: "https://www.prisma.io/images/og-image.png", 15 | 16 | title: "Prisma", 17 | url: "https://prisma.io", 18 | }, 19 | { 20 | category: "Open Source", 21 | description: "Utility-fist css framework", 22 | id: 3, 23 | imageUrl: 24 | "https://tailwindcss.com/_next/static/media/twitter-large-card.85c0ff9e455da585949ff0aa50981857.jpg", 25 | title: "TailwindCSS", 26 | url: "https://tailwindcss.com", 27 | }, 28 | { 29 | category: "Open Source", 30 | description: "GraphQL implementation ", 31 | id: 4, 32 | imageUrl: "https://www.apollographql.com/apollo-home.jpg", 33 | title: "Apollo GraphQL", 34 | url: "https://apollographql.com", 35 | }, 36 | ]; 37 | -------------------------------------------------------------------------------- /graphql/builder.ts: -------------------------------------------------------------------------------- 1 | import SchemaBuilder from "@pothos/core"; 2 | import PrismaPlugin from '@pothos/plugin-prisma'; 3 | import type PrismaTypes from '@pothos/plugin-prisma/generated'; 4 | import prisma from "../lib/prisma"; 5 | import RelayPlugin from "@pothos/plugin-relay"; 6 | import {createContext} from './context' 7 | 8 | export const builder = new SchemaBuilder<{ 9 | PrismaTypes: PrismaTypes, 10 | Context: ReturnType, 11 | }>({ 12 | plugins: [PrismaPlugin, RelayPlugin], 13 | relayOptions: {}, 14 | prisma: { 15 | client: prisma, 16 | } 17 | }) 18 | 19 | builder.queryType({ 20 | fields: (t) => ({ 21 | ok: t.boolean({ 22 | resolve: () => true, 23 | }), 24 | }), 25 | }); 26 | 27 | builder.mutationType({}) -------------------------------------------------------------------------------- /graphql/context.ts: -------------------------------------------------------------------------------- 1 | // graphql/context.ts 2 | import { getSession } from '@auth0/nextjs-auth0' 3 | import type { NextApiRequest, NextApiResponse } from 'next' 4 | 5 | export async function createContext({ req, res }: { req: NextApiRequest, res: NextApiResponse }) { 6 | const session = await getSession(req, res) 7 | 8 | // if the user is not logged in, return null 9 | if (!session || typeof session === 'undefined') return {} 10 | 11 | const { user, accessToken } = session 12 | 13 | return { 14 | user, 15 | accessToken, 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /graphql/resolvers.ts: -------------------------------------------------------------------------------- 1 | import prisma from '../lib/prisma' 2 | 3 | export const resolvers = { 4 | Query: { 5 | links: () => { 6 | return prisma.link.findMany() 7 | }, 8 | }, 9 | } -------------------------------------------------------------------------------- /graphql/schema.ts: -------------------------------------------------------------------------------- 1 | // graphql/schema.ts 2 | 3 | import { builder } from "./builder"; 4 | import "./types/Link" 5 | import "./types/User" 6 | 7 | export const schema = builder.toSchema() 8 | -------------------------------------------------------------------------------- /graphql/types/Link.ts: -------------------------------------------------------------------------------- 1 | // /graphql/types/Link.ts 2 | import { builder } from "../builder"; 3 | 4 | builder.prismaObject('Link', { 5 | fields: (t) => ({ 6 | id: t.exposeID('id'), 7 | title: t.exposeString('title'), 8 | url: t.exposeString('url'), 9 | description: t.exposeString('description'), 10 | imageUrl: t.exposeString('imageUrl'), 11 | category: t.exposeString('category'), 12 | users: t.relation('users') 13 | }), 14 | }) 15 | 16 | 17 | builder.queryField('links', (t) => 18 | t.prismaConnection({ 19 | type: 'Link', 20 | cursor: 'id', 21 | resolve: (query, _parent, _args, _ctx, _info) => 22 | prisma.link.findMany({ ...query }) 23 | }) 24 | ) 25 | 26 | builder.queryField('link', (t) => 27 | t.prismaField({ 28 | type: 'Link', 29 | nullable: true, 30 | args: { 31 | id: t.arg.id({ required: true }) 32 | }, 33 | resolve: (query, _parent, args, _info) => 34 | prisma.link.findUnique({ 35 | ...query, 36 | where: { 37 | id: Number(args.id), 38 | } 39 | }) 40 | }) 41 | ) 42 | 43 | 44 | builder.mutationField('createLink', (t) => 45 | t.prismaField({ 46 | type: 'Link', 47 | args: { 48 | title: t.arg.string({ required: true }), 49 | description: t.arg.string({ required: true }), 50 | url: t.arg.string({ required: true }), 51 | imageUrl: t.arg.string({ required: true }), 52 | category: t.arg.string({ required: true }), 53 | }, 54 | resolve: async (query, _parent, args, ctx) => { 55 | const { title, description, url, imageUrl, category } = args 56 | 57 | if (!(await ctx).user) { 58 | throw new Error("You have to be logged in to perform this action") 59 | } 60 | 61 | const user = await prisma.user.findUnique({ 62 | where: { 63 | email: (await ctx).user?.email, 64 | } 65 | }) 66 | 67 | if (!user || user.role !== "ADMIN") { 68 | throw new Error("You don have permission ot perform this action") 69 | } 70 | 71 | return await prisma.link.create({ 72 | ...query, 73 | data: { 74 | title, 75 | description, 76 | url, 77 | imageUrl, 78 | category, 79 | } 80 | }) 81 | } 82 | }) 83 | ) 84 | 85 | builder.mutationField('updateLink', (t) => 86 | t.prismaField({ 87 | type: 'Link', 88 | args: { 89 | id: t.arg.id({ required: true }), 90 | title: t.arg.string(), 91 | description: t.arg.string(), 92 | url: t.arg.string(), 93 | imageUrl: t.arg.string(), 94 | category: t.arg.string(), 95 | }, 96 | resolve: async (query, _parent, args, _ctx) => 97 | prisma.link.update({ 98 | ...query, 99 | where: { 100 | id: Number(args.id), 101 | }, 102 | data: { 103 | title: args.title ? args.title : undefined, 104 | url: args.url ? args.url : undefined, 105 | imageUrl: args.imageUrl ? args.imageUrl : undefined, 106 | category: args.category ? args.category : undefined, 107 | description: args.description ? args.description : undefined, 108 | } 109 | }) 110 | }) 111 | ) 112 | 113 | builder.mutationField('bookmarkLink', (t) => 114 | t.prismaField({ 115 | type: 'Link', 116 | args: { 117 | id: t.arg.id({ required: true }) 118 | }, 119 | resolve: async (query, _parent, args, ctx) => { 120 | if (!(await ctx).user) { 121 | throw new Error("You have to be logged in to perform this action") 122 | } 123 | 124 | const user = await prisma.user.findUnique({ 125 | where: { 126 | email: (await ctx).user?.email, 127 | } 128 | }) 129 | 130 | if (!user) throw Error('User not found') 131 | 132 | const link = await prisma.link.update({ 133 | ...query, 134 | where: { 135 | id: Number(args.id) 136 | }, 137 | data: { 138 | users: { 139 | connect: [{ email: (await ctx).user?.email }] 140 | } 141 | } 142 | }) 143 | 144 | return link 145 | } 146 | }) 147 | ) 148 | 149 | builder.mutationField('deleteLink', (t) => 150 | t.prismaField({ 151 | type: 'Link', 152 | args: { 153 | id: t.arg.id({ required: true }) 154 | }, 155 | resolve: async (query, _parent, args, _ctx) => 156 | prisma.link.delete({ 157 | ...query, 158 | where: { 159 | id: Number(args.id) 160 | } 161 | }) 162 | }) 163 | ) 164 | -------------------------------------------------------------------------------- /graphql/types/User.ts: -------------------------------------------------------------------------------- 1 | // /graphql/types/User.ts 2 | import { builder } from "../builder"; 3 | 4 | builder.prismaObject('User', { 5 | fields: (t) => ({ 6 | id: t.exposeID('id'), 7 | email: t.exposeString('email', { nullable: true, }), 8 | image: t.exposeString('image', { nullable: true, }), 9 | role: t.expose('role', { type: Role, }), 10 | bookmarks: t.relation('bookmarks'), 11 | }) 12 | }) 13 | 14 | const Role = builder.enumType('Role', { 15 | values: ['USER', 'ADMIN'] as const, 16 | }) 17 | 18 | builder.queryField('favorites', (t) => 19 | t.prismaField({ 20 | type: 'User', 21 | resolve: async (query, _parent, _args, ctx) => { 22 | if (!(await ctx).user) { 23 | throw new Error("You have to be logged in to perform this action") 24 | } 25 | 26 | const user = await prisma.user.findUnique({ 27 | ...query, 28 | where: { 29 | email: (await ctx).user?.email, 30 | } 31 | }) 32 | 33 | if (!user) throw Error('User does not exist'); 34 | 35 | return user 36 | } 37 | }) 38 | ) -------------------------------------------------------------------------------- /lib/apollo.ts: -------------------------------------------------------------------------------- 1 | // /lib/apollo.ts 2 | import { ApolloClient, InMemoryCache } from '@apollo/client' 3 | 4 | const apolloClient = new ApolloClient({ 5 | uri: '/api/graphql', 6 | cache: new InMemoryCache(), 7 | }) 8 | 9 | export default apolloClient 10 | -------------------------------------------------------------------------------- /lib/prisma.ts: -------------------------------------------------------------------------------- 1 | // /lib/prisma.ts 2 | import { PrismaClient } from '@prisma/client' 3 | 4 | let prisma: PrismaClient; 5 | 6 | declare global { 7 | var prisma: PrismaClient; 8 | } 9 | 10 | if (process.env.NODE_ENV === 'production') { 11 | prisma = new PrismaClient() 12 | } else { 13 | if (!global.prisma) { 14 | global.prisma = new PrismaClient() 15 | } 16 | prisma = global.prisma 17 | } 18 | export default prisma 19 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "awesome-links", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "@apollo/client": "3.7.4", 12 | "@auth0/nextjs-auth0": "2.1.0", 13 | "@pothos/core": "3.24.0", 14 | "@pothos/plugin-prisma": "3.40.0", 15 | "@pothos/plugin-relay": "3.32.0", 16 | "@prisma/client": "^4.8.1", 17 | "aws-sdk": "2.1295.0", 18 | "graphql": "16.6.0", 19 | "graphql-yoga": "3.3.0", 20 | "next": "^13.1.2", 21 | "react": "18.2.0", 22 | "react-dom": "18.2.0", 23 | "react-hook-form": "7.42.1", 24 | "react-hot-toast": "2.4.0" 25 | }, 26 | "devDependencies": { 27 | "@tailwindcss/forms": "^0.5.3", 28 | "@tailwindcss/typography": "^0.5.9", 29 | "@types/node": "^18.11.18", 30 | "@types/react": "^18.0.26", 31 | "autoprefixer": "^10.4.13", 32 | "postcss": "^8.4.21", 33 | "prisma": "^4.8.1", 34 | "tailwindcss": "^3.2.4", 35 | "ts-node": "^10.9.1", 36 | "typescript": "^4.9.4" 37 | }, 38 | "prisma": { 39 | "seed": "ts-node --transpile-only prisma/seed.ts" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | // pages/_app.tsx 2 | import '../styles/tailwind.css' 3 | import { UserProvider } from '@auth0/nextjs-auth0/client' 4 | import Layout from '../components/Layout' 5 | import { ApolloProvider } from '@apollo/client' 6 | import type { AppProps } from 'next/app' 7 | import apolloClient from '../lib/apollo' 8 | 9 | function MyApp({ Component, pageProps }: AppProps) { 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | ) 19 | } 20 | 21 | export default MyApp 22 | -------------------------------------------------------------------------------- /pages/about.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const about = () => { 4 | return ( 5 |
6 |

Hello from about

7 |
8 | ); 9 | }; 10 | 11 | export default about; 12 | -------------------------------------------------------------------------------- /pages/admin.tsx: -------------------------------------------------------------------------------- 1 | // pages/admin.tsx 2 | import React from 'react' 3 | import { type SubmitHandler, useForm } from 'react-hook-form' 4 | import { gql, useMutation } from '@apollo/client' 5 | import toast, { Toaster } from 'react-hot-toast' 6 | import type { GetServerSideProps } from 'next' 7 | import { getSession } from '@auth0/nextjs-auth0' 8 | 9 | type FormValues = { 10 | title: string; 11 | url: string; 12 | category: string; 13 | description: string; 14 | image: FileList; 15 | } 16 | 17 | const CreateLinkMutation = gql` 18 | mutation($title: String!, $url: String!, $imageUrl: String!, $category: String!, $description: String!) { 19 | createLink(title: $title, url: $url, imageUrl: $imageUrl, category: $category, description: $description) { 20 | title 21 | url 22 | imageUrl 23 | category 24 | description 25 | } 26 | } 27 | ` 28 | 29 | const Admin = () => { 30 | const [createLink, { data, loading, error }] = useMutation(CreateLinkMutation) 31 | const { 32 | register, 33 | handleSubmit, 34 | formState: { errors }, 35 | } = useForm() 36 | 37 | // Upload photo function 38 | const uploadPhoto = async (e: React.ChangeEvent) => { 39 | if (!e.target.files || e.target.files.length <= 0) return 40 | const file = e.target.files[0] 41 | const filename = encodeURIComponent(file.name) 42 | const res = await fetch(`/api/upload-image?file=${filename}`) 43 | const data = await res.json() 44 | const formData = new FormData() 45 | 46 | // @ts-ignore 47 | Object.entries({ ...data.fields, file }).forEach(([key, value]) => { 48 | // @ts-ignore 49 | formData.append(key, value) 50 | }) 51 | 52 | toast.promise( 53 | fetch(data.url, { 54 | method: 'POST', 55 | body: formData, 56 | }), 57 | { 58 | loading: 'Uploading...', 59 | success: 'Image successfully uploaded!🎉', 60 | error: `Upload failed 😥 Please try again ${error}`, 61 | }, 62 | ) 63 | } 64 | 65 | const onSubmit: SubmitHandler = async (data) => { 66 | const { title, url, category, description, image } = data 67 | const imageUrl = `https://${process.env.NEXT_PUBLIC_AWS_S3_BUCKET_NAME}.s3.amazonaws.com/${image[0]?.name}` 68 | const variables = { title, url, category, description, imageUrl } 69 | try { 70 | toast.promise(createLink({ variables }), { 71 | loading: 'Creating new link..', 72 | success: 'Link successfully created!🎉', 73 | error: `Something went wrong 😥 Please try again - ${error}`, 74 | }) 75 | } catch (error) { 76 | console.error(error) 77 | } 78 | } 79 | 80 | return ( 81 |
82 | 83 |

Create a new link

84 |
85 | 95 | 105 | 115 | 125 | 135 | 136 | 157 |
158 |
159 | ) 160 | } 161 | 162 | export default Admin 163 | 164 | export const getServerSideProps: GetServerSideProps = async ({ req, res }) => { 165 | const session = await getSession(req, res); 166 | 167 | if (!session) { 168 | return { 169 | redirect: { 170 | permanent: false, 171 | destination: '/api/auth/login', 172 | }, 173 | props: {}, 174 | }; 175 | } 176 | 177 | const user = await prisma.user.findUnique({ 178 | select: { 179 | email: true, 180 | role: true, 181 | }, 182 | where: { 183 | email: session.user.email, 184 | }, 185 | }); 186 | 187 | if (!user || user.role !== 'ADMIN') { 188 | return { 189 | redirect: { 190 | permanent: false, 191 | destination: '/404', 192 | }, 193 | props: {}, 194 | }; 195 | } 196 | 197 | return { 198 | props: {}, 199 | }; 200 | }; -------------------------------------------------------------------------------- /pages/api/auth/[...auth0].ts: -------------------------------------------------------------------------------- 1 | // pages/api/auth/[...auth0].ts 2 | import { handleAuth } from '@auth0/nextjs-auth0' 3 | 4 | export default handleAuth() 5 | -------------------------------------------------------------------------------- /pages/api/auth/hook.ts: -------------------------------------------------------------------------------- 1 | // pages/api/auth/hook.ts 2 | import prisma from '../../../lib/prisma'; 3 | import type { NextApiRequest, NextApiResponse } from 'next'; 4 | 5 | const handler = async (req: NextApiRequest, res: NextApiResponse) => { 6 | const { email, secret } = req.body; 7 | // 1 8 | if (req.method !== 'POST') { 9 | return res.status(403).json({ message: 'Method not allowed' }); 10 | } 11 | // 2 12 | if (secret !== process.env.AUTH0_HOOK_SECRET) { 13 | return res.status(403).json({ message: `You must provide the secret 🤫` }); 14 | } 15 | // 3 16 | if (email) { 17 | // 4 18 | await prisma.user.create({ 19 | data: { email }, 20 | }); 21 | return res.status(200).json({ 22 | message: `User with email: ${email} has been created successfully!`, 23 | }); 24 | } 25 | }; 26 | 27 | export default handler; 28 | -------------------------------------------------------------------------------- /pages/api/graphql.ts: -------------------------------------------------------------------------------- 1 | import { createYoga } from 'graphql-yoga' 2 | import type { NextApiRequest, NextApiResponse } from 'next' 3 | import { schema } from '../../graphql/schema' 4 | import { createContext } from '../../graphql/context' 5 | 6 | export default createYoga<{ 7 | req: NextApiRequest 8 | res: NextApiResponse 9 | }>({ 10 | schema, 11 | context: createContext, 12 | graphqlEndpoint: '/api/graphql' 13 | }) 14 | 15 | export const config = { 16 | api: { 17 | bodyParser: false 18 | } 19 | } -------------------------------------------------------------------------------- /pages/api/upload-image.ts: -------------------------------------------------------------------------------- 1 | // pages/api/upload-image.ts 2 | import aws from 'aws-sdk' 3 | import type { NextApiRequest, NextApiResponse } from 'next' 4 | 5 | export default async function handler(req: NextApiRequest, res: NextApiResponse) { 6 | try { 7 | // 1. 8 | const s3 = new aws.S3({ 9 | accessKeyId: process.env.APP_AWS_ACCESS_KEY, 10 | secretAccessKey: process.env.APP_AWS_SECRET_KEY, 11 | region: process.env.APP_AWS_REGION, 12 | }) 13 | 14 | // 2. 15 | aws.config.update({ 16 | accessKeyId: process.env.APP_AWS_ACCESS_KEY, 17 | secretAccessKey: process.env.APP_AWS_SECRET_KEY, 18 | region: process.env.APP_AWS_REGION, 19 | signatureVersion: 'v4', 20 | }) 21 | 22 | // 3. 23 | const post = await s3.createPresignedPost({ 24 | Bucket: process.env.AWS_S3_BUCKET_NAME, 25 | Fields: { 26 | key: req.query.file, 27 | }, 28 | Expires: 60, // seconds 29 | Conditions: [ 30 | ['content-length-range', 0, 5048576], // up to 1 MB 31 | ], 32 | }) 33 | 34 | // 4. 35 | return res.status(200).json(post) 36 | } catch (error) { 37 | console.log(error) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /pages/favorites.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AwesomeLink } from '../components/AwesomeLink'; 3 | import { gql, useQuery } from '@apollo/client'; 4 | import type { Link } from '.prisma/client'; 5 | 6 | const FavoritesQuery = gql` 7 | query { 8 | favorites { 9 | bookmarks{ 10 | title 11 | id 12 | url 13 | imageUrl 14 | description 15 | category 16 | } 17 | } 18 | } 19 | `; 20 | 21 | const Favorites = () => { 22 | const { data, loading, error } = useQuery(FavoritesQuery); 23 | if (error) return

Oops! Something went wrong {JSON.stringify(error)}

; 24 | 25 | return ( 26 |
27 |

My Favorites

28 | {loading ? ( 29 |

Loading...

30 | ) : ( 31 |
32 | {data.favorites.bookmarks.length === 0 ? ( 33 |

34 | You haven't bookmarked any links yet 👀 35 |

36 | ) : ( 37 | data.favorites.bookmarks.map((link: Link) => ( 38 |
39 | 47 |
48 | )) 49 | )} 50 |
51 | )} 52 |
53 | ); 54 | }; 55 | 56 | export default Favorites; 57 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | // /pages/index.tsx 2 | import Head from "next/head"; 3 | import { gql, useQuery, useMutation } from "@apollo/client"; 4 | import { AwesomeLink } from "../components/AwesomeLink"; 5 | import type { Link as Node } from "@prisma/client"; 6 | import Link from "next/link"; 7 | import { useUser } from "@auth0/nextjs-auth0/client"; 8 | 9 | const AllLinksQuery = gql` 10 | query allLinksQuery($first: Int, $after: ID) { 11 | links(first: $first, after: $after) { 12 | pageInfo { 13 | endCursor 14 | hasNextPage 15 | } 16 | edges { 17 | cursor 18 | node { 19 | imageUrl 20 | url 21 | title 22 | category 23 | description 24 | id 25 | } 26 | } 27 | } 28 | } 29 | `; 30 | 31 | function Home() { 32 | const { user } = useUser() 33 | const { data, loading, error, fetchMore } = useQuery(AllLinksQuery, { 34 | variables: { first: 3 }, 35 | }); 36 | 37 | if (!user) { 38 | return ( 39 |
40 | To view the awesome links you need to{' '} 41 | 42 | Login 43 | 44 |
45 | ); 46 | } 47 | 48 | if (loading) return

Loading...

; 49 | if (error) return

Oh no... {error.message}

; 50 | 51 | const { endCursor, hasNextPage } = data?.links.pageInfo; 52 | 53 | return ( 54 |
55 | 56 | Awesome Links 57 | 58 | 59 |
60 |
61 | {data?.links.edges.map(({ node }: { node: Node }) => ( 62 | 63 | 72 | 73 | ))} 74 |
75 | {hasNextPage ? ( 76 | 93 | ) : ( 94 |

95 | You've reached the end!{" "} 96 |

97 | )} 98 |
99 |
100 | ); 101 | } 102 | 103 | export default Home; 104 | -------------------------------------------------------------------------------- /pages/link/[id].tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import prisma from '../../lib/prisma'; 3 | import { useState } from 'react'; 4 | import { gql, useMutation } from '@apollo/client'; 5 | import toast, { Toaster } from 'react-hot-toast'; 6 | import type { GetServerSideProps, InferGetServerSidePropsType } from 'next'; 7 | 8 | const BookmarkLinkMutation = gql` 9 | mutation ($id: ID!) { 10 | bookmarkLink(id: $id) { 11 | title 12 | url 13 | imageUrl 14 | category 15 | description 16 | } 17 | } 18 | `; 19 | 20 | const Link = ({ link }: InferGetServerSidePropsType) => { 21 | const [isLoading, setIsLoading] = useState(false); 22 | const [createBookmark] = useMutation(BookmarkLinkMutation); 23 | 24 | const bookmark = async () => { 25 | setIsLoading(true); 26 | toast.promise(createBookmark({ variables: { id: link.id } }), { 27 | loading: 'working on it', 28 | success: 'Saved successfully! 🎉', 29 | error: `Something went wrong 😥 Please try again`, 30 | }); 31 | setIsLoading(false); 32 | }; 33 | 34 | return ( 35 |
36 |
37 | 38 | 58 |

{link.title}

59 | 60 |

{link.description}

61 | 62 | {link.url} 63 | 64 |
65 |
66 | ); 67 | }; 68 | 69 | export default Link; 70 | 71 | export const getServerSideProps: GetServerSideProps = async ({ params }) => { 72 | const id = params?.id; 73 | const link = await prisma.link.findUnique({ 74 | where: { 75 | id: Number(id) 76 | }, 77 | select: { 78 | id: true, 79 | title: true, 80 | category: true, 81 | url: true, 82 | imageUrl: true, 83 | description: true, 84 | }, 85 | }); 86 | 87 | if (!link) return { 88 | notFound: true 89 | } 90 | 91 | return { 92 | props: { 93 | link, 94 | }, 95 | }; 96 | }; 97 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | datasource db { 2 | provider = "postgresql" 3 | url = env("DATABASE_URL") 4 | } 5 | 6 | generator client { 7 | provider = "prisma-client-js" 8 | } 9 | 10 | generator pothos { 11 | provider = "prisma-pothos-types" 12 | } 13 | 14 | model User { 15 | id Int @id @default(autoincrement()) 16 | createdAt DateTime @default(now()) 17 | updatedAt DateTime @updatedAt 18 | email String? @unique 19 | image String? 20 | role Role @default(USER) 21 | bookmarks Link[] 22 | } 23 | 24 | enum Role { 25 | USER 26 | ADMIN 27 | } 28 | 29 | model Link { 30 | id Int @id @default(autoincrement()) 31 | createdAt DateTime @default(now()) 32 | updatedAt DateTime @updatedAt 33 | title String 34 | description String 35 | url String 36 | imageUrl String 37 | category String 38 | users User[] 39 | } 40 | -------------------------------------------------------------------------------- /prisma/seed.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | import { links } from '../data/links'; 3 | 4 | const prisma = new PrismaClient(); 5 | 6 | async function main() { 7 | await prisma.user.create({ 8 | data: { 9 | email: 'test@gmail.com', 10 | role: 'ADMIN', 11 | }, 12 | }); 13 | 14 | await prisma.link.createMany({ 15 | data: links, 16 | }); 17 | } 18 | 19 | main() 20 | .catch(e => { 21 | console.error(e) 22 | process.exit(1) 23 | }) 24 | .finally(async () => { 25 | await prisma.$disconnect() 26 | }) 27 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/prisma/awesome-links/f10bf6c66f2eaa4da83a5b93a7fc33c434aa703c/public/favicon.ico -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /styles/tailwind.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: [ 3 | './pages/**/*.{js,ts,jsx,tsx}', 4 | './components/**/*.{js,ts,jsx,tsx}', 5 | ], 6 | theme: { 7 | extend: {}, 8 | }, 9 | variants: { 10 | extend: {}, 11 | }, 12 | plugins: [require('@tailwindcss/typography'), require('@tailwindcss/forms')], 13 | }; 14 | -------------------------------------------------------------------------------- /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 | }, 22 | "include": [ 23 | "next-env.d.ts", 24 | "**/*.ts", 25 | "**/*.tsx" 26 | ], 27 | "exclude": [ 28 | "node_modules" 29 | ], 30 | "ts-node": { 31 | "compilerOptions": { 32 | "module": "commonjs" 33 | } 34 | } 35 | } 36 | --------------------------------------------------------------------------------