├── .github └── workflows │ └── cd.yml ├── .gitignore ├── .npmrc ├── .vscode └── settings.json ├── README.md ├── apps ├── api │ ├── .gitignore │ ├── dist │ │ └── server.js │ ├── env.example │ ├── package-lock.json │ ├── package.json │ ├── src │ │ ├── app.ts │ │ ├── controllers │ │ │ ├── auth.controller.ts │ │ │ ├── error.controller.ts │ │ │ └── files.controller.ts │ │ ├── core │ │ │ ├── jwt.ts │ │ │ ├── mongoose.ts │ │ │ └── redis.ts │ │ ├── env.config.ts │ │ ├── middleware │ │ │ ├── check-auth.ts │ │ │ └── user-auth.ts │ │ ├── models │ │ │ ├── file.ts │ │ │ └── user.ts │ │ ├── resolvers │ │ │ ├── index.ts │ │ │ ├── mutation.resolver.ts │ │ │ └── query.resolver.ts │ │ ├── schemas │ │ │ └── index.ts │ │ ├── server.ts │ │ └── services │ │ │ ├── auth.service.ts │ │ │ ├── files.services.ts │ │ │ ├── mail.service.ts │ │ │ └── oauth.service.ts │ ├── tsconfig.json │ └── tsup.config.ts └── web │ ├── .env.example │ ├── .eslintrc.js │ ├── .gitignore │ ├── README.md │ ├── app │ ├── auth │ │ ├── auth.tsx │ │ └── page.tsx │ ├── callback │ │ ├── github │ │ │ └── page.tsx │ │ └── verify │ │ │ └── page.tsx │ ├── draw │ │ ├── draw.tsx │ │ └── page.tsx │ ├── favicon.ico │ ├── files │ │ ├── [id] │ │ │ ├── fileDetail.tsx │ │ │ ├── page.tsx │ │ │ └── snapshot.json │ │ ├── files.tsx │ │ ├── layout.tsx │ │ └── page.tsx │ ├── index.scss │ ├── layout.tsx │ ├── page.tsx │ ├── pageWrapper.tsx │ └── public │ │ └── [id] │ │ ├── page.tsx │ │ └── public.tsx │ ├── codegen.ts │ ├── codegen │ ├── fragment-masking.ts │ ├── gql.ts │ ├── graphql.ts │ └── index.ts │ ├── components │ ├── auth │ │ ├── github-auth.tsx │ │ ├── sign-in-form.tsx │ │ └── sign-up-form.tsx │ ├── file-list │ │ ├── file-header.tsx │ │ └── file-item.tsx │ ├── file-workspace │ │ ├── file-ai-dialog.tsx │ │ ├── file-share-dialog.tsx │ │ ├── file-user-owner.tsx │ │ ├── file-workspace-header.tsx │ │ ├── useFormatShapes.ts │ │ ├── useYjs.ts │ │ └── whiteboard.tsx │ ├── sidebar │ │ ├── favorites.tsx │ │ ├── index.tsx │ │ ├── profile.tsx │ │ └── recent-file.tsx │ └── skeletons │ │ └── file-skeleton.tsx │ ├── hook │ └── useOnMountUnsafe.ts │ ├── next-env.d.ts │ ├── next.config.js │ ├── package.json │ ├── postcss.config.js │ ├── prettier.config.mjs │ ├── providers │ ├── apollo.provider.tsx │ └── index.provider.tsx │ ├── public │ ├── circles.svg │ ├── icon │ │ └── favicon.ico │ ├── logo.svg │ ├── next.svg │ ├── turborepo.svg │ ├── vercel.svg │ ├── video-landing.mp4 │ └── white-gradient.webp │ ├── query │ ├── auth.gql.ts │ └── file.gql.ts │ ├── services │ ├── auth.service.ts │ └── file.service.ts │ ├── store │ ├── file.store.ts │ ├── index.store.ts │ ├── navigation.store.ts │ └── user.store.ts │ ├── tailwind.config.js │ ├── tsconfig.json │ ├── utils │ ├── apollo-client.utils.ts │ ├── cookie-service.utils.ts │ ├── user-credentials.utils.ts │ └── user-profile.utils.ts │ └── validations │ ├── auth.validation.ts │ └── file.validation.ts ├── ecosystem.config.js ├── package.json ├── packages ├── eslint-config-custom │ ├── README.md │ ├── library.js │ ├── next.js │ ├── package.json │ └── react-internal.js ├── tsconfig │ ├── base.json │ ├── nextjs.json │ ├── package.json │ └── react-library.json └── ui │ ├── .eslintrc.js │ ├── components.json │ ├── components │ └── ui │ │ ├── alert.tsx │ │ ├── avatar.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── dialog.tsx │ │ ├── dropdown-menu.tsx │ │ ├── form.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── switch.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toast.tsx │ │ ├── toaster.tsx │ │ ├── tooltip.tsx │ │ └── use-toast.ts │ ├── lib │ └── utils.ts │ ├── package.json │ ├── postcss.config.js │ ├── styles │ └── globals.css │ ├── tailwind.config.js │ ├── tsconfig.json │ └── turbo │ └── generators │ ├── config.ts │ └── templates │ └── component.hbs ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── tsconfig.json └── turbo.json /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: cd 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | cd: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - name: pnpm-setup 16 | uses: pnpm/action-setup@v2 17 | 18 | - name: Deploy Using ssh 19 | uses: appleboy/ssh-action@master 20 | with: 21 | host: ${{ secrets.SSH_HOST }} 22 | username: ${{ secrets.SSH_USERNAME }} 23 | key: ${{ secrets.SSH_PRIVATE_KEY }} 24 | port: 22 25 | script: | 26 | cd /home/services-syncboard/htdocs/services-syncboard.jungrama.com 27 | git pull origin master 28 | git status 29 | 30 | - name: Install Node.js 31 | run: | 32 | [[ -s $HOME/.nvm/nvm.sh ]] && . $HOME/.nvm/nvm.sh 33 | nvm install 18.0.0 34 | nvm use 18.0.0 35 | 36 | - name: Install Depedencies 37 | run: | 38 | pnpm install 39 | 40 | - name: Build 41 | run: | 42 | pnpm run build 43 | -------------------------------------------------------------------------------- /.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 | build 15 | 16 | # misc 17 | .DS_Store 18 | *.pem 19 | 20 | # debug 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | # local env files 26 | .env 27 | .env.local 28 | .env.development.local 29 | .env.test.local 30 | .env.production.local 31 | 32 | # turbo 33 | .turbo 34 | 35 | # vercel 36 | .vercel 37 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers = true 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.workingDirectories": [ 3 | { 4 | "mode": "auto" 5 | } 6 | ], 7 | "nuxt.isNuxtApp": false 8 | } 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Syncboard - AI Powered Real-time Whiteboard 2 | ![thumbnail](https://github.com/JungRama/syncboard/assets/31382668/299feae8-e088-47ef-b8bf-f3a0360a7dfe) 3 | Syncboard is a user-friendly board editor that lets you draw and work together with your team in real-time. It also has a handy AI feature that helps you make diagrams and flowcharts effortlessly. 4 | 5 | ## What's inside? 6 | 7 | This project using Turborepo includes the following packages/apps: 8 | 9 | ### Apps and Packages 10 | 11 | - `api`: Backend services created with express and using graphql 12 | - `web`: Frontend service using nextjs 13 | - `ui`: React component for ui library using `shadcn/ui` shared for `web` applications 14 | - `eslint-config-custom`: `eslint` configurations (includes `eslint-config-next` and `eslint-config-prettier`) 15 | - `tsconfig`: `tsconfig.json`s used throughout the monorepo 16 | 17 | ### Built With 18 | 19 | - [Next.js](https://nextjs.org/) 20 | - [Express](https://expressjs.com/) 21 | - [GraphQL](https://graphql.org/) 22 | - [Apollo GraphQL](https://www.apollographql.com/) 23 | - [Shadcn/ui](https://ui.shadcn.com/) 24 | 25 | ### Want to running the project localy? 26 | 27 | Run the following command: 28 | 29 | 1. Clone the repo into a public GitHub repository (or fork https://github.com/JungRama/syncboard/fork). 30 | 31 | ```sh 32 | git clone https://github.com/JungRama/syncboard.git 33 | ``` 34 | 35 | 2. Go to the project folder 36 | 37 | ```sh 38 | cd syncboard 39 | ``` 40 | 41 | 3. Install packages 42 | 43 | ```sh 44 | npm install 45 | 46 | yarn install 47 | 48 | pnpm install 49 | ``` 50 | 51 | 4. Set up your `.env` file on apps/web and apps/api 52 | 53 | 5. Run development mode on root folder 54 | 55 | ```sh 56 | npm run dev 57 | 58 | yarn run dev 59 | 60 | pnpm run dev 61 | ``` 62 | 63 | Access the frontend in `localhost:3000`, sometimes when this port used it will be using other available port. 64 | 65 | Access the backend in `localhost:4000/graphql` 66 | For the Websocket service `localhost:1234` 67 | -------------------------------------------------------------------------------- /apps/api/.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 | 12 | # production 13 | dist 14 | 15 | # debug 16 | npm-debug.log* 17 | yarn-debug.log* 18 | yarn-error.log* 19 | 20 | # local env files 21 | .env.local 22 | .env.development.local 23 | .env.test.local 24 | .env.production.local 25 | 26 | # vercel 27 | .vercel 28 | 29 | public/storage 30 | -------------------------------------------------------------------------------- /apps/api/env.example: -------------------------------------------------------------------------------- 1 | PORT=8000 2 | 3 | MONGODB_URI=mongodb+srv://:@/?retryWrites=true&w=majority 4 | 5 | JWT_ACCESS_PRIVATE_KEY=XcVH/KjbFhUGSB1Ojv+Nrw== 6 | JWT_ACCESS_PUBLIC_KEY=WcZqBSFyXzSaacN6fzARVg== 7 | JWT_ACCESS_TOKEN_EXPIRED_IN=15 8 | 9 | JWT_REFRESH_PRIVATE_KEY=S60apQby8sdfaTo5LsWfrw== 10 | JWT_REFRESH_PUBLIC_KEY=1APYcPZipytM6sHWwuVjCw== 11 | JWT_ACCESS_TOKEN_EXPIRED_IN=10 12 | 13 | REDIS_HOST= 14 | REDIS_PORT= 15 | REDIS_PASSWORD= 16 | 17 | GITHUB_CLIENT_ID= 18 | GITHUB_CLIENT_SECRET= -------------------------------------------------------------------------------- /apps/api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node dist/server.js && ws", 8 | "dev": "tsup --watch --onSuccess \"node dist/server.js\" && ws", 9 | "ws": "node ./node_modules/y-websocket/bin/server.js", 10 | "ws-server": "HOST=localhost PORT=4002 npx y-websocket", 11 | "build": "tsup", 12 | "test": "echo \"Error: no test specified\" && exit 1" 13 | }, 14 | "keywords": [], 15 | "author": "", 16 | "license": "ISC", 17 | "dependencies": { 18 | "@apollo/server": "^4.9.5", 19 | "axios": "^1.6.2", 20 | "bcryptjs": "^2.4.3", 21 | "cookie-parser": "^1.4.6", 22 | "cors": "^2.8.5", 23 | "dotenv": "^16.3.1", 24 | "express": "^4.18.2", 25 | "express-rate-limit": "^7.1.5", 26 | "graphql": "^16.8.1", 27 | "identicon.js": "^2.3.3", 28 | "jsonwebtoken": "^9.0.2", 29 | "mongoose": "^8.0.0", 30 | "redis": "^4.6.10", 31 | "resend": "^2.0.0", 32 | "underscore": "^1.13.6", 33 | "validator": "^13.11.0", 34 | "y-websocket": "^1.5.0" 35 | }, 36 | "devDependencies": { 37 | "@types/bcryptjs": "^2.4.6", 38 | "@types/body-parser": "^1.19.5", 39 | "@types/compression": "^1.7.5", 40 | "@types/cookie-parser": "^1.4.6", 41 | "@types/cors": "^2.8.16", 42 | "@types/express": "^4.17.20", 43 | "@types/express-session": "^1.17.9", 44 | "@types/identicon.js": "^2.3.4", 45 | "@types/jsonwebtoken": "^9.0.5", 46 | "@types/morgan": "^1.9.8", 47 | "@types/passport": "^1.0.14", 48 | "@types/passport-local": "^1.0.37", 49 | "@types/passport-local-mongoose": "^6.1.3", 50 | "@types/underscore": "^1.11.14", 51 | "@types/validator": "^13.11.6", 52 | "tsup": "^7.2.0", 53 | "typescript": "^5.2.2" 54 | }, 55 | "peerDependencies": { 56 | "@types/bcryptjs": "^2.4.6", 57 | "@types/cors": "^2.8.16", 58 | "@types/express": "^4.17.20", 59 | "@types/jsonwebtoken": "^9.0.5", 60 | "@types/morgan": "^1.9.8", 61 | "@types/validator": "^13.11.6", 62 | "tsup": "^7.2.0", 63 | "typescript": "^5.2.2" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /apps/api/src/app.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import dotenv from 'dotenv' 3 | import cookieParser from 'cookie-parser' 4 | import { RATE_LIMIT } from './env.config' 5 | import { rateLimit } from 'express-rate-limit' 6 | 7 | // Create an instance of the Express application 8 | const app = express() 9 | 10 | // Load environment variables from the .env file 11 | dotenv.config() 12 | 13 | // Middleware to parse JSON request bodies 14 | app.use(express.json()) 15 | 16 | // Middleware to parse URL-encoded request bodies 17 | app.use(express.urlencoded({ extended: true })) 18 | 19 | // Middleware to parse cookies 20 | app.use(cookieParser()) 21 | 22 | // Serve static files from the 'public' directory 23 | app.use(express.static('public')) 24 | 25 | // Apply rate limiting to limit the number of requests 26 | app.use( 27 | rateLimit({ 28 | windowMs: 15 * 60 * 1000, // 15 minutes 29 | limit: RATE_LIMIT, 30 | standardHeaders: 'draft-7', 31 | legacyHeaders: false, // Disable the `X-RateLimit-*` headers. 32 | }) 33 | ) 34 | 35 | // Handle GET requests to the root URL '/' 36 | app.get('/', (req, res) => { 37 | res.send('Welcome to "Collaborative Whiteboard"!') 38 | }) 39 | 40 | // Serve static files from the 'public' directory 41 | app.use(express.static('public')) 42 | 43 | // Handle uncaught exceptions 44 | process.on('uncaughtException', (err) => { 45 | console.error('UNCAUGHT EXCEPTION 🔥 Shutting down...') 46 | console.error('Error🔥', err.message) 47 | process.exit(1) 48 | }) 49 | 50 | export default app 51 | -------------------------------------------------------------------------------- /apps/api/src/controllers/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLError } from 'graphql' 2 | import errorHandler from '~/controllers/error.controller' 3 | 4 | import type { Request, Response } from 'express' 5 | 6 | import checkAuth from '~/middleware/check-auth' 7 | import { UserAuthFn } from '~/middleware/user-auth' 8 | import authService from '~/services/auth.service' 9 | import { continueWithOAuth } from '~/services/oauth.service' 10 | 11 | interface SignupInput { 12 | input: { 13 | name: string 14 | email: string 15 | password: string 16 | passwordConfirm: string 17 | } 18 | } 19 | 20 | interface SignInInput { 21 | input: { 22 | name: string 23 | email: string 24 | password: string 25 | passwordConfirm: string 26 | } 27 | } 28 | 29 | interface oAuthInput { 30 | input: { 31 | strategy: string 32 | code: string 33 | } 34 | } 35 | 36 | const signup = async ( 37 | root: any, 38 | { input: { name, email, password, passwordConfirm } }: SignupInput, 39 | { req }: { req: Request } 40 | ) => { 41 | try { 42 | await authService.createUser({ 43 | name, 44 | email, 45 | password, 46 | passwordConfirm, 47 | }) 48 | 49 | return true 50 | } catch (error) { 51 | if (error.code === 11000) { 52 | throw new GraphQLError('User Already Exist', { 53 | extensions: { 54 | code: 'FORBIDDEN', 55 | }, 56 | }) 57 | } 58 | errorHandler(error) 59 | } 60 | } 61 | 62 | const login = async ( 63 | root: any, 64 | { input: { email, password } }: SignInInput, 65 | { req }: { req: Request } 66 | ) => { 67 | try { 68 | const loginService = await authService.loginUser({ 69 | email, 70 | password, 71 | }) 72 | 73 | return { 74 | access_token: loginService?.access_token, 75 | refresh_token: loginService?.refresh_token, 76 | } 77 | } catch (error) { 78 | errorHandler(error) 79 | } 80 | } 81 | 82 | const oAuth = async ( 83 | root: any, 84 | { input: { strategy, code } }: oAuthInput, 85 | { req }: { req: Request } 86 | ) => { 87 | try { 88 | const oAuthService = await continueWithOAuth(strategy, code) 89 | 90 | return { 91 | access_token: oAuthService?.access_token, 92 | refresh_token: oAuthService?.refresh_token, 93 | } 94 | } catch (error) { 95 | errorHandler(error) 96 | } 97 | } 98 | 99 | const refreshAccessToken = async ( 100 | root: any, 101 | { refresh_token }: { refresh_token: string }, 102 | { req }: { req: Request } 103 | ) => { 104 | try { 105 | const refreshTokenService = await authService.refreshToken(refresh_token) 106 | 107 | return { 108 | access_token: refreshTokenService?.access_token, 109 | refresh_token: refreshTokenService?.refresh_token, 110 | } 111 | } catch (error) { 112 | errorHandler(error) 113 | } 114 | } 115 | 116 | const verifyAccount = async ( 117 | root: any, 118 | { input: { code } }: { input: { code: string } }, 119 | { req }: { req: Request } 120 | ) => { 121 | try { 122 | const verifyAccount = await authService.verifyAccount(code) 123 | 124 | return { 125 | access_token: verifyAccount?.access_token, 126 | refresh_token: verifyAccount?.refresh_token, 127 | } 128 | } catch (error) { 129 | errorHandler(error) 130 | } 131 | } 132 | 133 | const logout = async ( 134 | root: any, 135 | args: any, 136 | { req, userAuth }: { req: Request; userAuth: UserAuthFn } 137 | ) => { 138 | try { 139 | const user = await checkAuth(req, userAuth) 140 | 141 | authService.logout(user) 142 | 143 | return true 144 | } catch (error) { 145 | errorHandler(error) 146 | } 147 | } 148 | 149 | const getMe = async ( 150 | root: any, 151 | args: any, 152 | { req, userAuth }: { req: Request; userAuth: UserAuthFn } 153 | ) => { 154 | try { 155 | const user = await checkAuth(req, userAuth) 156 | 157 | return { 158 | status: 'success', 159 | user, 160 | } 161 | } catch (error) { 162 | errorHandler(error) 163 | } 164 | } 165 | 166 | export default { 167 | signup, 168 | login, 169 | oAuth, 170 | verifyAccount, 171 | logout, 172 | getMe, 173 | refreshAccessToken, 174 | } 175 | -------------------------------------------------------------------------------- /apps/api/src/controllers/error.controller.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLError } from "graphql"; 2 | 3 | /** 4 | * Handles a cast error by throwing a GraphQLError with the appropriate message. 5 | * @param error - The cast error object. 6 | * @throws {GraphQLError} - Throws a GraphQLError with the appropriate message. 7 | */ 8 | const handleCastError = (error: any) => { 9 | const message = `Invalid ${error.path}: ${error.value}`; 10 | throw new GraphQLError(message, { 11 | extensions: { 12 | code: "GRAPHQL_VALIDATION_FAILED", 13 | }, 14 | }); 15 | }; 16 | 17 | /** 18 | * Handles a validation error by throwing a GraphQLError with the appropriate message. 19 | * @param error - The validation error object. 20 | * @throws {GraphQLError} - Throws a GraphQLError with the appropriate message. 21 | */ 22 | const handleValidationError = (error: any) => { 23 | const message = Object.values(error.errors).map((el: any) => el.message); 24 | throw new GraphQLError(`Invalid input: ${message.join(", ")}`, { 25 | extensions: { 26 | code: "GRAPHQL_VALIDATION_FAILED", 27 | }, 28 | }); 29 | }; 30 | 31 | /** 32 | * Error handler function that handles cast and validation errors. 33 | * @param err - The error object. 34 | * @throws {Error} - Throws the original error object if it's not a cast or validation error. 35 | */ 36 | const errorHandler = (err: any) => { 37 | if (err.name === "CastError") handleCastError(err); 38 | if (err.name === "ValidationError") handleValidationError(err); 39 | throw err; 40 | }; 41 | 42 | export default errorHandler; 43 | -------------------------------------------------------------------------------- /apps/api/src/controllers/files.controller.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express' 2 | import checkAuth from '~/middleware/check-auth' 3 | import { UserAuthFn } from '~/middleware/user-auth' 4 | import fileModel from '~/models/file' 5 | import filesServices from '~/services/files.services' 6 | import errorHandler from './error.controller' 7 | 8 | export interface FileInputUpdate { 9 | input: { 10 | id: string 11 | name?: string 12 | thumbnail?: string 13 | whiteboard?: string 14 | } 15 | } 16 | 17 | export interface NewUserAccessInput { 18 | input: { 19 | id: string 20 | user_id: string 21 | email: string 22 | role: string 23 | } 24 | } 25 | 26 | export interface toogleFavoriteInput { 27 | input: { 28 | id: string 29 | } 30 | } 31 | 32 | export interface toogleIsPublicInput { 33 | input: { 34 | id: string 35 | value: boolean 36 | } 37 | } 38 | 39 | const get = async ( 40 | parent: any, 41 | { search }: { search: string }, 42 | { req, userAuth }: { req: Request; userAuth: UserAuthFn } 43 | ) => { 44 | try { 45 | const user = await checkAuth(req, userAuth) 46 | 47 | const query = { 48 | 'userAccess.userId': user?._id, 49 | } 50 | 51 | if (search) { 52 | Object.assign(query, { 53 | name: { $regex: '.*' + search + '.*', $options: 'i' }, 54 | }) 55 | } 56 | 57 | const files = await filesServices.find(query) 58 | 59 | return files 60 | } catch (error) { 61 | errorHandler(error) 62 | } 63 | } 64 | 65 | const getById = async ( 66 | parent: any, 67 | { id, isPublic }: { id: string; isPublic: boolean }, 68 | { req, userAuth }: { req: Request; userAuth: UserAuthFn } 69 | ) => { 70 | try { 71 | let user, 72 | query = null 73 | 74 | if (!isPublic) { 75 | user = await checkAuth(req, userAuth) 76 | 77 | query = { 78 | 'userAccess.userId': user?._id, 79 | _id: id, 80 | } 81 | } else { 82 | query = { 83 | _id: id, 84 | isPublic: true, 85 | } 86 | } 87 | 88 | return filesServices.findOne(query) 89 | } catch (error) { 90 | errorHandler(error) 91 | } 92 | } 93 | 94 | const create = async ( 95 | parent: any, 96 | args: any, 97 | { req, userAuth }: { req: Request; userAuth: UserAuthFn } 98 | ) => { 99 | try { 100 | // Check if the user is authenticated 101 | const user = await checkAuth(req, userAuth) 102 | 103 | const file = await filesServices.create(user._id) 104 | 105 | return file 106 | } catch (error) { 107 | errorHandler(error) 108 | } 109 | } 110 | 111 | const update = async ( 112 | parent: any, 113 | { input }: FileInputUpdate, 114 | { req, userAuth }: { req: Request; userAuth: UserAuthFn } 115 | ) => { 116 | try { 117 | // Check if the user is authenticated 118 | await checkAuth(req, userAuth) 119 | 120 | const file = await filesServices.update({ 121 | input, 122 | }) 123 | 124 | return file 125 | } catch (error) { 126 | errorHandler(error) 127 | } 128 | } 129 | 130 | const del = () => {} 131 | 132 | const getFavorites = async ( 133 | parent: any, 134 | args: any, 135 | { req, userAuth }: { req: Request; userAuth: UserAuthFn } 136 | ) => { 137 | try { 138 | const user = await checkAuth(req, userAuth) 139 | 140 | const query = { 141 | 'userAccess.userId': user?._id, 142 | 'favoriteBy.userId': user?._id, 143 | } 144 | 145 | const files = await filesServices.find(query) 146 | 147 | return files 148 | } catch (error) { 149 | errorHandler(error) 150 | } 151 | } 152 | 153 | const toogleFavorite = async ( 154 | parent: any, 155 | { input }: toogleFavoriteInput, 156 | { req, userAuth }: { req: Request; userAuth: UserAuthFn } 157 | ) => { 158 | try { 159 | const user = await checkAuth(req, userAuth) 160 | 161 | filesServices.toogleFavorite(user._id, input.id) 162 | 163 | return await fileModel.find({ 164 | 'userAccess.userId': user?._id, 165 | 'favoriteBy.userId': user?._id, 166 | }) 167 | } catch (error) { 168 | errorHandler(error) 169 | } 170 | } 171 | 172 | const toogleIsPublic = async ( 173 | parent: any, 174 | { input }: toogleIsPublicInput, 175 | { req, userAuth }: { req: Request; userAuth: UserAuthFn } 176 | ) => { 177 | try { 178 | const user = await checkAuth(req, userAuth) 179 | 180 | const file = await filesServices.toogleIsPublic( 181 | user._id, 182 | input.id, 183 | input.value 184 | ) 185 | 186 | return file?.isPublic ?? false 187 | } catch (error) { 188 | errorHandler(error) 189 | } 190 | } 191 | 192 | const addNewUserAccess = async ( 193 | parent: any, 194 | { input }: NewUserAccessInput, 195 | { req, userAuth }: { req: Request; userAuth: UserAuthFn } 196 | ) => { 197 | try { 198 | await checkAuth(req, userAuth) 199 | 200 | const file = await filesServices.addNewUserAccess({ 201 | input, 202 | }) 203 | 204 | return file?.userAccess 205 | } catch (error) { 206 | errorHandler(error) 207 | } 208 | } 209 | 210 | const changeUserAccess = async ( 211 | parent: any, 212 | { input }: NewUserAccessInput, 213 | { req, userAuth }: { req: Request; userAuth: UserAuthFn } 214 | ) => { 215 | try { 216 | await checkAuth(req, userAuth) 217 | 218 | const file = await filesServices.changeUserAccess({ 219 | input, 220 | }) 221 | 222 | return file?.userAccess 223 | } catch (error) { 224 | errorHandler(error) 225 | } 226 | } 227 | 228 | export default { 229 | get, 230 | getById, 231 | addNewUserAccess, 232 | changeUserAccess, 233 | getFavorites, 234 | toogleFavorite, 235 | toogleIsPublic, 236 | create, 237 | update, 238 | del, 239 | } 240 | -------------------------------------------------------------------------------- /apps/api/src/core/jwt.ts: -------------------------------------------------------------------------------- 1 | import jwt, { JwtPayload, SignOptions } from 'jsonwebtoken' 2 | 3 | /** 4 | * Signs a JSON Web Token (JWT) using the provided payload, key, and options. 5 | * @param payload - The payload to be encoded in the JWT. 6 | * @param Key - The name of the environment variable that contains the secret key. 7 | * @param options - Additional options for signing the JWT. 8 | * @returns The signed JWT. 9 | */ 10 | export const signJwt = ( 11 | payload: JwtPayload, 12 | Key: string, 13 | options: SignOptions 14 | ) => { 15 | // Retrieve the secret key from the environment variables 16 | const keySecret = process.env[Key] ?? 'SECRET' 17 | 18 | // Sign the JWT using the payload, private key, and options 19 | return jwt.sign(payload, keySecret, { 20 | ...(options && options), 21 | }) 22 | } 23 | 24 | /** 25 | * Verifies a JSON Web Token (JWT) using the provided token and key. 26 | * @param token - The JWT to be verified. 27 | * @param Key - The name of the environment variable that contains the secret key. 28 | * @returns The decoded payload of the verified JWT. 29 | */ 30 | export const verifyJwt = (token: string, Key: string) => { 31 | // Retrieve the secret key from the environment variables 32 | const keySecret = process.env[Key] ?? 'SECRET' 33 | 34 | // Verify the JWT using the token and public key 35 | const decoded = jwt.verify(token, keySecret) 36 | 37 | return decoded as { 38 | user: string 39 | iat: number 40 | exp: number 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /apps/api/src/core/mongoose.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose' 2 | 3 | import { MONGODB_URI } from '~/env.config' 4 | 5 | /** 6 | * Connects to the database using the provided MongoDB URI. 7 | * 8 | * @return {Promise} A promise that resolves when the connection is established. 9 | */ 10 | async function connectDB() { 11 | try { 12 | await mongoose.connect(MONGODB_URI) 13 | 14 | const db = mongoose.connection 15 | db.on('error', console.error.bind(console, 'connection error:')) 16 | console.log('Database connected successfully') 17 | } catch (error) { 18 | console.log(error.message) 19 | process.exit(1) 20 | } 21 | } 22 | 23 | export default connectDB 24 | -------------------------------------------------------------------------------- /apps/api/src/core/redis.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "redis"; 2 | import { REDIS_HOST, REDIS_PORT, REDIS_PASSWORD } from "~/env.config"; 3 | 4 | // Create a Redis client 5 | const redisClient = createClient({ 6 | password: REDIS_PASSWORD, 7 | socket: { 8 | host: REDIS_HOST, 9 | port: REDIS_PORT, 10 | }, 11 | }); 12 | 13 | /** 14 | * Connects to the Redis server. 15 | * @returns {Promise} A promise that resolves when the connection is established. 16 | */ 17 | const connectRedis = async (): Promise => { 18 | try { 19 | await redisClient.connect(); 20 | } catch (error) { 21 | console.error((error as Error).message); 22 | setInterval(connectRedis, 5000); 23 | } 24 | }; 25 | 26 | // Connect to the Redis server 27 | connectRedis(); 28 | 29 | // Log a success message when the client connects 30 | redisClient.on("connect", () => 31 | console.log("Redis client connected successfully") 32 | ); 33 | 34 | // Log an error message when an error occurs 35 | redisClient.on("error", (err) => console.error(err)); 36 | 37 | export default redisClient; 38 | -------------------------------------------------------------------------------- /apps/api/src/env.config.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv' 2 | dotenv.config() 3 | 4 | export const NODE_ENV = process.env.NODE_ENV || 'development' 5 | 6 | export const PORT = process.env.PORT || '8000' 7 | 8 | export const FRONT_URI = process.env.FRONT_URI || 'http://localhost:3000' 9 | 10 | export const RATE_LIMIT = process.env.RATE_LIMIT 11 | ? parseInt(process.env.RATE_LIMIT) 12 | : 60 13 | 14 | export const MONGODB_URI = 15 | process.env.MONGODB_URI || 16 | 'mongodb+srv://:@cluster0.wxu482x.mongodb.net/?retryWrites=true&w=majority' 17 | 18 | /* ----------------------------------- JWT ACCESS KEY ---------------------------------- */ 19 | export const JWT_ACCESS_PRIVATE_KEY = 20 | process.env.JWT_ACCESS_PRIVATE_KEY || 'XcVH/KjbFhUGSB1Ojv+Nrw==' 21 | 22 | export const JWT_REFRESH_PRIVATE_KEY = 23 | process.env.JWT_REFRESH_PRIVATE_KEY || '1APYcPZipytM6sHWwuVjCw==' 24 | 25 | /* ------------------------------- JWT EXPIRED ------------------------------ */ 26 | export const JWT_ACCESS_TOKEN_EXPIRED_IN: number = process.env 27 | .JWT_ACCESS_TOKEN_EXPIRED_IN 28 | ? parseInt(process.env.JWT_ACCESS_TOKEN_EXPIRED_IN) 29 | : 120 * 24 30 | 31 | export const JWT_REFRESH_TOKEN_EXPIRED_IN = process.env 32 | .JWT_REFRESH_TOKEN_EXPIRED_IN 33 | ? parseInt(process.env.JWT_REFRESH_TOKEN_EXPIRED_IN) 34 | : 120 * 24 * 30 35 | 36 | /* ------------------------------ REDIS CONFIG ------------------------------ */ 37 | export const REDIS_HOST = process.env.REDIS_HOST || '' 38 | export const REDIS_PASSWORD = process.env.REDIS_PASSWORD || '' 39 | export const REDIS_PORT = process.env.REDIS_PORT 40 | ? parseInt(process.env.REDIS_PORT) 41 | : 19027 42 | 43 | export const RESEND_API_KEY = process.env.RESEND_API_KEY || '' 44 | -------------------------------------------------------------------------------- /apps/api/src/middleware/check-auth.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLError } from 'graphql' 2 | import { UserAuthFn } from './user-auth.js' 3 | import { Request } from 'express' 4 | 5 | /** 6 | * Checks if the user is authenticated. 7 | * 8 | * @param {Request} req - The request object. 9 | * @param {UserAuthFn} userAuth - The function to authenticate the user. 10 | */ 11 | const checkAuth = async (req: Request, userAuth: UserAuthFn) => { 12 | // Authenticate the user 13 | const authUser = await userAuth(req) 14 | 15 | if (!authUser) { 16 | // Throw an error if user is not authenticated 17 | throw new GraphQLError('You are not logged in', { 18 | extensions: { 19 | code: 'UNAUTHENTICATED', 20 | }, 21 | }) 22 | } 23 | 24 | return authUser 25 | } 26 | 27 | export default checkAuth 28 | -------------------------------------------------------------------------------- /apps/api/src/middleware/user-auth.ts: -------------------------------------------------------------------------------- 1 | import { GraphQLError } from 'graphql' 2 | import userModel, { IUser } from '~/models/user' 3 | import redisClient from '~/core/redis' 4 | import { verifyJwt } from '~/core/jwt' 5 | import { Request } from 'express' 6 | 7 | export type UserAuthFn = typeof userAuth 8 | 9 | /** 10 | * Function to authenticate user. 11 | * @param req - Express request object. 12 | * @returns {Promise} - Returns false if authentication fails, otherwise returns the authenticated user. 13 | */ 14 | const userAuth = async (req: Request) => { 15 | try { 16 | // Get the access token 17 | let access_token 18 | if ( 19 | req.headers.authorization && 20 | req.headers.authorization.startsWith('Bearer') 21 | ) { 22 | access_token = req.headers.authorization.split(' ')[1] 23 | } else if (req.cookies.access_token) { 24 | const { access_token: token } = req.cookies 25 | access_token = token 26 | } 27 | 28 | if (!access_token) return false 29 | 30 | // Validate the Access token 31 | const decoded = verifyJwt(access_token, 'JWT_ACCESS_PRIVATE_KEY') 32 | 33 | if (!decoded) return false 34 | 35 | // Check if the session is valid 36 | const session = await redisClient.get(decoded.user) 37 | 38 | if (!session) { 39 | throw new GraphQLError('Session has expired', { 40 | extensions: { 41 | code: 'FORBIDDEN', 42 | }, 43 | }) 44 | } 45 | 46 | // Check if user exist 47 | const user = await userModel 48 | .findById(JSON.parse(session).id) 49 | .select('+verified') 50 | 51 | if (!user || !user.verified) { 52 | throw new GraphQLError( 53 | 'The user belonging to this token no longer exists', 54 | { 55 | extensions: { 56 | code: 'FORBIDDEN', 57 | }, 58 | } 59 | ) 60 | } 61 | 62 | return user as IUser 63 | } catch (error) { 64 | return false 65 | } 66 | } 67 | 68 | export default userAuth 69 | -------------------------------------------------------------------------------- /apps/api/src/models/file.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Document, Schema } from 'mongoose' 2 | 3 | // Define the File interface 4 | export interface IFile extends Document { 5 | name: string 6 | thumbnail?: string 7 | isPublic?: boolean 8 | whiteboard: string 9 | userAccess: { 10 | userId: string 11 | role: string 12 | }[] 13 | favoriteBy: { 14 | userId: string 15 | }[] 16 | createdAt: Date 17 | updatedAt: Date 18 | } 19 | 20 | // Define the file schema 21 | const fileSchema = new Schema( 22 | { 23 | name: { 24 | type: String, 25 | required: true, 26 | }, 27 | thumbnail: { 28 | type: String, 29 | required: false, 30 | }, 31 | isPublic: { 32 | type: Boolean, 33 | default: false, 34 | }, 35 | whiteboard: { 36 | type: String, 37 | required: false, 38 | }, 39 | userAccess: [ 40 | { 41 | userId: { 42 | type: Schema.Types.ObjectId, 43 | ref: 'User', 44 | required: true, 45 | }, 46 | role: { 47 | type: String, 48 | required: true, 49 | }, 50 | }, 51 | ], 52 | favoriteBy: [ 53 | { 54 | userId: { 55 | type: Schema.Types.ObjectId, 56 | ref: 'User', 57 | required: true, 58 | }, 59 | }, 60 | ], 61 | }, 62 | { timestamps: true, toJSON: { virtuals: true }, toObject: { virtuals: true } } 63 | ) 64 | 65 | // Create the file model 66 | const fileModel = mongoose.model('File', fileSchema) 67 | export default fileModel 68 | -------------------------------------------------------------------------------- /apps/api/src/models/user.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Document, Schema } from 'mongoose' 2 | import validator from 'validator' 3 | import bcrypt from 'bcryptjs' 4 | 5 | // Define the User interface 6 | export interface IUser extends Document { 7 | _id: string 8 | name: string 9 | email: string 10 | photo: string 11 | password?: string 12 | passwordConfirm?: string | undefined 13 | role?: string 14 | verified: boolean 15 | comparePasswords: ( 16 | candidatePassword: string, 17 | hashedPassword: string 18 | ) => Promise 19 | } 20 | 21 | // Define the user schema 22 | const userSchema = new Schema( 23 | { 24 | name: { 25 | type: String, 26 | required: true, 27 | }, 28 | photo: { 29 | type: String, 30 | required: false, 31 | }, 32 | email: { 33 | type: String, 34 | required: true, 35 | unique: true, 36 | validate: [validator.isEmail, 'Please provide a valid email'], 37 | lowercase: true, 38 | }, 39 | password: { 40 | type: String, 41 | required: false, 42 | select: false, 43 | }, 44 | passwordConfirm: { 45 | type: String, 46 | required: [false, 'Please confirm your password'], 47 | validate: { 48 | validator: function (this: IUser, val: string) { 49 | return val === this.password 50 | }, 51 | message: 'Passwords do not match', 52 | }, 53 | }, 54 | role: { 55 | type: String, 56 | default: 'user', 57 | }, 58 | verified: { 59 | type: Boolean, 60 | default: false, 61 | }, 62 | }, 63 | { timestamps: true, toJSON: { virtuals: true }, toObject: { virtuals: true } } 64 | ) 65 | 66 | // Add an index to the email field 67 | userSchema.index({ email: 1 }) 68 | 69 | // Pre-save hook to hash the password 70 | userSchema.pre('save', async function (next) { 71 | if (!this.isModified('password')) return next() 72 | 73 | this.password = await bcrypt.hash(this.password ?? '', 12) 74 | 75 | this.passwordConfirm = undefined 76 | next() 77 | }) 78 | 79 | // Method to compare passwords 80 | userSchema.methods.comparePasswords = async function ( 81 | this: IUser, 82 | candidatePassword: string, 83 | hashedPassword: string 84 | ) { 85 | return await bcrypt.compare(candidatePassword, hashedPassword) 86 | } 87 | 88 | // Create the user model 89 | const userModel = mongoose.model('User', userSchema) 90 | export default userModel 91 | -------------------------------------------------------------------------------- /apps/api/src/resolvers/index.ts: -------------------------------------------------------------------------------- 1 | import Mutation from "./mutation.resolver"; 2 | import Query from "./query.resolver"; 3 | 4 | export { Mutation, Query }; 5 | -------------------------------------------------------------------------------- /apps/api/src/resolvers/mutation.resolver.ts: -------------------------------------------------------------------------------- 1 | import authController from '~/controllers/auth.controller' 2 | import fileController from '~/controllers/files.controller' 3 | 4 | export default { 5 | // Auth User 6 | signupUser: authController.signup, 7 | loginUser: authController.login, 8 | oAuth: authController.oAuth, 9 | verifyAccount: authController.verifyAccount, 10 | 11 | // Files 12 | createFile: fileController.create, 13 | updateFile: fileController.update, 14 | toogleFavorite: fileController.toogleFavorite, 15 | toogleIsPublic: fileController.toogleIsPublic, 16 | addNewUserAccess: fileController.addNewUserAccess, 17 | changeUserAccess: fileController.changeUserAccess, 18 | } 19 | -------------------------------------------------------------------------------- /apps/api/src/resolvers/query.resolver.ts: -------------------------------------------------------------------------------- 1 | import authController from '~/controllers/auth.controller.js' 2 | import filesController from '~/controllers/files.controller' 3 | 4 | export default { 5 | // Auth Users 6 | getMe: authController.getMe, 7 | refreshAccessToken: authController.refreshAccessToken, 8 | logoutUser: authController.logout, 9 | 10 | // Files 11 | getFiles: filesController.get, 12 | getFavorites: filesController.getFavorites, 13 | getFileById: filesController.getById, 14 | } 15 | -------------------------------------------------------------------------------- /apps/api/src/schemas/index.ts: -------------------------------------------------------------------------------- 1 | const typeDefs = `#graphql 2 | scalar DateTime 3 | type Query { 4 | # Auth 5 | refreshAccessToken(refresh_token: String!): TokenResponse! 6 | logoutUser: Boolean! 7 | 8 | # User 9 | getMe: UserResponse! 10 | 11 | # Files 12 | getFiles(search: String): [File]! 13 | getFileById(id: String!, isPublic: Boolean!): File! 14 | getFavorites: [File]! 15 | } 16 | 17 | type Mutation { 18 | # Auth 19 | loginUser(input: LoginInput!): TokenResponse! 20 | signupUser(input: SignUpInput!): Boolean 21 | oAuth(input: OAuthInput!): TokenResponse! 22 | verifyAccount(input: verifyAccountInput!): TokenResponse! 23 | 24 | # Files 25 | createFile: File! 26 | addNewUserAccess(input: NewUserAccessInput!): [UserAccess] 27 | changeUserAccess(input: ChangeUserAccessInput!): [UserAccess] 28 | toogleFavorite(input: ToogleFavoriteInput!): [File] 29 | toogleIsPublic(input: ToogleIsPublicInput!): Boolean! 30 | updateFile(input: UpdateFileInput!): File! 31 | deleteFile(input: DeleteFileInput!): Boolean 32 | } 33 | 34 | input UpdateFileInput { 35 | id: String! 36 | name: String 37 | thumbnail: String 38 | whiteboard: String 39 | } 40 | 41 | input DeleteFileInput { 42 | id: String! 43 | } 44 | 45 | input SignUpInput { 46 | name: String! 47 | email: String! 48 | password: String! 49 | passwordConfirm: String! 50 | } 51 | 52 | input LoginInput { 53 | email: String! 54 | password: String! 55 | } 56 | 57 | input OAuthInput { 58 | strategy: String! 59 | code: String! 60 | } 61 | 62 | input NewUserAccessInput { 63 | id: String! 64 | email: String! 65 | role: String! 66 | } 67 | 68 | input ChangeUserAccessInput { 69 | id: String! 70 | user_id: String! 71 | role: String! 72 | } 73 | 74 | input verifyAccountInput { 75 | id: String 76 | } 77 | 78 | input ToogleFavoriteInput{ 79 | id: String! 80 | } 81 | 82 | input ToogleIsPublicInput { 83 | id: String! 84 | value: Boolean! 85 | } 86 | 87 | type File { 88 | id: ID! 89 | name: String! 90 | isPublic: Boolean 91 | thumbnail: String 92 | whiteboard: String 93 | userAccess: [UserAccess!]! 94 | createdAt: DateTime 95 | updatedAt: DateTime 96 | } 97 | 98 | type UserAccess { 99 | userId: UserAccessDetail! 100 | role: String! 101 | } 102 | 103 | type UserAccessDetail { 104 | _id: String! 105 | name: String! 106 | email: String! 107 | photo: String 108 | } 109 | 110 | type TokenResponse { 111 | access_token: String! 112 | refresh_token: String! 113 | } 114 | 115 | type UserResponse { 116 | user: UserData! 117 | } 118 | 119 | type UserData { 120 | id: ID! 121 | name: String! 122 | email: String! 123 | role: String! 124 | photo: String 125 | createdAt: DateTime 126 | updatedAt: DateTime 127 | } 128 | ` 129 | 130 | export default typeDefs 131 | -------------------------------------------------------------------------------- /apps/api/src/server.ts: -------------------------------------------------------------------------------- 1 | import http from 'http' 2 | import express from 'express' 3 | import { ApolloServer } from '@apollo/server' 4 | import { ApolloServerPluginDrainHttpServer } from '@apollo/server/plugin/drainHttpServer' 5 | import { expressMiddleware } from '@apollo/server/express4' 6 | import typeDefs from './schemas/index' 7 | import { Mutation, Query } from './resolvers/index' 8 | import cors from 'cors' 9 | import connectDB from '~/core/mongoose' 10 | import app from './app' 11 | import userAuth, { UserAuthFn } from '~/middleware/user-auth' 12 | import { FRONT_URI, PORT } from '~/env.config' 13 | 14 | // Create an HTTP server using Express 15 | const httpServer = http.createServer(app) 16 | 17 | // Define CORS options 18 | const corsOptions = { 19 | origin: [ 20 | 'https://studio.apollographql.com', 21 | `http://localhost:${PORT}`, 22 | FRONT_URI, 23 | ], 24 | credentials: true, 25 | } 26 | 27 | // Apply CORS middleware to the Express app 28 | app.use(cors(corsOptions)) 29 | 30 | // Define GraphQL resolvers 31 | const resolvers = { 32 | Query, 33 | Mutation, 34 | } 35 | 36 | // Define the context type for Apollo Server 37 | type Context = { 38 | req: express.Request 39 | res: express.Response 40 | userAuth: UserAuthFn 41 | } 42 | 43 | // Start the server 44 | ;(async function () { 45 | // Create an Apollo Server instance 46 | const server = new ApolloServer({ 47 | typeDefs, 48 | resolvers, 49 | plugins: [ApolloServerPluginDrainHttpServer({ httpServer })], 50 | }) 51 | 52 | // Connect to the database 53 | await connectDB() 54 | 55 | // Start the Apollo Server 56 | await server.start() 57 | 58 | // Apply the Apollo Server middleware to the Express app 59 | app.use( 60 | '/graphql', 61 | cors(), 62 | express.json(), 63 | expressMiddleware(server, { 64 | context: async ({ 65 | req, 66 | res, 67 | }: { 68 | req: express.Request 69 | res: express.Response 70 | }): Promise => { 71 | return { req, res, userAuth } 72 | }, 73 | }) 74 | ) 75 | 76 | // Start the HTTP server 77 | await new Promise((resolve) => 78 | httpServer.listen({ port: PORT }, resolve) 79 | ) 80 | 81 | // Log the server URL 82 | console.log(`🚀 Server ready at http://localhost:${PORT}/graphql`) 83 | })() 84 | 85 | // Handle unhandled rejections 86 | process.on('unhandledRejection', (err) => { 87 | console.log('UNHANDLED REJECTION 🔥🔥 Shutting down...') 88 | console.error('Error🔥', (err as Error).message) 89 | 90 | // Close the HTTP server 91 | httpServer.close(async () => { 92 | process.exit(1) 93 | }) 94 | }) 95 | -------------------------------------------------------------------------------- /apps/api/src/services/auth.service.ts: -------------------------------------------------------------------------------- 1 | import redisClient from '~/core/redis' 2 | import userModel, { IUser } from '~/models/user' 3 | 4 | import { GraphQLError } from 'graphql' 5 | import { signJwt, verifyJwt } from '~/core/jwt' 6 | import { 7 | FRONT_URI, 8 | JWT_ACCESS_TOKEN_EXPIRED_IN, 9 | JWT_REFRESH_TOKEN_EXPIRED_IN, 10 | } from '~/env.config' 11 | 12 | import mailService from './mail.service' 13 | import authService from './auth.service' 14 | 15 | interface SignupInput { 16 | name: string 17 | email: string 18 | password: string 19 | passwordConfirm: string 20 | } 21 | 22 | interface SignInInput { 23 | email: string 24 | password: string 25 | } 26 | 27 | /** 28 | * SignTokens function 29 | * @param {IUser} user - The user object 30 | * @returns {Object} - The tokens object 31 | */ 32 | const signTokens = async (user: IUser) => { 33 | // Create a Session 34 | await redisClient.set(user.id, JSON.stringify(user), { 35 | EX: 60 * 60, 36 | }) 37 | 38 | // Create access token 39 | const access_token = signJwt({ user: user.id }, 'JWT_ACCESS_PRIVATE_KEY', { 40 | expiresIn: `${JWT_ACCESS_TOKEN_EXPIRED_IN}m`, 41 | }) 42 | 43 | // Create refresh token 44 | const refresh_token = signJwt({ user: user.id }, 'JWT_REFRESH_PRIVATE_KEY', { 45 | expiresIn: `${JWT_REFRESH_TOKEN_EXPIRED_IN}m`, 46 | }) 47 | 48 | return { access_token, refresh_token } 49 | } 50 | 51 | const createUser = async (input: SignupInput) => { 52 | let user = await userModel.findOne({ email: input.email }) 53 | 54 | if (user && !user.verified) { 55 | user.name = input.name 56 | user.password = input.password 57 | 58 | user.save() 59 | } else { 60 | user = await userModel.create({ 61 | name: input.name, 62 | email: input.email, 63 | password: input.password, 64 | passwordConfirm: input.passwordConfirm, 65 | verified: false, 66 | }) 67 | } 68 | 69 | await mailService.userRegisterEmail( 70 | input.name, 71 | input.email, 72 | FRONT_URI + `/callback/verify?code=${user?.id}` 73 | ) 74 | 75 | delete user?.id 76 | 77 | return user 78 | } 79 | 80 | const loginUser = async (input: SignInInput) => { 81 | const user = await userModel 82 | .findOne({ email: input.email }) 83 | .select('+password +verified') 84 | 85 | if ( 86 | !input.password || 87 | input.password === '' || 88 | !user || 89 | !(await user.comparePasswords(input.password, user.password ?? '')) 90 | ) { 91 | throw new GraphQLError('Invalid email or password', { 92 | extensions: { 93 | code: 'AUTHENTICATION_ERROR', 94 | }, 95 | }) 96 | } 97 | 98 | if (!user.verified) { 99 | throw new GraphQLError('Please verify your email address', { 100 | extensions: { 101 | code: 'AUTHENTICATION_ERROR', 102 | }, 103 | }) 104 | } 105 | 106 | if (user?.password) { 107 | delete user.password 108 | } 109 | 110 | const { access_token, refresh_token } = await signTokens(user) 111 | 112 | return { 113 | access_token, 114 | refresh_token, 115 | } 116 | } 117 | 118 | const refreshToken = async (current_refresh_token: string) => { 119 | // Verify the refresh token 120 | const decoded = verifyJwt(current_refresh_token, 'JWT_REFRESH_PRIVATE_KEY') 121 | 122 | if (!decoded) { 123 | throw new GraphQLError('Could not refresh access token', { 124 | extensions: { 125 | code: 'FORBIDDEN', 126 | }, 127 | }) 128 | } 129 | 130 | // Check if the user session exists in Redis 131 | const session = await redisClient.get(decoded.user) 132 | 133 | if (!session) { 134 | throw new GraphQLError('User session has expired', { 135 | extensions: { 136 | code: 'FORBIDDEN', 137 | }, 138 | }) 139 | } 140 | 141 | // Find the user by the session ID and check if they are verified 142 | const user = await userModel 143 | .findById(JSON.parse(session)._id) 144 | .select('+verified') 145 | 146 | if (!user || !user.verified) { 147 | throw new GraphQLError('Could not refresh access token', { 148 | extensions: { 149 | code: 'FORBIDDEN', 150 | }, 151 | }) 152 | } 153 | 154 | // Generate new access and refresh tokens 155 | const { access_token, refresh_token } = await signTokens(user) 156 | 157 | // Return the new access token and refresh token 158 | return { 159 | access_token, 160 | refresh_token, 161 | } 162 | } 163 | 164 | const verifyAccount = async (id: string) => { 165 | const user = await userModel.findOne({ 166 | id, 167 | verified: false, 168 | }) 169 | 170 | if (!user) { 171 | throw new GraphQLError('User not found', { 172 | extensions: { 173 | code: 'NOT_FOUND', 174 | }, 175 | }) 176 | } 177 | 178 | user.verified = true 179 | await user.save() 180 | 181 | const { access_token, refresh_token } = await authService.signTokens(user) 182 | 183 | return { 184 | access_token, 185 | refresh_token, 186 | } 187 | } 188 | 189 | const logout = async (user: IUser) => { 190 | if (user) { 191 | await redisClient.del(user._id.toString()) 192 | } 193 | 194 | return true 195 | } 196 | 197 | export default { 198 | signTokens, 199 | createUser, 200 | loginUser, 201 | refreshToken, 202 | verifyAccount, 203 | logout, 204 | } 205 | -------------------------------------------------------------------------------- /apps/api/src/services/files.services.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto' 2 | import fs from 'fs' 3 | import { GraphQLError } from 'graphql' 4 | import Identicon from 'identicon.js' 5 | import { RootQuerySelector } from 'mongoose' 6 | import _ from 'underscore' 7 | import { 8 | FileInputUpdate, 9 | NewUserAccessInput, 10 | } from '~/controllers/files.controller' 11 | import fileModel, { IFile } from '~/models/file' 12 | import userModel from '~/models/user' 13 | 14 | const find = async (query: RootQuerySelector & Partial) => { 15 | const files = await fileModel 16 | .find(query) 17 | .populate({ 18 | path: 'userAccess.userId', 19 | model: 'User', 20 | select: 'name photo email', 21 | }) 22 | .sort({ updatedAt: -1 }) 23 | 24 | return files 25 | } 26 | 27 | const findOne = async (query: RootQuerySelector & Partial) => { 28 | const files = await fileModel.findOne(query).populate({ 29 | path: 'userAccess.userId', 30 | model: 'User', 31 | select: 'name photo email', 32 | }) 33 | 34 | return files 35 | } 36 | 37 | const create = async (userId: string) => { 38 | const countFile = await fileModel.countDocuments({ 39 | userAccess: { 40 | $elemMatch: { 41 | userId: userId, 42 | role: 'OWNER', 43 | }, 44 | }, 45 | }) 46 | 47 | if (countFile >= 3) { 48 | throw new GraphQLError('For demo purpose you can only have 3 files!', { 49 | extensions: { 50 | code: 'VALIDATION', 51 | }, 52 | }) 53 | } 54 | 55 | const thumbName = crypto.randomUUID() 56 | const thumbnail = new Identicon(thumbName, 420) 57 | const dir = 'public/storage/' 58 | 59 | if (!fs.existsSync(dir)) { 60 | fs.mkdirSync(dir) 61 | } 62 | 63 | fs.writeFile( 64 | dir + thumbName + '.png', 65 | thumbnail.toString(), 66 | 'base64', 67 | function (err) { 68 | console.log(err) 69 | } 70 | ) 71 | 72 | const file = await fileModel.create({ 73 | name: 'Untitled File', 74 | thumbnail: thumbName + '.png', 75 | whiteboard: null, 76 | updatedAt: new Date(), 77 | userAccess: [ 78 | { 79 | userId: userId, 80 | role: 'OWNER', 81 | }, 82 | ], 83 | }) 84 | } 85 | 86 | const update = async ({ input }: FileInputUpdate) => { 87 | const getInput = _.omit(input, _.isNull) 88 | 89 | const file = await fileModel.findByIdAndUpdate( 90 | input.id, 91 | { 92 | ...getInput, 93 | }, 94 | { 95 | new: true, 96 | } 97 | ) 98 | } 99 | 100 | const toogleFavorite = async (userId: string, id: string) => { 101 | const file = await fileModel.findOne({ 102 | _id: id, 103 | 'userAccess.userId': userId, 104 | }) 105 | 106 | if (!file) { 107 | throw new GraphQLError('File not found!', { 108 | extensions: { 109 | code: 'VALIDATION', 110 | }, 111 | }) 112 | } 113 | 114 | const userIndex = file.favoriteBy.findIndex( 115 | (item) => item.userId.toString() == userId.toString() 116 | ) 117 | 118 | if (userIndex > -1) { 119 | await file.favoriteBy.splice(userIndex, 1) 120 | } else { 121 | await file.favoriteBy.push({ 122 | userId: userId, 123 | }) 124 | } 125 | 126 | await file.save() 127 | } 128 | 129 | const addNewUserAccess = async ({ input }: NewUserAccessInput) => { 130 | const selectedUser = await userModel.findOne({ 131 | email: input.email, 132 | }) 133 | 134 | if (!selectedUser) { 135 | throw new GraphQLError('User not found!', { 136 | extensions: { 137 | code: 'VALIDATION', 138 | }, 139 | }) 140 | } 141 | 142 | const file = await fileModel 143 | .findOneAndUpdate( 144 | { 145 | _id: input.id, 146 | }, 147 | { 148 | $push: { 149 | userAccess: { 150 | userId: selectedUser?._id, 151 | role: input.role, 152 | }, 153 | }, 154 | }, 155 | { 156 | new: true, 157 | } 158 | ) 159 | .populate({ 160 | path: 'userAccess.userId', 161 | model: 'User', 162 | select: 'name photo email', 163 | }) 164 | 165 | return file 166 | } 167 | 168 | const changeUserAccess = async ({ input }: NewUserAccessInput) => { 169 | let file = null 170 | 171 | if (input.role === 'REMOVE') { 172 | file = await fileModel.findOneAndUpdate( 173 | { 174 | _id: input.id, 175 | }, 176 | { 177 | $pull: { 178 | userAccess: { userId: input.user_id }, 179 | }, 180 | }, 181 | { 182 | new: true, 183 | } 184 | ) 185 | } else { 186 | file = await fileModel.findOneAndUpdate( 187 | { 188 | _id: input.id, 189 | 'userAccess.userId': input.user_id, 190 | }, 191 | { 192 | $set: { 193 | 'userAccess.$.role': input.role, 194 | }, 195 | }, 196 | { 197 | new: true, 198 | } 199 | ) 200 | } 201 | 202 | return await file?.populate({ 203 | path: 'userAccess.userId', 204 | model: 'User', 205 | select: 'name photo email', 206 | }) 207 | } 208 | 209 | const toogleIsPublic = async (userId: string, id: string, value: boolean) => { 210 | const file = await fileModel.findOneAndUpdate( 211 | { 212 | _id: id, 213 | userAccess: { 214 | $elemMatch: { 215 | userId: userId, 216 | role: 'OWNER', 217 | }, 218 | }, 219 | }, 220 | { 221 | isPublic: value, 222 | }, 223 | { 224 | new: true, 225 | } 226 | ) 227 | 228 | return file 229 | } 230 | 231 | export default { 232 | find, 233 | findOne, 234 | create, 235 | update, 236 | toogleFavorite, 237 | addNewUserAccess, 238 | changeUserAccess, 239 | toogleIsPublic, 240 | } 241 | -------------------------------------------------------------------------------- /apps/api/src/services/mail.service.ts: -------------------------------------------------------------------------------- 1 | import { Resend } from 'resend' 2 | import { RESEND_API_KEY } from '~/env.config' 3 | 4 | const resend = new Resend(RESEND_API_KEY) 5 | 6 | const userRegisterEmail = async (name: string, email: string, link: string) => { 7 | return await resend.emails.send({ 8 | from: 'onboarding@resend.dev', 9 | to: email, 10 | subject: 'Welcome to Syncboard', 11 | html: ` 12 | 13 | 14 | 15 | Verify your email address 16 | 23 | 24 | 25 |
26 |
27 |

