├── Procfile ├── client ├── .eslintrc.json ├── components │ ├── Playground │ │ ├── Playground.module.scss │ │ ├── Header │ │ │ ├── UsersBadges.tsx │ │ │ ├── Tools.tsx │ │ │ ├── Header.tsx │ │ │ └── Header.module.scss │ │ ├── Canvas │ │ │ ├── Canvas.module.scss │ │ │ └── Canvas.tsx │ │ └── Playground.tsx │ ├── JoinForm │ │ ├── JoinForm.tsx │ │ ├── GlobalJoinForm.tsx │ │ ├── CustomJoinForm.tsx │ │ └── JoinForm.module.scss │ ├── Hero │ │ ├── index.tsx │ │ └── Hero.module.scss │ └── SEO.tsx ├── public │ ├── logo.png │ ├── favicon.ico │ ├── logo_square.png │ ├── vercel.svg │ └── logo.svg ├── styles │ ├── _colors.scss │ ├── globals.scss │ └── _mixins.scss ├── utils │ ├── Interface.ts │ ├── Strings.ts │ ├── Analytics.ts │ └── Helper.ts ├── assets │ ├── fonts │ │ └── Poppins.ttf │ ├── cursor.svg │ ├── copy.svg │ └── github-logo.svg ├── next-env.d.ts ├── next.config.js ├── pages │ ├── api │ │ └── hello.ts │ ├── index.tsx │ ├── _app.tsx │ ├── playground │ │ └── [roomId] │ │ │ └── [username].tsx │ └── join │ │ └── [option].tsx ├── .gitignore ├── tsconfig.json ├── package.json ├── hooks │ └── context │ │ └── useToolsSettings.tsx ├── README.md └── Types.d.ts ├── .prettierrc ├── server ├── .gitignore ├── package.json ├── utils │ └── users.js └── index.js ├── .gitignore ├── package.json └── Readme.md /Procfile: -------------------------------------------------------------------------------- 1 | web: npm run server -------------------------------------------------------------------------------- /client/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /client/components/Playground/Playground.module.scss: -------------------------------------------------------------------------------- 1 | .playground { 2 | height: 100vh; 3 | } 4 | -------------------------------------------------------------------------------- /client/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agarwalamn/liveboard/HEAD/client/public/logo.png -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agarwalamn/liveboard/HEAD/client/public/favicon.ico -------------------------------------------------------------------------------- /client/styles/_colors.scss: -------------------------------------------------------------------------------- 1 | $primary-background-color: #14213d; 2 | $primary-foreground-color: #fca311; 3 | -------------------------------------------------------------------------------- /client/utils/Interface.ts: -------------------------------------------------------------------------------- 1 | export enum HeroOption { 2 | Custom = 'custom', 3 | Global = 'global', 4 | } 5 | -------------------------------------------------------------------------------- /client/assets/fonts/Poppins.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agarwalamn/liveboard/HEAD/client/assets/fonts/Poppins.ttf -------------------------------------------------------------------------------- /client/public/logo_square.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/agarwalamn/liveboard/HEAD/client/public/logo_square.png -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /client/assets/cursor.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/utils/Strings.ts: -------------------------------------------------------------------------------- 1 | export const CREATE_AND_JOIN_TEXT = 'CREATE CUSTOM ROOM'; 2 | export const GLOBAL_JOIN_TEXT = 'JOIN GLOBAL ROOM'; 3 | export const LIVEBOARD = 'LIVEBOARD'; 4 | export const HINT_MESSAGE = 'Hint: Your can click on either side to continue'; 5 | -------------------------------------------------------------------------------- /client/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | 5 | // NOTE: This file should not be edited 6 | // see https://nextjs.org/docs/basic-features/typescript for more information. 7 | -------------------------------------------------------------------------------- /client/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | module.exports = { 3 | reactStrictMode: true, 4 | webpack(config) { 5 | config.module.rules.push({ 6 | test: /\.svg$/, 7 | use: ['@svgr/webpack'], 8 | }); 9 | 10 | return config; 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /client/styles/globals.scss: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: Poppins; 3 | src: url(../assets/fonts/Poppins.ttf); 4 | } 5 | 6 | html, 7 | body { 8 | padding: 0; 9 | margin: 0; 10 | font-family: Poppins, sans-serif; 11 | height: 100%; 12 | } 13 | 14 | a { 15 | color: inherit; 16 | text-decoration: none; 17 | } 18 | -------------------------------------------------------------------------------- /client/assets/copy.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/pages/api/hello.ts: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | import type { NextApiRequest, NextApiResponse } from 'next' 3 | 4 | type Data = { 5 | name: string 6 | } 7 | 8 | export default function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse 11 | ) { 12 | res.status(200).json({ name: 'John Doe' }) 13 | } 14 | -------------------------------------------------------------------------------- /client/utils/Analytics.ts: -------------------------------------------------------------------------------- 1 | import Analytics from 'analytics'; 2 | import googleAnalytics from '@analytics/google-analytics'; 3 | 4 | const analytics = Analytics({ 5 | app: 'app-name', 6 | plugins: [ 7 | googleAnalytics({ 8 | trackingId: 'G-KTEJZCWD9Y', 9 | }), 10 | ], 11 | }); 12 | 13 | /* export the instance for usage in your app */ 14 | export default analytics; 15 | -------------------------------------------------------------------------------- /client/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from 'next'; 2 | 3 | import { Hero } from 'components/Hero'; 4 | import analytics from 'utils/Analytics'; 5 | 6 | const Home: NextPage = () => { 7 | analytics.page({ 8 | title: 'Liveboard', 9 | href: `https://live-board.vercel.app/`, 10 | path: '/', 11 | }); 12 | 13 | return ; 14 | }; 15 | 16 | export default Home; 17 | -------------------------------------------------------------------------------- /client/utils/Helper.ts: -------------------------------------------------------------------------------- 1 | import { 2 | animals, 3 | colors, 4 | Config, 5 | uniqueNamesGenerator, 6 | } from 'unique-names-generator'; 7 | 8 | const config: Config = { 9 | dictionaries: [colors, animals], 10 | separator: '-', 11 | }; 12 | 13 | export const generateRandomRoomName = () => { 14 | const characterName: string = uniqueNamesGenerator(config); 15 | return characterName; 16 | }; 17 | -------------------------------------------------------------------------------- /client/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '../styles/globals.scss'; 2 | import type { AppProps } from 'next/app'; 3 | 4 | import 'react-toastify/dist/ReactToastify.css'; 5 | import 'bootstrap/dist/css/bootstrap.min.css'; 6 | import { ToastContainer } from 'react-toastify'; 7 | 8 | function MyApp({ Component, pageProps }: AppProps) { 9 | return ( 10 | <> 11 | 12 | 13 | 14 | ); 15 | } 16 | 17 | export default MyApp; 18 | -------------------------------------------------------------------------------- /client/styles/_mixins.scss: -------------------------------------------------------------------------------- 1 | @mixin for-phone-only { 2 | @media (max-width: 599px) { 3 | @content; 4 | } 5 | } 6 | @mixin for-tablet-portrait-up { 7 | @media (min-width: 600px) { 8 | @content; 9 | } 10 | } 11 | @mixin for-tablet-landscape-up { 12 | @media (min-width: 900px) { 13 | @content; 14 | } 15 | } 16 | @mixin for-desktop-up { 17 | @media (min-width: 1200px) { 18 | @content; 19 | } 20 | } 21 | @mixin for-big-desktop-up { 22 | @media (min-width: 1800px) { 23 | @content; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /client/.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 | 36 | # typescript 37 | *.tsbuildinfo 38 | -------------------------------------------------------------------------------- /server/.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 | 36 | # typescript 37 | *.tsbuildinfo 38 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env 29 | .env.local 30 | .env.development.local 31 | .env.test.local 32 | .env.production.local 33 | 34 | # vercel 35 | .vercel 36 | 37 | # typescript 38 | *.tsbuildinfo 39 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "baseUrl": "." 18 | }, 19 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 20 | "exclude": ["node_modules"] 21 | } 22 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "node index.js", 9 | "dev": "nodemon index.js" 10 | }, 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "cors": "^2.8.5", 15 | "dotenv": "^14.2.0", 16 | "express": "^4.17.2", 17 | "socket.io": "^4.4.1" 18 | }, 19 | "optionalDependencies": { 20 | "bufferutil": "^4.0.6", 21 | "utf-8-validate": "^5.0.8" 22 | }, 23 | "devDependencies": { 24 | "nodemon": "^2.0.15" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /client/components/JoinForm/JoinForm.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import cn from 'classnames'; 3 | 4 | import styles from './JoinForm.module.scss'; 5 | import { HeroOption } from 'utils/Interface'; 6 | import { LIVEBOARD } from 'utils/Strings'; 7 | import CustomJoinForm from './CustomJoinForm'; 8 | import GlobalJoinForm from './GlobalJoinForm'; 9 | 10 | interface JoinForm { 11 | variant: HeroOption; 12 | } 13 | 14 | export const JoinForm = ({ variant }: JoinForm) => { 15 | return ( 16 |
22 |
{LIVEBOARD}
23 | {variant === HeroOption.Custom ? : } 24 |
25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /client/components/Playground/Header/UsersBadges.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import styles from './Header.module.scss'; 4 | 5 | interface UserBadgeProps { 6 | users: string[]; 7 | } 8 | 9 | interface BadgeProps { 10 | color?: string; 11 | text: string; 12 | } 13 | 14 | const COLORS = ['#fc112d', '#11fc1a', '#00D1FF']; 15 | 16 | const Badge = ({ color, text }: BadgeProps) => ( 17 |
18 | {text} 19 |
20 | ); 21 | 22 | export const UsersBadges = ({ users }: UserBadgeProps) => { 23 | const usersToShow = users.slice(0, 3); 24 | 25 | return ( 26 |
27 | {usersToShow.map((user, index) => ( 28 | 29 | ))} 30 | 31 | {users.length > 3 && } 32 |
33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "private": true, 4 | "scripts": { 5 | "dev": "next dev", 6 | "build": "next build", 7 | "start": "next start", 8 | "lint": "next lint" 9 | }, 10 | "dependencies": { 11 | "@analytics/google-analytics": "^0.5.3", 12 | "@svgr/webpack": "^6.1.2", 13 | "@types/socket.io": "^3.0.2", 14 | "analytics": "^0.8.0", 15 | "bootstrap": "^5.1.3", 16 | "classnames": "^2.3.1", 17 | "framer-motion": "^5.2.1", 18 | "next": "12.0.3", 19 | "react": "17.0.2", 20 | "react-dom": "17.0.2", 21 | "react-toastify": "^8.1.0", 22 | "sass": "^1.43.4", 23 | "socket.io-client": "^4.4.1", 24 | "unique-names-generator": "^4.6.0" 25 | }, 26 | "devDependencies": { 27 | "@types/node": "16.11.6", 28 | "@types/react": "17.0.34", 29 | "eslint": "7.32.0", 30 | "eslint-config-next": "12.0.3", 31 | "typescript": "4.4.4" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /server/utils/users.js: -------------------------------------------------------------------------------- 1 | const users = []; 2 | 3 | const addUser = ({ id, name, room }) => { 4 | if (!name || !room) return { error: 'Username and room are required.' }; 5 | 6 | name = name.trim().toLowerCase(); 7 | room = room.trim().toLowerCase(); 8 | 9 | if (getuserInRoom(name, room)) return { error: 'Username is taken.' }; 10 | 11 | const user = { id, name, room }; 12 | 13 | users.push(user); 14 | 15 | return { user }; 16 | }; 17 | 18 | const removeUser = (id) => { 19 | const index = users.findIndex((user) => user.id === id); 20 | if (index !== -1) { 21 | return users.splice(index, 1)[0]; 22 | } 23 | }; 24 | 25 | const getUser = (id) => users.find((user) => user.id === id); 26 | const getuserInRoom = (name, room) => 27 | users.find((user) => user.name === name && user.room === room); 28 | 29 | const getUsersInRoom = (room) => users.filter((user) => user.room === room); 30 | 31 | module.exports = { addUser, removeUser, getUser, getUsersInRoom }; 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "liveboard", 3 | "version": "1.0.1", 4 | "description": "Liveboard, share live window", 5 | "main": " ", 6 | "scripts": { 7 | "install-client": "npm install --prefix client", 8 | "install-server": "npm install --prefix server", 9 | "server": "npm run start --prefix server", 10 | "client": "npm start --prefix client", 11 | "heroku-prebuild": "cd server && npm install" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/agarwalamn/liveboard.git" 16 | }, 17 | "keywords": [ 18 | "socket.io", 19 | "node", 20 | "express", 21 | "livesharing", 22 | "canvas", 23 | "html", 24 | "reactjs" 25 | ], 26 | "author": "agarwalamn", 27 | "license": "ISC", 28 | "bugs": { 29 | "url": "https://github.com/agarwalamn/liveboard/issues" 30 | }, 31 | "homepage": "https://github.com/agarwalamn/liveboard#readme", 32 | "devDependencies": { 33 | "prettier": "^2.4.1" 34 | }, 35 | "dependencies": { 36 | "socket.io": "^4.4.1" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /client/assets/github-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /client/pages/playground/[roomId]/[username].tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from 'next'; 2 | import React from 'react'; 3 | import { NextRouter, useRouter } from 'next/router'; 4 | 5 | import { Playground } from 'components/Playground/Playground'; 6 | import analytics from 'utils/Analytics'; 7 | 8 | const PlaygroundPage: NextPage = () => { 9 | const router = useRouter(); 10 | const { roomId, username } = router.query; 11 | 12 | analytics.page({ 13 | title: 'Liveboard', 14 | href: `https://live-board.vercel.app/`, 15 | roomId, 16 | username, 17 | }); 18 | 19 | return ; 20 | }; 21 | 22 | // Handles the case when option is anything other than the custom/global and redirects back to home 23 | export async function getServerSideProps({ query }: NextRouter) { 24 | const { roomId, username } = query; 25 | 26 | if (!roomId && !username) { 27 | return { 28 | redirect: { 29 | destination: '/', 30 | permanent: false, 31 | }, 32 | }; 33 | } 34 | 35 | return { 36 | props: {}, 37 | }; 38 | } 39 | 40 | export default PlaygroundPage; 41 | -------------------------------------------------------------------------------- /client/components/Playground/Canvas/Canvas.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../../styles/colors'; 2 | 3 | .canvasContainer { 4 | flex: 1; 5 | height: 100%; 6 | left: 0; 7 | overflow: hidden; 8 | position: absolute; 9 | top: 0; 10 | width: 100%; 11 | z-index: 1; 12 | canvas { 13 | cursor: pointer; 14 | } 15 | } 16 | 17 | .pointerContainer { 18 | height: 100%; 19 | left: 0; 20 | position: absolute; 21 | top: 0; 22 | width: 100%; 23 | z-index: -1; 24 | 25 | svg { 26 | transform: scale(0.8); 27 | path { 28 | fill: $primary-background-color; 29 | } 30 | } 31 | 32 | .pointer { 33 | align-items: center; 34 | display: flex; 35 | flex-direction: column; 36 | font-weight: bold; 37 | justify-content: center; 38 | left: 100px; 39 | position: absolute; 40 | top: 100px; 41 | z-index: 100; 42 | } 43 | } 44 | 45 | .userPointer { 46 | font-size: 12px; 47 | text-transform: uppercase; 48 | background-color: #11fc1a; 49 | padding: 0 8px; 50 | border-radius: 8px; 51 | margin-top: 2px; 52 | color: $primary-background-color; 53 | font-weight: bold; 54 | } 55 | -------------------------------------------------------------------------------- /client/pages/join/[option].tsx: -------------------------------------------------------------------------------- 1 | import type { NextPage } from 'next'; 2 | import React from 'react'; 3 | import { NextRouter, useRouter } from 'next/router'; 4 | 5 | import { JoinForm } from 'components/JoinForm/JoinForm'; 6 | import { HeroOption } from 'utils/Interface'; 7 | import analytics from 'utils/Analytics'; 8 | 9 | const Join: NextPage = () => { 10 | const router = useRouter(); 11 | const { 12 | query: { option = HeroOption.Global }, 13 | } = router; 14 | 15 | if (router.isFallback) { 16 | return
We are loading page for you
; 17 | } 18 | 19 | analytics.page({ 20 | title: 'Liveboard', 21 | href: `https://live-board.vercel.app/`, 22 | path: `/${option}`, 23 | }); 24 | 25 | return ; 26 | }; 27 | 28 | // Handles the case when option is anything other than the custom/global and redirects back to home 29 | export async function getServerSideProps({ query }: NextRouter) { 30 | const { option } = query; 31 | 32 | if (option !== HeroOption.Global && option !== HeroOption.Custom) { 33 | return { 34 | redirect: { 35 | destination: '/', 36 | permanent: false, 37 | }, 38 | }; 39 | } 40 | 41 | return { 42 | props: {}, 43 | }; 44 | } 45 | 46 | export default Join; 47 | -------------------------------------------------------------------------------- /client/hooks/context/useToolsSettings.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | createContext, 3 | PropsWithChildren, 4 | ReactElement, 5 | useContext, 6 | useState, 7 | } from 'react'; 8 | 9 | interface ToolSettingsValueProps { 10 | color: string; 11 | stroke: number; 12 | updateColor: (color: string) => void; 13 | updateStroke: (stroke: number) => void; 14 | } 15 | 16 | const ToolSettingsContext = createContext({ 17 | color: '', 18 | stroke: 1, 19 | updateColor: (_) => {}, 20 | updateStroke: (_) => {}, 21 | }); 22 | 23 | const ToolSettingsProvider = ({ 24 | children, 25 | }: PropsWithChildren<{}>): ReactElement => { 26 | const [color, setColor] = useState('#fca311'); 27 | const [stroke, setStroke] = useState(1); 28 | 29 | const updateColor = (updatedColor: string) => { 30 | setColor(updatedColor); 31 | }; 32 | const updateStroke = (updatedStroke: number) => { 33 | setStroke(updatedStroke); 34 | }; 35 | 36 | return ( 37 | 40 | {children} 41 | 42 | ); 43 | }; 44 | 45 | export const useToolSettings = () => useContext(ToolSettingsContext); 46 | 47 | export default ToolSettingsProvider; 48 | -------------------------------------------------------------------------------- /client/components/Playground/Header/Tools.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import styles from './Header.module.scss'; 4 | import { useToolSettings } from 'hooks/context/useToolsSettings'; 5 | 6 | const availableStrokeOptions = [1, 2, 3, 4, 5, 6]; 7 | 8 | /** 9 | * This component will contain the tools needed for the header option 10 | * @returns react component with tools 11 | */ 12 | export const Tools = () => { 13 | const { color, updateColor, stroke, updateStroke } = useToolSettings(); 14 | 15 | return ( 16 |
17 |
18 | updateColor(e.target.value)} 24 | /> 25 |
{color}
26 |
27 |
28 | 38 |
39 |
40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 |
2 | logo 3 |

Liveboard

4 |
5 | 6 | ![Vercel](https://therealsujitk-vercel-badge.vercel.app/?app=liveboard) ![Version](https://img.shields.io/badge/version-2.0.0-green) ![github](https://img.shields.io/github/followers/agarwalamn?label=agarwalamn&style=social) 7 | 8 |
9 |

10 | Live sharing board powered by socket.io, NextJs and Nodejs👨‍🏫 11 |

12 |
13 | 14 | **Checkout👨‍💻** : https://live-board.vercel.app/ 15 | 16 | ## Installation 17 | 18 | ```bash 19 | #clone repo 20 | git clone https://github.com/agarwalamn/liveboard.git 21 | 22 | # change directory 23 | cd liveboard 24 | 25 | # setup frontend 26 | $ cd client 27 | $ npm install 28 | $ npm run dev 29 | 30 | # setup server 31 | $ cd server 32 | $ npm install 33 | $ npm run dev 34 | 35 | #Boom✨ your project is live 36 | ``` 37 | 38 | ## Techstack: 39 | 40 | - 🚄 NextJs 41 | - 🚒 Express + Socket.io 42 | 43 |
44 | 45 |

46 | Built with ❤️ by 47 | agarwalamn 48 | 49 |

50 |
51 | 52 | --- 53 | -------------------------------------------------------------------------------- /client/components/JoinForm/GlobalJoinForm.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import cn from 'classnames'; 3 | 4 | import styles from './JoinForm.module.scss'; 5 | import { HeroOption } from 'utils/Interface'; 6 | import { useRouter } from 'next/router'; 7 | 8 | import SEO from 'components/SEO'; 9 | 10 | const GlobalJoinForm = () => { 11 | const [name, setName] = useState(''); 12 | const router = useRouter(); 13 | 14 | const handleContinue = () => { 15 | if (name.trim().length <= 0) return; 16 | router.push(`/playground/${HeroOption.Global}/${name}`); 17 | }; 18 | 19 | return ( 20 | 24 |
e.preventDefault()}> 25 |
26 | setName(e.target.value)} 33 | required 34 | /> 35 | 41 |
42 |
43 |
44 | ); 45 | }; 46 | 47 | export default GlobalJoinForm; 48 | -------------------------------------------------------------------------------- /client/components/Playground/Header/Header.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { toast } from 'react-toastify'; 3 | 4 | import styles from './Header.module.scss'; 5 | import { Tools } from './Tools'; 6 | import { UsersBadges } from './UsersBadges'; 7 | import GithubLogo from 'assets/github-logo.svg'; 8 | import CopyIcon from 'assets/copy.svg'; 9 | import { Users } from '../Playground'; 10 | 11 | interface HeaderProps { 12 | roomName: string; 13 | usersInRoom: Users[]; 14 | } 15 | 16 | export const Header = ({ roomName, usersInRoom }: HeaderProps) => { 17 | const copyToClipBoard = () => { 18 | navigator.clipboard.writeText( 19 | `${process.env.NEXT_PUBLIC_LIVEBOARD_FRONTEND_URL}/join/custom?inviteRoomCode=${roomName}`, 20 | ); 21 | toast('🔗 Link copied to your clipboard !'); 22 | }; 23 | return ( 24 |
25 | 26 |
27 | {roomName} 28 | 31 |
32 |
33 | user.name)} /> 34 | 39 | 40 | 41 |
42 |
43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /client/components/Playground/Playground.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState } from 'react'; 2 | import { useRouter } from 'next/router'; 3 | 4 | import ToolSettingsProvider from 'hooks/context/useToolsSettings'; 5 | import Canvas from './Canvas/Canvas'; 6 | import { Header } from './Header/Header'; 7 | import styles from './Playground.module.scss'; 8 | import SEO from 'components/SEO'; 9 | 10 | export interface Users { 11 | id: string; 12 | name: string; 13 | room: string; 14 | } 15 | 16 | export const Playground = ({}) => { 17 | const router = useRouter(); 18 | const containerRef = useRef(null); 19 | const [usersInRoom, setUsersInRoom] = useState([]); 20 | 21 | const { 22 | query: { roomId, username }, 23 | } = router; 24 | 25 | const updateUserInCurrentRoom = (data: Users[]) => { 26 | setUsersInRoom(data); 27 | }; 28 | 29 | if (router.isFallback) { 30 | return
We are loading page for you
; 31 | } 32 | 33 | if (!roomId && !username) { 34 | router.back(); 35 | } 36 | 37 | return ( 38 | 39 | 40 |
41 |
42 | 48 |
49 |
50 |
51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | ``` 12 | 13 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 14 | 15 | You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file. 16 | 17 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. 18 | 19 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 20 | 21 | ## Learn More 22 | 23 | To learn more about Next.js, take a look at the following resources: 24 | 25 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 26 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 27 | 28 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 29 | 30 | ## Deploy on Vercel 31 | 32 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 33 | 34 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 35 | -------------------------------------------------------------------------------- /client/components/Hero/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import cn from 'classnames'; 3 | import { useRouter } from 'next/dist/client/router'; 4 | 5 | import { 6 | CREATE_AND_JOIN_TEXT, 7 | GLOBAL_JOIN_TEXT, 8 | HINT_MESSAGE, 9 | LIVEBOARD, 10 | } from 'utils/Strings'; 11 | import styles from './Hero.module.scss'; 12 | import { HeroOption } from 'utils/Interface'; 13 | import SEO from '../SEO'; 14 | 15 | export const Hero = () => { 16 | const router = useRouter(); 17 | 18 | const handleOptionClick = (option: HeroOption) => { 19 | router.push(`/join/${option}`); 20 | }; 21 | 22 | return ( 23 | 27 |
28 |
29 |
{LIVEBOARD}
30 |
handleOptionClick(HeroOption.Custom)} 33 | tabIndex={0} 34 | role="button" 35 | > 36 |
{CREATE_AND_JOIN_TEXT}
37 |
38 |
handleOptionClick(HeroOption.Global)} 41 | tabIndex={0} 42 | role="button" 43 | > 44 |
{GLOBAL_JOIN_TEXT}
45 |
46 |
{HINT_MESSAGE}
47 |
48 |
49 |
50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /client/components/Hero/Hero.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/colors'; 2 | @import '../../styles/mixins'; 3 | 4 | .heroContainer { 5 | min-height: 100vh; 6 | min-width: 100vw; 7 | } 8 | 9 | .heroLogo { 10 | color: $primary-foreground-color; 11 | font-size: 36px; 12 | font-weight: bold; 13 | mix-blend-mode: difference; 14 | position: absolute; 15 | text-align: center; 16 | width: 100%; 17 | z-index: 1; 18 | } 19 | 20 | .optionContainer { 21 | display: flex; 22 | left: 0; 23 | min-height: 100%; 24 | min-width: 100%; 25 | position: absolute; 26 | top: 0; 27 | 28 | @include for-phone-only { 29 | flex-direction: column; 30 | } 31 | } 32 | 33 | .option { 34 | align-items: center; 35 | cursor: pointer; 36 | display: flex; 37 | flex: 1; 38 | font-size: 46px; 39 | justify-content: center; 40 | text-align: center; 41 | transition: all 1s; 42 | white-space: 'pre-wrap'; 43 | width: 100%; 44 | 45 | &:hover, 46 | &:focus { 47 | flex: 2; 48 | 49 | .label { 50 | transform: scale(1.5); 51 | } 52 | } 53 | 54 | @include for-phone-only { 55 | font-size: 24px; 56 | } 57 | } 58 | 59 | .optionPrimary { 60 | background-color: $primary-background-color; 61 | color: $primary-foreground-color; 62 | } 63 | 64 | .optionSecondary { 65 | background-color: $primary-foreground-color; 66 | color: $primary-background-color; 67 | } 68 | 69 | .label { 70 | font-weight: bold; 71 | transition: 0.2s all; 72 | width: 200px; 73 | } 74 | 75 | .footer { 76 | bottom: 0; 77 | color: $primary-foreground-color; 78 | font-size: 16px; 79 | font-weight: bold; 80 | mix-blend-mode: difference; 81 | padding: 15px 0; 82 | position: absolute; 83 | text-align: center; 84 | width: 100%; 85 | z-index: 1; 86 | } 87 | -------------------------------------------------------------------------------- /client/components/JoinForm/CustomJoinForm.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import cn from 'classnames'; 3 | import { useRouter } from 'next/router'; 4 | 5 | import styles from './JoinForm.module.scss'; 6 | import SEO from 'components/SEO'; 7 | 8 | const CustomJoinForm = () => { 9 | const router = useRouter(); 10 | const { inviteRoomCode } = router.query; 11 | 12 | const [name, setName] = useState(''); 13 | const [roomName, setRoomName] = useState((inviteRoomCode as string) ?? ''); 14 | 15 | const handleContinue = () => { 16 | if (name.trim().length <= 0 || roomName.trim().length <= 0) return; 17 | router.push(`/playground/${roomName}/${name}`); 18 | }; 19 | 20 | return ( 21 | 33 |
e.preventDefault()}> 34 |
35 | setName(e.target.value)} 42 | required 43 | /> 44 | setRoomName(e.target.value)} 51 | required 52 | disabled={!!inviteRoomCode} 53 | /> 54 | 60 |
61 |
62 |
63 | ); 64 | }; 65 | 66 | export default CustomJoinForm; 67 | -------------------------------------------------------------------------------- /client/components/SEO.tsx: -------------------------------------------------------------------------------- 1 | import React, { PropsWithChildren } from 'react'; 2 | import Head from 'next/head'; 3 | 4 | interface SEOProps { 5 | title?: string; 6 | description?: string; 7 | } 8 | 9 | const DEFAULT_SEO_CONFIG = { 10 | title: 'LiveBoard', 11 | description: 'live board sharing', 12 | site: 'https://live-board.vercel.app', 13 | image: 14 | 'https://raw.githubusercontent.com/agarwalamn/liveboard/main/client/public/logo_square.png', 15 | twitter_handle: '@AgarwalAmn', 16 | }; 17 | 18 | export default function SEO({ 19 | title = DEFAULT_SEO_CONFIG.title, 20 | description = DEFAULT_SEO_CONFIG.description, 21 | children, 22 | }: PropsWithChildren) { 23 | return ( 24 | <> 25 | 26 | {title} 27 | 28 | 29 | 30 | {/* Twitter */} 31 | 32 | 33 | 38 | 39 | 44 | 49 | {/* Open Graph */} 50 | 51 | 56 | 61 | 62 | 63 | 64 | {children} 65 | 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /client/Types.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@analytics/google-analytics' { 2 | type GoogleAnalyticsOptions = { 3 | /** Google Analytics site tracking Id */ 4 | trackingId: string; 5 | 6 | /** Enable Google Analytics debug mode */ 7 | debug?: boolean; 8 | 9 | /** Enable Anonymizing IP addresses sent to Google Analytics. See details below */ 10 | anonymizeIp?: boolean; 11 | 12 | /** Map Custom dimensions to send extra information to Google Analytics. See details below */ 13 | customDimensions?: object; 14 | 15 | /** Reset custom dimensions by key on analytics.page() calls. Useful for single page apps. */ 16 | resetCustomDimensionsOnPage?: object; 17 | 18 | /** Mapped dimensions will be set to the page & sent as properties of all subsequent events on that page. If false, analytics will only pass custom dimensions as part of individual events */ 19 | setCustomDimensionsToPage?: boolean; 20 | 21 | /** Custom tracker name for google analytics. Use this if you need multiple googleAnalytics scripts loaded */ 22 | instanceName?: string; 23 | 24 | /** Custom URL for google analytics script, if proxying calls */ 25 | customScriptSrc?: string; 26 | 27 | /** Additional cookie properties for configuring the ga cookie */ 28 | cookieConfig?: object; 29 | 30 | /** Set custom google analytic tasks */ 31 | tasks?: object; 32 | }; 33 | 34 | type AnalyticsPlugin = { 35 | /** Name of plugin */ 36 | name: string; 37 | 38 | /** exposed events of plugin */ 39 | EVENTS?: any; 40 | 41 | /** Configuration of plugin */ 42 | config?: any; 43 | 44 | /** Load analytics scripts method */ 45 | initialize?: (...params: any[]) => any; 46 | 47 | /** Page visit tracking method */ 48 | page?: (...params: any[]) => any; 49 | 50 | /** Custom event tracking method */ 51 | track?: (...params: any[]) => any; 52 | 53 | /** User identify method */ 54 | identify?: (...params: any[]) => any; 55 | 56 | /** Function to determine if analytics script loaded */ 57 | loaded?: (...params: any[]) => any; 58 | 59 | /** Fire function when plugin ready */ 60 | ready?: (...params: any[]) => any; 61 | }; 62 | 63 | function GoogleAnalytics(options: GoogleAnalyticsOptions): AnalyticsPlugin; 64 | export default GoogleAnalytics; 65 | } 66 | -------------------------------------------------------------------------------- /client/components/JoinForm/JoinForm.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../styles/colors'; 2 | @import '../../styles/mixins'; 3 | 4 | @mixin customColorScheme { 5 | background-color: $primary-background-color; 6 | color: $primary-foreground-color; 7 | } 8 | 9 | @mixin globalColorScheme { 10 | background-color: $primary-foreground-color; 11 | color: $primary-background-color; 12 | } 13 | 14 | .formContainer { 15 | display: flex; 16 | flex-direction: column; 17 | font-weight: bold; 18 | height: 100vh; 19 | text-align: center; 20 | width: 100vw; 21 | } 22 | 23 | .heading { 24 | font-size: 36px; 25 | font-weight: 700; 26 | padding: 10px 0; 27 | text-align: center; 28 | } 29 | 30 | .custom { 31 | @include customColorScheme; 32 | } 33 | 34 | .global { 35 | @include globalColorScheme; 36 | } 37 | 38 | .form { 39 | align-items: center; 40 | display: flex; 41 | flex-direction: column; 42 | flex: 2; 43 | justify-content: center; 44 | } 45 | 46 | .inputContainer { 47 | & > label { 48 | display: block; 49 | } 50 | 51 | & > input { 52 | background-color: transparent; 53 | border-left: none; 54 | border-right: none; 55 | border-top: none; 56 | display: block; 57 | font-size: 24px; 58 | font-weight: bold; 59 | margin: 32px 0; 60 | outline: none; 61 | padding: 6px; 62 | transition: all 0.5s; 63 | 64 | &:focus { 65 | border-bottom-width: 5px; 66 | transform: scale(1.5); 67 | } 68 | 69 | &:disabled { 70 | opacity: 0.5; 71 | } 72 | } 73 | } 74 | 75 | .customInput { 76 | @include customColorScheme; 77 | border-bottom: 2px solid $primary-foreground-color; 78 | transition: all 1s; 79 | 80 | &::placeholder { 81 | color: $primary-foreground-color; 82 | font-weight: bold; 83 | } 84 | } 85 | 86 | .globalinput { 87 | @include globalColorScheme; 88 | border-bottom: 2px solid $primary-background-color; 89 | color: $primary-background-color; 90 | 91 | &::placeholder { 92 | color: $primary-background-color; 93 | font-weight: bold; 94 | } 95 | } 96 | 97 | .continueBtn { 98 | border-radius: 5px; 99 | border: none; 100 | display: block; 101 | font-size: 20px; 102 | font-weight: bold; 103 | margin: 50px 0; 104 | padding: 12px; 105 | width: 100%; 106 | } 107 | 108 | .customBtn { 109 | @include globalColorScheme; 110 | } 111 | 112 | .globalBtn { 113 | @include customColorScheme; 114 | } 115 | -------------------------------------------------------------------------------- /server/index.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | 3 | const express = require('express'); 4 | const cors = require('cors'); 5 | const { Server } = require('socket.io'); 6 | const http = require('http'); 7 | const { 8 | addUser, 9 | getUser, 10 | removeUser, 11 | getUsersInRoom, 12 | } = require('./utils/users'); 13 | 14 | const server = () => { 15 | // intialize 16 | const app = express(); 17 | const server = http.createServer(app); 18 | app.use(cors()); 19 | 20 | const io = new Server(server, { 21 | cors: { 22 | origin: '*:*', 23 | methods: ['GET', 'POST'], 24 | }, 25 | }); 26 | 27 | //handlers 28 | const connectionHandler = (socket) => { 29 | socket.on('join', ({ name, room }, callback) => { 30 | const { error, user } = addUser({ id: socket.id, name, room }); 31 | if (error) { 32 | console.log(error); 33 | return callback(error); 34 | } 35 | 36 | socket.join(user.room); 37 | // all users in current room 38 | const users = getUsersInRoom(user.room); 39 | 40 | // show join notification 41 | socket.broadcast.to(user.room).emit('notification', { 42 | message: `👽 ${user.name} hooped in the server`, 43 | }); 44 | 45 | // show users in room 46 | io.to(user.room).emit('users', users); 47 | callback(); 48 | }); 49 | 50 | // send drawing data to user 51 | socket.on('drawing', (data) => { 52 | const user = getUser(socket.id); 53 | if (!user) return; 54 | io.to(user.room).emit('drawing', data); 55 | }); 56 | 57 | // share cursor data to users 58 | socket.on('sharecursor', (data) => { 59 | const user = getUser(socket.id); 60 | if (!user) return; 61 | io.to(user.room).emit('sharecursor', data); 62 | }); 63 | 64 | socket.on('disconnect', () => { 65 | const user = getUser(socket.id); 66 | if (!user) return; 67 | removeUser(socket.id); 68 | 69 | socket.broadcast.to(user.room).emit('notification', { 70 | message: `👽 ${user.name} left the server`, 71 | }); 72 | 73 | const users = getUsersInRoom(user.room); 74 | io.to(user.room).emit('users', users); 75 | }); 76 | }; 77 | 78 | io.on('connection', connectionHandler); 79 | 80 | // for deployment testing 81 | app.get('/', function (_, res) { 82 | res.send('You are not expected to be here'); 83 | }); 84 | 85 | // start server 86 | server.listen(process.env.PORT || 4000, () => 87 | console.log(`server is running on port ${process.env.PORT || 4000}`), 88 | ); 89 | 90 | console.log(process.env.PORT); 91 | }; 92 | 93 | server(); 94 | -------------------------------------------------------------------------------- /client/components/Playground/Header/Header.module.scss: -------------------------------------------------------------------------------- 1 | @import '../../../styles/_colors.scss'; 2 | @import '../../../styles/_mixins.scss'; 3 | 4 | // header styles 5 | .header { 6 | background-color: $primary-background-color; 7 | color: $primary-foreground-color; 8 | display: flex; 9 | font-size: 1.25rem; 10 | font-weight: bold; 11 | justify-content: space-between; 12 | padding: 1.25rem; 13 | text-transform: uppercase; 14 | position: absolute; 15 | width: 100%; 16 | z-index: 10; 17 | } 18 | 19 | .toolsContainer { 20 | align-items: center; 21 | display: none; 22 | 23 | @include for-tablet-landscape-up { 24 | display: flex; 25 | } 26 | 27 | > div:not(:last-child) { 28 | border-right: 3px solid $primary-foreground-color; 29 | } 30 | 31 | .colorPicker { 32 | align-items: center; 33 | display: flex; 34 | padding: 0 0.5rem; 35 | 36 | .colorCode { 37 | background: transparent; 38 | border: none; 39 | color: $primary-foreground-color; 40 | font-size: 1.25rem; 41 | font-weight: bold; 42 | outline: none; 43 | padding: 0 0.5rem; 44 | text-transform: uppercase; 45 | } 46 | 47 | input[type='color'] { 48 | -webkit-appearance: none; 49 | border-radius: 4px; 50 | border: 0; 51 | box-shadow: 2px 2px 5px rgba(0, 0, 0, 0.1); 52 | height: 2rem; 53 | overflow: hidden; 54 | padding: 0; 55 | width: 2rem; 56 | } 57 | 58 | input[type='color']::-webkit-color-swatch-wrapper { 59 | padding: 0; 60 | } 61 | 62 | input[type='color']::-webkit-color-swatch { 63 | border: none; 64 | } 65 | } 66 | 67 | .strokePicker { 68 | padding: 0 1rem; 69 | select { 70 | border-radius: 4px; 71 | border: 2px solid $primary-foreground-color; 72 | color: $primary-background-color; 73 | font-weight: bold; 74 | min-height: 2rem; 75 | min-width: 6.25rem; 76 | outline: 2px solid $primary-foreground-color; 77 | 78 | option { 79 | background-color: $primary-background-color; 80 | color: $primary-foreground-color; 81 | font-weight: bold; 82 | } 83 | } 84 | } 85 | } 86 | 87 | .stroke { 88 | hr { 89 | color: black; 90 | height: 2px; 91 | } 92 | } 93 | 94 | .subOptions { 95 | display: flex; 96 | } 97 | 98 | .badge { 99 | align-items: center; 100 | background-color: $primary-foreground-color; 101 | border-radius: 50%; 102 | color: $primary-background-color; 103 | display: flex; 104 | font-weight: bold; 105 | justify-content: center; 106 | width: 40px; 107 | height: 40px; 108 | align-items: center; 109 | border: 1px solid $primary-background-color; 110 | } 111 | 112 | .badgeGroup { 113 | display: flex; 114 | 115 | div { 116 | margin: 0 -8px; 117 | } 118 | } 119 | 120 | .githubLogo { 121 | cursor: pointer; 122 | margin-left: 32px; 123 | } 124 | 125 | .roomInfo { 126 | flex: 1; 127 | text-align: center; 128 | button { 129 | background: none; 130 | border: none; 131 | } 132 | 133 | svg { 134 | fill: $primary-foreground-color; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /client/components/Playground/Canvas/Canvas.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react'; 2 | import { toast } from 'react-toastify'; 3 | import io, { Socket } from 'socket.io-client'; 4 | 5 | import { Users } from '../Playground'; 6 | import { useToolSettings } from 'hooks/context/useToolsSettings'; 7 | 8 | import styles from './Canvas.module.scss'; 9 | import CursorIcon from 'assets/cursor.svg'; 10 | 11 | interface SocketPayload { 12 | x0: number; 13 | y0: number; 14 | x1: number; 15 | y1: number; 16 | color: string; 17 | name: string; 18 | emit?: boolean; 19 | stroke: number; 20 | } 21 | 22 | interface CanvasProps { 23 | name: string; 24 | room: string; 25 | usersInRoom: Users[]; 26 | updateUserInCurrentRoom: (_: Users[]) => void; 27 | } 28 | 29 | const Canvas = ({ 30 | name, 31 | room, 32 | usersInRoom, 33 | updateUserInCurrentRoom, 34 | }: CanvasProps) => { 35 | // state variables 36 | const [userPointerCordinates, setUserPointerCordinates] = 37 | useState>(); 38 | 39 | // references 40 | const canvasRef = useRef(null); 41 | let socketRef = useRef(); 42 | 43 | const { color, stroke } = useToolSettings(); 44 | 45 | const ENDPOINT = 46 | process.env.NEXT_PUBLIC_LIVEBOARD_BACKEND_URL || 'http://localhost:5000'; 47 | 48 | const handleErrors = (msg: string) => { 49 | toast(`⛔ ${msg}`); 50 | }; 51 | 52 | const moveCursor = (name: string, x: number, y: number) => { 53 | setUserPointerCordinates({ 54 | ...userPointerCordinates, 55 | [name]: { 56 | x, 57 | y, 58 | }, 59 | }); 60 | }; 61 | 62 | //useRef is used to get the values of the dom element 63 | useEffect(() => { 64 | socketRef.current = io(ENDPOINT, { transports: ['websocket'] }) as Socket; 65 | 66 | socketRef.current.on('connect_error', (err: any) => { 67 | console.log(`connect_error due to ${err}`); 68 | }); 69 | 70 | socketRef.current.emit('join', { name, room }, (error: string) => { 71 | if (error) handleErrors(error); 72 | }); 73 | }, [ENDPOINT, name, room]); 74 | 75 | useEffect(() => { 76 | const canvas = canvasRef.current; // getting the data of the canvas 77 | if (!canvas) return; 78 | const context = canvas.getContext('2d'); // geting 2d of the canvas 79 | 80 | if (!context) return; 81 | 82 | const onResize = () => { 83 | canvas.width = window.innerWidth; 84 | canvas.height = window.innerHeight; 85 | }; 86 | 87 | window.addEventListener('resize', onResize, false); 88 | onResize(); 89 | }, []); 90 | 91 | useEffect(() => { 92 | const canvas = canvasRef.current; // getting the data of the canvas 93 | if (!canvas) return; 94 | const context = canvas.getContext('2d'); // geting 2d of the canvas 95 | if (!context) return; 96 | 97 | const current: any = {}; 98 | let drawing = false; 99 | 100 | const drawLine = ({ 101 | x0, 102 | y0, 103 | x1, 104 | y1, 105 | color, 106 | name, 107 | emit, 108 | stroke, 109 | }: SocketPayload) => { 110 | context.beginPath(); 111 | context.moveTo(x0, y0); 112 | context.lineTo(x1, y1); 113 | context.strokeStyle = color; 114 | context.lineWidth = stroke; 115 | context.stroke(); 116 | context.closePath(); 117 | 118 | if (!emit) { 119 | return; 120 | } 121 | 122 | const w = canvas.width; 123 | const h = canvas.height; 124 | 125 | socketRef.current.emit('drawing', { 126 | x0: x0 / w, 127 | y0: y0 / h, 128 | x1: x1 / w, 129 | y1: y1 / h, 130 | color, 131 | name, 132 | stroke, 133 | }); 134 | }; 135 | 136 | const onMovePointerEvent = (name: string, x: number, y: number) => { 137 | moveCursor(name, x, y); 138 | socketRef.current.emit('sharecursor', { 139 | name, 140 | x, 141 | y, 142 | }); 143 | }; 144 | 145 | // geeting mouse and touch actions 146 | const onMouseDown = (e: any) => { 147 | drawing = true; 148 | current.x = e.clientX || e.touches[0].clientX; //Set Starting X 149 | current.y = e.clientY || e.touches[0].clientY; //Set Starting Y 150 | }; 151 | 152 | const onMouseMove = (e: any) => { 153 | onMovePointerEvent(name, e.clientX, e.clientY); 154 | if (!drawing) { 155 | return; 156 | } 157 | drawLine({ 158 | x0: current.x, //Starting X 159 | y0: current.y, //Starting Y 160 | x1: e.clientX || e.touches[0].clientX, //Current X 161 | y1: e.clientY || e.touches[0].clientY, //Current Y 162 | color, 163 | name, 164 | stroke, 165 | emit: true, 166 | }); 167 | current.x = e.clientX || e.touches[0].clientX; 168 | current.y = e.clientY || e.touches[0].clientY; 169 | }; 170 | 171 | const onMouseUp = (e: any) => { 172 | if (!drawing) { 173 | return; 174 | } 175 | 176 | drawing = false; 177 | drawLine({ 178 | x0: current.x, 179 | y0: current.y, 180 | x1: e.clientX || e.touches[0].clientX, 181 | y1: e.clientY || e.touches[0].clientY, 182 | color: color, 183 | stroke, 184 | name, 185 | emit: true, 186 | }); 187 | }; 188 | 189 | //To throttle the number of event calls.. 190 | const throttle = (callback: any, delay: number) => { 191 | let previousCall = new Date().getTime(); 192 | return function () { 193 | const time = new Date().getTime(); 194 | 195 | if (time - previousCall >= delay) { 196 | previousCall = time; 197 | callback.apply(null, arguments); 198 | } 199 | }; 200 | }; 201 | 202 | canvas.addEventListener('mousedown', onMouseDown, false); 203 | canvas.addEventListener('mouseup', onMouseUp, false); 204 | canvas.addEventListener('mouseout', onMouseUp, false); 205 | canvas.addEventListener('mousemove', throttle(onMouseMove, 10), false); 206 | 207 | //for mobile 208 | canvas.addEventListener('touchstart', onMouseDown, false); 209 | // canvas.addEventListener("touchend", onMouseUp, false); 210 | canvas.addEventListener('touchcancel', onMouseUp, false); 211 | canvas.addEventListener('touchmove', throttle(onMouseMove, 10), false); 212 | 213 | const onDrawingEvent = (data: any) => { 214 | const w = canvas.width; 215 | const h = canvas.height; 216 | drawLine({ 217 | x0: data.x0 * w, 218 | y0: data.y0 * h, 219 | x1: data.x1 * w, 220 | y1: data.y1 * h, 221 | color: data.color, 222 | stroke: data.stroke, 223 | name: data.name, 224 | }); 225 | }; 226 | 227 | socketRef.current.on('drawing', onDrawingEvent); 228 | }, [color, name, stroke]); 229 | 230 | useEffect(() => { 231 | const showJoinNotification = ({ message }: { message: string }) => { 232 | toast(message); 233 | }; 234 | 235 | socketRef.current.on('sharecursor', ({ name, x, y }: any) => 236 | moveCursor(name, x, y), 237 | ); 238 | socketRef.current.on('notification', showJoinNotification); 239 | }, [socketRef]); 240 | 241 | useEffect(() => { 242 | socketRef.current.on('users', updateUserInCurrentRoom); 243 | }, [updateUserInCurrentRoom]); 244 | 245 | return ( 246 |
247 | 248 |
249 | {usersInRoom.map((user) => 250 | user.name == name ? ( 251 | <> 252 | ) : ( 253 |
269 | 270 |
{user.name}
271 |
272 | ), 273 | )} 274 |
275 |
276 | ); 277 | }; 278 | 279 | export default Canvas; 280 | -------------------------------------------------------------------------------- /client/public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | --------------------------------------------------------------------------------