├── .eslintrc.json ├── app ├── favicon.ico ├── (dashboard) │ ├── projects │ │ └── page.tsx │ ├── settings │ │ └── page.tsx │ ├── layout.tsx │ ├── Sidebar.tsx │ └── page.tsx ├── globals.css ├── providers.tsx ├── _components │ ├── PageHeader.tsx │ ├── Logo.tsx │ ├── Issue.tsx │ ├── StatusRing.tsx │ └── Status.tsx ├── (auth) │ ├── layout.tsx │ ├── signin │ │ └── page.tsx │ └── signup │ │ └── page.tsx ├── layout.tsx ├── gqlProvider.tsx └── api │ └── graphql │ ├── schema.ts │ ├── route.ts │ └── resolvers.ts ├── .env.example ├── .prettierrc ├── utils ├── url.ts ├── token.ts └── auth.ts ├── types.ts ├── postcss.config.mjs ├── gql ├── signinMutation.ts ├── signupMutation.ts ├── issuesQuery.ts ├── updateIssueMutation.ts └── createIssueMutation.ts ├── migrate.ts ├── migrations ├── meta │ ├── _journal.json │ └── 0000_snapshot.json └── 0000_spotty_scarecrow.sql ├── db ├── db.ts └── schema.ts ├── drizzle.config.ts ├── .gitignore ├── public ├── vercel.svg └── next.svg ├── tsconfig.json ├── tailwind.config.ts ├── next.config.mjs ├── package.json └── README.md /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/eligolding/clientside-gql/main/app/favicon.ico -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | TURSO_CONNECTION_URL="your turso db url" 2 | TURSO_AUTH_TOKEN="your db token" 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "printWidth": 80 5 | } 6 | -------------------------------------------------------------------------------- /utils/url.ts: -------------------------------------------------------------------------------- 1 | export const url = process.env.APP_URL ?? 'http://localhost:3000/api/graphql' 2 | -------------------------------------------------------------------------------- /types.ts: -------------------------------------------------------------------------------- 1 | export type GQLContext = { 2 | user?: { id: string; email: string; createdAt: string } | null 3 | } 4 | -------------------------------------------------------------------------------- /app/(dashboard)/projects/page.tsx: -------------------------------------------------------------------------------- 1 | const ProjectsPage = () => { 2 | return
Projects
3 | } 4 | 5 | export default ProjectsPage 6 | -------------------------------------------------------------------------------- /app/(dashboard)/settings/page.tsx: -------------------------------------------------------------------------------- 1 | const SettingsPage = () => { 2 | return
Settings
3 | } 4 | 5 | export default SettingsPage 6 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer utilities { 6 | .text-balance { 7 | text-wrap: balance; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /app/providers.tsx: -------------------------------------------------------------------------------- 1 | import { NextUIProvider } from '@nextui-org/react' 2 | 3 | export function Providers({ children }: { children: React.ReactNode }) { 4 | return {children} 5 | } 6 | -------------------------------------------------------------------------------- /gql/signinMutation.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'urql' 2 | 3 | export const SigninMutation = gql` 4 | mutation Mutation($input: AuthInput!) { 5 | signin(input: $input) { 6 | token 7 | } 8 | } 9 | ` 10 | -------------------------------------------------------------------------------- /gql/signupMutation.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'urql' 2 | 3 | export const SignupMutation = gql` 4 | mutation Mutation($input: AuthInput!) { 5 | createUser(input: $input) { 6 | token 7 | } 8 | } 9 | ` 10 | -------------------------------------------------------------------------------- /gql/issuesQuery.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'urql' 2 | 3 | export const IssuesQuery = gql` 4 | query { 5 | issues { 6 | content 7 | createdAt 8 | id 9 | name 10 | status 11 | } 12 | } 13 | ` 14 | -------------------------------------------------------------------------------- /migrate.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config' 2 | import { resolve } from 'node:path' 3 | import { db } from './db/db' 4 | import { migrate } from 'drizzle-orm/libsql/migrator' 5 | ;(async () => { 6 | await migrate(db, { migrationsFolder: resolve(__dirname, './migrations') }) 7 | })() 8 | -------------------------------------------------------------------------------- /gql/updateIssueMutation.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'urql' 2 | 3 | export const EditIssueIssueMutation = gql` 4 | mutation EditIssue($input: EditIssueInput!) { 5 | editIssue(input: $input) { 6 | createdAt 7 | id 8 | name 9 | status 10 | } 11 | } 12 | ` 13 | -------------------------------------------------------------------------------- /migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "5", 3 | "dialect": "sqlite", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "5", 8 | "when": 1714775059845, 9 | "tag": "0000_spotty_scarecrow", 10 | "breakpoints": true 11 | } 12 | ] 13 | } -------------------------------------------------------------------------------- /app/_components/PageHeader.tsx: -------------------------------------------------------------------------------- 1 | const PageHeader = ({ title, children }) => { 2 | return ( 3 |
4 | {title} 5 | {children} 6 |
7 | ) 8 | } 9 | 10 | export default PageHeader 11 | -------------------------------------------------------------------------------- /gql/createIssueMutation.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'urql' 2 | 3 | export const CreateIssueMutation = gql` 4 | mutation CreateIssue($input: CreateIssueInput!) { 5 | createIssue(input: $input) { 6 | createdAt 7 | id 8 | name 9 | status 10 | } 11 | } 12 | ` 13 | -------------------------------------------------------------------------------- /app/_components/Logo.tsx: -------------------------------------------------------------------------------- 1 | const Logo = () => { 2 | return ( 3 |
4 | {`//`} 5 | Parallel. 6 |
7 | ) 8 | } 9 | 10 | export default Logo 11 | -------------------------------------------------------------------------------- /db/db.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config' 2 | import { drizzle } from 'drizzle-orm/libsql' 3 | import { createClient } from '@libsql/client' 4 | import * as schema from './schema' 5 | 6 | const client = createClient({ 7 | url: process.env.TURSO_CONNECTION_URL!, 8 | authToken: process.env.TURSO_AUTH_TOKEN!, 9 | }) 10 | 11 | export const db = drizzle(client, { schema }) 12 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config' 2 | import type { Config } from 'drizzle-kit' 3 | export default { 4 | schema: './db/schema.ts', 5 | out: './migrations', 6 | driver: 'turso', 7 | dbCredentials: { 8 | url: process.env.TURSO_CONNECTION_URL!, 9 | authToken: process.env.TURSO_AUTH_TOKEN!, 10 | }, 11 | verbose: true, 12 | strict: true, 13 | } satisfies Config 14 | -------------------------------------------------------------------------------- /utils/token.ts: -------------------------------------------------------------------------------- 1 | export const tokenKey = 'parallel_user_token' 2 | 3 | export const getToken = () => { 4 | if (typeof window !== 'undefined') return localStorage.getItem(tokenKey) 5 | } 6 | 7 | export const setToken = (token: string) => { 8 | if (typeof window !== 'undefined') 9 | return localStorage.setItem(tokenKey, token) 10 | } 11 | 12 | export const isAuth = () => Boolean(getToken()) 13 | -------------------------------------------------------------------------------- /app/_components/Issue.tsx: -------------------------------------------------------------------------------- 1 | import Status from './Status' 2 | 3 | const Issue = ({ issue }) => { 4 | const displayId = issue.id.split('-').pop().slice(-3) 5 | 6 | return ( 7 |
8 | 9 | {`PAR-${displayId}`.toUpperCase()} 10 | 11 | 12 | {issue.name} 13 |
14 | ) 15 | } 16 | 17 | export default Issue 18 | -------------------------------------------------------------------------------- /app/(auth)/layout.tsx: -------------------------------------------------------------------------------- 1 | import Logo from '../_components/Logo' 2 | 3 | const AuthLayout = ({ children }) => { 4 | return ( 5 |
6 |
7 |
8 |
9 | 10 |
11 |
{children}
12 |
13 |
14 |
15 | ) 16 | } 17 | 18 | export default AuthLayout 19 | -------------------------------------------------------------------------------- /.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 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | .env -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Inter } from 'next/font/google' 2 | import './globals.css' 3 | import { Providers } from './providers' 4 | import GQLProvider from './gqlProvider' 5 | 6 | const inter = Inter({ subsets: ['latin'] }) 7 | 8 | export default function RootLayout({ 9 | children, 10 | }: Readonly<{ 11 | children: React.ReactNode 12 | }>) { 13 | return ( 14 | 15 | 16 | 17 | {children} 18 | 19 | 20 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss' 2 | import { nextui } from '@nextui-org/react' 3 | 4 | const config: Config = { 5 | content: [ 6 | './pages/**/*.{js,ts,jsx,tsx,mdx}', 7 | './components/**/*.{js,ts,jsx,tsx,mdx}', 8 | './app/**/*.{js,ts,jsx,tsx,mdx}', 9 | './node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}', 10 | ], 11 | theme: { 12 | extend: { 13 | backgroundImage: { 14 | 'gradient-radial': 'radial-gradient(var(--tw-gradient-stops))', 15 | 'gradient-conic': 16 | 'conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))', 17 | }, 18 | }, 19 | }, 20 | darkMode: 'class', 21 | plugins: [nextui()], 22 | } 23 | export default config 24 | -------------------------------------------------------------------------------- /migrations/0000_spotty_scarecrow.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE `issues` ( 2 | `id` integer PRIMARY KEY NOT NULL, 3 | `name` text NOT NULL, 4 | `projectId` text, 5 | `userId` text NOT NULL, 6 | `content` text NOT NULL, 7 | `text` text DEFAULT 'backlog' NOT NULL, 8 | `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL 9 | ); 10 | --> statement-breakpoint 11 | CREATE TABLE `projects` ( 12 | `id` integer PRIMARY KEY NOT NULL, 13 | `name` text NOT NULL, 14 | `userId` text NOT NULL, 15 | `content` text NOT NULL, 16 | `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL 17 | ); 18 | --> statement-breakpoint 19 | CREATE TABLE `users` ( 20 | `id` text PRIMARY KEY NOT NULL, 21 | `created_at` text DEFAULT CURRENT_TIMESTAMP NOT NULL, 22 | `email` text NOT NULL 23 | ); 24 | --> statement-breakpoint 25 | CREATE UNIQUE INDEX `users_email_unique` ON `users` (`email`); -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | async headers() { 4 | return [ 5 | { 6 | // matching all API routes 7 | source: '/api/:path*', 8 | headers: [ 9 | { key: 'Access-Control-Allow-Credentials', value: 'true' }, 10 | { key: 'Access-Control-Allow-Origin', value: '*' }, // replace this your actual origin 11 | { 12 | key: 'Access-Control-Allow-Methods', 13 | value: 'GET,DELETE,PATCH,POST,PUT', 14 | }, 15 | { 16 | key: 'Access-Control-Allow-Headers', 17 | value: 18 | 'X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version', 19 | }, 20 | ], 21 | }, 22 | ] 23 | }, 24 | } 25 | 26 | export default nextConfig 27 | -------------------------------------------------------------------------------- /app/(dashboard)/layout.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { useEffect } from 'react' 3 | import Sidebar from './Sidebar' 4 | import { isAuth } from '@/utils/token' 5 | import { redirect } from 'next/navigation' 6 | 7 | const DashboardLayout = ({ children }) => { 8 | useEffect(() => { 9 | if (!isAuth()) { 10 | redirect('/signin') 11 | } 12 | }, []) 13 | 14 | return ( 15 |
16 | 19 |
20 |
21 |
22 | {children} 23 |
24 |
25 |
26 |
27 | ) 28 | } 29 | 30 | export default DashboardLayout 31 | -------------------------------------------------------------------------------- /app/_components/StatusRing.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { cn } from '@nextui-org/react' 4 | 5 | const getStatusClass = (status: string) => { 6 | return status === 'BACKLOG' 7 | ? 'border-slate-400 hover:border-slate-600' 8 | : status === 'INPROGRESS' 9 | ? 'border-yellow-300 border-yellow-500' 10 | : '' 11 | } 12 | 13 | const StatusRing = ({ status }) => { 14 | const statusClass = getStatusClass(status) 15 | 16 | return status === 'DONE' ? ( 17 |
18 |
19 |
20 |
21 | ) : ( 22 |
28 | ) 29 | } 30 | 31 | export default StatusRing 32 | -------------------------------------------------------------------------------- /app/gqlProvider.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { PropsWithChildren, useMemo } from 'react' 4 | import { 5 | UrqlProvider, 6 | ssrExchange, 7 | fetchExchange, 8 | createClient, 9 | } from '@urql/next' 10 | import { cacheExchange } from '@urql/exchange-graphcache' 11 | 12 | import { url } from '@/utils/url' 13 | import { getToken } from '@/utils/token' 14 | 15 | export default function GQLProvider({ children }: PropsWithChildren) { 16 | const [client, ssr] = useMemo(() => { 17 | const ssr = ssrExchange({ 18 | isClient: typeof window !== 'undefined', 19 | }) 20 | 21 | const client = createClient({ 22 | url, 23 | exchanges: [cacheExchange({}), ssr, fetchExchange], 24 | fetchOptions: () => { 25 | const token = getToken() 26 | 27 | return token 28 | ? { 29 | headers: { authorization: `Bearer ${token}` }, 30 | } 31 | : {} 32 | }, 33 | }) 34 | 35 | return [client, ssr] 36 | }, []) 37 | 38 | return ( 39 | 40 | {children} 41 | 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /app/api/graphql/schema.ts: -------------------------------------------------------------------------------- 1 | const schema = `#graphql 2 | 3 | type User { 4 | id: ID! 5 | email: String! 6 | createdAt: String! 7 | token: String 8 | issues: [Issue]! 9 | } 10 | 11 | enum IssueStatus { 12 | BACKLOG 13 | TODO 14 | INPROGRESS 15 | DONE 16 | } 17 | 18 | type Issue { 19 | id: ID! 20 | createdAt: String! 21 | userId: String! 22 | user: User! 23 | status: IssueStatus 24 | content: String! 25 | name: String! 26 | } 27 | 28 | input AuthInput { 29 | email: String! 30 | password: String! 31 | } 32 | 33 | 34 | input CreateIssueInput { 35 | name: String! 36 | content: String! 37 | status: IssueStatus 38 | } 39 | 40 | input EditIssueInput { 41 | name: String 42 | content: String 43 | status: IssueStatus 44 | id: ID! 45 | } 46 | 47 | input IssuesFilterInput { 48 | statuses: [IssueStatus] 49 | } 50 | 51 | type Query { 52 | me: User 53 | issues(input: IssuesFilterInput): [Issue]! 54 | } 55 | 56 | type Mutation { 57 | deleteIssue(id: ID!): ID! 58 | createIssue(input: CreateIssueInput!): Issue! 59 | editIssue(input: EditIssueInput!): Issue! 60 | createUser(input: AuthInput!): User 61 | signin(input: AuthInput!): User 62 | } 63 | ` 64 | 65 | export default schema 66 | -------------------------------------------------------------------------------- /app/(dashboard)/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import Logo from '@/app/_components/Logo' 2 | import { Boxes, LayoutGrid, Settings } from 'lucide-react' 3 | import Link from 'next/link' 4 | 5 | const links = [ 6 | { href: '/', name: 'Issues', Icon: Boxes }, 7 | { href: '/projects', name: 'Projects', Icon: LayoutGrid }, 8 | { href: '/settings', name: 'Settings', Icon: Settings }, 9 | ] 10 | 11 | const Sidebar = () => { 12 | return ( 13 |
14 |
15 | 16 | 17 | 18 |
19 |
20 |
21 | {links.map((link) => { 22 | return ( 23 |
24 | 25 |
26 | 27 | {link.name} 28 |
29 | 30 |
31 | ) 32 | })} 33 |
34 |
35 |
36 | ) 37 | } 38 | 39 | export default Sidebar 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "parallel", 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 | "db:push": "drizzle-kit push:sqlite --config ./drizzle.config.ts" 11 | }, 12 | "dependencies": { 13 | "@apollo/client": "^3.10.2", 14 | "@apollo/server": "^4.10.4", 15 | "@as-integrations/next": "^3.0.0", 16 | "@libsql/client": "^0.6.0", 17 | "@nextui-org/react": "^2.3.6", 18 | "@urql/exchange-graphcache": "^7.0.2", 19 | "@urql/next": "^1.1.1", 20 | "bcrypt": "^5.1.1", 21 | "dotenv": "^16.4.5", 22 | "drizzle-kit": "^0.20.17", 23 | "drizzle-orm": "^0.30.10", 24 | "framer-motion": "^11.1.7", 25 | "graphql": "^16.8.1", 26 | "jsonwebtoken": "^9.0.2", 27 | "lucide-react": "^0.378.0", 28 | "next": "14.2.3", 29 | "react": "^18", 30 | "react-dom": "^18", 31 | "urql": "^4.0.7" 32 | }, 33 | "devDependencies": { 34 | "@types/bcrypt": "^5.0.2", 35 | "@types/jsonwebtoken": "^9.0.6", 36 | "@types/node": "^20", 37 | "@types/react": "^18", 38 | "@types/react-dom": "^18", 39 | "eslint": "^8", 40 | "eslint-config-next": "14.2.3", 41 | "postcss": "^8", 42 | "tailwindcss": "^3.4.1", 43 | "typescript": "^5" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/api/graphql/route.ts: -------------------------------------------------------------------------------- 1 | import { startServerAndCreateNextHandler } from '@as-integrations/next' 2 | import { ApolloServer } from '@apollo/server' 3 | import { 4 | ApolloServerPluginLandingPageLocalDefault, 5 | ApolloServerPluginLandingPageProductionDefault, 6 | } from '@apollo/server/plugin/landingPage/default' 7 | import { NextRequest } from 'next/server' 8 | import typeDefs from './schema' 9 | import resolvers from './resolvers' 10 | import { getUserFromToken } from '@/utils/auth' 11 | 12 | let plugins = [] 13 | if (process.env.NODE_ENV === 'production') { 14 | plugins = [ 15 | ApolloServerPluginLandingPageProductionDefault({ 16 | embed: true, 17 | graphRef: 'myGraph@prod', 18 | }), 19 | ] 20 | } else { 21 | plugins = [ApolloServerPluginLandingPageLocalDefault({ embed: true })] 22 | } 23 | 24 | const server = new ApolloServer({ 25 | resolvers, 26 | typeDefs, 27 | plugins, 28 | }) 29 | 30 | const handler = startServerAndCreateNextHandler(server, { 31 | context: async (req) => { 32 | const user = await getUserFromToken(req.headers.get('authorization') ?? '') 33 | return { 34 | req, 35 | user, 36 | } 37 | }, 38 | }) 39 | 40 | export async function GET(request: NextRequest) { 41 | return handler(request) 42 | } 43 | 44 | export async function POST(request: NextRequest) { 45 | return handler(request) 46 | } 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Frontend Masters](https://static.frontendmasters.com/assets/brand/logos/full.png)](https://frontendmasters.com/courses/client-graphql-react-v2/) 2 | 3 | This repo is a companion to the [Client-Side GraphQL course](https://frontendmasters.com/courses/client-graphql-react-v2/) on Frontend Masters. 4 | 5 | Here's a link to the [Course Notes](https://clumsy-humor-894.notion.site/Client-side-GraphQL-with-React-4248372d51604858aaf9eeb9127b6433) 6 | 7 | ## Getting Started 8 | 9 | This course repository requires Node version 20+ and a [TursoDB account](https://turso.tech/). More details can be found in [the course notes](https://clumsy-humor-894.notion.site/Client-side-GraphQL-with-React-4248372d51604858aaf9eeb9127b6433). 10 | 11 | 1. Fork/Clone [the repo](https://github.com/Hendrixer/clientside-gql) 12 | 2. Install the Dependencies with `npm install` 13 | 3. Create a [Turso](https://turso.tech/) DB account (free) 14 | - Follow the instructions to make a new DB 15 | - Create a new Group (default is fine) and choose a location 16 | - Create a new Database 17 | - Create a Database Token (Either through Turso website or the CLI) 18 | - Using Turso Website: Click "Create Database Token" 19 | - Using [the CLI](https://docs.turso.tech/cli/installation): Generate a token for your db with this command `turso db tokens create [your db name]` 20 | 4. Create a `.env` file on the root and add these environment variables. If you need the URL, you can use the "Copy URL" button in the Overview tab on Turso: 21 | 22 | ```bash 23 | TURSO_CONNECTION_URL="your turso db url" 24 | TURSO_AUTH_TOKEN="your db token" 25 | ``` 26 | 27 | 5. Push the schema to your Turso DB with this command `npm run db:push` 28 | -------------------------------------------------------------------------------- /app/(auth)/signin/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { SigninMutation } from '@/gql/signinMutation' 4 | import { setToken } from '@/utils/token' 5 | import { Button, Input } from '@nextui-org/react' 6 | import { useRouter } from 'next/navigation' 7 | import { useState } from 'react' 8 | import { useMutation } from 'urql' 9 | 10 | const SigninPage = () => { 11 | const [signinResult, signin] = useMutation(SigninMutation) 12 | const [state, setState] = useState({ password: '', email: '' }) 13 | const router = useRouter() 14 | 15 | const handleSignin = async (e) => { 16 | e.preventDefault() 17 | const result = await signin({ input: state }) 18 | 19 | if (result.data.signin) { 20 | setToken(result.data.signin.token) 21 | router.push('/') 22 | } 23 | } 24 | 25 | return ( 26 |
27 |
Sign in
28 |
29 |
30 | setState((s) => ({ ...s, email: v }))} 33 | variant="faded" 34 | label="Email" 35 | classNames={{ 36 | inputWrapper: 'bg-slate-50 border-slate-100', 37 | }} 38 | /> 39 |
40 |
41 | setState((s) => ({ ...s, password: v }))} 45 | label="Password" 46 | type="password" 47 | classNames={{ inputWrapper: 'bg-slate-50 border-slate-100' }} 48 | /> 49 |
50 |
51 | 54 |
55 |
56 |
57 | ) 58 | } 59 | 60 | export default SigninPage 61 | -------------------------------------------------------------------------------- /app/(auth)/signup/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { SignupMutation } from '@/gql/signupMutation' 4 | import { setToken } from '@/utils/token' 5 | import { Button, Input } from '@nextui-org/react' 6 | import { useRouter } from 'next/navigation' 7 | import { useState } from 'react' 8 | import { useMutation } from 'urql' 9 | 10 | const SignupPage = () => { 11 | const [SignupResult, signup] = useMutation(SignupMutation) 12 | const [state, setState] = useState({ password: '', email: '' }) 13 | const router = useRouter() 14 | 15 | const handleSignup = async (e) => { 16 | e.preventDefault() 17 | const result = await signup({ input: state }) 18 | 19 | if (result.data.createUser) { 20 | setToken(result.data.createUser.token) 21 | router.push('/') 22 | } 23 | } 24 | 25 | return ( 26 |
27 |
Sign up
28 |
29 |
30 | setState((s) => ({ ...s, email: v }))} 33 | variant="faded" 34 | label="Email" 35 | classNames={{ 36 | inputWrapper: 'bg-slate-50 border-slate-100', 37 | }} 38 | /> 39 |
40 |
41 | setState((s) => ({ ...s, password: v }))} 45 | label="Password" 46 | type="password" 47 | classNames={{ inputWrapper: 'bg-slate-50 border-slate-100' }} 48 | /> 49 |
50 |
51 | 54 |
55 |
56 |
57 | ) 58 | } 59 | 60 | export default SignupPage 61 | -------------------------------------------------------------------------------- /db/schema.ts: -------------------------------------------------------------------------------- 1 | import { randomUUID } from 'crypto' 2 | import { relations, sql } from 'drizzle-orm' 3 | import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core' 4 | 5 | const id = () => 6 | text('id') 7 | .primaryKey() 8 | .$default(() => randomUUID()) 9 | 10 | const createdAt = () => 11 | text('created_at') 12 | .default(sql`CURRENT_TIMESTAMP`) 13 | .notNull() 14 | 15 | const boolean = (field: string) => integer(field, { mode: 'boolean' }) 16 | 17 | export const users = sqliteTable('users', { 18 | id: id(), 19 | createdAt: createdAt(), 20 | email: text('email').unique().notNull(), 21 | password: text('password').notNull(), 22 | }) 23 | 24 | export const userRelations = relations(users, ({ many }) => ({ 25 | issues: many(issues), 26 | projects: many(projects), 27 | })) 28 | 29 | export const issues = sqliteTable('issues', { 30 | id: id(), 31 | name: text('name').notNull(), 32 | projectId: text('projectId'), 33 | userId: text('userId').notNull(), 34 | content: text('content').notNull(), 35 | status: text('text', { enum: ['backlog', 'todo', 'inprogress', 'done'] }) 36 | .default('backlog') 37 | .notNull(), 38 | createdAt: createdAt(), 39 | }) 40 | 41 | export const issueRelations = relations(issues, ({ one }) => ({ 42 | user: one(users, { 43 | fields: [issues.userId], 44 | references: [users.id], 45 | }), 46 | project: one(projects, { 47 | fields: [issues.projectId], 48 | references: [projects.id], 49 | }), 50 | })) 51 | 52 | export const projects = sqliteTable('projects', { 53 | id: id(), 54 | name: text('name').notNull(), 55 | userId: text('userId').notNull(), 56 | content: text('content').notNull(), 57 | createdAt: createdAt(), 58 | }) 59 | 60 | export const projectReferences = relations(projects, ({ one }) => ({ 61 | user: one(users, { 62 | fields: [projects.userId], 63 | references: [users.id], 64 | }), 65 | })) 66 | 67 | export type InsertUser = typeof users.$inferInsert 68 | export type SelectUser = typeof users.$inferSelect 69 | 70 | export type InsertIssues = typeof issues.$inferInsert 71 | export type SelectIssues = typeof issues.$inferSelect 72 | 73 | export type InsertProjects = typeof projects.$inferInsert 74 | export type SelectProjects = typeof projects.$inferSelect 75 | -------------------------------------------------------------------------------- /app/_components/Status.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { 4 | Dropdown, 5 | DropdownItem, 6 | DropdownMenu, 7 | DropdownTrigger, 8 | } from '@nextui-org/react' 9 | import StatusRing from './StatusRing' 10 | import { useMutation } from 'urql' 11 | import { EditIssueIssueMutation } from '@/gql/updateIssueMutation' 12 | 13 | const Status = ({ status, issueId }) => { 14 | const [editResult, editIssue] = useMutation(EditIssueIssueMutation) 15 | 16 | const onAction = async (newStatus: string) => { 17 | await editIssue({ input: { id: issueId, status: newStatus } }) 18 | } 19 | 20 | return ( 21 | 26 | 27 | 30 | 31 | 51 | } 54 | > 55 | Backlog 56 | 57 | } 60 | > 61 | In Progress 62 | 63 | }> 64 | Done 65 | 66 | 67 | 68 | ) 69 | } 70 | 71 | export default Status 72 | -------------------------------------------------------------------------------- /utils/auth.ts: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken' 2 | import { db } from '@/db/db' 3 | import { eq } from 'drizzle-orm' 4 | import { users } from '@/db/schema' 5 | import bcrypt from 'bcrypt' 6 | 7 | const SECRET = 'use_an_ENV_VAR' 8 | 9 | export const createTokenForUser = (userId: string) => { 10 | const token = jwt.sign({ id: userId }, SECRET) 11 | return token 12 | } 13 | 14 | export const getUserFromToken = async (header?: string) => { 15 | if (!header) { 16 | return null 17 | } 18 | 19 | const token = (header.split('Bearer')[1] ?? '').trim() 20 | let id: string 21 | 22 | try { 23 | const user = jwt.verify(token, SECRET) as { id: string } 24 | id = user.id 25 | } catch (e) { 26 | console.error('invalid jwt', e) 27 | return null 28 | } 29 | 30 | const user = await db.query.users.findFirst({ 31 | where: eq(users.id, id), 32 | columns: { 33 | id: true, 34 | email: true, 35 | createdAt: true, 36 | }, 37 | }) 38 | 39 | return user 40 | } 41 | 42 | export const signin = async ({ 43 | email, 44 | password, 45 | }: { 46 | email: string 47 | password: string 48 | }) => { 49 | const match = await db.query.users.findFirst({ 50 | where: eq(users.email, email), 51 | }) 52 | 53 | if (!match) return null 54 | 55 | const correctPW = await comparePW(password, match.password) 56 | 57 | if (!correctPW) { 58 | return null 59 | } 60 | 61 | const token = createTokenForUser(match.id) 62 | const { password: pw, ...user } = match 63 | 64 | return { user, token } 65 | } 66 | 67 | export const signup = async ({ 68 | email, 69 | password, 70 | }: { 71 | email: string 72 | password: string 73 | }) => { 74 | const hashedPW = await hashPW(password) 75 | const rows = await db 76 | .insert(users) 77 | .values({ email, password: hashedPW }) 78 | .returning({ 79 | id: users.id, 80 | email: users.email, 81 | createdAt: users.createdAt, 82 | }) 83 | 84 | const user = rows[0] 85 | const token = createTokenForUser(user.id) 86 | 87 | return { user, token } 88 | } 89 | 90 | const hashPW = (password: string) => { 91 | return bcrypt.hash(password, 10) 92 | } 93 | 94 | const comparePW = (password: string, hashedPW: string) => { 95 | return bcrypt.compare(password, hashedPW) 96 | } 97 | -------------------------------------------------------------------------------- /app/api/graphql/resolvers.ts: -------------------------------------------------------------------------------- 1 | import { db } from '@/db/db' 2 | import { InsertIssues, SelectIssues, issues, users } from '@/db/schema' 3 | import { GQLContext } from '@/types' 4 | import { getUserFromToken, signin, signup } from '@/utils/auth' 5 | import { and, asc, desc, eq, or, sql } from 'drizzle-orm' 6 | import { GraphQLError } from 'graphql' 7 | 8 | const resolvers = { 9 | IssueStatus: { 10 | BACKLOG: 'backlog', 11 | TODO: 'todo', 12 | INPROGRESS: 'inprogress', 13 | DONE: 'done', 14 | }, 15 | 16 | Issue: { 17 | user: (issue, args, ctx) => { 18 | if (!ctx.user) 19 | throw new GraphQLError('UNAUTHORIZED', { extensions: { code: 401 } }) 20 | 21 | return db.query.users.findFirst({ 22 | where: eq(users.id, issue.userId), 23 | }) 24 | }, 25 | }, 26 | 27 | User: { 28 | issues: (user, args, ctx) => { 29 | if (!ctx.user) 30 | throw new GraphQLError('UNAUTHORIZED', { extensions: { code: 401 } }) 31 | 32 | return db.query.issues.findMany({ 33 | where: eq(issues.userId, user.id), 34 | }) 35 | }, 36 | }, 37 | Query: { 38 | me: async (_, __, ctx) => { 39 | return ctx.user 40 | }, 41 | issues: async ( 42 | _, 43 | { 44 | input, 45 | }: { 46 | input?: { 47 | statuses?: SelectIssues['status'][] 48 | projects?: SelectIssues['projectId'][] 49 | } 50 | }, 51 | ctx: GQLContext 52 | ) => { 53 | if (!ctx.user) 54 | throw new GraphQLError('UNAUTHORIZED', { extensions: { code: 401 } }) 55 | 56 | const andFilters = [eq(issues.userId, ctx.user.id)] 57 | 58 | if (input && input.statuses && input.statuses.length) { 59 | const statusFilters = input.statuses.map((status) => 60 | eq(issues.status, status) 61 | ) 62 | 63 | andFilters.push(or(...statusFilters)) 64 | } 65 | 66 | const data = await db.query.issues.findMany({ 67 | where: and(...andFilters), 68 | orderBy: [ 69 | asc(sql`case ${issues.status} 70 | when "backlog" then 1 71 | when "inprogress" then 2 72 | when "done" then 3 73 | end`), 74 | desc(issues.createdAt), 75 | ], 76 | }) 77 | 78 | return data 79 | }, 80 | }, 81 | Mutation: { 82 | deleteIssue: async (_, { id }, ctx) => { 83 | if (!ctx.user) 84 | throw new GraphQLError('UNAUTHORIZED', { extensions: { code: 401 } }) 85 | 86 | await db.delete(issues).where(eq(issues.id, id)) 87 | return id 88 | }, 89 | createIssue: async ( 90 | _, 91 | { input }: { input: Omit }, 92 | ctx: GQLContext 93 | ) => { 94 | if (!ctx.user) 95 | throw new GraphQLError('UNAUTHORIZED', { extensions: { code: 401 } }) 96 | 97 | const issue = await db 98 | .insert(issues) 99 | .values({ ...input, userId: ctx.user.id }) 100 | .returning() 101 | 102 | return issue[0] 103 | }, 104 | 105 | createUser: async (_, args) => { 106 | const data = await signup(args.input) 107 | 108 | if (!data || !data.user || !data.token) { 109 | throw new GraphQLError('could not create user', { 110 | extensions: { code: 'AUTH_ERROR' }, 111 | }) 112 | } 113 | 114 | return { ...data.user, token: data.token } 115 | }, 116 | editIssue: async (_, { input }, ctx: GQLContext) => { 117 | if (!ctx.user) 118 | throw new GraphQLError('UNAUTHORIZED', { extensions: { code: 401 } }) 119 | 120 | const { id, ...updates } = input 121 | 122 | const issue = await db 123 | .update(issues) 124 | .set(updates) 125 | .where(eq(issues.id, id)) 126 | .returning() 127 | 128 | return issue[0] 129 | }, 130 | signin: async (_, args) => { 131 | const data = await signin(args.input) 132 | 133 | if (!data || !data.user || !data.token) { 134 | throw new GraphQLError('UNAUTHORIZED', { 135 | extensions: { code: 'AUTH_ERROR' }, 136 | }) 137 | } 138 | 139 | return { ...data.user, token: data.token } 140 | }, 141 | }, 142 | } 143 | 144 | export default resolvers 145 | -------------------------------------------------------------------------------- /app/(dashboard)/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { IssuesQuery } from '@/gql/issuesQuery' 3 | import PageHeader from '../_components/PageHeader' 4 | import { useMutation, useQuery } from 'urql' 5 | import { useState } from 'react' 6 | import { 7 | Button, 8 | Modal, 9 | ModalBody, 10 | ModalContent, 11 | ModalFooter, 12 | ModalHeader, 13 | Spinner, 14 | Textarea, 15 | Tooltip, 16 | useDisclosure, 17 | } from '@nextui-org/react' 18 | import { PlusIcon } from 'lucide-react' 19 | import { CreateIssueMutation } from '@/gql/createIssueMutation' 20 | import Issue from '../_components/Issue' 21 | 22 | const IssuesPage = () => { 23 | const [{ data, fetching, error }, replay] = useQuery({ 24 | query: IssuesQuery, 25 | }) 26 | 27 | const [newIssueResult, createNewIssue] = useMutation(CreateIssueMutation) 28 | const { isOpen, onOpen, onOpenChange } = useDisclosure() 29 | const [issueName, setIssueName] = useState('') 30 | const [issueDescription, setIssueDescription] = useState('') 31 | 32 | const onCreate = async (close) => { 33 | const result = await createNewIssue({ 34 | input: { name: issueName, content: issueDescription }, 35 | }) 36 | 37 | if (result.data) { 38 | await replay({ requestPolicy: 'network-only' }) 39 | close() 40 | setIssueName('') 41 | setIssueDescription('') 42 | } 43 | } 44 | 45 | return ( 46 |
47 | 48 | 49 | 55 | 56 | 57 | {fetching && } 58 | {error &&
error
} 59 | {data && 60 | data.issues.map((issue) => ( 61 |
62 | 63 |
64 | ))} 65 | 66 | 72 | 73 | {(onClose) => ( 74 | <> 75 | 76 | New issue 77 | 78 | 79 |
80 | setIssueName(e.target.value)} 87 | /> 88 |
89 |
90 |