├── .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 |
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 | [](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 |
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 |
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 |
105 |
106 |
107 |
110 |
117 |
118 | >
119 | )}
120 |
121 |
122 |
123 | )
124 | }
125 |
126 | export default IssuesPage
127 |
--------------------------------------------------------------------------------
/migrations/meta/0000_snapshot.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "5",
3 | "dialect": "sqlite",
4 | "id": "e796e0ad-7fbb-488c-a7ea-375a0e480659",
5 | "prevId": "00000000-0000-0000-0000-000000000000",
6 | "tables": {
7 | "issues": {
8 | "name": "issues",
9 | "columns": {
10 | "id": {
11 | "name": "id",
12 | "type": "integer",
13 | "primaryKey": true,
14 | "notNull": true,
15 | "autoincrement": false
16 | },
17 | "name": {
18 | "name": "name",
19 | "type": "text",
20 | "primaryKey": false,
21 | "notNull": true,
22 | "autoincrement": false
23 | },
24 | "projectId": {
25 | "name": "projectId",
26 | "type": "text",
27 | "primaryKey": false,
28 | "notNull": false,
29 | "autoincrement": false
30 | },
31 | "userId": {
32 | "name": "userId",
33 | "type": "text",
34 | "primaryKey": false,
35 | "notNull": true,
36 | "autoincrement": false
37 | },
38 | "content": {
39 | "name": "content",
40 | "type": "text",
41 | "primaryKey": false,
42 | "notNull": true,
43 | "autoincrement": false
44 | },
45 | "text": {
46 | "name": "text",
47 | "type": "text",
48 | "primaryKey": false,
49 | "notNull": true,
50 | "autoincrement": false,
51 | "default": "'backlog'"
52 | },
53 | "created_at": {
54 | "name": "created_at",
55 | "type": "text",
56 | "primaryKey": false,
57 | "notNull": true,
58 | "autoincrement": false,
59 | "default": "CURRENT_TIMESTAMP"
60 | }
61 | },
62 | "indexes": {},
63 | "foreignKeys": {},
64 | "compositePrimaryKeys": {},
65 | "uniqueConstraints": {}
66 | },
67 | "projects": {
68 | "name": "projects",
69 | "columns": {
70 | "id": {
71 | "name": "id",
72 | "type": "integer",
73 | "primaryKey": true,
74 | "notNull": true,
75 | "autoincrement": false
76 | },
77 | "name": {
78 | "name": "name",
79 | "type": "text",
80 | "primaryKey": false,
81 | "notNull": true,
82 | "autoincrement": false
83 | },
84 | "userId": {
85 | "name": "userId",
86 | "type": "text",
87 | "primaryKey": false,
88 | "notNull": true,
89 | "autoincrement": false
90 | },
91 | "content": {
92 | "name": "content",
93 | "type": "text",
94 | "primaryKey": false,
95 | "notNull": true,
96 | "autoincrement": false
97 | },
98 | "created_at": {
99 | "name": "created_at",
100 | "type": "text",
101 | "primaryKey": false,
102 | "notNull": true,
103 | "autoincrement": false,
104 | "default": "CURRENT_TIMESTAMP"
105 | }
106 | },
107 | "indexes": {},
108 | "foreignKeys": {},
109 | "compositePrimaryKeys": {},
110 | "uniqueConstraints": {}
111 | },
112 | "users": {
113 | "name": "users",
114 | "columns": {
115 | "id": {
116 | "name": "id",
117 | "type": "text",
118 | "primaryKey": true,
119 | "notNull": true,
120 | "autoincrement": false
121 | },
122 | "created_at": {
123 | "name": "created_at",
124 | "type": "text",
125 | "primaryKey": false,
126 | "notNull": true,
127 | "autoincrement": false,
128 | "default": "CURRENT_TIMESTAMP"
129 | },
130 | "email": {
131 | "name": "email",
132 | "type": "text",
133 | "primaryKey": false,
134 | "notNull": true,
135 | "autoincrement": false
136 | }
137 | },
138 | "indexes": {
139 | "users_email_unique": {
140 | "name": "users_email_unique",
141 | "columns": [
142 | "email"
143 | ],
144 | "isUnique": true
145 | }
146 | },
147 | "foreignKeys": {},
148 | "compositePrimaryKeys": {},
149 | "uniqueConstraints": {}
150 | }
151 | },
152 | "enums": {},
153 | "_meta": {
154 | "schemas": {},
155 | "tables": {},
156 | "columns": {}
157 | }
158 | }
--------------------------------------------------------------------------------