Welcome to Syncboard!

28 |
29 |
30 |

Hi, ${name}

31 |

Thank you for register on syncboard, please verify your email by clicking link below

32 | ${link} 33 |

Best regards,
34 | Jung Rama 35 |

36 | 39 |
40 | 41 | `, 42 | }) 43 | } 44 | 45 | export default { 46 | userRegisterEmail, 47 | } 48 | -------------------------------------------------------------------------------- /apps/api/src/services/oauth.service.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { GraphQLError } from 'graphql' 3 | import userModel from '~/models/user' 4 | import authService from './auth.service' 5 | 6 | export const getOAuthProfile = async (strategy: string, token: string) => { 7 | let profile = null 8 | 9 | if (strategy === 'GITHUB') { 10 | profile = await axios({ 11 | method: 'get', 12 | url: `https://api.github.com/user`, 13 | headers: { 14 | Authorization: 'token ' + token, 15 | }, 16 | }) 17 | } 18 | 19 | if (!profile) { 20 | throw new Error('Profile not found') 21 | } 22 | 23 | return profile.data 24 | } 25 | 26 | /** 27 | * Authenticates the user using OAuth. 28 | * 29 | * @param {any} parent - The parent object. 30 | * @param {oAuthInput} input - The input object containing the strategy and code. 31 | * @param {Request} req - The request object. 32 | * @param {Response} res - The response object. 33 | * @return {Promise} The object containing the status, access token, and refresh token. 34 | */ 35 | export const continueWithOAuth = async (strategy: string, code: string) => { 36 | if (strategy === 'GITHUB') { 37 | const githubOauth = await axios.get( 38 | 'https://github.com/login/oauth/access_token', 39 | { 40 | params: { 41 | client_id: process.env.GITHUB_CLIENT_ID, 42 | client_secret: process.env.GITHUB_CLIENT_SECRET, 43 | code: code, 44 | }, 45 | headers: { 46 | accept: 'application/json', 47 | }, 48 | } 49 | ) 50 | 51 | if (githubOauth.data.error) { 52 | throw new GraphQLError('Github oauth error!', { 53 | extensions: { 54 | code: 'AUTHENTICATION_ERROR', 55 | }, 56 | }) 57 | } 58 | 59 | const profile = await getOAuthProfile( 60 | 'GITHUB', 61 | githubOauth.data.access_token 62 | ) 63 | 64 | let user = await userModel.findOne({ email: profile.email }) 65 | 66 | if (!user) { 67 | user = await userModel.create({ 68 | name: profile.name, 69 | email: profile.email, 70 | photo: profile.avatar_url, 71 | password: '', 72 | passwordConfirm: '', 73 | verified: true, 74 | }) 75 | } 76 | 77 | const { access_token, refresh_token } = await authService.signTokens(user) 78 | 79 | return { 80 | access_token, 81 | refresh_token, 82 | } 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /apps/api/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2016", 4 | "module": "commonjs", 5 | "paths": { 6 | "~/*": ["./src/*"] 7 | }, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "strict": true, 11 | "skipLibCheck": true, 12 | "useUnknownInCatchVariables": false 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /apps/api/tsup.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig, type Options } from "tsup"; 2 | 3 | export default defineConfig((options: Options) => ({ 4 | entryPoints: ["src/server.ts"], 5 | clean: true, 6 | format: ["cjs"], 7 | ...options, 8 | })); 9 | -------------------------------------------------------------------------------- /apps/web/.env.example: -------------------------------------------------------------------------------- 1 | GITHUB_CLIENT_ID= 2 | -------------------------------------------------------------------------------- /apps/web/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['custom/next'], 3 | plugins: ['sort-keys-fix'], 4 | rules: { 5 | 'sort-keys-fix/sort-keys-fix': 'warn', 6 | 'react/jsx-sort-props': [ 7 | '2', 8 | { 9 | callbacksLast: true, 10 | shorthandFirst: false, 11 | shorthandLast: true, 12 | multiline: 'last', 13 | ignoreCase: true, 14 | noSortAlphabetically: true, 15 | }, 16 | ], 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /apps/web/.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.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | -------------------------------------------------------------------------------- /apps/web/README.md: -------------------------------------------------------------------------------- 1 | ## Getting Started 2 | 3 | First, run the development server: 4 | 5 | ```bash 6 | yarn dev 7 | ``` 8 | 9 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 10 | 11 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 12 | 13 | To create [API routes](https://nextjs.org/docs/app/building-your-application/routing/router-handlers) add an `api/` directory to the `app/` directory with a `route.ts` file. For individual endpoints, create a subfolder in the `api` directory, like `api/hello/route.ts` would map to [http://localhost:3000/api/hello](http://localhost:3000/api/hello). 14 | 15 | ## Learn More 16 | 17 | To learn more about Next.js, take a look at the following resources: 18 | 19 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 20 | - [Learn Next.js](https://nextjs.org/learn/foundations/about-nextjs) - an interactive Next.js tutorial. 21 | 22 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 23 | 24 | ## Deploy on Vercel 25 | 26 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_source=github.com&utm_medium=referral&utm_campaign=turborepo-readme) from the creators of Next.js. 27 | 28 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 29 | -------------------------------------------------------------------------------- /apps/web/app/auth/auth.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import SignInForm from '@/components/auth/sign-in-form'; 4 | import SignUpForm from '@/components/auth/sign-up-form'; 5 | import { 6 | Tabs, 7 | TabsContent, 8 | TabsList, 9 | TabsTrigger, 10 | } from '@ui/components/ui/tabs'; 11 | import Image from 'next/image'; 12 | 13 | export const metadata = { 14 | title: 'Syncboard - Auth', 15 | icons: '/icon/favicon.ico', 16 | }; 17 | 18 | export default function Auth() { 19 | return ( 20 |
21 |
22 |
23 |
24 | logo 30 | 31 | 32 | 33 | 34 | Sign In 35 | 36 | 37 | Sign Up 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 |
49 |
50 |
51 |
52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /apps/web/app/auth/page.tsx: -------------------------------------------------------------------------------- 1 | import Auth from './auth'; 2 | 3 | export const metadata = { 4 | title: 'Syncboard - Auth', 5 | icons: '/icon/favicon.ico', 6 | }; 7 | 8 | export default function AuthPage() { 9 | return ; 10 | } 11 | -------------------------------------------------------------------------------- /apps/web/app/callback/github/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useOnMountUnsafe } from '@/hook/useOnMountUnsafe'; 4 | import useAuthService from '@/services/auth.service'; 5 | import { setUser } from '@/store/user.store'; 6 | import { setAccessToken, setRefreshToken } from '@/utils/cookie-service.utils'; 7 | import { useToast } from '@ui/components/ui/use-toast'; 8 | import { Loader } from 'lucide-react'; 9 | import { useRouter, useSearchParams } from 'next/navigation'; 10 | import { useDispatch } from 'react-redux'; 11 | 12 | export default function GithubCallback(): JSX.Element { 13 | const dispatch = useDispatch(); 14 | const router = useRouter(); 15 | const { toast } = useToast(); 16 | const searchParams = useSearchParams(); 17 | 18 | const { 19 | mutateOAuth, 20 | errorOAuth, 21 | 22 | getMe, 23 | } = useAuthService(); 24 | 25 | const continueWithOAuth = async () => { 26 | const { data: oAuthData } = await mutateOAuth({ 27 | variables: { 28 | input: { 29 | code: searchParams.get('code') as string, 30 | strategy: 'GITHUB', 31 | }, 32 | }, 33 | onError: () => { 34 | router.push('/auth'); 35 | }, 36 | }); 37 | 38 | if (!errorOAuth && oAuthData?.oAuth?.access_token) { 39 | setAccessToken(oAuthData.oAuth.access_token); 40 | setRefreshToken(oAuthData.oAuth.refresh_token); 41 | 42 | const { data: dataMe } = await getMe(); 43 | 44 | if (!dataMe) { 45 | toast({ 46 | title: 'Error', 47 | description: 'Something went wrong! we could not sign you in', 48 | variant: 'destructive', 49 | }); 50 | } else { 51 | dispatch(setUser(dataMe.getMe.user)); 52 | router.push('/files'); 53 | } 54 | } else { 55 | toast({ 56 | title: 'Error', 57 | description: 'Something went wrong! we could not sign you in', 58 | variant: 'destructive', 59 | }); 60 | } 61 | }; 62 | 63 | useOnMountUnsafe(() => { 64 | continueWithOAuth(); 65 | }); 66 | 67 | return ( 68 |
69 | 70 |
71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /apps/web/app/callback/verify/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useOnMountUnsafe } from '@/hook/useOnMountUnsafe'; 4 | import useAuthService from '@/services/auth.service'; 5 | import { setUser } from '@/store/user.store'; 6 | import { setAccessToken, setRefreshToken } from '@/utils/cookie-service.utils'; 7 | import { useToast } from '@ui/components/ui/use-toast'; 8 | import { Loader } from 'lucide-react'; 9 | import { useRouter, useSearchParams } from 'next/navigation'; 10 | import { useDispatch } from 'react-redux'; 11 | 12 | export default function GithubCallback(): JSX.Element { 13 | const dispatch = useDispatch(); 14 | const router = useRouter(); 15 | const { toast } = useToast(); 16 | const searchParams = useSearchParams(); 17 | 18 | const { 19 | mutateVerifyAccount, 20 | errorVerifyAccount, 21 | 22 | getMe, 23 | } = useAuthService(); 24 | 25 | const verifyAccountCode = async () => { 26 | const { data: verify } = await mutateVerifyAccount({ 27 | variables: { 28 | input: { 29 | id: searchParams.get('code') as string, 30 | }, 31 | }, 32 | onError: () => { 33 | router.push('/auth'); 34 | }, 35 | }); 36 | 37 | if (!errorVerifyAccount && verify?.verifyAccount?.access_token) { 38 | setAccessToken(verify?.verifyAccount.access_token); 39 | setRefreshToken(verify?.verifyAccount.refresh_token); 40 | 41 | const { data: dataMe } = await getMe(); 42 | 43 | if (!dataMe) { 44 | toast({ 45 | title: 'Error', 46 | description: 'Something went wrong! we could not sign you in', 47 | variant: 'destructive', 48 | }); 49 | } else { 50 | dispatch(setUser(dataMe.getMe.user)); 51 | router.push('/files'); 52 | } 53 | } else { 54 | toast({ 55 | title: 'Error', 56 | description: 'Something went wrong! we could not sign you in', 57 | variant: 'destructive', 58 | }); 59 | } 60 | }; 61 | 62 | useOnMountUnsafe(() => { 63 | verifyAccountCode(); 64 | }); 65 | 66 | return ( 67 |
68 | 69 |
70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /apps/web/app/draw/draw.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import FileAIDialog from '@/components/file-workspace/file-ai-dialog'; 4 | import Whiteboard from '@/components/file-workspace/whiteboard'; 5 | import { Button } from '@ui/components/ui/button'; 6 | import Link from 'next/link'; 7 | 8 | export default function Draw(): JSX.Element { 9 | return ( 10 |
11 |
12 |
13 | 16 |

17 | Login to use more feature like have
{' '} 18 | multiple whiteboard or share whiteboard in realtime 19 |

20 |
21 | 22 |
23 |
24 | 30 |
31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /apps/web/app/draw/page.tsx: -------------------------------------------------------------------------------- 1 | import Draw from './draw'; 2 | 3 | export const metadata = { 4 | title: 'Syncboard - Draw or Use AI', 5 | icons: '/icon/favicon.ico', 6 | }; 7 | 8 | export default function Page({ params }): JSX.Element { 9 | return ; 10 | } 11 | -------------------------------------------------------------------------------- /apps/web/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jungrama/syncboard/f47c0790d56ca787314371dd4370c0d5e1589680/apps/web/app/favicon.ico -------------------------------------------------------------------------------- /apps/web/app/files/[id]/fileDetail.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import FileAIDialog from '@/components/file-workspace/file-ai-dialog'; 4 | import FileShareDialog from '@/components/file-workspace/file-share-dialog'; 5 | import FileWorkspaceHeader from '@/components/file-workspace/file-workspace-header'; 6 | import Whiteboard from '@/components/file-workspace/whiteboard'; 7 | import { getFileById } from '@/services/file.service'; 8 | import { RootState } from '@/store/index.store'; 9 | import { Loader } from 'lucide-react'; 10 | import { notFound } from 'next/navigation'; 11 | import { useEffect, useState } from 'react'; 12 | import { useSelector } from 'react-redux'; 13 | 14 | export default function FileDetail({ params }): JSX.Element { 15 | const { id: roomId } = params; 16 | const userData = useSelector((state: RootState) => state.user?.user); 17 | 18 | const getData = getFileById(roomId, false); 19 | const dataFile = getData.data?.getFileById; 20 | const [users, setUsers] = useState(dataFile?.userAccess); 21 | const [isUserHaveAccess, setIsUserHaveAccess] = useState(false); 22 | const [isUserReadOnly, setIsUserReadOnly] = useState(true); 23 | const [isPublic, setIsPublic] = useState(false); 24 | 25 | useEffect(() => { 26 | if (!getData.loading) { 27 | const usersAccess = dataFile?.userAccess ?? []; 28 | const checkCurrentUserAccess = usersAccess?.find( 29 | (item) => item.userId._id === userData?.id, 30 | ); 31 | const checkCurrentUserHaveAccess = !!checkCurrentUserAccess; 32 | 33 | setIsUserReadOnly(checkCurrentUserAccess?.role === 'VIEW'); 34 | 35 | setUsers(usersAccess); 36 | 37 | setIsPublic(dataFile?.isPublic ?? false); 38 | 39 | setIsUserHaveAccess(() => { 40 | return checkCurrentUserHaveAccess; 41 | }); 42 | 43 | if (!checkCurrentUserHaveAccess) { 44 | notFound(); 45 | } 46 | } 47 | }, [getData.loading]); 48 | 49 | if (getData.loading) { 50 | return ( 51 |
52 | 53 |
54 | ); 55 | } 56 | 57 | return ( 58 |
59 |
60 | setUsers(updatedUsers)} 71 | onIsPublicChange={(val) => setIsPublic(val)} 72 | > 73 | } 74 | aiComponent={} 75 | /> 76 |
77 | 78 |
79 | 84 |
85 |
86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /apps/web/app/files/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import FileDetail from './fileDetail'; 2 | 3 | export const metadata = { 4 | title: 'Syncboard - Draw or Use AI', 5 | icons: '/icon/favicon.ico', 6 | }; 7 | 8 | export default function Page({ params }): JSX.Element { 9 | return ( 10 | <> 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /apps/web/app/files/[id]/snapshot.json: -------------------------------------------------------------------------------- 1 | {"store":{"document:document":{"gridSize":10,"name":"","meta":{},"id":"document:document","typeName":"document"},"page:page":{"meta":{},"id":"page:page","name":"Page 1","index":"a1","typeName":"page"},"shape:x4EiC--VNcE77nJwwdlex":{"x":298.9473571777344,"y":400.00001525878906,"rotation":0,"isLocked":false,"opacity":1,"meta":{},"id":"shape:x4EiC--VNcE77nJwwdlex","type":"arrow","parentId":"page:page","index":"a1","props":{"dash":"draw","size":"m","fill":"none","color":"black","labelColor":"black","bend":0,"start":{"type":"point","x":0,"y":0},"end":{"type":"point","x":227.3685302734375,"y":-195.36843872070312},"arrowheadStart":"none","arrowheadEnd":"arrow","text":"","font":"draw"},"typeName":"shape"},"shape:Vyt4FjxsYHUBvySYamrQt":{"x":528.8421630859375,"y":108.631591796875,"rotation":0,"isLocked":false,"opacity":1,"meta":{},"id":"shape:Vyt4FjxsYHUBvySYamrQt","type":"geo","props":{"w":188.631591796875,"h":167.57894897460938,"geo":"rectangle","color":"black","labelColor":"black","fill":"none","dash":"draw","size":"m","font":"draw","text":"","align":"middle","verticalAlign":"middle","growY":0,"url":""},"parentId":"page:page","index":"a2","typeName":"shape"},"shape:0eBqQkNbiLmWQw9nMuYhG":{"x":169.26315307617188,"y":400.0000305175781,"rotation":0,"isLocked":false,"opacity":1,"meta":{},"id":"shape:0eBqQkNbiLmWQw9nMuYhG","type":"geo","props":{"w":128.00006103515625,"h":129.68423461914062,"geo":"rectangle","color":"black","labelColor":"black","fill":"none","dash":"draw","size":"m","font":"draw","text":"","align":"middle","verticalAlign":"middle","growY":0,"url":""},"parentId":"page:page","index":"a3","typeName":"shape"}},"schema":{"schemaVersion":1,"storeVersion":4,"recordVersions":{"asset":{"version":1,"subTypeKey":"type","subTypeVersions":{"image":2,"video":2,"bookmark":0}},"camera":{"version":1},"document":{"version":2},"instance":{"version":21},"instance_page_state":{"version":5},"page":{"version":1},"shape":{"version":3,"subTypeKey":"type","subTypeVersions":{"group":0,"text":1,"bookmark":1,"draw":1,"geo":7,"note":4,"line":1,"frame":0,"arrow":1,"highlight":0,"embed":4,"image":2,"video":1}},"instance_presence":{"version":5},"pointer":{"version":1}}}} -------------------------------------------------------------------------------- /apps/web/app/files/files.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import FileHeader from '@/components/file-list/file-header'; 4 | import FileItem from '@/components/file-list/file-item'; 5 | import Sidebar from '@/components/sidebar'; 6 | import FileSkeleton from '@/components/skeletons/file-skeleton'; 7 | import { getFiles } from '@/services/file.service'; 8 | import { RootState } from '@/store/index.store'; 9 | import { toogleSidebar } from '@/store/navigation.store'; 10 | import { Separator } from '@ui/components/ui/separator'; 11 | import { cn } from '@ui/lib/utils'; 12 | import { File, FilePlus } from 'lucide-react'; 13 | import { useSearchParams } from 'next/navigation'; 14 | import { useDispatch, useSelector } from 'react-redux'; 15 | 16 | export default function Files(): JSX.Element { 17 | const params = useSearchParams(); 18 | const dispatch = useDispatch(); 19 | const showSidebar = useSelector( 20 | (state: RootState) => state.navigation.showSidebar, 21 | ); 22 | 23 | const fileItemView = useSelector( 24 | (state: RootState) => state.file.fileItemView, 25 | ); 26 | 27 | const { data: dataFiles, loading: loadingFiles } = getFiles( 28 | params.get('search'), 29 | ); 30 | 31 | return ( 32 |
33 |
39 | 40 |
41 |
{ 43 | dispatch(toogleSidebar()); 44 | }} 45 | className={cn( 46 | 'sidebar-overlay fixed left-0 top-0 z-20 h-full w-full bg-black opacity-50', 47 | showSidebar ? 'block' : 'hidden', 48 | )} 49 | >
50 | 51 |
52 | 53 | 54 | 55 |
56 |
57 | {loadingFiles ? ( 58 | <> 59 | {[...Array(8)].map((_, i) => ( 60 |
64 | 65 |
66 | ))} 67 | 68 | ) : ( 69 | <> 70 | {dataFiles?.getFiles.map((item, i) => ( 71 |
79 | 87 |
88 | ))} 89 | 90 | {dataFiles && dataFiles?.getFiles?.length <= 0 && ( 91 |
92 | 96 |

97 | No files found 98 |

99 |

100 | Get started by creating a new file. 101 |

102 |
103 | )} 104 | 105 | )} 106 |
107 |
108 |
109 |
110 | ); 111 | } 112 | -------------------------------------------------------------------------------- /apps/web/app/files/layout.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { GET_ME_QUERY } from '@/query/auth.gql'; 4 | import { setUser } from '@/store/user.store'; 5 | import { getAccessToken } from '@/utils/cookie-service.utils'; 6 | import { useQuery } from '@apollo/client'; 7 | import { Loader } from 'lucide-react'; 8 | import { redirect } from 'next/navigation'; 9 | import { useLayoutEffect } from 'react'; 10 | import { useDispatch } from 'react-redux'; 11 | 12 | export default function LayoutFiles({ 13 | children, 14 | }: { 15 | children: React.ReactNode; 16 | }) { 17 | const token = getAccessToken(); 18 | const dispatch = useDispatch(); 19 | 20 | useLayoutEffect(() => { 21 | if (!token) { 22 | redirect('/auth'); 23 | } 24 | }, []); 25 | 26 | const getMe = useQuery(GET_ME_QUERY); 27 | dispatch(setUser(getMe.data?.getMe?.user)); 28 | 29 | if (getMe.data) { 30 | return <>{children}; 31 | } else { 32 | return ( 33 |
34 | 35 |
36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /apps/web/app/files/page.tsx: -------------------------------------------------------------------------------- 1 | import Files from './files'; 2 | 3 | export const metadata = { 4 | title: 'Syncboard - My Files', 5 | icons: '/icon/favicon.ico', 6 | }; 7 | 8 | export default function FilePage(): JSX.Element { 9 | return ; 10 | } 11 | -------------------------------------------------------------------------------- /apps/web/app/index.scss: -------------------------------------------------------------------------------- 1 | /* width */ 2 | ::-webkit-scrollbar { 3 | width: 3px; 4 | } 5 | 6 | /* Track */ 7 | ::-webkit-scrollbar-track { 8 | background: #f1f1f1; 9 | } 10 | 11 | /* Handle */ 12 | ::-webkit-scrollbar-thumb { 13 | background: #888; 14 | } 15 | 16 | /* Handle on hover */ 17 | ::-webkit-scrollbar-thumb:hover { 18 | background: #555; 19 | } 20 | 21 | .tlui-help-menu { 22 | display: none !important; 23 | } 24 | 25 | .tlui-debug-panel { 26 | display: none !important; 27 | } 28 | -------------------------------------------------------------------------------- /apps/web/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import '@ui/styles/globals.css'; 2 | 3 | import type { Metadata } from 'next'; 4 | import { Plus_Jakarta_Sans } from 'next/font/google'; 5 | 6 | const plusJakartaSans = Plus_Jakarta_Sans({ subsets: ['latin'] }); 7 | import './index.scss'; 8 | import MainProvider from '@/providers/index.provider'; 9 | 10 | export const metadata: Metadata = { 11 | title: 'Syncboard - Collaborative Whiteboard', 12 | icons: '/icon/favicon.ico', 13 | description: 14 | 'Collaborate with team in one simple board. Generate any diagram with the help of AI', 15 | }; 16 | 17 | export default function RootLayout({ 18 | children, 19 | }: { 20 | children: React.ReactNode; 21 | }): JSX.Element { 22 | return ( 23 | 24 | 25 | {children} 26 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /apps/web/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from 'next'; 2 | import PageWrapper from './pageWrapper'; 3 | 4 | export const metadata: Metadata = { 5 | icons: '/icon/favicon.ico', 6 | }; 7 | 8 | export default function Page(): JSX.Element { 9 | return ( 10 | <> 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /apps/web/app/pageWrapper.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Github, LogIn, Pencil, Play } from 'lucide-react'; 4 | import Image from 'next/image'; 5 | import Link from 'next/link'; 6 | import { useState } from 'react'; 7 | 8 | export default function PageWrapper(): JSX.Element { 9 | const [play, setPlay] = useState(false); 10 | 11 | const playVideo = () => { 12 | (document.getElementById('video-landing') as HTMLVideoElement).play(); 13 | 14 | play ? setPlay(false) : setPlay(true); 15 | }; 16 | 17 | return ( 18 | <> 19 |
20 | 68 |
69 | 70 |
71 |
72 |
73 |
74 |
75 |
76 | AI FEATURE 77 |
78 |

79 | Generate any diagram with the help of AI 80 |

81 |
82 |
83 |

84 | Collaborate with team in one simple{' '} 85 | board 86 |

87 |
88 | 89 |
90 | {/*
*/} 91 | 96 | 97 | {!play && ( 98 |
99 |
playVideo()} 101 | className="inline-flex cursor-pointer items-center gap-x-2 rounded-full border border-gray-200 bg-white px-4 py-3 text-sm font-semibold text-gray-800 shadow-sm hover:bg-gray-50 disabled:pointer-events-none disabled:opacity-50 dark:border-gray-700 dark:bg-slate-900 dark:text-white dark:hover:bg-gray-800 dark:focus:outline-none dark:focus:ring-1 dark:focus:ring-gray-600" 102 | > 103 | 104 | Play the overview 105 |
106 |
107 | )} 108 | 109 |
110 |
111 |
112 | 113 |
114 |
115 |
116 |
117 |
118 |
119 | 120 | ); 121 | } 122 | -------------------------------------------------------------------------------- /apps/web/app/public/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import Public from './public'; 2 | 3 | export const metadata = { 4 | title: 'Syncboard - Draw or Use AI', 5 | icons: '/icon/favicon.ico', 6 | }; 7 | 8 | export default function Page({ params }): JSX.Element { 9 | return ; 10 | } 11 | -------------------------------------------------------------------------------- /apps/web/app/public/[id]/public.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Whiteboard from '@/components/file-workspace/whiteboard'; 4 | import { getFileById } from '@/services/file.service'; 5 | import { Loader } from 'lucide-react'; 6 | import { notFound } from 'next/navigation'; 7 | import { useEffect } from 'react'; 8 | 9 | export default function Public({ params }): JSX.Element { 10 | const { id: roomId } = params; 11 | 12 | const getData = getFileById(roomId, true); 13 | const dataFile = getData.data?.getFileById; 14 | 15 | useEffect(() => { 16 | if (!getData.loading) { 17 | if (!getData.data?.getFileById.isPublic) { 18 | notFound(); 19 | } 20 | } 21 | }, [getData.loading]); 22 | 23 | if (getData.loading) { 24 | return ( 25 |
26 | 27 |
28 | ); 29 | } 30 | 31 | return ( 32 |
33 |
34 | 39 |
40 |
41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /apps/web/codegen.ts: -------------------------------------------------------------------------------- 1 | import { CodegenConfig } from '@graphql-codegen/cli'; 2 | 3 | const config: CodegenConfig = { 4 | schema: 'http://localhost:4000/graphql', 5 | // this assumes that all your source files are in a top-level `src/` directory - you might need to adjust this to your file structure 6 | documents: ['./query/**/*.{ts,tsx}'], 7 | generates: { 8 | './codegen/': { 9 | preset: 'client', 10 | plugins: [], 11 | presetConfig: { 12 | gqlTagName: 'gql', 13 | }, 14 | }, 15 | }, 16 | ignoreNoDocuments: true, 17 | }; 18 | 19 | export default config; 20 | -------------------------------------------------------------------------------- /apps/web/codegen/fragment-masking.ts: -------------------------------------------------------------------------------- 1 | import { ResultOf, DocumentTypeDecoration, TypedDocumentNode } from '@graphql-typed-document-node/core'; 2 | import { FragmentDefinitionNode } from 'graphql'; 3 | import { Incremental } from './graphql'; 4 | 5 | 6 | export type FragmentType> = TDocumentType extends DocumentTypeDecoration< 7 | infer TType, 8 | any 9 | > 10 | ? [TType] extends [{ ' $fragmentName'?: infer TKey }] 11 | ? TKey extends string 12 | ? { ' $fragmentRefs'?: { [key in TKey]: TType } } 13 | : never 14 | : never 15 | : never; 16 | 17 | // return non-nullable if `fragmentType` is non-nullable 18 | export function useFragment( 19 | _documentNode: DocumentTypeDecoration, 20 | fragmentType: FragmentType> 21 | ): TType; 22 | // return nullable if `fragmentType` is nullable 23 | export function useFragment( 24 | _documentNode: DocumentTypeDecoration, 25 | fragmentType: FragmentType> | null | undefined 26 | ): TType | null | undefined; 27 | // return array of non-nullable if `fragmentType` is array of non-nullable 28 | export function useFragment( 29 | _documentNode: DocumentTypeDecoration, 30 | fragmentType: ReadonlyArray>> 31 | ): ReadonlyArray; 32 | // return array of nullable if `fragmentType` is array of nullable 33 | export function useFragment( 34 | _documentNode: DocumentTypeDecoration, 35 | fragmentType: ReadonlyArray>> | null | undefined 36 | ): ReadonlyArray | null | undefined; 37 | export function useFragment( 38 | _documentNode: DocumentTypeDecoration, 39 | fragmentType: FragmentType> | ReadonlyArray>> | null | undefined 40 | ): TType | ReadonlyArray | null | undefined { 41 | return fragmentType as any; 42 | } 43 | 44 | 45 | export function makeFragmentData< 46 | F extends DocumentTypeDecoration, 47 | FT extends ResultOf 48 | >(data: FT, _fragment: F): FragmentType { 49 | return data as FragmentType; 50 | } 51 | export function isFragmentReady( 52 | queryNode: DocumentTypeDecoration, 53 | fragmentNode: TypedDocumentNode, 54 | data: FragmentType, any>> | null | undefined 55 | ): data is FragmentType { 56 | const deferredFields = (queryNode as { __meta__?: { deferredFields: Record } }).__meta__ 57 | ?.deferredFields; 58 | 59 | if (!deferredFields) return true; 60 | 61 | const fragDef = fragmentNode.definitions[0] as FragmentDefinitionNode | undefined; 62 | const fragName = fragDef?.name?.value; 63 | 64 | const fields = (fragName && deferredFields[fragName]) || []; 65 | return fields.length > 0 && fields.every(field => data && field in data); 66 | } 67 | -------------------------------------------------------------------------------- /apps/web/codegen/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./fragment-masking"; 2 | export * from "./gql"; -------------------------------------------------------------------------------- /apps/web/components/auth/github-auth.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Button } from '@ui/components/ui/button'; 4 | import { Github } from 'lucide-react'; 5 | 6 | export default function GithubAuth() { 7 | return ( 8 | 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /apps/web/components/auth/sign-in-form.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useLayoutEffect } from 'react'; 4 | 5 | import { Alert, AlertDescription } from '@ui/components/ui/alert'; 6 | import { 7 | Card, 8 | CardContent, 9 | CardDescription, 10 | CardFooter, 11 | CardHeader, 12 | CardTitle, 13 | } from '@ui/components/ui/card'; 14 | import { 15 | Form, 16 | FormControl, 17 | FormField, 18 | FormItem, 19 | FormLabel, 20 | FormMessage, 21 | } from '@ui/components/ui/form'; 22 | import { Input } from '@ui/components/ui/input'; 23 | import { useToast } from '@ui/components/ui/use-toast'; 24 | import { AlertCircleIcon, Loader2Icon } from 'lucide-react'; 25 | 26 | import useAuthService from '@/services/auth.service'; 27 | import { setUser } from '@/store/user.store'; 28 | import { 29 | getAccessToken, 30 | setAccessToken, 31 | setRefreshToken, 32 | } from '@/utils/cookie-service.utils'; 33 | import { signInValidationSchema } from '@/validations/auth.validation'; 34 | import { zodResolver } from '@hookform/resolvers/zod'; 35 | import { redirect, useRouter } from 'next/navigation'; 36 | import { useForm } from 'react-hook-form'; 37 | import { useDispatch } from 'react-redux'; 38 | import { z } from 'zod'; 39 | import { Button } from '@ui/components/ui/button'; 40 | import GithubAuth from './github-auth'; 41 | 42 | export default function SignInForm() { 43 | const dispatch = useDispatch(); 44 | const { toast } = useToast(); 45 | const router = useRouter(); 46 | const token = getAccessToken(); 47 | 48 | useLayoutEffect(() => { 49 | if (token) { 50 | redirect('/files'); 51 | } 52 | }, []); 53 | 54 | const { 55 | mutateLogin, 56 | loadingLogin, 57 | errorLogin, 58 | 59 | getMe, 60 | loadingGetMe, 61 | } = useAuthService(); 62 | 63 | const formHandler = useForm>({ 64 | resolver: zodResolver(signInValidationSchema), 65 | }); 66 | 67 | const signInCredentialAction = async (data) => { 68 | const { data: loginData } = await mutateLogin({ 69 | variables: { 70 | input: { 71 | email: data.email, 72 | password: data.password, 73 | }, 74 | }, 75 | }); 76 | 77 | if (!errorLogin && loginData?.loginUser?.access_token) { 78 | setAccessToken(loginData.loginUser.access_token); 79 | setRefreshToken(loginData.loginUser.refresh_token); 80 | 81 | const { data: dataMe } = await getMe(); 82 | 83 | if (!dataMe) { 84 | toast({ 85 | title: 'Error', 86 | description: 'Something went wrong! we could not sign you in', 87 | variant: 'destructive', 88 | }); 89 | 90 | return; 91 | } 92 | 93 | dispatch(setUser(dataMe.getMe.user)); 94 | 95 | router.push('/files'); 96 | } 97 | }; 98 | 99 | return ( 100 |
101 | 102 | 103 | 104 | Welcome Back 🎉 105 | 106 | Enter your credential or use sso to get access to your account 107 | 108 | 109 | 110 | {errorLogin && ( 111 | 112 |
113 | 114 | {errorLogin.message} 115 |
116 |
117 | )} 118 | ( 122 | 123 | Email 124 | 125 | 126 | 127 | 128 | 129 | )} 130 | /> 131 | 132 | ( 136 | 137 | Password 138 | 139 | 140 | 141 | 142 | 143 | )} 144 | /> 145 |
146 | 147 |
148 | 158 | 159 |
160 |
161 |
162 | 163 |
164 |
165 | 166 | Or continue with 167 | 168 |
169 |
170 |
171 | 172 | 173 |
174 |
175 |
176 |
177 | 178 | ); 179 | } 180 | -------------------------------------------------------------------------------- /apps/web/components/file-list/file-header.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { mutateCreateFile } from '@/services/file.service'; 4 | import { setFileItemView } from '@/store/file.store'; 5 | import { RootState } from '@/store/index.store'; 6 | import { toogleSidebar } from '@/store/navigation.store'; 7 | import { Button } from '@ui/components/ui/button'; 8 | import { useToast } from '@ui/components/ui/use-toast'; 9 | import { cn } from '@ui/lib/utils'; 10 | import { Grid, List, Loader, Menu } from 'lucide-react'; 11 | import { useRouter } from 'next/navigation'; 12 | import { useEffect } from 'react'; 13 | import { useDispatch, useSelector } from 'react-redux'; 14 | 15 | export default function FileHeader() { 16 | const router = useRouter(); 17 | const [createFile, { loading, error }] = mutateCreateFile(); 18 | const { toast } = useToast(); 19 | const dispatch = useDispatch(); 20 | 21 | const fileItemView = useSelector( 22 | (state: RootState) => state.file.fileItemView, 23 | ); 24 | 25 | const createNewFile = async () => { 26 | const response = await createFile(); 27 | 28 | const fileId = response.data?.createFile.id; 29 | 30 | if (fileId) { 31 | setTimeout(() => { 32 | router.push(`/files/${fileId}`); 33 | }, 1000); 34 | } 35 | }; 36 | 37 | useEffect(() => { 38 | if (error) { 39 | toast({ description: error.message, variant: 'destructive' }); 40 | } 41 | }, [error]); 42 | 43 | return ( 44 |
45 |
46 | 53 |
54 | 55 |
56 | 59 | 60 | 72 | 84 |
85 |
86 | ); 87 | } 88 | -------------------------------------------------------------------------------- /apps/web/components/file-list/file-item.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { mutateToogleFavorite } from '@/services/file.service'; 4 | import { setFavorites } from '@/store/file.store'; 5 | import { initialName, userProfile } from '@/utils/user-profile.utils'; 6 | import { Avatar, AvatarFallback, AvatarImage } from '@ui/components/ui/avatar'; 7 | import { cn } from '@ui/lib/utils'; 8 | import { formatDistance } from 'date-fns'; 9 | import { Heart } from 'lucide-react'; 10 | import Link from 'next/link'; 11 | import { useDispatch, useSelector } from 'react-redux'; 12 | 13 | export default function FileItem(props) { 14 | const dispatch = useDispatch(); 15 | 16 | const { variant, title, thumbnail, id, lastUpdate, users } = props; 17 | 18 | const favorites = useSelector((state: any) => state.file.favorites); 19 | 20 | const isFavorite = favorites?.find((item) => item.id === id); 21 | 22 | const [mutateFavorite] = mutateToogleFavorite(); 23 | 24 | const toogleFavorite = (e) => { 25 | e.preventDefault(); 26 | e.stopPropagation(); 27 | 28 | if (isFavorite) { 29 | dispatch(setFavorites(favorites.filter((item) => item.id !== id))); 30 | } else { 31 | dispatch(setFavorites([...favorites, { id, name: title }])); 32 | } 33 | 34 | mutateFavorite({ 35 | variables: { 36 | input: { 37 | id, 38 | }, 39 | }, 40 | }); 41 | }; 42 | 43 | return ( 44 |
50 |
toogleFavorite(e)} 58 | > 59 | 60 |
61 | 62 |
68 |
69 | 74 |
75 | 76 |
82 |
83 |

84 | {title ?? 'Untitled'} 85 |

86 |

87 | Edited{' '} 88 | {lastUpdate 89 | ? formatDistance(new Date(lastUpdate), new Date(), { 90 | addSuffix: true, 91 | }) 92 | : 'a while ago'} 93 |

94 |
95 |
96 |
97 | {users.map((item) => { 98 | const user = item.userId; 99 | 100 | return ( 101 |
102 | 103 | 106 | 107 | {initialName(user.name)} 108 | 109 | 110 |
111 | ); 112 | })} 113 |
114 |
115 |
116 |
117 | 118 |
119 | ); 120 | } 121 | -------------------------------------------------------------------------------- /apps/web/components/file-workspace/file-ai-dialog.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { 4 | Dialog, 5 | DialogContent, 6 | DialogHeader, 7 | DialogTitle, 8 | DialogTrigger, 9 | } from '@ui/components/ui/dialog'; 10 | 11 | import { Button } from '@ui/components/ui/button'; 12 | import { Input } from '@ui/components/ui/input'; 13 | import { AlertCircleIcon, HelpCircleIcon, Loader } from 'lucide-react'; 14 | 15 | import { Alert, AlertDescription, AlertTitle } from '@ui/components/ui/alert'; 16 | import { Textarea } from '@ui/components/ui/textarea'; 17 | import { 18 | Tooltip, 19 | TooltipContent, 20 | TooltipProvider, 21 | TooltipTrigger, 22 | } from '@ui/components/ui/tooltip'; 23 | import OpenAI from 'openai'; 24 | import { useSelector } from 'react-redux'; 25 | import { RootState } from '@/store/index.store'; 26 | import { TLGeoShape } from '@tldraw/tldraw'; 27 | import { formatShapes } from './useFormatShapes'; 28 | import { useEffect, useState } from 'react'; 29 | import { getOpenAIKey, setOpenAIKey } from '@/utils/cookie-service.utils'; 30 | import { useToast } from '@ui/components/ui/use-toast'; 31 | 32 | export default function FileAIDialog() { 33 | const { toast } = useToast(); 34 | const editorTL = useSelector((state: RootState) => state.file.editor); 35 | 36 | const [dialog, setDialog] = useState(false); 37 | const [error, setError] = useState(null); 38 | const [loading, setLoading] = useState(false); 39 | 40 | const [apiKey, setApiKey] = useState(null); 41 | const [prompt, setPrompt] = useState(null); 42 | 43 | const saveApiKeyToCookie = (value) => { 44 | setOpenAIKey(value); 45 | }; 46 | 47 | useEffect(() => { 48 | setApiKey(getOpenAIKey() as string); 49 | }, [getOpenAIKey()]); 50 | 51 | const buildWithAI = async () => { 52 | try { 53 | if (!apiKey || !prompt) { 54 | toast({ 55 | variant: 'destructive', 56 | title: 57 | 'Please Enter ' + 58 | (!apiKey ? 'API Key ' : '') + 59 | (!prompt ? 'Prompt' : ''), 60 | }); 61 | return; 62 | } 63 | 64 | setLoading(true); 65 | 66 | const openai = new OpenAI({ 67 | apiKey: apiKey, 68 | dangerouslyAllowBrowser: true, 69 | }); 70 | 71 | const completion = await openai.chat.completions.create({ 72 | messages: [ 73 | { 74 | role: 'system', 75 | content: 76 | 'create a a step-by-step process explaining things.{ shapes: [{type: string, // value can be ellipse, rectangle, diamond // ellipse (start/end) used to represents the start or end of a process in a flowchart // diamond (decision): Represents a decision point, required a yes/no or true/false in arrow and need to have 2 output // rectangle (process): Depicts a process step or action in the flowchart description: string, // describe step 10 - 50 char id: string, // generate random uuid }], arrows: [{ id: string, // generate random uuid start: string, // id of shape to start end: string, // id of shape to end, description: string, // describe about this arrow. this is optional, usually used when connected with diamond shape. }] } you need to provide with those JSON format', 77 | }, 78 | { 79 | role: 'user', 80 | content: prompt, 81 | }, 82 | ], 83 | model: 'gpt-3.5-turbo-1106', 84 | n: 1, 85 | response_format: { 86 | type: 'json_object', 87 | }, 88 | }); 89 | 90 | setDialog(false); 91 | setLoading(false); 92 | 93 | if (editorTL) { 94 | editorTL?.createShapes( 95 | formatShapes( 96 | editorTL, 97 | JSON.parse(completion.choices[0].message.content ?? '{}'), 98 | ), 99 | ); 100 | 101 | editorTL?.zoomToFit(); 102 | } 103 | } catch (error) { 104 | setLoading(false); 105 | toast({ 106 | variant: 'destructive', 107 | title: error.message, 108 | }); 109 | } 110 | }; 111 | 112 | return ( 113 | 114 | 115 | 118 | 119 | 120 | 121 | Create with AI 122 | 123 | 124 | 125 | 126 | We don't save your API Key 127 | 128 | Your api key saved on the client cookies. Using this feature is at 129 | your own risk. 130 | 131 | 132 | 133 |
134 |
135 | (e.target.type = 'text')} 141 | onChange={(e) => { 142 | setApiKey(e.target.value); 143 | }} 144 | onBlur={(e) => { 145 | e.target.type = 'password'; 146 | saveApiKeyToCookie(e.target.value); 147 | }} 148 | > 149 |
150 |
151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | How to get OpenAI API Key 159 | 160 | 161 | 162 | 163 |
164 |
165 | 166 | 171 | 172 | 176 |
177 |
178 | ); 179 | } 180 | -------------------------------------------------------------------------------- /apps/web/components/file-workspace/file-user-owner.tsx: -------------------------------------------------------------------------------- 1 | import { initialName, userProfile } from '@/utils/user-profile.utils'; 2 | import { Avatar, AvatarFallback, AvatarImage } from '@ui/components/ui/avatar'; 3 | import { 4 | Tooltip, 5 | TooltipContent, 6 | TooltipProvider, 7 | TooltipTrigger, 8 | } from '@ui/components/ui/tooltip'; 9 | 10 | export default function FileUserOwner({ 11 | name, 12 | photo, 13 | withTooltip = true, 14 | }: { 15 | name: string; 16 | photo?: string | null; 17 | withTooltip?: boolean; 18 | }) { 19 | return ( 20 | <> 21 | {withTooltip && ( 22 | 23 | 24 | 25 |
26 | 27 | 30 | 31 | {initialName(name)} 32 | 33 | 34 |
35 |
36 | 37 |

{name}

38 |
39 |
40 |
41 | )} 42 | 43 | {!withTooltip && ( 44 | 45 | 48 | 49 | {initialName(name)} 50 | 51 | 52 | )} 53 | 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /apps/web/components/file-workspace/file-workspace-header.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { GetFileByIdQuery } from '@/codegen/graphql'; 4 | import { ArrowLeft, Dot, Loader, Menu, MoreHorizontal } from 'lucide-react'; 5 | import Link from 'next/link'; 6 | import ContentEditable from 'react-contenteditable'; 7 | import FileShareDialog from './file-share-dialog'; 8 | import FileUserOwner from './file-user-owner'; 9 | import _ from 'underscore'; 10 | import { mutateUpdateFile } from '@/services/file.service'; 11 | import { useState } from 'react'; 12 | import FileAIDialog from './file-ai-dialog'; 13 | import { 14 | DropdownMenu, 15 | DropdownMenuContent, 16 | DropdownMenuTrigger, 17 | } from '@ui/components/ui/dropdown-menu'; 18 | 19 | export default function FileWorkspaceHeader({ 20 | name, 21 | users, 22 | roomId, 23 | isReadOnly, 24 | shareComponent, 25 | aiComponent, 26 | }: { 27 | name: string; 28 | users: GetFileByIdQuery['getFileById']['userAccess']; 29 | roomId: string; 30 | isReadOnly: boolean; 31 | shareComponent: JSX.Element; 32 | aiComponent: JSX.Element; 33 | }) { 34 | const [nameFile, setNameFile] = useState(name); 35 | const [updateFile, { loading }] = mutateUpdateFile(); 36 | 37 | const actionNameChange = _.debounce((value: string) => { 38 | if (value === '' || !value) { 39 | value = 'Untitled'; 40 | } 41 | 42 | setNameFile(value); 43 | 44 | updateFile({ 45 | variables: { 46 | input: { 47 | id: roomId, 48 | name: value, 49 | }, 50 | }, 51 | }); 52 | }, 1000); 53 | 54 | return ( 55 |
56 |
57 | 58 | 59 | 60 | { 65 | actionNameChange(event.target.value); 66 | }} 67 | className="inline-block cursor-text border-b-2 border-transparent bg-transparent text-xl font-medium focus:border-b-2 focus:border-white focus:outline-none" // Use a custom HTML tag (uses a div by default) 68 | /> 69 | {loading && } 70 |
71 | 72 |
73 | 74 | 75 | 76 | 77 | 78 |

Users

79 |
80 | {users.map((item) => { 81 | const user = item.userId; 82 | 83 | return ( 84 | 89 | ); 90 | })} 91 |
92 | 93 | {!isReadOnly && aiComponent} 94 | {shareComponent} 95 |
96 |
97 | 98 |
99 |
100 | {users.map((item) => { 101 | const user = item.userId; 102 | 103 | return ( 104 | 109 | ); 110 | })} 111 |
112 | 113 | {!isReadOnly && aiComponent} 114 | {shareComponent} 115 |
116 |
117 |
118 | ); 119 | } 120 | -------------------------------------------------------------------------------- /apps/web/components/file-workspace/useFormatShapes.ts: -------------------------------------------------------------------------------- 1 | const createShape = (idT, x, y, type, text, parent) => { 2 | const id = `shape:shape-${idT}`; 3 | 4 | return { 5 | x: x, 6 | y: y, 7 | rotation: 0, 8 | isLocked: false, 9 | opacity: 1, 10 | meta: {}, 11 | id: id, 12 | type: 'geo', 13 | props: { 14 | w: 400, 15 | h: 200, 16 | geo: type, 17 | color: 'black', 18 | labelColor: 'black', 19 | fill: 'none', 20 | dash: 'draw', 21 | size: 's', 22 | font: 'draw', 23 | text: text ?? '', 24 | align: 'middle', 25 | verticalAlign: 'middle', 26 | growY: 0, 27 | url: '', 28 | }, 29 | parentId: parent, 30 | index: 'a1', 31 | typeName: 'shape', 32 | }; 33 | }; 34 | 35 | const createArrow = (idT, start, end, text, parent) => { 36 | const id = `shape:arrow-${idT}`; 37 | 38 | return { 39 | x: 0, 40 | y: 0, 41 | rotation: 0, 42 | isLocked: false, 43 | opacity: 1, 44 | meta: {}, 45 | id: id, 46 | type: 'arrow', 47 | parentId: parent, 48 | index: 'a3', 49 | props: { 50 | dash: 'draw', 51 | size: 'm', 52 | fill: 'none', 53 | color: 'black', 54 | labelColor: 'black', 55 | bend: 0, 56 | start: { 57 | type: 'binding', 58 | boundShapeId: 'shape:shape-' + start, 59 | normalizedAnchor: { x: 0.5, y: 0.5 }, 60 | isExact: false, 61 | }, 62 | end: { 63 | type: 'binding', 64 | boundShapeId: 'shape:shape-' + end, 65 | normalizedAnchor: { x: 0.57272636366283, y: 0.7747743679480183 }, 66 | isExact: false, 67 | }, 68 | arrowheadStart: 'none', 69 | arrowheadEnd: 'arrow', 70 | text: text ?? '', 71 | font: 'draw', 72 | }, 73 | typeName: 'shape', 74 | }; 75 | }; 76 | 77 | export const formatShapes = (editor, response) => { 78 | const allShape: any[] = []; 79 | 80 | const createGroup = 'shape:' + crypto.randomUUID(); 81 | 82 | response.shapes.forEach((shape, index) => { 83 | allShape.push( 84 | createShape( 85 | shape.id, 86 | (() => { 87 | if (index % 2 == 0) { 88 | return 365; 89 | } else { 90 | return 0; 91 | } 92 | })(), 93 | 165 + index * 200 * 2, 94 | shape.type, 95 | shape.description, 96 | createGroup, 97 | ), 98 | ); 99 | }); 100 | 101 | response.arrows.forEach((arrow) => { 102 | allShape.push( 103 | createArrow( 104 | arrow.id, 105 | arrow.start, 106 | arrow.end, 107 | arrow.description, 108 | createGroup, 109 | ), 110 | ); 111 | }); 112 | 113 | allShape.push({ 114 | x: 1541.0184375679096, 115 | y: 212.86121054942242, 116 | rotation: 0, 117 | isLocked: false, 118 | opacity: 1, 119 | meta: {}, 120 | id: createGroup, 121 | type: 'group', 122 | parentId: 'page:page', 123 | index: 'a3', 124 | props: {}, 125 | typeName: 'shape', 126 | }); 127 | 128 | return allShape; 129 | }; 130 | -------------------------------------------------------------------------------- /apps/web/components/file-workspace/whiteboard.tsx: -------------------------------------------------------------------------------- 1 | 'use-client'; 2 | 3 | import { 4 | Editor, 5 | TLStore, 6 | TLUiOverrides, 7 | Tldraw, 8 | menuItem, 9 | } from '@tldraw/tldraw'; 10 | import '@tldraw/tldraw/tldraw.css'; 11 | import { useRouter } from 'next/navigation'; 12 | 13 | import { mutateUpdateFile } from '@/services/file.service'; 14 | import { setEditor } from '@/store/file.store'; 15 | import { RootState } from '@/store/index.store'; 16 | import { useDispatch, useSelector } from 'react-redux'; 17 | import _ from 'underscore'; 18 | import { useYjsStore } from './useYjs'; 19 | 20 | export default function Whiteboard({ 21 | roomId, 22 | defaultValue, 23 | isReadOnly, 24 | useRealtime = true, 25 | }) { 26 | const router = useRouter(); 27 | const [updateFile] = mutateUpdateFile(); 28 | 29 | const editorTL = useSelector((state: RootState) => state.file.editor); 30 | 31 | const dispatch = useDispatch(); 32 | 33 | const overideUI: TLUiOverrides = { 34 | menu(_editor, menu) { 35 | const newMenuItem = menuItem({ 36 | id: 'back-to-files', 37 | title: 'Back to all workspace', 38 | // @ts-expect-error 39 | label: 'Back to all workspace', 40 | readonlyOk: false, 41 | onSelect() { 42 | router.push('/files'); 43 | }, 44 | }); 45 | menu.unshift(newMenuItem); 46 | return menu; 47 | }, 48 | }; 49 | 50 | let store; 51 | if (useRealtime) { 52 | store = useYjsStore({ 53 | roomId: roomId, 54 | hostUrl: process.env.NEXT_PUBLIC_WS_URL, 55 | defaultWhiteboard: defaultValue, 56 | onUpdate: _.debounce((store: TLStore) => { 57 | // Do something 58 | }, 3000), 59 | }); 60 | } 61 | 62 | const handleMount = (editor: Editor) => { 63 | dispatch(setEditor(editor)); 64 | 65 | if (editorTL) { 66 | if (isReadOnly) { 67 | editorTL.updateInstanceState({ isReadonly: true }); 68 | } 69 | editorTL.zoomToFit(); 70 | } 71 | }; 72 | 73 | return ( 74 | <> 75 | { 78 | handleMount(editor); 79 | }} 80 | store={store} 81 | persistenceKey={!store ? 'whiteboard' : undefined} 82 | > 83 | 84 | ); 85 | } 86 | -------------------------------------------------------------------------------- /apps/web/components/sidebar/favorites.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { getFavorites } from '@/services/file.service'; 4 | import { Button } from '@ui/components/ui/button'; 5 | import { Heart, PenTool } from 'lucide-react'; 6 | import Link from 'next/link'; 7 | import { useDispatch, useSelector } from 'react-redux'; 8 | import { useEffect } from 'react'; 9 | import { setFavorites } from '@/store/file.store'; 10 | 11 | export default function Favorites() { 12 | const dispatch = useDispatch(); 13 | const favorites = useSelector((state: any) => state.file.favorites); 14 | 15 | const { data: dataFavorites, loading: loadingFavorites } = getFavorites(); 16 | 17 | useEffect(() => { 18 | if (!loadingFavorites) { 19 | dispatch(setFavorites(dataFavorites?.getFavorites)); 20 | } 21 | }, [dataFavorites]); 22 | 23 | return ( 24 | <> 25 |
26 |
27 | 28 |

Favorites

29 |
30 |
31 | 32 |
33 | {favorites && 34 | favorites.map((file) => ( 35 | 45 | ))} 46 |
47 | 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /apps/web/components/sidebar/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@ui/components/ui/button'; 2 | import { Input } from '@ui/components/ui/input'; 3 | import { Separator } from '@ui/components/ui/separator'; 4 | import { Loader, Search } from 'lucide-react'; 5 | import Favorites from './favorites'; 6 | import Profile from './profile'; 7 | import Image from 'next/image'; 8 | import { useState } from 'react'; 9 | import _ from 'underscore'; 10 | import { useRouter } from 'next/navigation'; 11 | 12 | export default function Sidebar() { 13 | const [search, setSearch] = useState(''); 14 | const [loadingSearch, setLoadingSearch] = useState(false); 15 | const router = useRouter(); 16 | 17 | const searchFile = (e) => { 18 | setSearch(e.target.value); 19 | setLoadingSearch(true); 20 | searchDebounce(e.target.value); 21 | }; 22 | 23 | const searchDebounce = _.debounce((value) => { 24 | router.push(`/files/?search=${value}`); 25 | setLoadingSearch(false); 26 | }, 2000); 27 | 28 | return ( 29 | <> 30 |
31 | 34 |
35 | 36 | 37 | 38 | 39 |
40 | 41 | 42 | 48 | 49 | {loadingSearch && ( 50 |
51 | 52 |
53 | )} 54 |
55 | 56 | 57 | 58 | 59 | 60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /apps/web/components/sidebar/profile.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Button } from '@ui/components/ui/button'; 4 | import { Avatar, AvatarFallback, AvatarImage } from '@ui/components/ui/avatar'; 5 | import { LogOut } from 'lucide-react'; 6 | import { useDispatch, useSelector } from 'react-redux'; 7 | import { RootState } from '@/store/index.store'; 8 | import useAuthService from '@/services/auth.service'; 9 | import { 10 | removeAccessToken, 11 | removeRefreshToken, 12 | } from '@/utils/cookie-service.utils'; 13 | import { setUser } from '@/store/user.store'; 14 | import { useRouter } from 'next/navigation'; 15 | import { initialName, userProfile } from '@/utils/user-profile.utils'; 16 | import { clearAllCredentials } from '@/utils/user-credentials.utils'; 17 | 18 | export default function Profile() { 19 | const userData = useSelector((state: RootState) => state.user?.user); 20 | const dispatch = useDispatch(); 21 | 22 | const { logout, loadingLogout, errorLogout } = useAuthService(); 23 | 24 | const logoutAction = () => { 25 | logout(); 26 | clearAllCredentials('/auth'); 27 | dispatch(setUser(null)); 28 | }; 29 | 30 | return ( 31 |
32 | {userData && ( 33 | <> 34 |
35 | 36 | 39 | 40 | {initialName(userData.name)} 41 | 42 | 43 | {userData?.name} 44 |
45 | 46 | 53 | 54 | )} 55 |
56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /apps/web/components/sidebar/recent-file.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from '@ui/components/ui/button'; 2 | import { Clock, PenTool } from 'lucide-react'; 3 | 4 | export default function RecentFile() { 5 | return ( 6 | <> 7 |
8 |
9 | 10 |

Recent Files

11 |
12 |
13 | 14 |
15 | 22 |
23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /apps/web/components/skeletons/file-skeleton.tsx: -------------------------------------------------------------------------------- 1 | export default function FileSkeleton() { 2 | return ( 3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /apps/web/hook/useOnMountUnsafe.ts: -------------------------------------------------------------------------------- 1 | import type { EffectCallback } from 'react'; 2 | import { useEffect, useRef } from 'react'; 3 | 4 | export function useOnMountUnsafe(effect: EffectCallback) { 5 | const initialized = useRef(false); 6 | 7 | useEffect(() => { 8 | if (!initialized.current) { 9 | initialized.current = true; 10 | effect(); 11 | } 12 | }, []); 13 | } 14 | -------------------------------------------------------------------------------- /apps/web/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 | -------------------------------------------------------------------------------- /apps/web/next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | reactStrictMode: true, 3 | transpilePackages: ['ui'], 4 | }; 5 | -------------------------------------------------------------------------------- /apps/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "version": "1.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "dev:win": "set HOST=localhost && set PORT=1234 && npx y-websocket --kill-others", 11 | "gql-compile": "graphql-codegen", 12 | "gql-watch": "graphql-codegen -w" 13 | }, 14 | "dependencies": { 15 | "@apollo/client": "^3.8.7", 16 | "@graphql-codegen/cli": "^5.0.0", 17 | "@graphql-codegen/client-preset": "^4.1.0", 18 | "@graphql-typed-document-node/core": "^3.2.0", 19 | "@hookform/resolvers": "^3.3.2", 20 | "@parcel/watcher": "^2.3.0", 21 | "@reduxjs/toolkit": "^1.9.7", 22 | "@tldraw/tldraw": "2.0.0-alpha.17", 23 | "cookies-next": "^4.1.0", 24 | "date-fns": "^2.30.0", 25 | "graphql": "^16.8.1", 26 | "lucide-react": "^0.244.0", 27 | "next": "^13.4.19", 28 | "next13-progressbar": "^1.1.1", 29 | "nprogress": "^0.2.0", 30 | "openai": "^4.23.0", 31 | "react": "^18.2.0", 32 | "react-contenteditable": "^3.3.7", 33 | "react-dom": "^18.2.0", 34 | "react-hook-form": "^7.48.2", 35 | "react-redux": "^8.1.3", 36 | "sass": "^1.69.5", 37 | "tailwind": "^4.0.0", 38 | "ui": "workspace:*", 39 | "underscore": "^1.13.6", 40 | "y-utility": "^0.1.3", 41 | "y-webrtc": "^10.2.5", 42 | "y-websocket": "^1.5.0", 43 | "yjs": "^13.6.8", 44 | "zod": "^3.22.4", 45 | "zustand": "^4.4.6" 46 | }, 47 | "devDependencies": { 48 | "@next/eslint-plugin-next": "^13.4.19", 49 | "@types/node": "^17.0.12", 50 | "@types/react": "^18.0.22", 51 | "@types/react-dom": "^18.0.7", 52 | "eslint-config-custom": "workspace:*", 53 | "eslint-plugin-sort-keys-fix": "^1.1.2", 54 | "prettier-plugin-tailwindcss": "^0.5.7", 55 | "tsconfig": "workspace:*", 56 | "typescript": "^5.2.2" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /apps/web/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require("ui/postcss.config"); 2 | -------------------------------------------------------------------------------- /apps/web/prettier.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('prettier').Config & import('prettier-plugin-tailwindcss').options} */ 2 | const config = { 3 | plugins: ['prettier-plugin-tailwindcss'], 4 | tabWidth: 2, 5 | semi: true, 6 | singleQuote: true, 7 | }; 8 | 9 | export default config; 10 | -------------------------------------------------------------------------------- /apps/web/providers/apollo.provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { ApolloProvider } from '@apollo/client'; 4 | import client from '@/utils/apollo-client.utils'; 5 | 6 | export default function ApolloClientProvider({ 7 | children, 8 | }: { 9 | children: React.ReactNode; 10 | }) { 11 | return {children}; 12 | } 13 | -------------------------------------------------------------------------------- /apps/web/providers/index.provider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import ApolloClientProvider from '@/providers/apollo.provider'; 4 | import { Next13ProgressBar } from 'next13-progressbar'; 5 | 6 | import { store } from '@/store/index.store'; 7 | import { Provider as ReduxProvider } from 'react-redux'; 8 | 9 | import { Toaster } from '@ui/components/ui/toaster'; 10 | 11 | export default function MainProvider({ 12 | children, 13 | }: { 14 | children: React.ReactNode; 15 | }) { 16 | return ( 17 | <> 18 | 24 | 25 | 26 | {children} 27 | 28 | 29 | 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /apps/web/public/circles.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /apps/web/public/icon/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jungrama/syncboard/f47c0790d56ca787314371dd4370c0d5e1589680/apps/web/public/icon/favicon.ico -------------------------------------------------------------------------------- /apps/web/public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/web/public/turborepo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | -------------------------------------------------------------------------------- /apps/web/public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /apps/web/public/video-landing.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jungrama/syncboard/f47c0790d56ca787314371dd4370c0d5e1589680/apps/web/public/video-landing.mp4 -------------------------------------------------------------------------------- /apps/web/public/white-gradient.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jungrama/syncboard/f47c0790d56ca787314371dd4370c0d5e1589680/apps/web/public/white-gradient.webp -------------------------------------------------------------------------------- /apps/web/query/auth.gql.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@/codegen/gql'; 2 | 3 | export const SIGN_IN_MUTATION = gql(/* GraphQL */ ` 4 | mutation Login($input: LoginInput!) { 5 | loginUser(input: $input) { 6 | access_token 7 | refresh_token 8 | } 9 | } 10 | `); 11 | 12 | export const SIGN_UP_MUTATION = gql(/* GraphQL */ ` 13 | mutation SignUp($input: SignUpInput!) { 14 | signupUser(input: $input) 15 | } 16 | `); 17 | 18 | export const OAUTH_MUTATION = gql(/* GraphQL */ ` 19 | mutation OAuth($input: OAuthInput!) { 20 | oAuth(input: $input) { 21 | access_token 22 | refresh_token 23 | } 24 | } 25 | `); 26 | 27 | export const VERIFY_ACCOUNT_MUTATION = gql(/* GraphQL */ ` 28 | mutation VerifyAccount($input: verifyAccountInput!) { 29 | verifyAccount(input: $input) { 30 | access_token 31 | refresh_token 32 | } 33 | } 34 | `); 35 | 36 | export const GET_ME_QUERY = gql(/* GraphQL */ ` 37 | query GetMe { 38 | getMe { 39 | user { 40 | createdAt 41 | email 42 | photo 43 | id 44 | name 45 | updatedAt 46 | } 47 | } 48 | } 49 | `); 50 | 51 | export const REFRESH_TOKEN_QUERY = gql(/* GraphQL */ ` 52 | query RefreshAccessToken($refreshAccessToken: String!) { 53 | refreshAccessToken(refresh_token: $refreshAccessToken) { 54 | access_token 55 | refresh_token 56 | } 57 | } 58 | `); 59 | 60 | export const LOGOUT_QUERY = gql(/* GraphQL */ ` 61 | query Logout { 62 | logoutUser 63 | } 64 | `); 65 | -------------------------------------------------------------------------------- /apps/web/query/file.gql.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@/codegen/gql'; 2 | 3 | export const GET_FILES_QUERY = gql(/* GraphQL */ ` 4 | query GetFiles($search: String) { 5 | getFiles(search: $search) { 6 | id 7 | name 8 | thumbnail 9 | updatedAt 10 | isPublic 11 | userAccess { 12 | userId { 13 | _id 14 | name 15 | photo 16 | email 17 | } 18 | role 19 | } 20 | } 21 | } 22 | `); 23 | 24 | export const GET_FAVORITES_QUERY = gql(/* GraphQL */ ` 25 | query GetFavorites { 26 | getFavorites { 27 | name 28 | id 29 | } 30 | } 31 | `); 32 | 33 | export const GET_FILE_BY_ID_QUERY = gql(/* GraphQL */ ` 34 | query GetFileById($id: String!, $isPublic: Boolean!) { 35 | getFileById(id: $id, isPublic: $isPublic) { 36 | id 37 | name 38 | thumbnail 39 | updatedAt 40 | whiteboard 41 | isPublic 42 | userAccess { 43 | userId { 44 | _id 45 | name 46 | photo 47 | email 48 | } 49 | role 50 | } 51 | } 52 | } 53 | `); 54 | 55 | export const CREATE_FILE_MUTATION = gql(/* GraphQL */ ` 56 | mutation CreateFile { 57 | createFile { 58 | id 59 | } 60 | } 61 | `); 62 | 63 | export const UPDATE_FILE_MUTATION = gql(/* GraphQL */ ` 64 | mutation UpdateFile($input: UpdateFileInput!) { 65 | updateFile(input: $input) { 66 | id 67 | } 68 | } 69 | `); 70 | 71 | export const ADD_NEW_USER_ACCESS_MUTATION = gql(/* GraphQL */ ` 72 | mutation AddNewUserAccess($input: NewUserAccessInput!) { 73 | addNewUserAccess(input: $input) { 74 | userId { 75 | _id 76 | name 77 | email 78 | photo 79 | } 80 | role 81 | } 82 | } 83 | `); 84 | 85 | export const CHANGE_USER_ACCESS_MUTATION = gql(/* GraphQL */ ` 86 | mutation ChangeUserAccess($input: ChangeUserAccessInput!) { 87 | changeUserAccess(input: $input) { 88 | userId { 89 | _id 90 | name 91 | email 92 | photo 93 | } 94 | role 95 | } 96 | } 97 | `); 98 | 99 | export const TOOGLE_IS_PUBLIC_MUTATION = gql(/* GraphQL */ ` 100 | mutation ToogleIsPublic($input: ToogleIsPublicInput!) { 101 | toogleIsPublic(input: $input) 102 | } 103 | `); 104 | 105 | export const TOOGLE_FAVORITE_MUTATION = gql(/* GraphQL */ ` 106 | mutation ToogleFavorite($input: ToogleFavoriteInput!) { 107 | toogleFavorite(input: $input) { 108 | id 109 | } 110 | } 111 | `); 112 | -------------------------------------------------------------------------------- /apps/web/services/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | GET_ME_QUERY, 3 | LOGOUT_QUERY, 4 | OAUTH_MUTATION, 5 | SIGN_IN_MUTATION, 6 | SIGN_UP_MUTATION, 7 | VERIFY_ACCOUNT_MUTATION, 8 | } from '@/query/auth.gql'; 9 | import { useLazyQuery, useMutation } from '@apollo/client'; 10 | 11 | const useAuthService = () => { 12 | const [mutateLogin, { loading: loadingLogin, error: errorLogin }] = 13 | useMutation(SIGN_IN_MUTATION); 14 | 15 | const [mutateOAuth, { loading: loadingOAuth, error: errorOAuth }] = 16 | useMutation(OAUTH_MUTATION); 17 | 18 | const [ 19 | mutateVerifyAccount, 20 | { loading: loadingVerifyAccount, error: errorVerifyAccount }, 21 | ] = useMutation(VERIFY_ACCOUNT_MUTATION); 22 | 23 | const [mutateRegister, { loading: loadingRegister, error: errorRegister }] = 24 | useMutation(SIGN_UP_MUTATION); 25 | 26 | const [getMe, { loading: loadingGetMe, error: errorGetMe }] = 27 | useLazyQuery(GET_ME_QUERY); 28 | 29 | const [logout, { loading: loadingLogout, error: errorLogout }] = 30 | useLazyQuery(LOGOUT_QUERY); 31 | 32 | return { 33 | mutateLogin, 34 | loadingLogin, 35 | errorLogin, 36 | 37 | mutateOAuth, 38 | loadingOAuth, 39 | errorOAuth, 40 | 41 | mutateVerifyAccount, 42 | loadingVerifyAccount, 43 | errorVerifyAccount, 44 | 45 | mutateRegister, 46 | loadingRegister, 47 | errorRegister, 48 | 49 | getMe, 50 | loadingGetMe, 51 | errorGetMe, 52 | 53 | logout, 54 | loadingLogout, 55 | errorLogout, 56 | }; 57 | }; 58 | 59 | export default useAuthService; 60 | -------------------------------------------------------------------------------- /apps/web/services/file.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ADD_NEW_USER_ACCESS_MUTATION, 3 | CHANGE_USER_ACCESS_MUTATION, 4 | CREATE_FILE_MUTATION, 5 | GET_FAVORITES_QUERY, 6 | GET_FILES_QUERY, 7 | GET_FILE_BY_ID_QUERY, 8 | TOOGLE_FAVORITE_MUTATION, 9 | TOOGLE_IS_PUBLIC_MUTATION, 10 | UPDATE_FILE_MUTATION, 11 | } from '@/query/file.gql'; 12 | import { useMutation, useQuery } from '@apollo/client'; 13 | 14 | export const getFiles = (search: string | null) => { 15 | return useQuery(GET_FILES_QUERY, { 16 | variables: { 17 | search: search ?? undefined, 18 | }, 19 | fetchPolicy: 'network-only', 20 | nextFetchPolicy: 'cache-first', 21 | }); 22 | }; 23 | 24 | export const getFavorites = () => { 25 | return useQuery(GET_FAVORITES_QUERY, { 26 | fetchPolicy: 'network-only', 27 | nextFetchPolicy: 'cache-first', 28 | }); 29 | }; 30 | 31 | export const getFileById = (id: string, isPublic: boolean) => { 32 | return useQuery(GET_FILE_BY_ID_QUERY, { 33 | variables: { 34 | id, 35 | isPublic, 36 | }, 37 | }); 38 | }; 39 | 40 | export const mutateCreateFile = () => { 41 | return useMutation(CREATE_FILE_MUTATION); 42 | }; 43 | 44 | export const mutateUpdateFile = () => { 45 | return useMutation(UPDATE_FILE_MUTATION); 46 | }; 47 | 48 | export const mutateAddNewUserAccess = () => { 49 | return useMutation(ADD_NEW_USER_ACCESS_MUTATION); 50 | }; 51 | 52 | export const mutateChangeUserAccess = () => { 53 | return useMutation(CHANGE_USER_ACCESS_MUTATION); 54 | }; 55 | 56 | export const mutateToogleIsPublic = () => { 57 | return useMutation(TOOGLE_IS_PUBLIC_MUTATION); 58 | }; 59 | 60 | export const mutateToogleFavorite = () => { 61 | return useMutation(TOOGLE_FAVORITE_MUTATION); 62 | }; 63 | 64 | // export const useFileService = () => { 65 | // const [ 66 | // getFiles, 67 | // { loading: loadingGetFiles, error: errorGetFiles, data: dataFiles }, 68 | // ] = useLazyQuery(GET_FILES_QUERY); 69 | 70 | // const [ 71 | // mutateCreateFile, 72 | // { loading: loadingCreateFile, error: errorCreateFile }, 73 | // ] = useMutation(CREATE_FILE_MUTATION); 74 | 75 | // const [ 76 | // mutateUpdateFile, 77 | // { loading: loadingUpdateFile, error: errorUpdateFile }, 78 | // ] = useMutation(UPDATE_FILE_MUTATION); 79 | 80 | // return { 81 | // getFiles, 82 | // loadingGetFiles, 83 | // errorGetFiles, 84 | // dataFiles, 85 | 86 | // getFileById, 87 | 88 | // mutateCreateFile, 89 | // loadingCreateFile, 90 | // errorCreateFile, 91 | 92 | // mutateUpdateFile, 93 | // loadingUpdateFile, 94 | // errorUpdateFile, 95 | // }; 96 | // }; 97 | 98 | // export default useFileService; 99 | -------------------------------------------------------------------------------- /apps/web/store/file.store.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit'; 2 | import { Editor } from '@tldraw/tldraw'; 3 | 4 | export const fileStore = createSlice({ 5 | name: 'file', 6 | initialState: { 7 | editor: null, 8 | fileItemView: 'GRID', 9 | favorites: [], 10 | } as { 11 | editor: Editor | null; 12 | fileItemView: 'GRID' | 'LIST'; 13 | favorites: any[]; 14 | }, 15 | reducers: { 16 | setEditor: (state, action) => { 17 | state.editor = action.payload ?? null; 18 | }, 19 | setFileItemView: (state, action) => { 20 | state.fileItemView = action.payload ?? 'GRID'; 21 | }, 22 | setFavorites: (state, action) => { 23 | state.favorites = action.payload ?? null; 24 | }, 25 | }, 26 | }); 27 | 28 | export const { setEditor, setFileItemView, setFavorites } = fileStore.actions; 29 | 30 | export default fileStore.reducer; 31 | -------------------------------------------------------------------------------- /apps/web/store/index.store.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from '@reduxjs/toolkit'; 2 | import userReducer from './user.store'; 3 | import fileReducer from './file.store'; 4 | import navigationReducer from './navigation.store'; 5 | 6 | export const store = configureStore({ 7 | reducer: { 8 | user: userReducer, 9 | file: fileReducer, 10 | navigation: navigationReducer, 11 | }, 12 | }); 13 | 14 | export type RootState = ReturnType; 15 | export type AppDispatch = typeof store.dispatch; 16 | -------------------------------------------------------------------------------- /apps/web/store/navigation.store.ts: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit'; 2 | 3 | export const navigationStore = createSlice({ 4 | name: 'navigation', 5 | initialState: { 6 | showSidebar: false, 7 | } as { 8 | showSidebar: boolean; 9 | }, 10 | reducers: { 11 | toogleSidebar: (state) => { 12 | state.showSidebar = !state.showSidebar; 13 | }, 14 | }, 15 | }); 16 | 17 | export const { toogleSidebar } = navigationStore.actions; 18 | 19 | export default navigationStore.reducer; 20 | -------------------------------------------------------------------------------- /apps/web/store/user.store.ts: -------------------------------------------------------------------------------- 1 | import { GetMeQuery } from '@/codegen/graphql'; 2 | import { createSlice } from '@reduxjs/toolkit'; 3 | 4 | export const userStore = createSlice({ 5 | name: 'user', 6 | initialState: { 7 | user: null, 8 | } as { user: GetMeQuery['getMe']['user'] | null }, 9 | reducers: { 10 | setUser: (state, action) => { 11 | state.user = action.payload ?? null; 12 | }, 13 | }, 14 | }); 15 | 16 | export const { setUser } = userStore.actions; 17 | 18 | export default userStore.reducer; 19 | -------------------------------------------------------------------------------- /apps/web/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = require("ui/tailwind.config"); 2 | -------------------------------------------------------------------------------- /apps/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/nextjs.json", 3 | "compilerOptions": { 4 | "plugins": [{ "name": "next" }], 5 | "baseUrl": ".", 6 | "strictNullChecks": true 7 | }, 8 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 9 | "exclude": ["node_modules"] 10 | } 11 | -------------------------------------------------------------------------------- /apps/web/utils/apollo-client.utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApolloClient, 3 | InMemoryCache, 4 | HttpLink, 5 | ApolloLink, 6 | Observable, 7 | } from '@apollo/client'; 8 | import { setContext } from '@apollo/client/link/context'; 9 | import { onError } from '@apollo/client/link/error'; 10 | import { 11 | getAccessToken, 12 | getRefreshToken, 13 | setAccessToken, 14 | setRefreshToken, 15 | } from './cookie-service.utils'; 16 | import { clearAllCredentials } from './user-credentials.utils'; 17 | import { REFRESH_TOKEN_QUERY } from '@/query/auth.gql'; 18 | 19 | const httpLink = new HttpLink({ 20 | uri: process.env.NEXT_PUBLIC_GRAPH_URL, 21 | }); 22 | 23 | const errorLink = onError( 24 | ({ graphQLErrors, networkError, operation, forward }) => { 25 | if (operation.operationName === 'RefreshAccessToken') { 26 | return clearAllCredentials('/auth'); 27 | } else if (graphQLErrors) { 28 | graphQLErrors.forEach((error) => { 29 | if (error.extensions.code === 'UNAUTHENTICATED') { 30 | const observable = new Observable((observer) => { 31 | (async () => { 32 | const accessToken = await refreshToken(); 33 | 34 | if (!accessToken) { 35 | return clearAllCredentials('/auth'); 36 | } 37 | 38 | // Retry the failed request 39 | const subscriber = { 40 | next: observer.next.bind(observer), 41 | error: observer.error.bind(observer), 42 | complete: observer.complete.bind(observer), 43 | }; 44 | 45 | forward(operation).subscribe(subscriber); 46 | })(); 47 | }).subscribe((val) => { 48 | return val; 49 | }); 50 | 51 | return observable; 52 | } else { 53 | return error; 54 | } 55 | }); 56 | } 57 | 58 | if (networkError) console.log(`[Network error]: ${networkError}`); 59 | }, 60 | ); 61 | 62 | const authLink = setContext((_, { headers }) => { 63 | const token = getAccessToken(); 64 | return { 65 | headers: { 66 | ...headers, 67 | authorization: token ? `Bearer ${token}` : '', 68 | }, 69 | }; 70 | }); 71 | 72 | const refreshToken = async () => { 73 | const response = await client.query({ 74 | query: REFRESH_TOKEN_QUERY, 75 | variables: { 76 | refreshAccessToken: getRefreshToken(), 77 | }, 78 | }); 79 | 80 | const token = response.data.refreshAccessToken; 81 | 82 | if (token) { 83 | setAccessToken(token.access_token); 84 | setRefreshToken(token.refresh_token); 85 | } 86 | 87 | return token; 88 | }; 89 | 90 | const client = new ApolloClient({ 91 | link: ApolloLink.from([errorLink, authLink, httpLink]), 92 | cache: new InMemoryCache(), 93 | connectToDevTools: true, 94 | }); 95 | 96 | export default client; 97 | -------------------------------------------------------------------------------- /apps/web/utils/cookie-service.utils.ts: -------------------------------------------------------------------------------- 1 | import { setCookie, getCookie, deleteCookie } from 'cookies-next'; 2 | import { OptionsType } from 'cookies-next/lib/types'; 3 | 4 | const access_token_key = 'access_token'; 5 | const refresh_token_key = 'refresh_token'; 6 | const open_ai_api_key = 'open_ai_api_key'; 7 | 8 | const accessTokenOptions: OptionsType = { 9 | maxAge: 60 * 60 * 24, // 1 days 10 | sameSite: 'lax', 11 | secure: true, 12 | path: '/', 13 | }; 14 | 15 | const refreshTokenOptions: OptionsType = { 16 | maxAge: 60 * 60 * 24 * 30, // 30 days 17 | sameSite: 'lax', 18 | secure: true, 19 | path: '/', 20 | }; 21 | 22 | const openAiTokenOptions: OptionsType = { 23 | maxAge: 60 * 60 * 24 * 30, // 30 days 24 | sameSite: 'lax', 25 | secure: true, 26 | path: '/', 27 | }; 28 | 29 | export const getAccessToken = () => { 30 | return getCookie(access_token_key); 31 | }; 32 | 33 | export const setAccessToken = (token: string | null) => { 34 | return setCookie(access_token_key, token || '', accessTokenOptions); 35 | }; 36 | 37 | export const removeAccessToken = () => { 38 | return deleteCookie(access_token_key); 39 | }; 40 | 41 | export const getRefreshToken = () => { 42 | return getCookie(refresh_token_key); 43 | }; 44 | 45 | export const setRefreshToken = (token: string | null) => { 46 | return setCookie(refresh_token_key, token || '', refreshTokenOptions); 47 | }; 48 | 49 | export const removeRefreshToken = () => { 50 | return deleteCookie(refresh_token_key); 51 | }; 52 | 53 | export const getOpenAIKey = () => { 54 | return getCookie(open_ai_api_key); 55 | }; 56 | 57 | export const setOpenAIKey = (key: string | null) => { 58 | return setCookie(open_ai_api_key, key || '', openAiTokenOptions); 59 | }; 60 | 61 | export const removeOpenAIKey = () => { 62 | return deleteCookie(open_ai_api_key); 63 | }; 64 | -------------------------------------------------------------------------------- /apps/web/utils/user-credentials.utils.ts: -------------------------------------------------------------------------------- 1 | import { removeAccessToken, removeRefreshToken } from './cookie-service.utils'; 2 | 3 | export const clearAllCredentials = (redirect?: string) => { 4 | removeAccessToken(); 5 | removeRefreshToken(); 6 | if (redirect) { 7 | window.location.href = redirect; 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /apps/web/utils/user-profile.utils.ts: -------------------------------------------------------------------------------- 1 | export const initialName = (name: string) => { 2 | let words = name.split(' '); 3 | if (words.length > 1) { 4 | const initial = words[0].charAt(0) + words[1].charAt(0); 5 | return initial.toUpperCase(); 6 | } else { 7 | const initial = 8 | words[0].charAt(0) + words[0].charAt(1) ?? words[0].charAt(0); 9 | return initial.toUpperCase(); 10 | } 11 | }; 12 | 13 | export const userProfile = (photo: string | null | undefined, text: string) => { 14 | const avatar = photo ?? `https://avatar.vercel.sh/vercel.svg?text=${text}`; 15 | return avatar; 16 | }; 17 | -------------------------------------------------------------------------------- /apps/web/validations/auth.validation.ts: -------------------------------------------------------------------------------- 1 | import z from 'zod'; 2 | 3 | export const signInValidationSchema = z.object({ 4 | email: z.string().email(), 5 | password: z.string().min(6), 6 | }); 7 | 8 | export const signUpValidationSchema = z.object({ 9 | name: z.string().min(3), 10 | email: z.string().email(), 11 | password: z.string().min(6), 12 | passwordConfirm: z.string().min(6), 13 | }); 14 | -------------------------------------------------------------------------------- /apps/web/validations/file.validation.ts: -------------------------------------------------------------------------------- 1 | import z from 'zod'; 2 | 3 | export const addNewUserValidationSchema = z.object({ 4 | email: z.string().email(), 5 | role: z.string(), 6 | }); 7 | -------------------------------------------------------------------------------- /ecosystem.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps: [ 3 | { 4 | name: 'services-syncboard', 5 | script: 'npm', 6 | args: 'start', 7 | cwd: './apps/api', 8 | watch: true, 9 | env_production: { 10 | NODE_ENV: 'production', 11 | PORT: '4001', 12 | }, 13 | env_development: { 14 | NODE_ENV: 'production', 15 | PORT: '4001', 16 | }, 17 | }, 18 | { 19 | name: 'y-websocket-server', 20 | script: 'npx', 21 | args: 'y-websocket', 22 | cwd: './apps/api', // Your script's directory 23 | watch: true, // Watch for file changes and restart 24 | env: { 25 | HOST: 'localhost', 26 | PORT: 4002, 27 | }, 28 | }, 29 | ], 30 | } 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "build": "turbo run build", 5 | "dev": "turbo run dev", 6 | "lint": "turbo run lint", 7 | "format": "prettier --write \"**/*.{ts,tsx,md}\"", 8 | "commit": "cz" 9 | }, 10 | "devDependencies": { 11 | "eslint": "^8.48.0", 12 | "prettier": "^3.0.3", 13 | "tsconfig": "workspace:*", 14 | "turbo": "latest" 15 | }, 16 | "packageManager": "pnpm@8.6.10", 17 | "name": "collaborative-whiteboard", 18 | "dependencies": { 19 | "cz-conventional-changelog": "^3.3.0" 20 | }, 21 | "config": { 22 | "commitizen": { 23 | "path": "./node_modules/cz-conventional-changelog" 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /packages/eslint-config-custom/README.md: -------------------------------------------------------------------------------- 1 | # `@turbo/eslint-config` 2 | 3 | Collection of internal eslint configurations. 4 | -------------------------------------------------------------------------------- /packages/eslint-config-custom/library.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require("node:path"); 2 | 3 | const project = resolve(process.cwd(), "tsconfig.json"); 4 | 5 | /* 6 | * This is a custom ESLint configuration for use with 7 | * typescript packages. 8 | * 9 | * This config extends the Vercel Engineering Style Guide. 10 | * For more information, see https://github.com/vercel/style-guide 11 | * 12 | */ 13 | 14 | module.exports = { 15 | extends: [ 16 | "@vercel/style-guide/eslint/node", 17 | "@vercel/style-guide/eslint/typescript", 18 | ].map(require.resolve), 19 | parserOptions: { 20 | project, 21 | }, 22 | globals: { 23 | React: true, 24 | JSX: true, 25 | }, 26 | settings: { 27 | "import/resolver": { 28 | typescript: { 29 | project, 30 | }, 31 | }, 32 | }, 33 | ignorePatterns: ["node_modules/", "dist/"], 34 | }; 35 | -------------------------------------------------------------------------------- /packages/eslint-config-custom/next.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require("node:path"); 2 | 3 | const project = resolve(process.cwd(), "tsconfig.json"); 4 | 5 | /* 6 | * This is a custom ESLint configuration for use with 7 | * Next.js apps. 8 | * 9 | * This config extends the Vercel Engineering Style Guide. 10 | * For more information, see https://github.com/vercel/style-guide 11 | * 12 | */ 13 | 14 | module.exports = { 15 | extends: [ 16 | "@vercel/style-guide/eslint/node", 17 | "@vercel/style-guide/eslint/browser", 18 | "@vercel/style-guide/eslint/typescript", 19 | "@vercel/style-guide/eslint/react", 20 | "@vercel/style-guide/eslint/next", 21 | "eslint-config-turbo", 22 | ].map(require.resolve), 23 | parserOptions: { 24 | project, 25 | }, 26 | globals: { 27 | React: true, 28 | JSX: true, 29 | }, 30 | settings: { 31 | "import/resolver": { 32 | typescript: { 33 | project, 34 | }, 35 | }, 36 | }, 37 | ignorePatterns: ["node_modules/", "dist/"], 38 | // add rules configurations here 39 | rules: { 40 | "import/no-default-export": "off", 41 | }, 42 | }; 43 | -------------------------------------------------------------------------------- /packages/eslint-config-custom/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint-config-custom", 3 | "license": "MIT", 4 | "version": "0.0.0", 5 | "private": true, 6 | "devDependencies": { 7 | "@vercel/style-guide": "^5.0.0", 8 | "eslint-config-turbo": "^1.10.12", 9 | "typescript": "^4.5.3" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/eslint-config-custom/react-internal.js: -------------------------------------------------------------------------------- 1 | const { resolve } = require("node:path"); 2 | 3 | const project = resolve(process.cwd(), "tsconfig.json"); 4 | 5 | /* 6 | * This is a custom ESLint configuration for use with 7 | * internal (bundled by their consumer) libraries 8 | * that utilize React. 9 | * 10 | * This config extends the Vercel Engineering Style Guide. 11 | * For more information, see https://github.com/vercel/style-guide 12 | * 13 | */ 14 | 15 | module.exports = { 16 | extends: [ 17 | "@vercel/style-guide/eslint/browser", 18 | "@vercel/style-guide/eslint/typescript", 19 | "@vercel/style-guide/eslint/react", 20 | ].map(require.resolve), 21 | parserOptions: { 22 | project, 23 | }, 24 | globals: { 25 | JSX: true, 26 | }, 27 | settings: { 28 | "import/resolver": { 29 | typescript: { 30 | project, 31 | }, 32 | }, 33 | }, 34 | ignorePatterns: ["node_modules/", "dist/", ".eslintrc.js"], 35 | 36 | rules: { 37 | // add specific rules configurations here 38 | }, 39 | }; 40 | -------------------------------------------------------------------------------- /packages/tsconfig/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Default", 4 | "compilerOptions": { 5 | "composite": false, 6 | "declaration": true, 7 | "declarationMap": true, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "inlineSources": false, 11 | "isolatedModules": true, 12 | "moduleResolution": "node", 13 | "noUnusedLocals": false, 14 | "noUnusedParameters": false, 15 | "preserveWatchOutput": true, 16 | "skipLibCheck": true, 17 | "strict": true, 18 | "strictNullChecks": true 19 | }, 20 | "exclude": ["node_modules"] 21 | } 22 | -------------------------------------------------------------------------------- /packages/tsconfig/nextjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Next.js", 4 | "extends": "./base.json", 5 | "compilerOptions": { 6 | "plugins": [{ "name": "next" }], 7 | "allowJs": true, 8 | "declaration": false, 9 | "declarationMap": false, 10 | "incremental": true, 11 | "jsx": "preserve", 12 | "lib": ["dom", "dom.iterable", "esnext"], 13 | "module": "esnext", 14 | "noEmit": true, 15 | "resolveJsonModule": true, 16 | "strict": false, 17 | "target": "es5", 18 | "paths": { 19 | "@/*": ["./*"], 20 | "@ui/*": ["../../packages/ui/*"], 21 | "@ui/components/ui/*": ["../../packages/ui/components/ui/*"] 22 | } 23 | }, 24 | "include": ["src", "next-env.d.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /packages/tsconfig/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tsconfig", 3 | "version": "0.0.0", 4 | "private": true, 5 | "license": "MIT", 6 | "publishConfig": { 7 | "access": "public" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/tsconfig/react-library.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "React Library", 4 | "extends": "./base.json", 5 | "compilerOptions": { 6 | "jsx": "react-jsx", 7 | "lib": ["ES2015", "DOM"], 8 | "module": "ESNext", 9 | "target": "es6" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/ui/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["custom/react-internal"], 3 | }; 4 | -------------------------------------------------------------------------------- /packages/ui/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tailwind": { 6 | "config": "tailwind.config.js", 7 | "css": "styles/globals.css", 8 | "baseColor": "slate", 9 | "cssVariables": true 10 | }, 11 | "aliases": { 12 | "components": "@ui/components", 13 | "utils": "@ui/lib/utils" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /packages/ui/components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@ui/lib/utils" 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-background text-foreground", 12 | destructive: 13 | "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | } 20 | ) 21 | 22 | const Alert = React.forwardRef< 23 | HTMLDivElement, 24 | React.HTMLAttributes & VariantProps 25 | >(({ className, variant, ...props }, ref) => ( 26 |
32 | )) 33 | Alert.displayName = "Alert" 34 | 35 | const AlertTitle = React.forwardRef< 36 | HTMLParagraphElement, 37 | React.HTMLAttributes 38 | >(({ className, ...props }, ref) => ( 39 |
44 | )) 45 | AlertTitle.displayName = "AlertTitle" 46 | 47 | const AlertDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |
56 | )) 57 | AlertDescription.displayName = "AlertDescription" 58 | 59 | export { Alert, AlertTitle, AlertDescription } 60 | -------------------------------------------------------------------------------- /packages/ui/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@ui/lib/utils" 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | Avatar.displayName = AvatarPrimitive.Root.displayName 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )) 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )) 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 49 | 50 | export { Avatar, AvatarImage, AvatarFallback } 51 | -------------------------------------------------------------------------------- /packages/ui/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@ui/lib/utils"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ); 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean; 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button"; 46 | return ( 47 | 52 | ); 53 | } 54 | ); 55 | Button.displayName = "Button"; 56 | 57 | export { Button, buttonVariants }; 58 | -------------------------------------------------------------------------------- /packages/ui/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@ui/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

41 | )) 42 | CardTitle.displayName = "CardTitle" 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLParagraphElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |

53 | )) 54 | CardDescription.displayName = "CardDescription" 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |

61 | )) 62 | CardContent.displayName = "CardContent" 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )) 74 | CardFooter.displayName = "CardFooter" 75 | 76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 77 | -------------------------------------------------------------------------------- /packages/ui/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { Cross2Icon } from "@radix-ui/react-icons" 6 | 7 | import { cn } from "@ui/lib/utils" 8 | 9 | const Dialog = DialogPrimitive.Root 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger 12 | 13 | const DialogPortal = DialogPrimitive.Portal 14 | 15 | const DialogClose = DialogPrimitive.Close 16 | 17 | const DialogOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )) 30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 31 | 32 | const DialogContent = React.forwardRef< 33 | React.ElementRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, children, ...props }, ref) => ( 36 | 37 | 38 | 46 | {children} 47 | 48 | 49 | Close 50 | 51 | 52 | 53 | )) 54 | DialogContent.displayName = DialogPrimitive.Content.displayName 55 | 56 | const DialogHeader = ({ 57 | className, 58 | ...props 59 | }: React.HTMLAttributes) => ( 60 |
67 | ) 68 | DialogHeader.displayName = "DialogHeader" 69 | 70 | const DialogFooter = ({ 71 | className, 72 | ...props 73 | }: React.HTMLAttributes) => ( 74 |
81 | ) 82 | DialogFooter.displayName = "DialogFooter" 83 | 84 | const DialogTitle = React.forwardRef< 85 | React.ElementRef, 86 | React.ComponentPropsWithoutRef 87 | >(({ className, ...props }, ref) => ( 88 | 96 | )) 97 | DialogTitle.displayName = DialogPrimitive.Title.displayName 98 | 99 | const DialogDescription = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | DialogDescription.displayName = DialogPrimitive.Description.displayName 110 | 111 | export { 112 | Dialog, 113 | DialogPortal, 114 | DialogOverlay, 115 | DialogTrigger, 116 | DialogClose, 117 | DialogContent, 118 | DialogHeader, 119 | DialogFooter, 120 | DialogTitle, 121 | DialogDescription, 122 | } 123 | -------------------------------------------------------------------------------- /packages/ui/components/ui/form.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as LabelPrimitive from "@radix-ui/react-label" 3 | import { Slot } from "@radix-ui/react-slot" 4 | import { 5 | Controller, 6 | ControllerProps, 7 | FieldPath, 8 | FieldValues, 9 | FormProvider, 10 | useFormContext, 11 | } from "react-hook-form" 12 | 13 | import { cn } from "@ui/lib/utils" 14 | import { Label } from "@ui/components/ui/label" 15 | 16 | const Form = FormProvider 17 | 18 | type FormFieldContextValue< 19 | TFieldValues extends FieldValues = FieldValues, 20 | TName extends FieldPath = FieldPath 21 | > = { 22 | name: TName 23 | } 24 | 25 | const FormFieldContext = React.createContext( 26 | {} as FormFieldContextValue 27 | ) 28 | 29 | const FormField = < 30 | TFieldValues extends FieldValues = FieldValues, 31 | TName extends FieldPath = FieldPath 32 | >({ 33 | ...props 34 | }: ControllerProps) => { 35 | return ( 36 | 37 | 38 | 39 | ) 40 | } 41 | 42 | const useFormField = () => { 43 | const fieldContext = React.useContext(FormFieldContext) 44 | const itemContext = React.useContext(FormItemContext) 45 | const { getFieldState, formState } = useFormContext() 46 | 47 | const fieldState = getFieldState(fieldContext.name, formState) 48 | 49 | if (!fieldContext) { 50 | throw new Error("useFormField should be used within ") 51 | } 52 | 53 | const { id } = itemContext 54 | 55 | return { 56 | id, 57 | name: fieldContext.name, 58 | formItemId: `${id}-form-item`, 59 | formDescriptionId: `${id}-form-item-description`, 60 | formMessageId: `${id}-form-item-message`, 61 | ...fieldState, 62 | } 63 | } 64 | 65 | type FormItemContextValue = { 66 | id: string 67 | } 68 | 69 | const FormItemContext = React.createContext( 70 | {} as FormItemContextValue 71 | ) 72 | 73 | const FormItem = React.forwardRef< 74 | HTMLDivElement, 75 | React.HTMLAttributes 76 | >(({ className, ...props }, ref) => { 77 | const id = React.useId() 78 | 79 | return ( 80 | 81 |
82 | 83 | ) 84 | }) 85 | FormItem.displayName = "FormItem" 86 | 87 | const FormLabel = React.forwardRef< 88 | React.ElementRef, 89 | React.ComponentPropsWithoutRef 90 | >(({ className, ...props }, ref) => { 91 | const { error, formItemId } = useFormField() 92 | 93 | return ( 94 |