├── .gitignore
├── .npmrc
├── .vscode
└── settings.json
├── LICENSE
├── README.md
├── apps
├── coscribe-frontend
│ ├── .gitignore
│ ├── README.md
│ ├── api
│ │ ├── auth.ts
│ │ └── room.ts
│ ├── app
│ │ ├── (auth)
│ │ │ ├── signin
│ │ │ │ └── page.tsx
│ │ │ └── signup
│ │ │ │ └── page.tsx
│ │ ├── canvas
│ │ │ └── [roomId]
│ │ │ │ └── page.tsx
│ │ ├── dashboard
│ │ │ └── page.tsx
│ │ ├── globals.css
│ │ ├── layout.tsx
│ │ └── page.tsx
│ ├── components
│ │ ├── Auth
│ │ │ ├── Signin.tsx
│ │ │ └── Signup.tsx
│ │ ├── Canvas
│ │ │ ├── Canvas.tsx
│ │ │ └── RoomCanvas.tsx
│ │ ├── Dashboard
│ │ │ └── Dashboard.tsx
│ │ ├── LandingPage
│ │ │ ├── CTA.tsx
│ │ │ ├── Features.tsx
│ │ │ ├── Footer.tsx
│ │ │ ├── HeroSection.tsx
│ │ │ ├── Landing.tsx
│ │ │ ├── Navbar.tsx
│ │ │ └── VideoSection.tsx
│ │ ├── ProtectedRoutes
│ │ │ ├── CanvasProtectedRoute.tsx
│ │ │ └── ProtectedRoute.tsx
│ │ ├── Toolbars
│ │ │ ├── ColorSelector.tsx
│ │ │ ├── SideToolbar.tsx
│ │ │ └── Toolbar.tsx
│ │ └── dialogbox
│ │ │ ├── CreateRoom.tsx
│ │ │ ├── JoinRoom.tsx
│ │ │ └── confirmation.tsx
│ ├── config.ts
│ ├── draw
│ │ ├── SelectionManager.ts
│ │ ├── draw.ts
│ │ ├── eraser.ts
│ │ └── http.ts
│ ├── eslint.config.mjs
│ ├── lib
│ │ └── react-query.tsx
│ ├── next.config.ts
│ ├── package-lock.json
│ ├── package.json
│ ├── postcss.config.mjs
│ ├── public
│ │ ├── abstract-logo.png
│ │ ├── eraser-cursor.png
│ │ ├── final.mp4
│ │ ├── logo.png
│ │ ├── next.svg
│ │ └── window.svg
│ ├── tailwind.config.ts
│ └── tsconfig.json
├── http-backend
│ ├── package.json
│ ├── src
│ │ ├── index.ts
│ │ └── middleware.ts
│ ├── tsconfig.json
│ ├── tsconfig.tsbuildinfo
│ └── turbo.json
└── ws-backend
│ ├── package.json
│ ├── src
│ └── index.ts
│ ├── tsconfig.json
│ ├── tsconfig.tsbuildinfo
│ └── turbo.json
├── package.json
├── packages
├── common
│ ├── package.json
│ ├── src
│ │ └── types.ts
│ ├── tsconfig.json
│ ├── tsconfig.tsbuildinfo
│ └── turbo.json
├── db
│ ├── package.json
│ ├── prisma
│ │ ├── migrations
│ │ │ ├── 20250208045612_init
│ │ │ │ └── migration.sql
│ │ │ ├── 20250208180508_added_further_models
│ │ │ │ └── migration.sql
│ │ │ ├── 20250209055528_added_optional
│ │ │ │ └── migration.sql
│ │ │ ├── 20250209163559_unique
│ │ │ │ └── migration.sql
│ │ │ ├── 20250215193156_message_id_chat_schema
│ │ │ │ └── migration.sql
│ │ │ ├── 20250215193742_removed_message_id
│ │ │ │ └── migration.sql
│ │ │ ├── 20250219191655_added_users_array_to_rooms
│ │ │ │ └── migration.sql
│ │ │ ├── 20250221055846_removed_unique_slug
│ │ │ │ └── migration.sql
│ │ │ ├── 20250308174257_room_id_string
│ │ │ │ └── migration.sql
│ │ │ └── migration_lock.toml
│ │ └── schema.prisma
│ ├── src
│ │ └── index.ts
│ └── tsconfig.json
├── eslint-config
│ ├── README.md
│ ├── base.js
│ ├── next.js
│ ├── package.json
│ └── react-internal.js
├── typescript-config
│ ├── base.json
│ ├── nextjs.json
│ ├── package.json
│ └── react-library.json
└── ui
│ ├── eslint.config.mjs
│ ├── package.json
│ ├── src
│ ├── button.tsx
│ ├── card.tsx
│ ├── code.tsx
│ ├── index.css
│ └── input.tsx
│ ├── tailwind.config.ts
│ ├── tsconfig.json
│ └── turbo
│ └── generators
│ ├── config.ts
│ └── templates
│ └── component.hbs
├── pnpm-lock.yaml
├── pnpm-workspace.yaml
├── turbo.json
└── vercel.json
/.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 | # Local env files
9 | .env
10 | .env.local
11 | .env.development.local
12 | .env.test.local
13 | .env.production.local
14 |
15 | # Testing
16 | coverage
17 |
18 | # Turbo
19 | .turbo
20 |
21 | # Vercel
22 | .vercel
23 |
24 | # Build Outputs
25 | .next/
26 | out/
27 | build
28 | dist
29 |
30 |
31 | # Debug
32 | npm-debug.log*
33 | yarn-debug.log*
34 | yarn-error.log*
35 |
36 | # Misc
37 | .DS_Store
38 | *.pem
39 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | registry=https://registry.npmjs.org/
2 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "eslint.workingDirectories": [
3 | {
4 | "mode": "auto"
5 | }
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Devansh Sabharwal
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Turborepo starter
2 |
3 | This Turborepo starter is maintained by the Turborepo core team.
4 |
5 | ## Using this example
6 |
7 | Run the following command:
8 |
9 | ```sh
10 | npx create-turbo@latest
11 | ```
12 |
13 | ## What's inside?
14 |
15 | This Turborepo includes the following packages/apps:
16 |
17 | ### Apps and Packages
18 |
19 | - `docs`: a [Next.js](https://nextjs.org/) app
20 | - `web`: another [Next.js](https://nextjs.org/) app
21 | - `@repo/ui`: a stub React component library shared by both `web` and `docs` applications
22 | - `@repo/eslint-config`: `eslint` configurations (includes `eslint-config-next` and `eslint-config-prettier`)
23 | - `@repo/typescript-config`: `tsconfig.json`s used throughout the monorepo
24 |
25 | Each package/app is 100% [TypeScript](https://www.typescriptlang.org/).
26 |
27 | ### Utilities
28 |
29 | This Turborepo has some additional tools already setup for you:
30 |
31 | - [TypeScript](https://www.typescriptlang.org/) for static type checking
32 | - [ESLint](https://eslint.org/) for code linting
33 | - [Prettier](https://prettier.io) for code formatting
34 |
35 | ### Build
36 |
37 | To build all apps and packages, run the following command:
38 |
39 | ```
40 | cd my-turborepo
41 | pnpm build
42 | ```
43 |
44 | ### Develop
45 |
46 | To develop all apps and packages, run the following command:
47 |
48 | ```
49 | cd my-turborepo
50 | pnpm dev
51 | ```
52 |
53 | ### Remote Caching
54 |
55 | > [!TIP]
56 | > Vercel Remote Cache is free for all plans. Get started today at [vercel.com](https://vercel.com/signup?/signup?utm_source=remote-cache-sdk&utm_campaign=free_remote_cache).
57 |
58 | Turborepo can use a technique known as [Remote Caching](https://turbo.build/repo/docs/core-concepts/remote-caching) to share cache artifacts across machines, enabling you to share build caches with your team and CI/CD pipelines.
59 |
60 | By default, Turborepo will cache locally. To enable Remote Caching you will need an account with Vercel. If you don't have an account you can [create one](https://vercel.com/signup?utm_source=turborepo-examples), then enter the following commands:
61 |
62 | ```
63 | cd my-turborepo
64 | npx turbo login
65 | ```
66 |
67 | This will authenticate the Turborepo CLI with your [Vercel account](https://vercel.com/docs/concepts/personal-accounts/overview).
68 |
69 | Next, you can link your Turborepo to your Remote Cache by running the following command from the root of your Turborepo:
70 |
71 | ```
72 | npx turbo link
73 | ```
74 |
75 | ## Useful Links
76 |
77 | Learn more about the power of Turborepo:
78 |
79 | - [Tasks](https://turbo.build/repo/docs/core-concepts/monorepos/running-tasks)
80 | - [Caching](https://turbo.build/repo/docs/core-concepts/caching)
81 | - [Remote Caching](https://turbo.build/repo/docs/core-concepts/remote-caching)
82 | - [Filtering](https://turbo.build/repo/docs/core-concepts/monorepos/filtering)
83 | - [Configuration Options](https://turbo.build/repo/docs/reference/configuration)
84 | - [CLI Usage](https://turbo.build/repo/docs/reference/command-line-reference)
85 |
--------------------------------------------------------------------------------
/apps/coscribe-frontend/.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.*
7 | .yarn/*
8 | !.yarn/patches
9 | !.yarn/plugins
10 | !.yarn/releases
11 | !.yarn/versions
12 |
13 | # testing
14 | /coverage
15 |
16 | # next.js
17 | /.next/
18 | /out/
19 |
20 | # production
21 | /build
22 |
23 | # misc
24 | .DS_Store
25 | *.pem
26 |
27 | # debug
28 | npm-debug.log*
29 | yarn-debug.log*
30 | yarn-error.log*
31 | .pnpm-debug.log*
32 |
33 | # env files (can opt-in for committing if needed)
34 | .env*
35 |
36 | # vercel
37 | .vercel
38 |
39 | # typescript
40 | *.tsbuildinfo
41 | next-env.d.ts
42 |
--------------------------------------------------------------------------------
/apps/coscribe-frontend/README.md:
--------------------------------------------------------------------------------
1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/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 | # or
12 | pnpm dev
13 | # or
14 | bun dev
15 | ```
16 |
17 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
18 |
19 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
20 |
21 | This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
22 |
23 | ## Learn More
24 |
25 | To learn more about Next.js, take a look at the following resources:
26 |
27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
29 |
30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
31 |
32 | ## Deploy on Vercel
33 |
34 | 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.
35 |
36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
37 |
--------------------------------------------------------------------------------
/apps/coscribe-frontend/api/auth.ts:
--------------------------------------------------------------------------------
1 | import { HTTP_URL } from "@/config";
2 | export const signupUser = async (userData: { email: string; password: string,name:string }) => {
3 | const response = await fetch(`${HTTP_URL}/signup`, {
4 | method: "POST",
5 | headers: {
6 | "Content-Type": "application/json",
7 | },
8 | body: JSON.stringify(userData),
9 | });
10 |
11 | if (!response.ok) {
12 | const errorData = await response.json();
13 | throw new Error(errorData.message);
14 | }
15 |
16 | return response.json();
17 | };
18 | export const signInUser = async (userData: { email: string; password: string }) => {
19 |
20 | const response = await fetch(`${HTTP_URL}/signin`, {
21 | method: "POST",
22 | headers: {
23 | "Content-Type": "application/json",
24 | },
25 | body: JSON.stringify(userData),
26 | });
27 |
28 | if (!response.ok) {
29 | const errorData = await response.json();
30 | console.log(errorData);
31 | throw new Error(errorData.message);
32 | }
33 |
34 | return response.json();
35 | };
36 |
37 |
--------------------------------------------------------------------------------
/apps/coscribe-frontend/api/room.ts:
--------------------------------------------------------------------------------
1 | import {HTTP_URL} from "@/config"
2 | export async function createRoom(name:string){
3 | const response = await fetch(`${HTTP_URL}/create-room`,{
4 | method: "POST",
5 | headers: {
6 | "Content-Type": "application/json",
7 | authorization: "Bearer " + localStorage.getItem('token') || ''
8 | },
9 | body: JSON.stringify({name}),
10 | });
11 | if (!response.ok) {
12 | const errorData = await response.json();
13 | throw new Error(errorData.message);
14 | }
15 |
16 | return response.json();
17 | }
18 | export async function joinRoom(roomId:string){
19 | const response = await fetch(`${HTTP_URL}/join-room/${roomId}`,{
20 | headers: {
21 | authorization: "Bearer " + localStorage.getItem('token') || ''
22 | }
23 | });
24 | if(!response.ok){
25 | const errorData = await response.json();
26 | throw new Error(errorData.message);
27 | }
28 | return response.json();
29 | }
30 | export async function leaveRoom(roomId:string){
31 | const response = await fetch(`${HTTP_URL}/leave-room`,{
32 | method: "POST",
33 | headers: {
34 | "Content-Type": "application/json",
35 | authorization: "Bearer " + localStorage.getItem('token') || ''
36 | },
37 | body: JSON.stringify({roomId}),
38 | });
39 | if (!response.ok) {
40 | const errorData = await response.json();
41 | throw new Error(errorData.message);
42 | }
43 | return response.json();
44 | }
--------------------------------------------------------------------------------
/apps/coscribe-frontend/app/(auth)/signin/page.tsx:
--------------------------------------------------------------------------------
1 | import { AuthPage } from "@/components/Auth/Signin";
2 |
3 | export default function Signin(){
4 | return
5 | }
--------------------------------------------------------------------------------
/apps/coscribe-frontend/app/(auth)/signup/page.tsx:
--------------------------------------------------------------------------------
1 | import { AuthPage } from "@/components/Auth/Signup";
2 |
3 | export default function Signup(){
4 | return
5 | }
--------------------------------------------------------------------------------
/apps/coscribe-frontend/app/canvas/[roomId]/page.tsx:
--------------------------------------------------------------------------------
1 | import { RoomCanvas } from "@/components/Canvas/RoomCanvas";
2 |
3 | export type paramsType = Promise<{ roomId: string }>;
4 |
5 | export default async function CanvasPage(props: { params: paramsType }){
6 | const { roomId } = await props.params;
7 |
8 | return (
9 |
10 |
11 |
12 | );
13 | }
--------------------------------------------------------------------------------
/apps/coscribe-frontend/app/dashboard/page.tsx:
--------------------------------------------------------------------------------
1 | import { Dashboard } from "@/components/Dashboard/Dashboard";
2 | export default function DashboardPage() {
3 | return (
4 |
5 |
6 |
7 | )
8 | }
9 |
--------------------------------------------------------------------------------
/apps/coscribe-frontend/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root {
6 | --background: #ffffff;
7 | --foreground: #171717;
8 | }
9 |
10 | @media (prefers-color-scheme: dark) {
11 | :root {
12 | --background: #0a0a0a;
13 | --foreground: #ededed;
14 | }
15 | }
16 |
17 | body {
18 | color: var(--foreground);
19 | background: var(--background);
20 | font-family: Arial, Helvetica, sans-serif;
21 | }
22 |
23 | html {
24 | scroll-behavior: smooth;
25 | }
26 |
27 |
28 |
--------------------------------------------------------------------------------
/apps/coscribe-frontend/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Geist, Geist_Mono } from "next/font/google";
3 | import "./globals.css";
4 | import ReactQueryProvider from "@/lib/react-query";
5 | import { Plus_Jakarta_Sans } from 'next/font/google';
6 | import { Toaster } from "react-hot-toast";
7 |
8 | const plusJakartaSans = Plus_Jakarta_Sans({
9 | subsets: ['latin'],
10 | weight: ['400', '500','600', '700','800'],
11 | display: 'swap',
12 | variable: '--font-jakarta',
13 | });
14 | const geistSans = Geist({
15 | variable: "--font-geist-sans",
16 | subsets: ["latin"],
17 | });
18 |
19 | const geistMono = Geist_Mono({
20 | variable: "--font-geist-mono",
21 | subsets: ["latin"],
22 | });
23 |
24 | export const metadata: Metadata = {
25 | title: "CoScribe",
26 | description: "Create and Collaborate",
27 | icons: [
28 | {
29 | rel: "icon",
30 | type: "image/png",
31 | sizes: "32x32",
32 | url: "/abstract-logo.png", // Path to your favicon
33 | },
34 | {
35 | rel: "apple-touch-icon",
36 | sizes: "180x180",
37 | url: "/abstract-logo.png", // Use the same or a different image for Apple devices
38 | },
39 | ],
40 | };
41 |
42 | export default function RootLayout({
43 | children,
44 | }: Readonly<{ children: React.ReactNode }>) {
45 |
46 |
47 | return (
48 |
49 |
52 |
53 | {children}
54 |
55 |
56 |
57 |
58 |
59 |
60 | );
61 | }
62 |
--------------------------------------------------------------------------------
/apps/coscribe-frontend/app/page.tsx:
--------------------------------------------------------------------------------
1 | import Landing from "@/components/LandingPage/Landing";
2 |
3 | export default function Home(){
4 | return
5 |
6 |
7 | }
--------------------------------------------------------------------------------
/apps/coscribe-frontend/components/Auth/Signin.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 | import { useMutation } from "@tanstack/react-query";
3 | import Input, { PasswordInput } from "@repo/ui/input"
4 | import { useState } from "react"
5 | import { signInUser } from "@/api/auth";
6 | import { Button } from "@repo/ui/button";
7 | import { useRouter } from "next/navigation";
8 | import Link from "next/link";
9 | import { X } from "lucide-react";
10 | import { HashLoader } from "react-spinners";
11 | import toast from "react-hot-toast";
12 | export function AuthPage(){
13 | const router = useRouter();
14 | const [email,setEmail] = useState("");
15 | const [password,setPassword] = useState("");
16 | const mutate = useMutation({
17 | mutationFn: signInUser,
18 | onSuccess: (data)=>{
19 | toast.success('User Signed in Successfully')
20 | localStorage.setItem('token',data.token);
21 | router.push('./dashboard')
22 | },
23 | onError: (err)=>{
24 | setEmail("");
25 | setPassword("");
26 | toast.error(err.message);
27 | }
28 | })
29 | const handleSignup = (e: React.FormEvent)=>{
30 | e.preventDefault();
31 | mutate.mutate({email,password});
32 | }
33 | return (
34 |
35 |
36 |
37 |
38 |
Sign in to your account
39 |
40 | Sign in to sketch ideas together on CoScribe.
41 |
42 |
43 |
44 |
45 |
46 |
47 |
76 |
77 |
78 |
79 | Create an account?{" "}
80 |
81 | Sign up
82 |
83 |
84 |
85 |
86 |
87 | );
88 | }
--------------------------------------------------------------------------------
/apps/coscribe-frontend/components/Auth/Signup.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import { useMutation } from "@tanstack/react-query";
4 | import Input, { PasswordInput } from "@repo/ui/input"
5 | import { useState } from "react"
6 | import { signupUser } from "@/api/auth";
7 | import { Button } from "@repo/ui/button";
8 | import { useRouter } from "next/navigation";
9 | import Link from "next/link";
10 | import { X } from "lucide-react";
11 | import { HashLoader } from "react-spinners";
12 | import toast from "react-hot-toast";
13 | import {CreateUserSchema} from "@repo/common/types"
14 |
15 | export function AuthPage(){
16 | const router = useRouter();
17 | const [email,setEmail] = useState("");
18 | const [password,setPassword] = useState("");
19 | const [name,setName] = useState("");
20 | const [errors,setErrors] = useState<{[key:string]:string}>({})
21 | const mutate = useMutation({
22 | mutationFn: signupUser,
23 | onSuccess: ()=>{
24 | toast.success('User Signed up Successfully')
25 | router.push('/signin')
26 | },
27 | onError: (err)=>{
28 | setEmail("");
29 | setPassword("");
30 | setName("");
31 | toast.error(err.message);
32 | }
33 | })
34 | const handleSignup = (e: React.FormEvent)=>{
35 | e.preventDefault();
36 | const result = CreateUserSchema.safeParse({ email, password, name });
37 | const newErrors:{[key:string]:string} = {}
38 | if(!result.success){
39 | result.error.issues.forEach(element => {
40 | if(!newErrors[element.path[0]])newErrors[element.path[0]] = element.message
41 | });
42 | console.log(result.error);
43 | setErrors(newErrors);
44 | return;
45 | }
46 | setErrors({})
47 | mutate.mutate({email,password,name});
48 | }
49 | return (
50 |
51 |
52 |
53 |
54 |
Create your account
55 |
56 | Sign up to start using CoScribe
57 |
58 |
59 |
60 |
61 |
62 |
63 |
107 |
108 |
109 |
110 | Already have an account?{" "}
111 |
112 | Sign in
113 |
114 |
115 |
116 |
117 |
118 | );
119 | };
--------------------------------------------------------------------------------
/apps/coscribe-frontend/components/Canvas/Canvas.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef, useState } from "react";
2 | import { Draw } from "@/draw/draw";
3 | import ProtectedRoute from "../ProtectedRoutes/ProtectedRoute";
4 | import { Toolbar } from "../Toolbars/Toolbar";
5 | import SideToolbar from "../Toolbars/SideToolbar";
6 |
7 |
8 | interface CanvasProps {
9 | roomId: string;
10 | socket: WebSocket;
11 | }
12 |
13 | export function Canvas(props: CanvasProps) {
14 | const canvasRef = useRef(null);
15 | const [shape, setShape] = useState<"circle" | "pencil" | "rect" | "diamond" | "arrow" | "line" | "text" | "eraser" | "select">("select");
16 | const [draw, setDraw] = useState();
17 | const [color,setColor] = useState("white");
18 | const [bgColor,setBgColor] = useState("#ffffff00");
19 | const [strokeWidth,setStrokeWidth] = useState(2);
20 | const [strokeStyle,setStrokeStyle] = useState("solid");
21 | // Update canvas resolution on mount and window resize
22 | useEffect(() => {
23 | const canvas = canvasRef.current;
24 | if (!canvas) return;
25 |
26 | // Set initial canvas resolution
27 | canvas.width = window.innerWidth;
28 | canvas.height = window.innerHeight;
29 |
30 | const handleResize = () => {
31 | canvas.width = window.innerWidth;
32 | canvas.height = window.innerHeight;
33 | };
34 |
35 | window.addEventListener("resize", handleResize);
36 | return () => window.removeEventListener("resize", handleResize);
37 | }, []);
38 |
39 | // Update the tool in the Draw instance when the shape changes
40 | useEffect(() => {
41 | draw?.setTool(shape);
42 |
43 | }, [shape, draw]);
44 | useEffect(()=>{
45 | draw?.setColor(color);
46 | draw?.setBgColor(bgColor);
47 | draw?.setStrokeWidth(strokeWidth);
48 | draw?.setStrokeStyle(strokeStyle);
49 | },[color,bgColor,strokeWidth,strokeStyle,draw])
50 |
51 | // Initialize the Draw instance when the canvas ref is available
52 | useEffect(() => {
53 | if (canvasRef.current) {
54 | const canvas = canvasRef.current;
55 | const ctx = canvas.getContext("2d");
56 | if (!ctx) return;
57 |
58 | const d = new Draw(canvas, ctx, props.socket, props.roomId,setShape);
59 | setDraw(d);
60 |
61 | return () => {
62 | d.destroy();
63 | };
64 | }
65 | }, [canvasRef, props.socket, props.roomId]);
66 |
67 | // Update the cursor based on the selected tool
68 | useEffect(() => {
69 | const canvas = canvasRef.current;
70 | if (!canvas) return;
71 |
72 | switch (shape) {
73 | case "eraser":
74 | canvas.style.cursor = `url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='20'%20height='20'%20viewBox='0%200%2040%2040'%3E%3Ccircle%20cx='20'%20cy='20'%20r='18'%20fill='none'%20stroke='white'%20stroke-width='4'/%3E%3C/svg%3E") 20 20, auto`;
75 | break;
76 | case "text":
77 | canvas.style.cursor = "text"; // Text cursor
78 | break;
79 | case "select":
80 | canvas.style.cursor = "auto";
81 | break;
82 | default:
83 | canvas.style.cursor = "crosshair"; // Default cursor for other tools
84 | }
85 | }, [shape]);
86 |
87 | return (
88 |
89 |
90 |
96 |
97 |
107 |
108 |
109 | );
110 | }
111 |
112 |
--------------------------------------------------------------------------------
/apps/coscribe-frontend/components/Canvas/RoomCanvas.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 | import { WSS_URL} from "@/config";
3 | import { useEffect, useState } from "react";
4 | import { Canvas } from "./Canvas";
5 | import { CanvasProtectedRoute } from "../ProtectedRoutes/CanvasProtectedRoute";
6 | import { ClipLoader } from "react-spinners";
7 |
8 | interface CanvasProps {
9 | roomId: string;
10 | }
11 |
12 | export function RoomCanvas(props: CanvasProps) {
13 | const [socket, setSocket] = useState(null);
14 |
15 | useEffect(() => {
16 | const token = localStorage.getItem('token');
17 | if(!token) return;
18 | const wsUrl = `${WSS_URL}?token=${encodeURIComponent(token)}`;
19 | if (!wsUrl) {
20 | console.error("WebSocket URL is undefined");
21 | return;
22 | }
23 | const ws = new WebSocket(wsUrl);
24 |
25 | ws.onopen = () => {
26 | setSocket(ws);
27 | ws.send(JSON.stringify({
28 | type: "join_room",
29 | roomId: props.roomId
30 | }));
31 | };
32 | // Cleanup function to prevent multiple WS connections
33 | return () => {
34 | ws.close();
35 | };
36 | }, [props.roomId]); // ✅ Add dependency array
37 |
38 | if (!socket) {
39 | return
;
40 | }
41 |
42 | return (
43 |
44 |
45 |
46 |
47 |
48 | );
49 | }
50 |
--------------------------------------------------------------------------------
/apps/coscribe-frontend/components/Dashboard/Dashboard.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 | import logo from '../../public/logo.png';
3 | import { Plus, Users, Clock, Pencil, Trash2, Check, Copy, X } from 'lucide-react';
4 | import ProtectedRoute from "../ProtectedRoutes/ProtectedRoute"
5 | import { useEffect,useRef,useState } from 'react'
6 | import Dialogbox from '../dialogbox/CreateRoom';
7 | import JoinDialogbox from '../dialogbox/JoinRoom';
8 | import { useRouter } from 'next/navigation';
9 | import { useMutation } from '@tanstack/react-query';
10 | import { joinRoom, leaveRoom } from '@/api/room';
11 | import { ConfirmDialog } from '../dialogbox/confirmation';
12 | import Image from 'next/image';
13 | import { HTTP_URL } from '@/config';
14 | import { ScaleLoader } from 'react-spinners';
15 | import toast from 'react-hot-toast';
16 | interface Room{
17 | roomId: string;
18 | slug: string;
19 | createdAt: string;
20 | participants: string[];
21 | noOfParticipants: number
22 | }
23 | export function Dashboard(){
24 | const [rooms,setRooms] = useState([]);
25 | const [loading,setLoading] = useState(false)
26 | const router = useRouter();
27 | const fetchRooms = async () => {
28 | setLoading(true);
29 | try{
30 | const response = await fetch(`${HTTP_URL}/rooms`, {
31 | headers: {
32 | authorization: "Bearer " + localStorage.getItem('token') || ''
33 | }
34 | });
35 | if (!response.ok) {
36 | const errorData = await response.json();
37 | toast.error(errorData.message);
38 | if(errorData.message==="User not found") router.push('/')
39 | }
40 | const data = await response.json();
41 | setRooms(data.messages.rooms);
42 | }catch(e){
43 | toast.error("Error occured");
44 | console.log(e);
45 | }
46 | finally{
47 | setLoading(false);
48 |
49 | }
50 | };
51 |
52 | useEffect(() => {
53 | fetchRooms();
54 | }, []);
55 |
56 |
57 | return
58 |
63 |
64 | }
65 | interface Props{
66 | onCreateRoom: ()=>Promise
67 | }
68 | function Header({onCreateRoom}:Props){
69 | const router = useRouter();
70 | const [showLogoutConfirm, setShowLogoutConfirm] = useState(false);
71 | const [createRoom,setCreateRoom] = useState(false);
72 | return
73 |
setShowLogoutConfirm(false)}
76 | onConfirm={()=>{
77 | localStorage.removeItem('token');
78 | router.push('/')
79 | }}
80 | title="Confirm Logout"
81 | message="Are you sure you want to logout? You will need to sign in again to access your rooms."
82 | confirmText="Logout"
83 | />
84 |
85 |
90 |
91 | {setCreateRoom(true)}}
94 | >
95 |
96 | Create Room
97 |
98 | {
101 | setShowLogoutConfirm(true)
102 | }}
103 | >
104 | Logout
105 |
106 |
107 |
108 | {createRoom && }
109 |
110 | }
111 | function Actions({onCreateRoom}:Props){
112 | const [createRoom,setCreateRoom] = useState(false);
113 | const [joinRoom,setJoinRoom] = useState(false);
114 | return
115 |
116 |
{setCreateRoom(true)}} className="bg-white p-6 rounded-lg shadow-md border-2 border-gray-300 hover:border-black cursor-pointer transition-all group">
117 |
118 |
121 |
122 |
Create New Room
123 |
Start a new collaborative drawing session
124 |
125 |
126 |
127 |
{setJoinRoom(true)}} className="bg-white p-6 rounded-lg border-2 shadow-md border-gray-300 hover:border-black cursor-pointer transition-all group">
128 |
129 |
130 |
131 |
132 |
133 |
Join Existing Room
134 |
Enter a room code to collaborate
135 |
136 |
137 |
138 |
139 | {joinRoom &&
}
140 | {createRoom &&
}
141 |
142 | }
143 |
144 | interface RoomProps{
145 | rooms: Room[]
146 | onCreateRoom: ()=>Promise
147 | loading:boolean
148 | }
149 | function Rooms(props: RoomProps) {
150 | const router = useRouter();
151 | const join = useMutation({
152 | mutationFn: joinRoom,
153 | onSuccess:()=>{
154 | router.push(`./canvas/${selectedRoom.current}`)
155 | selectedRoom.current = null;
156 | },
157 | })
158 | const leave = useMutation({
159 | mutationFn:leaveRoom,
160 | onSuccess:async ()=>{
161 | await props.onCreateRoom();
162 | selectedRoom.current = null;
163 |
164 | },
165 | })
166 | const selectedRoom = useRef(null);
167 | const [copy, setCopy] = useState(null);
168 | const [showParticipants, setShowParticipants] = useState(false);
169 | const [participants, setParticipants] = useState(null);
170 | const [overlayPosition, setOverlayPosition] = useState({ top: 0, left: 0 });
171 | const [showDeleteConfirm,setShowDeleteConfirm] = useState(false);
172 | const handleCopyRoomId = async (id: string) => {
173 | await navigator.clipboard.writeText(id);
174 | setCopy(id);
175 | setTimeout(() => setCopy(null), 2000);
176 | };
177 |
178 | const handleParticipants = (e: React.MouseEvent, participants: string[]) => {
179 | e.stopPropagation();
180 | const button = e.currentTarget as HTMLElement;
181 | const rect = button.getBoundingClientRect();
182 | setOverlayPosition({
183 | top: rect.bottom + window.scrollY + 8,
184 | left: rect.left + window.scrollX , // Center the overlay relative to the icon
185 | });
186 | setParticipants(participants);
187 | setShowParticipants(true);
188 | };
189 |
190 | const handleDeleteRoom = (roomId: string) => {
191 | selectedRoom.current = roomId;
192 | toast.promise(
193 | leave.mutateAsync(roomId),
194 | {
195 | loading: (
196 |
197 | Deleting room...
198 |
199 | ),
200 | success: (
201 |
202 | Room Deleted
203 |
204 | ),
205 | error: (err) => (
206 |
207 | {err.message}
208 |
209 | ),
210 | },
211 | {
212 | style: {
213 | background: "#FAFAFA",
214 | color: "#1e1e1e", // Dark text
215 | borderRadius: "12px", // More rounded corners
216 | padding: "16px 20px", // More padding
217 | boxShadow: "0 4px 24px rgba(0, 0, 0, 0.1)", // Softer shadow
218 | border: "2px solid #e5e7eb", // Light border
219 | maxWidth: "500px", // Limit width
220 | },
221 | position:"top-center"
222 | },
223 | );
224 | };
225 | const handleJoinRoom = (roomId:string)=>{
226 | selectedRoom.current = roomId;
227 | toast.promise(
228 | join.mutateAsync(roomId),
229 | {
230 | loading: (
231 |
232 | Joining room...
233 |
234 | ),
235 | success: (
236 |
237 | Room Joined successfully!
238 |
239 | ),
240 | error: (err) => (
241 |
242 | {err.message}
243 |
244 | ),
245 | },
246 | {
247 | style: {
248 | background: "#FAFAFA",
249 | color: "#1e1e1e", // Dark text
250 | borderRadius: "12px", // More rounded corners
251 | padding: "16px 20px", // More padding
252 | boxShadow: "0 4px 24px rgba(0, 0, 0, 0.1)", // Softer shadow
253 | border: "2px solid #e5e7eb", // Light border
254 | maxWidth: "500px", // Limit width
255 | },
256 | position:"top-center"
257 | },
258 | );
259 | }
260 |
261 | return (
262 |
263 |
setShowDeleteConfirm(false)}
266 | onConfirm={()=>{if (selectedRoom.current) handleDeleteRoom(selectedRoom.current)}}
267 | title="Delete Room"
268 | message="Are you sure you want to delete this room? This action cannot be undone."
269 | confirmText="Delete"
270 | />
271 |
272 |
273 |
Your Rooms
274 |
275 | {!props.loading &&
276 | {props.rooms.map((room) => (
277 |
278 |
279 |
280 |
281 |
282 |
{room.slug}
283 |
284 |
285 |
286 | Created {room.createdAt}
287 |
288 |
289 | Room ID: {room.roomId}
290 | {
292 | e.stopPropagation();
293 | handleCopyRoomId(room.roomId);
294 | }}
295 | className="ml-1 p-1 text-gray-400 hover:text-blue-600 rounded-full hover:bg-blue-50"
296 | >
297 | {copy === room.roomId ? (
298 |
299 | ) : (
300 |
301 | )}
302 |
303 |
304 |
305 |
306 |
307 |
308 | handleParticipants(e, room.participants)}
310 | className="flex items-center hover:bg-blue-50 rounded-md px-2 py-1"
311 | >
312 |
313 | {room.noOfParticipants}
314 |
315 | handleJoinRoom(room.roomId)} className="ml-2 sm:ml-4 sm:px-3 py-1 text-sm text-blue-600 hover:bg-blue-50 rounded-md">
316 | Join
317 |
318 | {
320 | e.stopPropagation();
321 | selectedRoom.current = room.roomId
322 |
323 | setShowDeleteConfirm(true);
324 | }}
325 | className="ml-2 p-1 text-gray-400 hover:text-red-600 rounded-full hover:bg-red-50
326 | opacity-100 sm:opacity-0 sm:group-hover/item:opacity-100 transition-opacity duration-200"
327 |
328 | >
329 |
330 |
331 |
332 |
333 |
334 | ))}
335 | {props.rooms.length===0 &&
No Rooms found
}
336 |
337 | }
338 |
339 |
340 | {props.loading &&
}
341 |
342 |
343 | {/* Participants Overlay */}
344 | {showParticipants && participants && (
345 | <>
346 | setShowParticipants(false)}
349 | />
350 |
358 |
359 |
Participants
360 | setShowParticipants(false)}
362 | className="text-gray-400 hover:text-gray-600"
363 | >
364 |
365 |
366 |
367 |
368 | {participants.map((participant, index) => (
369 |
370 | {participant}
371 |
372 | ))}
373 |
374 |
375 | >
376 | )}
377 |
378 | );
379 | }
--------------------------------------------------------------------------------
/apps/coscribe-frontend/components/LandingPage/CTA.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 |
3 | export function Footer1(){
4 | return
5 |
6 |
Ready to start creating?
7 |
Join thousands of teams who trust DrawFlow for their visual collaboration needs.
8 |
9 | Get Started for Free
10 |
11 |
12 |
13 |
14 | }
--------------------------------------------------------------------------------
/apps/coscribe-frontend/components/LandingPage/Features.tsx:
--------------------------------------------------------------------------------
1 | import { Cloud, Pencil, Share2, Stars, Users, Zap } from "lucide-react";
2 | import { ReactElement } from "react";
3 |
4 | const featureItems = [
5 | {
6 | id: 1,
7 | title: "Real-time Collaboration",
8 | description: "Work together with your team in real-time, seeing changes as they happen.",
9 | icon: , // Increased icon size
10 | },
11 | {
12 | id: 2,
13 | title: "Team Workspaces",
14 | description: "Create and manage multiple workspaces for different teams and projects.",
15 | icon: , // Increased icon size
16 | },
17 | {
18 | id: 3,
19 | title: "Cloud Storage",
20 | description: "Automatically save and sync your drawings across all devices.",
21 | icon: , // Increased icon size
22 | },
23 | {
24 | id: 4,
25 | title: "Smart Drawing Tools",
26 | description: "Powerful yet intuitive tools that adapt to your creative workflow.",
27 | icon: , // Increased icon size
28 | },
29 | {
30 | id: 5,
31 | title: "Lightning Fast",
32 | description: "Optimized performance for smooth drawing and collaboration experience.",
33 | icon: , // Increased icon size
34 | },
35 | {
36 | id: 6,
37 | title: "Custom Styles",
38 | description: "Personalize your drawings with custom colors, fonts, and styles.",
39 | icon: , // Increased icon size
40 | },
41 | ];
42 |
43 | export function Features() {
44 | return (
45 |
46 |
Robust Features
47 |
48 | {featureItems.map((item) => (
49 |
55 | ))}
56 |
57 |
58 | );
59 | }
60 |
61 | interface CardProps {
62 | title: string;
63 | description: string;
64 | icon: ReactElement;
65 | }
66 |
67 | function Card(props: CardProps) {
68 | return (
69 |
70 |
71 | {props.icon}
72 |
73 |
{props.title}
74 |
{props.description}
75 |
76 | );
77 | }
--------------------------------------------------------------------------------
/apps/coscribe-frontend/components/LandingPage/Footer.tsx:
--------------------------------------------------------------------------------
1 | import { Twitter, Linkedin } from "lucide-react";
2 | import Image from "next/image";
3 | import Link from "next/link";
4 |
5 | const Footer = () => {
6 | return (
7 |
70 | );
71 | };
72 |
73 | export default Footer;
--------------------------------------------------------------------------------
/apps/coscribe-frontend/components/LandingPage/HeroSection.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 |
3 | export function HeroSection(){
4 | return
5 |
6 |
7 | Create
8 | Freely
9 |
10 |
11 | Collaborate
12 | Instantly
13 |
14 |
15 | Brainstorm, sketch, and collaborate seamlessly—all in real time
16 |
17 |
18 | Start Drawing
19 | Watch demo
20 |
21 |
22 |
23 |
24 | }
25 |
--------------------------------------------------------------------------------
/apps/coscribe-frontend/components/LandingPage/Landing.tsx:
--------------------------------------------------------------------------------
1 | import { Navbar } from "./Navbar";
2 | import { Features } from "./Features";
3 | import Footer from "./Footer";
4 | import VideoSection from "./VideoSection";
5 | import { HeroSection } from "./HeroSection";
6 | import { Footer1 } from "./CTA";
7 |
8 | export default function Landing() {
9 | return (
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | );
19 | }
20 |
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/apps/coscribe-frontend/components/LandingPage/Navbar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import Image from 'next/image';
3 | import logo from '../../public/logo.png';
4 | import { Menu, X } from 'lucide-react';
5 | import { useState, useRef, useEffect } from 'react';
6 | import Link from 'next/link';
7 |
8 | export function Navbar() {
9 | const [isMenuOpen, setIsMenuOpen] = useState(false);
10 | const menuRef = useRef(null); // Ref for the dropdown menu
11 |
12 | const toggleMenu = () => {
13 | setIsMenuOpen(!isMenuOpen);
14 | };
15 |
16 | useEffect(() => {
17 | const handleClickOutside = (event: MouseEvent) => {
18 | if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
19 | setIsMenuOpen(false);
20 | }
21 | };
22 | if (isMenuOpen) {
23 | document.addEventListener('mousedown', handleClickOutside);
24 | }
25 |
26 | return () => {
27 | document.removeEventListener('mousedown', handleClickOutside);
28 | };
29 | }, [isMenuOpen]);
30 |
31 | return (
32 |
33 |
34 | {/* Logo */}
35 |
36 |
37 |
38 |
39 | {/* Desktop Navigation (visible on sm and above) */}
40 |
41 |
42 | Features
43 |
44 |
50 | Github
51 |
52 |
53 | Signin
54 |
55 |
59 | Try Now
60 |
61 |
62 |
63 | {/* Mobile Menu Button (visible on sm and below) */}
64 |
65 |
66 | {isMenuOpen ? : }
67 |
68 |
69 |
70 |
71 | {/* Mobile Menu Dropdown (overlay) */}
72 | {isMenuOpen && (
73 |
77 |
78 |
79 | Features
80 |
81 |
87 | Github
88 |
89 |
90 | Signin
91 |
92 |
96 | Try Now
97 |
98 |
99 |
100 | )}
101 |
102 | );
103 | }
--------------------------------------------------------------------------------
/apps/coscribe-frontend/components/LandingPage/VideoSection.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 | import { useEffect, useRef } from "react";
3 |
4 | const VideoSection = () => {
5 | const videoRef = useRef(null);
6 |
7 | useEffect(() => {
8 | const observer = new IntersectionObserver(
9 | ([entry]) => {
10 | if (videoRef.current) {
11 | if (entry.isIntersecting) {
12 | videoRef.current.play(); // Auto-play when in view
13 | } else {
14 | videoRef.current.pause(); // Pause when out of view
15 | }
16 | }
17 | },
18 | { threshold: 0.5 } // Adjust threshold for when video should start playing
19 | );
20 |
21 | if (videoRef.current) {
22 | observer.observe(videoRef.current);
23 | }
24 |
25 | return () => {
26 | if (videoRef.current) observer.unobserve(videoRef.current);
27 | };
28 | }, []);
29 |
30 |
31 |
32 | return (
33 |
34 |
35 | {/* Video Container */}
36 |
37 |
44 |
45 | Your browser does not support the video tag.
46 |
47 |
48 |
49 |
50 | );
51 | };
52 | export default VideoSection
--------------------------------------------------------------------------------
/apps/coscribe-frontend/components/ProtectedRoutes/CanvasProtectedRoute.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 | import { useEffect, useState } from "react";
3 | import { useRouter } from "next/navigation";
4 | import { ClipLoader } from "react-spinners";
5 | import { HTTP_URL } from "@/config";
6 | interface ProtectedRouteProps {
7 | roomId: string;
8 | children: React.ReactNode;
9 | }
10 |
11 | export function CanvasProtectedRoute({ roomId, children }: ProtectedRouteProps) {
12 | const [isAuthorized, setIsAuthorized] = useState(false);
13 | const router = useRouter();
14 |
15 | useEffect(() => {
16 | const checkRoomAccess = async () => {
17 | try {
18 | // Fetch room details from the server
19 | const response = await fetch(`${HTTP_URL}/room/${roomId}`, {
20 | headers: {
21 | authorization: "Bearer " + localStorage.getItem("token"),
22 | },
23 | });
24 |
25 | if (!response.ok) {
26 | // Redirect to dashboard if the user is not authorized
27 | alert("Room doesnt exist");
28 | router.push("/dashboard");
29 | return;
30 | }
31 |
32 | // If authorized, allow rendering of the children
33 | setIsAuthorized(true);
34 | } catch (error) {
35 | console.error("Error checking room access:", error);
36 | router.push("/dashboard");
37 | }
38 | };
39 |
40 | checkRoomAccess();
41 | }, [roomId, router]);
42 |
43 | if (!isAuthorized) {
44 | return
;
45 | }
46 | return <>{children}>;
47 | }
--------------------------------------------------------------------------------
/apps/coscribe-frontend/components/ProtectedRoutes/ProtectedRoute.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useRouter } from "next/navigation";
4 | import { useEffect } from "react";
5 |
6 | export default function ProtectedRoute({ children }: { children: React.ReactNode }) {
7 | const router = useRouter();
8 |
9 | useEffect(() => {
10 | const token = localStorage.getItem("token");
11 | if (!token) {
12 | router.push("/");
13 | }
14 | }, [router]);
15 |
16 | return <>{children}>;
17 | }
--------------------------------------------------------------------------------
/apps/coscribe-frontend/components/Toolbars/ColorSelector.tsx:
--------------------------------------------------------------------------------
1 | import { Dispatch, SetStateAction, useEffect, useRef, useState } from "react";
2 | import { ColorPicker, useColor } from "react-color-palette";
3 | import "react-color-palette/css";
4 |
5 | interface Props {
6 | setStrokeColor: Dispatch>;
7 | bg?: boolean;
8 | }
9 |
10 | export default function ColorSelector({ bg,setStrokeColor }: Props) {
11 | const [color, setColor] = useColor(bg?"#ffffff00": "#ffffff");
12 | const [isOpen, setIsOpen] = useState(false);
13 | const divRef = useRef(null);
14 | const buttonRef = useRef(null);
15 |
16 | const demoColors = [bg?"red":"#ffffff", "#FF8383", "#2f9e44",bg?"#ffffff00": "#9479E1"];
17 |
18 | useEffect(() => {
19 | const handleClick = (e: MouseEvent) => {
20 | if (
21 | divRef.current &&
22 | !divRef.current.contains(e.target as Node) &&
23 | buttonRef.current &&
24 | !buttonRef.current.contains(e.target as Node)
25 | ) {
26 | setIsOpen(false);
27 | }
28 | };
29 |
30 | document.addEventListener("mousedown", handleClick);
31 | return () => {
32 | document.removeEventListener("mousedown", handleClick);
33 | };
34 | }, []);
35 |
36 | useEffect(() => {
37 | setStrokeColor(color.hex); // Update stroke color
38 | }, [color, setStrokeColor]);
39 |
40 | return (
41 |
42 |
43 | {demoColors.map((demoColor, index) => (
44 | setColor({ ...color, hex: demoColor })}
48 | style={{
49 | backgroundColor: demoColor === "#ffffff00" ? "white" : demoColor,
50 | backgroundImage:
51 | demoColor === "#ffffff00"
52 | ? "linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%, #ccc), linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%, #ccc)"
53 | : "none",
54 | backgroundSize: "6px 6px",
55 | backgroundPosition: "0 0, 3px 3px",
56 | }}
57 | className={`w-8 h-8 border-2 ${
58 | // Modified comparison to handle both regular colors and transparent
59 | (bg && demoColor === "#ffffff00" && color.hex === "#ffffff00") ||
60 | (!bg && color.hex === demoColor)
61 | ? "border-black shadow-md scale-105"
62 | : "border-gray-400"
63 | } transition-all duration-200 rounded-md`}
64 | />
65 | ))}
66 |
67 | {/* Color Picker Trigger */}
68 | setIsOpen(!isOpen)}
71 | style={{
72 | backgroundColor: color.hex === "#ffffff00" ? "white" : color.hex,
73 | backgroundImage:
74 | color.hex === "#ffffff00"
75 | ? "linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%, #ccc), linear-gradient(45deg, #ccc 25%, transparent 25%, transparent 75%, #ccc 75%, #ccc)"
76 | : "none",
77 | backgroundSize: "6px 6px",
78 | backgroundPosition: "0 0, 3px 3px",
79 | border: color.hex === "#ffffff00" ? "2px solid #ccc" : "none",
80 | }}
81 | className="w-8 h-8 border rounded-md shadow flex items-center justify-center ml-2"
82 | />
83 |
84 |
85 | {/* Color Picker Dialog */}
86 | {isOpen && (
87 |
91 | {
94 | // Ensure alpha is 1 unless user explicitly picks transparent
95 | const newHex = newColor.hex === "#ffffff00" ? "#ffffff00" : newColor.hex.slice(0, 7) + "ff";
96 | setColor({ ...newColor, hex: newHex });
97 | setStrokeColor(newHex); // Update stroke color immediately
98 | }}
99 | />
100 |
101 | )}
102 |
103 | );
104 | }
--------------------------------------------------------------------------------
/apps/coscribe-frontend/components/Toolbars/SideToolbar.tsx:
--------------------------------------------------------------------------------
1 | import { Dispatch, SetStateAction } from "react";
2 | import ColorSelector from "./ColorSelector";
3 | interface SideToolbarProps{
4 | color: string
5 | setColor: Dispatch>
6 | bgColor: string
7 | setBgColor: Dispatch>
8 | strokeWidth: number
9 | setStrokeWidth: Dispatch>
10 | strokeStyle:string
11 | setStrokeStyle:Dispatch>
12 | }
13 | export default function SideToolbar({setColor,setBgColor,setStrokeWidth,setStrokeStyle}:SideToolbarProps) {
14 | return (
15 |
16 |
20 |
21 |
Background Color
22 |
23 |
24 |
25 |
Stroke Width
26 |
27 |
28 |
29 |
Stroke Style
30 |
31 |
32 |
33 | )
34 | }
35 | import { useState } from "react";
36 | interface StrokeWidthProps{
37 | setStrokeWidth: Dispatch>
38 | }
39 | function StrokeWidth({setStrokeWidth}:StrokeWidthProps) {
40 | const widths = [2, 4, 6];
41 | const [selectedWidth, setSelectedWidth] = useState(2);
42 |
43 | return (
44 |
45 | {widths.map((width) => (
46 |
{
49 | setSelectedWidth(width)
50 | setStrokeWidth(width)
51 | }}
52 | className={`h-6 flex items-center cursor-pointer transition-all duration-200 mt-2 px-2 ${
53 | selectedWidth === width ? "bg-blue-500" : "hover:bg-[#373741]"
54 | }`}>
55 |
58 |
59 | ))}
60 |
61 | );
62 | }
63 | interface StrokeStyleProps{
64 | setStrokeStyle:Dispatch>
65 |
66 | }
67 | function StrokeStyle({setStrokeStyle}:StrokeStyleProps) {
68 | const styles = [
69 | { name: "Solid", style: "solid" },
70 | { name: "Dashed", style: "dashed" },
71 | { name: "Dotted", style: "dotted" },
72 | ];
73 | const [selectedStyle, setSelectedStyle] = useState("solid");
74 |
75 | return (
76 |
77 | {styles.map((stroke) => (
78 |
{
81 | setSelectedStyle(stroke.style)
82 | setStrokeStyle(stroke.style)
83 | }}
84 | className={`h-6 flex items-center cursor-pointer transition-all duration-200 px-2 mt-2 ${
85 | selectedStyle === stroke.style ? "bg-blue-500" : "hover:bg-[#373741]"
86 | }`}>
87 |
91 |
92 | ))}
93 |
94 | );
95 | }
96 |
97 |
--------------------------------------------------------------------------------
/apps/coscribe-frontend/components/Toolbars/Toolbar.tsx:
--------------------------------------------------------------------------------
1 | import { Draw } from '@/draw/draw';
2 | import { Square, Circle, Diamond, ArrowRight, Pencil, Type, Eraser, Trash2, MousePointer, Minus, LogOutIcon, LucideProps } from 'lucide-react';
3 | import { Dispatch, ForwardRefExoticComponent, RefAttributes, SetStateAction, useEffect, useState } from 'react';
4 | import { clearCanvas } from "@/draw/http";
5 | import { ConfirmDialog } from '../dialogbox/confirmation';
6 | import { useRouter } from 'next/navigation';
7 | export function Toolbar({
8 | shape,
9 | setShape,
10 | roomId,
11 | draw
12 | }: {
13 | shape:"select" | "text" | "circle" | "line" | "rect" | "diamond" | "arrow" | "pencil" | "eraser"
14 | setShape: Dispatch>;
15 | roomId: string;
16 | draw: Draw | undefined;
17 | }) {
18 | const [trashOpen,setTrashOpen] = useState(false);
19 | const [logoutOpen,setLogoutOpen] = useState(false);
20 | const router = useRouter();
21 | return (
22 |
23 |
24 |
32 |
40 |
49 |
58 |
67 |
75 |
83 |
91 |
99 |
100 |
{setTrashOpen(true)}}
103 | >
104 |
105 |
106 |
107 |
108 |
109 |
setLogoutOpen(true)}
112 | >
113 |
114 |
115 |
116 |
117 |
118 |
119 |
setLogoutOpen(false)}
122 | onConfirm={()=>{router.push('/dashboard')}}
123 | title="Confirm Logout"
124 | message="Are you sure you want to go to the dashboard? You can join anytime you want."
125 | confirmText="Go to Dashboard"
126 | />
127 | setTrashOpen(false)}
130 | onConfirm={() => { clearCanvas(roomId); draw?.clean(); }}
131 | title="Confirm Deleting "
132 | message="Are you sure you want to delete all your drawings? This action is irreversible and cannot be undone"
133 | confirmText="Delete"
134 | />
135 |
136 | );
137 | }
138 |
139 | interface ToolbarProps {
140 | shape:"select" | "text" | "circle" | "line" | "rect" | "diamond" | "arrow" | "pencil" | "eraser"
141 | setShape: Dispatch>;
142 | text: "select" | "text" | "circle" | "line" | "rect" | "diamond" | "arrow" | "pencil" | "eraser";
143 | icon: ForwardRefExoticComponent & RefAttributes>
144 | id: string;
145 | title: string
146 | }
147 |
148 | function ToolBarButton({shape, setShape, text, icon: Icon,id,title}: ToolbarProps) {
149 | const isSelected = shape === text; // Check if the current tool is selected
150 | useEffect(() => {
151 | const handleKeyDown = (event: KeyboardEvent) => {
152 | const match = title.match(/— (\w) or (\d)/);
153 | if (match) {
154 | const keyChar = match[1].toLowerCase();
155 | const keyNum = match[2];
156 |
157 | if ((event.key.toLowerCase() === keyChar || event.key === keyNum)) {
158 | if(shape!=="text")setShape(text);
159 | }
160 | }
161 | };
162 |
163 | document.addEventListener("keydown", handleKeyDown);
164 | return () => {
165 | document.removeEventListener("keydown", handleKeyDown);
166 | };
167 | }, [setShape,shape,text,title]);
168 | return (
169 | {
175 | setShape(text);
176 | }}
177 | >
178 |
179 |
180 |
181 |
182 | {/* ID indicator */}
183 |
184 | {id}
185 |
186 |
187 | );
188 | }
--------------------------------------------------------------------------------
/apps/coscribe-frontend/components/dialogbox/CreateRoom.tsx:
--------------------------------------------------------------------------------
1 | import { Dispatch, SetStateAction, useEffect, useRef } from "react";
2 | import { useMutation } from "@tanstack/react-query";
3 | import { createRoom } from "@/api/room";
4 | import toast from "react-hot-toast";
5 |
6 | interface DialogboxProps {
7 | createRoom: boolean;
8 | setCreateRoom: Dispatch>;
9 | onCreateRoom: () => Promise;
10 | }
11 |
12 | export default function Dialogbox({
13 | createRoom: CR,
14 | setCreateRoom,
15 | onCreateRoom,
16 | }: DialogboxProps) {
17 |
18 | const dialogRef = useRef(null);
19 | const inputRef = useRef(null);
20 |
21 |
22 | const mutate = useMutation({
23 | mutationFn: createRoom,
24 | onSuccess: async () => {
25 | setTimeout(async ()=>{
26 | await onCreateRoom();
27 | },1000)
28 | },
29 | });
30 |
31 |
32 | const handleCreateRoom = async (roomName: string) => {
33 | // Start the toast promise
34 | setCreateRoom(false);
35 | toast.promise(
36 | mutate.mutateAsync(roomName),
37 | {
38 | loading: (
39 |
40 | Creating room...
41 |
42 | ),
43 | success: (
44 |
45 | Room created successfully!
46 |
47 | ),
48 | error: (err) => (
49 |
50 | {err.message}
51 |
52 | ),
53 | },
54 | {
55 | style: {
56 | background: "#FAFAFA",
57 | color: "#1e1e1e", // Dark text
58 | borderRadius: "12px", // More rounded corners
59 | padding: "16px 20px", // More padding
60 | boxShadow: "0 4px 24px rgba(0, 0, 0, 0.1)", // Softer shadow
61 | border: "2px solid #e5e7eb", // Light border
62 | maxWidth: "500px", // Limit width
63 | },
64 | position:"top-center"
65 | },
66 | );
67 |
68 | };
69 |
70 | useEffect(() => {
71 | const handleKeyDown = (e: KeyboardEvent) => {
72 | if (e.key === "Enter" && inputRef.current) {
73 | const roomName = inputRef.current.value.trim();
74 | if (roomName) {
75 | handleCreateRoom(roomName);
76 | }
77 | }
78 | };
79 |
80 | document.addEventListener("keydown", handleKeyDown);
81 | return () => {
82 | document.removeEventListener("keydown", handleKeyDown);
83 | };
84 | }, [mutate, setCreateRoom,handleCreateRoom]);
85 |
86 | useEffect(() => {
87 | const handleClick = (e: MouseEvent) => {
88 | if (dialogRef.current && !dialogRef.current.contains(e.target as Node)) {
89 | setCreateRoom(false);
90 | }
91 | };
92 |
93 | if (CR) {
94 | document.addEventListener("mousedown", handleClick);
95 | }
96 | return () => {
97 | document.removeEventListener("mousedown", handleClick);
98 | };
99 | }, [CR, setCreateRoom]);
100 |
101 |
102 |
103 | return (
104 | <>
105 | {CR && (
106 |
107 |
111 |
112 | Create New Room
113 |
114 |
Enter Room Name
115 |
121 |
122 | setCreateRoom(false)}
125 | >
126 | Cancel
127 |
128 | {
132 | if (inputRef.current?.value) {
133 | handleCreateRoom(inputRef.current.value);
134 | }
135 | }}
136 | >
137 | {mutate.isPending ? "Creating": "Create"}
138 |
139 |
140 |
141 |
142 | )}
143 | >
144 | );
145 | }
--------------------------------------------------------------------------------
/apps/coscribe-frontend/components/dialogbox/JoinRoom.tsx:
--------------------------------------------------------------------------------
1 | import { joinRoom } from "@/api/room"
2 | import { useMutation } from "@tanstack/react-query"
3 | import { useRouter } from "next/navigation"
4 | import { Dispatch, SetStateAction, useEffect, useRef } from "react"
5 | import toast from "react-hot-toast"
6 | interface DialogboxProps{
7 | joinRoom:boolean,
8 | setJoinRoom:Dispatch>
9 | }
10 | export default function JoinDialogbox({joinRoom:JoinRoom,setJoinRoom}:DialogboxProps){
11 | const router = useRouter();
12 | const dialogRef = useRef(null)
13 | const inputRef = useRef(null);
14 | const join = useMutation({
15 | mutationFn: joinRoom,
16 | onSuccess:()=>{
17 | router.push(`./canvas/${inputRef.current?.value}`)
18 | },
19 | })
20 | const handleJoinRoom = (roomId:string)=>{
21 |
22 | toast.promise(
23 | join.mutateAsync(roomId),
24 | {
25 | loading: (
26 |
27 | Joining room...
28 |
29 | ),
30 | success: (
31 |
32 | Room Joined successfully! , Redirecting...
33 |
34 | ),
35 | error: (err) => (
36 |
37 | {err.message}
38 |
39 | ),
40 | },
41 | {
42 | style: {
43 | background: "#FAFAFA",
44 | color: "#1e1e1e", // Dark text
45 | borderRadius: "12px", // More rounded corners
46 | padding: "16px 20px", // More padding
47 | boxShadow: "0 4px 24px rgba(0, 0, 0, 0.1)", // Softer shadow
48 | border: "2px solid #e5e7eb", // Light border
49 | maxWidth: "500px", // Limit width
50 | },
51 | position:"top-center"
52 | },
53 | );
54 | console.log("Joining Room..");
55 | }
56 | useEffect(() => {
57 | const handleKeyDown = (e: KeyboardEvent) => {
58 | if (e.key === "Enter" && inputRef.current) {
59 | const roomId = inputRef.current.value.trim();
60 | if (roomId) {
61 | handleJoinRoom(roomId);
62 | }
63 | }
64 | };
65 |
66 | document.addEventListener("keydown", handleKeyDown);
67 | return () => {
68 | document.removeEventListener("keydown", handleKeyDown);
69 | };
70 | }, [setJoinRoom,join,handleJoinRoom]);
71 | useEffect(()=>{
72 | const handleClick = (e:MouseEvent)=>{
73 | if(dialogRef.current && !dialogRef.current.contains(e.target as Node)){
74 | setJoinRoom(false);
75 | }
76 | }
77 | if(JoinRoom){
78 | document.addEventListener('mousedown',handleClick);
79 | }
80 | return ()=>{
81 | document.removeEventListener('mousedown',handleClick);
82 | }
83 | },[JoinRoom,setJoinRoom])
84 |
85 |
86 | return <>
87 |
88 |
Join New Room
89 |
Enter Room Id
90 |
91 |
92 | setJoinRoom(false)}>Cancel
93 | inputRef.current?.value && handleJoinRoom(inputRef.current.value)}>Join
94 |
95 |
96 |
97 | >
98 | }
--------------------------------------------------------------------------------
/apps/coscribe-frontend/components/dialogbox/confirmation.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { X } from 'lucide-react';
3 |
4 | interface ConfirmDialogProps {
5 | isOpen: boolean;
6 | onClose: () => void;
7 | onConfirm: () => void;
8 | title: string;
9 | message: string;
10 | confirmText?: string;
11 | cancelText?: string;
12 | }
13 |
14 | export function ConfirmDialog({
15 | isOpen,
16 | onClose,
17 | onConfirm,
18 | title,
19 | message,
20 | confirmText = "Confirm",
21 | cancelText = "Cancel"
22 | }: ConfirmDialogProps) {
23 | if (!isOpen) return null;
24 |
25 | return (
26 |
27 | {/* Backdrop with high z-index */}
28 |
32 |
33 | {/* Dialog container */}
34 |
35 | {/* Dialog content */}
36 |
37 |
{title}
38 |
42 |
43 |
44 |
45 |
46 |
49 |
50 |
51 |
55 | {cancelText}
56 |
57 | {
59 | onConfirm();
60 | onClose();
61 | }}
62 | className="px-4 py-2 text-sm font-medium text-white bg-black border border-transparent rounded-md hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
63 | >
64 | {confirmText}
65 |
66 |
67 |
68 |
69 | );
70 | }
--------------------------------------------------------------------------------
/apps/coscribe-frontend/config.ts:
--------------------------------------------------------------------------------
1 | export const HTTP_URL = process.env.NEXT_PUBLIC_HTTP_URL
2 | export const WSS_URL = process.env.NEXT_PUBLIC_WSS_URL
--------------------------------------------------------------------------------
/apps/coscribe-frontend/draw/SelectionManager.ts:
--------------------------------------------------------------------------------
1 | import { Tool } from "./draw";
2 |
3 | export interface ResizeHandle {
4 | x: number;
5 | y: number;
6 | cursor: string;
7 | position: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
8 | }
9 |
10 | export class SelectionManager {
11 | private canvas: HTMLCanvasElement;
12 | private selectedShape: Tool | null = null;
13 | private isDragging: boolean = false;
14 | private isResizing: boolean = false;
15 | private dragOffset: { x: number; y: number } = { x: 0, y: 0 };
16 | private dragEndOffset: { x: number; y: number } = { x: 0, y: 0 };
17 | private activeResizeHandle: ResizeHandle | null = null;
18 | private originalShapeBounds: { x: number; y: number; width: number; height: number } | null = null;
19 | private ctx: CanvasRenderingContext2D;
20 | private setCursor(cursor: string) {
21 | this.canvas.style.cursor = cursor;
22 | }
23 |
24 | private resetCursor() {
25 | this.canvas.style.cursor = 'auto';
26 | }
27 | constructor(ctx: CanvasRenderingContext2D,canvas:HTMLCanvasElement) {
28 | this.ctx = ctx;
29 | this.canvas = canvas;
30 | }
31 |
32 | getSelectedShape(): Tool | null {
33 | return this.selectedShape;
34 | }
35 |
36 | setSelectedShape(shape: Tool | null) {
37 | this.selectedShape = shape;
38 | }
39 |
40 | isShapeSelected(): boolean {
41 | return this.selectedShape !== null;
42 | }
43 |
44 | isDraggingShape(): boolean {
45 | return this.isDragging;
46 | }
47 |
48 | isResizingShape(): boolean {
49 | return this.isResizing;
50 | }
51 |
52 | getShapeBounds(shape: Tool): { x: number; y: number; width: number; height: number } {
53 | let bounds = {
54 | x: shape.x,
55 | y: shape.y,
56 | width: 0,
57 | height: 0
58 | };
59 |
60 | switch (shape.type) {
61 | case "rect":
62 | bounds.width = shape.width || 0;
63 | bounds.height = shape.height || 0;
64 | if (bounds.width < 0) {
65 | bounds.x += bounds.width;
66 | bounds.width = Math.abs(bounds.width);
67 | }
68 | if (bounds.height < 0) {
69 | bounds.y += bounds.height;
70 | bounds.height = Math.abs(bounds.height);
71 | }
72 | bounds.x-=10;
73 | bounds.y-=10;
74 | bounds.width+=20;
75 | bounds.height+=20;
76 | break;
77 | case "circle":
78 | bounds.width = (shape.width || 0) * 2;
79 | bounds.height = (shape.height || 0) * 2;
80 | break;
81 | case "diamond":
82 | const size = shape.size || 0;
83 | bounds.width = size * 2;
84 | bounds.height = size * 2;
85 | bounds.x -= size;
86 | bounds.y -= size;
87 | break;
88 | case "line":
89 | case "arrow":
90 | bounds.width = Math.abs(shape.endX - shape.x)+20;
91 | bounds.height = Math.abs(shape.endY - shape.y)+20;
92 | bounds.x = Math.min(shape.x, shape.endX)-10;
93 | bounds.y = Math.min(shape.y, shape.endY)-10;
94 | break;
95 | case "text":
96 | this.ctx.font = '24px Comic Sans MS, cursive';
97 | const metrics = this.ctx.measureText(shape.text || "");
98 | bounds.x = shape.x-10
99 | bounds.y = shape.y-10
100 | bounds.width = metrics.width+20;
101 | bounds.height = 48;
102 | break;
103 | }
104 |
105 | return bounds;
106 | }
107 |
108 | private getResizeHandles(bounds: { x: number; y: number; width: number; height: number }): ResizeHandle[] {
109 | return [
110 | { x: bounds.x, y: bounds.y, cursor: 'nw-resize', position: 'top-left' },
111 | { x: bounds.x + bounds.width, y: bounds.y, cursor: 'ne-resize', position: 'top-right' },
112 | { x: bounds.x, y: bounds.y + bounds.height, cursor: 'sw-resize', position: 'bottom-left' },
113 | { x: bounds.x + bounds.width, y: bounds.y + bounds.height, cursor: 'se-resize', position: 'bottom-right' }
114 | ];
115 | }
116 |
117 | drawSelectionBox(bounds: { x: number; y: number; width: number; height: number }) {
118 | this.ctx.save();
119 | this.ctx.strokeStyle = '#6082B6';
120 | // this.ctx.setLineDash([5, 5]);
121 | this.ctx.strokeRect(bounds.x, bounds.y, bounds.width, bounds.height);
122 |
123 | // Draw resize handles
124 | this.ctx.fillStyle = '#6082B6';
125 | const handles = this.getResizeHandles(bounds);
126 | handles.forEach(handle => {
127 | this.ctx.beginPath();
128 | this.ctx.arc(handle.x, handle.y, 7, 0, Math.PI * 2);
129 | this.ctx.fill();
130 | });
131 |
132 | this.ctx.restore();
133 | }
134 |
135 | isPointInShape(x: number, y: number, shape: Tool): boolean {
136 | const bounds = this.getShapeBounds(shape);
137 | return x >= bounds.x && x <= bounds.x + bounds.width &&
138 | y >= bounds.y && y <= bounds.y + bounds.height;
139 | }
140 |
141 | getResizeHandleAtPoint(x: number, y: number, bounds: { x: number; y: number; width: number; height: number }): ResizeHandle | null {
142 | const handles = this.getResizeHandles(bounds);
143 | const handleRadius = 5;
144 |
145 | return handles.find(handle => {
146 | const dx = x - handle.x;
147 | const dy = y - handle.y;
148 | return (dx * dx + dy * dy) <= handleRadius * handleRadius;
149 | }) || null;
150 | }
151 |
152 | startDragging(x: number, y: number) {
153 | if (this.selectedShape) {
154 | this.isDragging = true;
155 | this.dragOffset = {
156 | x: x - this.selectedShape.x,
157 | y: y - this.selectedShape.y
158 | };
159 |
160 | if (this.selectedShape.type === "line" || this.selectedShape.type === "arrow") {
161 | this.dragEndOffset = {
162 | x: x - this.selectedShape.endX,
163 | y: y - this.selectedShape.endY
164 | };
165 | }
166 | this.setCursor('move');
167 | }
168 | }
169 |
170 | startResizing(x: number, y: number) {
171 | if (this.selectedShape) {
172 | const bounds = this.getShapeBounds(this.selectedShape);
173 | const handle = this.getResizeHandleAtPoint(x, y, bounds);
174 |
175 | if (handle) {
176 | this.isResizing = true;
177 | this.activeResizeHandle = handle;
178 | this.originalShapeBounds = { ...bounds };
179 | this.setCursor(handle.cursor);
180 | }
181 | }
182 | }
183 |
184 | updateDragging(x: number, y: number) {
185 | if (this.isDragging && this.selectedShape) {
186 | if (this.selectedShape.type === "line" || this.selectedShape.type === "arrow") {
187 | // Calculate the movement delta
188 | const dx = x - this.dragOffset.x;
189 | const dy = y - this.dragOffset.y;
190 |
191 | // Move both start and end points by the same amount
192 | const moveX = dx - this.selectedShape.x;
193 | const moveY = dy - this.selectedShape.y;
194 |
195 | this.selectedShape.x = dx;
196 | this.selectedShape.y = dy;
197 | this.selectedShape.endX += moveX;
198 | this.selectedShape.endY += moveY;
199 | } else if (this.selectedShape.type === "circle") {
200 | // Calculate the movement delta
201 | const dx = x - this.dragOffset.x;
202 | const dy = y - this.dragOffset.y;
203 |
204 | if(!this.selectedShape.width || !this.selectedShape.height) return;
205 | // Move the circle's start and end points by the same amount
206 | this.selectedShape.x = dx;
207 | this.selectedShape.y = dy;
208 | this.selectedShape.endX = dx + (this.selectedShape.width * 2); // Diameter = radius * 2
209 | this.selectedShape.endY = dy + (this.selectedShape.height * 2); // Diameter = radius * 2
210 | }
211 | else {
212 | // For other shapes, just update the position
213 | this.selectedShape.x = x - this.dragOffset.x;
214 | this.selectedShape.y = y - this.dragOffset.y;
215 | }
216 |
217 | }
218 |
219 | }
220 |
221 | updateResizing(x: number, y: number) {
222 | if (this.isResizing && this.selectedShape && this.activeResizeHandle && this.originalShapeBounds) {
223 | const newBounds = { ...this.originalShapeBounds };
224 | this.setCursor(this.activeResizeHandle.cursor);
225 | switch (this.activeResizeHandle.position) {
226 |
227 | case 'top-left':
228 | newBounds.width += newBounds.x - x;
229 | newBounds.height += newBounds.y - y;
230 | newBounds.x = x;
231 | newBounds.y = y;
232 | break;
233 | case 'top-right':
234 | newBounds.width = x - newBounds.x;
235 | newBounds.height += newBounds.y - y;
236 | newBounds.y = y;
237 | break;
238 | case 'bottom-left':
239 | newBounds.width += newBounds.x - x;
240 | newBounds.height = y - newBounds.y;
241 | newBounds.x = x;
242 | break;
243 | case 'bottom-right':
244 | newBounds.width = x - newBounds.x;
245 | newBounds.height = y - newBounds.y;
246 | break;
247 | }
248 |
249 | if (this.selectedShape.type === "rect") {
250 | this.selectedShape.x = newBounds.x;
251 | this.selectedShape.y = newBounds.y;
252 | this.selectedShape.width = newBounds.width;
253 | this.selectedShape.height = newBounds.height;
254 | }
255 | else if (this.selectedShape.type === "circle") {
256 | // Update the circle's start/end points and radii
257 | this.selectedShape.x = newBounds.x; // Left edge of bounding box
258 | this.selectedShape.endX = newBounds.x + newBounds.width; // Right edge of bounding box
259 | this.selectedShape.y = newBounds.y; // Top edge of bounding box
260 | this.selectedShape.endY = newBounds.y + newBounds.height; // Bottom edge of bounding box
261 |
262 | // Update the radii (width/height are radiusX and radiusY)
263 | this.selectedShape.width = Math.max((newBounds.width / 2),0); // radiusX = diameter / 2
264 | this.selectedShape.height = Math.max((newBounds.height / 2),0); // radiusY = diameter / 2
265 | }
266 | else if (this.selectedShape.type === "diamond") {
267 | this.selectedShape.size = Math.max(Math.abs(newBounds.width), Math.abs(newBounds.height)) / 2;
268 | }
269 | else if (this.selectedShape.type === "line" || this.selectedShape.type === "arrow") {
270 | // Update line/arrow endpoints based on the resize handle
271 | switch (this.activeResizeHandle.position) {
272 | case 'top-left':
273 | this.selectedShape.x = x;
274 | this.selectedShape.y = y;
275 | break;
276 | case 'top-right':
277 | this.selectedShape.endX = x;
278 | this.selectedShape.y = y;
279 | break;
280 | case 'bottom-left':
281 | this.selectedShape.x = x;
282 | this.selectedShape.endY = y;
283 | break;
284 | case 'bottom-right':
285 | this.selectedShape.endX = x;
286 | this.selectedShape.endY = y;
287 | break;
288 | }
289 | }
290 | }
291 | }
292 |
293 | stopDragging() {
294 | this.isDragging = false;
295 | this.resetCursor();
296 | }
297 |
298 | stopResizing() {
299 | this.isResizing = false;
300 | this.activeResizeHandle = null;
301 | this.originalShapeBounds = null;
302 | this.resetCursor();
303 | }
304 | }
--------------------------------------------------------------------------------
/apps/coscribe-frontend/draw/draw.ts:
--------------------------------------------------------------------------------
1 | import { getExistingChats } from "./http";
2 | import { eraseShape } from "./eraser";
3 | import { SelectionManager } from "./SelectionManager";
4 |
5 | export interface Tool {
6 | id: string;
7 | type:
8 | | "rect"
9 | | "circle"
10 | | "pencil"
11 | | "diamond"
12 | | "arrow"
13 | | "line"
14 | | "text"
15 | | "eraser"
16 | | "select"
17 | x: number;
18 | y: number;
19 | endX: number;
20 | endY: number;
21 | width?: number;
22 | height?: number;
23 | rotation?: number;
24 | text?: string;
25 | size?: number;
26 | path?: { x: number; y: number }[]; // for draw drawing
27 | color: string // Make color required
28 | bgColor: string
29 | strokeWidth:number
30 | strokeStyle:string
31 | }
32 |
33 | export class Draw {
34 | private ctx: CanvasRenderingContext2D;
35 | private canvas: HTMLCanvasElement;
36 | private socket: WebSocket;
37 | private roomId: string;
38 | private existingStrokes: Tool[] = [];
39 | private clicked: boolean = false;
40 | private startX: number = 0;
41 | private startY: number = 0;
42 | private currShape = "select";
43 | private selectedShape: Tool | null = null;
44 | private tempPath: { x: number; y: number }[] = [];
45 | private selectionManager: SelectionManager;
46 | private setShape;
47 | private currColor: string = "white";
48 | private currBgColor: string = "#ffffff00";
49 | private currStrokeWidth:number = 2;
50 | private currStrokeStyle:string = "solid"
51 | constructor(canvas: HTMLCanvasElement, ctx: CanvasRenderingContext2D, socket: WebSocket, roomId: string, setShape: any) {
52 | this.ctx = ctx;
53 | this.canvas = canvas;
54 | this.socket = socket;
55 | this.roomId = roomId;
56 | this.setShape = setShape;
57 | this.selectionManager = new SelectionManager(ctx, canvas);
58 | this.init();
59 | this.initHandlers();
60 | this.initMouseHandlers();
61 | }
62 |
63 | destroy() {
64 | this.canvas.removeEventListener("mousedown", this.mouseDownHandler);
65 | this.canvas.removeEventListener("mouseup", this.mouseUpHandler);
66 | this.canvas.removeEventListener("mousemove", this.mouseMoveHandler);
67 | }
68 |
69 | setTool(shape: Tool["type"]) {
70 | this.currShape = shape;
71 | if (shape !== "select") {
72 | this.selectedShape = null;
73 | this.selectionManager.setSelectedShape(null);
74 | this.clearCanvas();
75 | }
76 | }
77 |
78 | setColor(color: string) {
79 | this.currColor = color;
80 | }
81 |
82 | setBgColor(color: string) {
83 | this.currBgColor = color;
84 | }
85 | setStrokeWidth(width:number){
86 | this.currStrokeWidth = width;
87 | }
88 | setStrokeStyle(style:string){
89 | this.currStrokeStyle = style;
90 | }
91 | async init() {
92 | this.existingStrokes = await getExistingChats(this.roomId);
93 | this.clearCanvas();
94 | }
95 |
96 | clean() {
97 | this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
98 | this.existingStrokes = [];
99 | this.socket.send(JSON.stringify({
100 | type: "clean",
101 | roomId: this.roomId
102 | }));
103 | }
104 |
105 | initHandlers() {
106 | this.socket.onmessage = (event) => {
107 | const message = JSON.parse(event.data);
108 | if (message.type == "chat") {
109 | const parsedShape = JSON.parse(message.message);
110 | this.existingStrokes.push(parsedShape);
111 | this.clearCanvas();
112 | }
113 | if (message.type == "eraser") {
114 | this.existingStrokes = this.existingStrokes.filter((shape) => shape.id !== message.id);
115 | this.clearCanvas();
116 | }
117 | if (message.type == "clean") {
118 | this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
119 | this.existingStrokes = [];
120 | }
121 | if (message.type == "update") {
122 | const parsedShape = JSON.parse(message.message);
123 | const shapeIndex = this.existingStrokes.findIndex((shape) => shape.id === parsedShape.id);
124 | if (shapeIndex !== -1) {
125 | this.existingStrokes[shapeIndex] = parsedShape;
126 | this.clearCanvas();
127 | }
128 | }
129 | }
130 | }
131 |
132 | initMouseHandlers() {
133 | this.canvas.addEventListener("mousedown", this.mouseDownHandler);
134 | this.canvas.addEventListener("mouseup", this.mouseUpHandler);
135 | this.canvas.addEventListener("mousemove", this.mouseMoveHandler);
136 | }
137 |
138 | clearCanvas() {
139 | this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
140 | this.ctx.lineCap = "round";
141 | this.ctx.lineJoin = "round";
142 |
143 | this.existingStrokes.forEach((shape) => {
144 | this.ctx.save();
145 | switch (shape.strokeStyle) {
146 | case "solid":
147 | this.ctx.setLineDash([]); // Solid line
148 | break;
149 | case "dotted":
150 | this.ctx.setLineDash([shape.strokeWidth, shape.strokeWidth*2]); // Increased dot spacing
151 | break;
152 | case "dashed":
153 | this.ctx.setLineDash([shape.strokeWidth*4, shape.strokeWidth*2]); // Longer dashes with more space
154 | break;
155 | default:
156 | this.ctx.setLineDash([]); // Default to solid
157 | }
158 | // Set the colors for the current shape
159 | this.ctx.strokeStyle = shape.color;
160 | this.ctx.fillStyle = shape.bgColor;
161 | this.ctx.lineWidth = shape.strokeWidth;
162 |
163 |
164 | if (shape.type === "pencil") {
165 | const path = shape.path;
166 | if (!path || path.length === 0) return;
167 | this.ctx.beginPath();
168 | this.ctx.moveTo(path[0].x, path[0].y);
169 |
170 | for (let i = 1; i < path.length; i++) {
171 | this.ctx.lineTo(path[i].x, path[i].y);
172 | }
173 | this.ctx.stroke();
174 | this.ctx.closePath();
175 | }
176 | if (shape.type === "circle") {
177 | this.ctx.beginPath();
178 | if (!shape.width || !shape.height) return;
179 | const centerX = (shape.x + shape.endX) / 2;
180 | const centerY = (shape.y + shape.endY) / 2;
181 | this.ctx.ellipse(centerX, centerY, shape.width, shape.height, 0, 0, 2 * Math.PI);
182 | this.ctx.fill();
183 | this.ctx.stroke();
184 | }
185 | if (shape.type == "rect") {
186 | if (!shape.width || !shape.height) return;
187 | this.ctx.fillRect(shape.x, shape.y, shape.width, shape.height);
188 | this.ctx.strokeRect(shape.x, shape.y, shape.width, shape.height);
189 | }
190 | if (shape.type == "diamond") {
191 | const size = shape.size;
192 | if (!size) return;
193 | this.drawDiamond(shape.x, shape.y, size, true);
194 | }
195 | if (shape.type == "arrow") {
196 | this.drawLine(shape.x, shape.y, shape.endX, shape.endY, true);
197 | }
198 | if (shape.type == "line") {
199 | this.drawLine(shape.x, shape.y, shape.endX, shape.endY, false);
200 | }
201 | if (shape.type == "text") {
202 | if (shape.text) {
203 | this.ctx.font = '24px Comic Sans MS, cursive';
204 | this.ctx.fillText(shape.text, shape.x, shape.y + 24);
205 | }
206 | }
207 |
208 | this.ctx.restore();
209 | });
210 |
211 | // Set current colors for new drawings
212 | this.ctx.strokeStyle = this.currColor;
213 | this.ctx.fillStyle = this.currBgColor;
214 | this.ctx.lineWidth = this.currStrokeWidth;
215 | switch (this.currStrokeStyle) {
216 | case "solid":
217 | this.ctx.setLineDash([]); // Solid line
218 | break;
219 | case "dotted":
220 | this.ctx.setLineDash([this.currStrokeWidth, this.currStrokeWidth*2]); // Increased dot spacing
221 | break;
222 | case "dashed":
223 | this.ctx.setLineDash([this.currStrokeWidth*4, this.currStrokeWidth*2]); // Longer dashes with more space
224 | break;
225 | default:
226 | this.ctx.setLineDash([]); // Default to solid
227 | }
228 |
229 | // Draw selection box if there's a selected shape
230 | if (this.selectionManager.isShapeSelected() && this.currShape === "select") {
231 | const selectedShape = this.selectionManager.getSelectedShape();
232 | if (selectedShape) {
233 | const bounds = this.selectionManager.getShapeBounds(selectedShape);
234 | this.selectionManager.drawSelectionBox(bounds);
235 | }
236 | }
237 | }
238 |
239 | mouseDownHandler = (e: MouseEvent) => {
240 | const rect = this.canvas.getBoundingClientRect();
241 | const x = e.clientX - rect.left;
242 | const y = e.clientY - rect.top;
243 |
244 | if (this.currShape === "select") {
245 | const selectedShape = this.selectionManager.getSelectedShape();
246 | if (selectedShape) {
247 | const bounds = this.selectionManager.getShapeBounds(selectedShape);
248 | const handle = this.selectionManager.getResizeHandleAtPoint(x, y, bounds);
249 |
250 | if (handle) {
251 | this.selectionManager.startResizing(x, y);
252 | return;
253 | }
254 | }
255 |
256 | // Check if clicked on an existing shape
257 | for (let i = this.existingStrokes.length - 1; i >= 0; i--) {
258 | const shape = this.existingStrokes[i];
259 | if (this.selectionManager.isPointInShape(x, y, shape)) {
260 | this.selectedShape = shape;
261 | this.selectionManager.setSelectedShape(shape);
262 | this.selectionManager.startDragging(x, y);
263 | this.clearCanvas();
264 | return;
265 | }
266 | }
267 |
268 | this.selectedShape = null;
269 | this.selectionManager.setSelectedShape(null);
270 | this.clearCanvas();
271 | return;
272 | }
273 |
274 | this.clicked = true;
275 | this.startX = x;
276 | this.startY = y;
277 |
278 | if (this.currShape === "pencil") {
279 | this.tempPath = [{ x: this.startX, y: this.startY }];
280 | this.ctx.beginPath();
281 | this.ctx.moveTo(this.startX, this.startY);
282 | }
283 | else if (this.currShape == "text") {
284 | this.clicked = false;
285 | this.addInput(e.clientX, e.clientY);
286 | }
287 | else {
288 | this.selectedShape = {
289 | type: this.currShape as Tool["type"],
290 | x: this.startX,
291 | y: this.startY,
292 | id: `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
293 | endX: this.startX,
294 | endY: this.startY,
295 | color: this.currColor,
296 | bgColor: this.currBgColor,
297 | strokeWidth: this.currStrokeWidth,
298 | strokeStyle: this.currStrokeStyle
299 | };
300 | }
301 | }
302 |
303 | mouseUpHandler = (e: MouseEvent) => {
304 | if(this.currShape !== "pencil" && this.currShape !== "eraser" && this.currShape !== "line") this.setShape("select");
305 | if (this.currShape === "select") {
306 | if (this.selectionManager.isDraggingShape() || this.selectionManager.isResizingShape()) {
307 | const selectedShape = this.selectionManager.getSelectedShape();
308 | if (selectedShape) {
309 | const index = this.existingStrokes.findIndex(shape => shape.id === selectedShape.id);
310 | if (index !== -1) {
311 | this.existingStrokes[index] = selectedShape;
312 | this.clearCanvas();
313 | this.socket.send(JSON.stringify({
314 | type: "update",
315 | id: selectedShape.id,
316 | roomId: this.roomId,
317 | message: JSON.stringify(selectedShape)
318 | }));
319 | }
320 | }
321 | }
322 | this.selectionManager.stopDragging();
323 | this.selectionManager.stopResizing();
324 | return;
325 | }
326 |
327 | if (this.selectedShape) {
328 | if (this.selectedShape.type == "circle") {
329 | this.selectedShape.height = Math.abs(e.clientY - this.startY)/2;
330 | this.selectedShape.width = Math.abs(e.clientX - this.startX) / 2
331 | }
332 | if (this.selectedShape.type == "rect") {
333 | this.selectedShape.height = (e.clientY - this.startY);
334 | this.selectedShape.width = (e.clientX - this.startX);
335 | }
336 | const currSize = Math.max(Math.abs(e.clientX - this.startX), Math.abs(e.clientY - this.startY));
337 |
338 | this.selectedShape.size = currSize;
339 | this.selectedShape.endX = e.clientX;
340 | this.selectedShape.endY = e.clientY;
341 | }
342 |
343 | this.clicked = false;
344 | if (this.currShape === "pencil") {
345 | if (this.tempPath.length <= 1) return;
346 | this.selectedShape = {
347 | id: `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
348 | type: "pencil",
349 | x: 0,
350 | y: 0,
351 | endX: 0,
352 | endY: 0,
353 | path: [...this.tempPath],
354 | color: this.currColor,
355 | bgColor: this.currBgColor,
356 | strokeWidth: this.currStrokeWidth,
357 | strokeStyle: this.currStrokeStyle
358 |
359 | };
360 | this.existingStrokes.push(this.selectedShape);
361 | this.socket.send(JSON.stringify({
362 | type: "chat",
363 | id: this.selectedShape.id,
364 | roomId: this.roomId,
365 | message: JSON.stringify(this.selectedShape)
366 | }));
367 | this.tempPath = [];
368 | }
369 | else if (this.currShape != "eraser" && this.currShape != "select" && this.currShape != "text") {
370 | if (!this.selectedShape) return;
371 | this.existingStrokes.push(this.selectedShape);
372 | this.socket.send(JSON.stringify({
373 | type: "chat",
374 | id: this.selectedShape.id,
375 | roomId: this.roomId,
376 | message: JSON.stringify(this.selectedShape)
377 | }));
378 | this.selectedShape = null;
379 | }
380 | }
381 |
382 | mouseMoveHandler = (e: MouseEvent) => {
383 | const rect = this.canvas.getBoundingClientRect();
384 | const x = e.clientX - rect.left;
385 | const y = e.clientY - rect.top;
386 | if (this.currShape === "select") {
387 | if (this.selectionManager.isDraggingShape()) {
388 | this.selectionManager.updateDragging(x, y);
389 | this.clearCanvas();
390 | }
391 | else if (this.selectionManager.isResizingShape()) {
392 | this.selectionManager.updateResizing(x, y);
393 | this.clearCanvas();
394 | }
395 | return;
396 | }
397 |
398 | if (this.clicked) {
399 | this.ctx.strokeStyle=this.currColor
400 | this.ctx.lineCap = "round";
401 | this.ctx.lineJoin = "round";
402 | this.ctx.lineWidth = this.currStrokeWidth;
403 | switch (this.currStrokeStyle) {
404 | case "solid":
405 | this.ctx.setLineDash([]); // Solid line
406 | break;
407 | case "dotted":
408 | this.ctx.setLineDash([this.currStrokeWidth, this.currStrokeWidth*2]); // Increased dot spacing
409 | break;
410 | case "dashed":
411 | this.ctx.setLineDash([this.currStrokeWidth*4, this.currStrokeWidth*2]); // Longer dashes with more space
412 | break;
413 | default:
414 | this.ctx.setLineDash([]); // Default to solid
415 | }
416 | if (this.currShape == "rect") {
417 | this.drawRect(e);
418 | }
419 | else if (this.currShape == "circle") {
420 | requestAnimationFrame(() => {
421 | this.drawCircle(e);
422 | });
423 | }
424 | else if (this.currShape === "pencil") {
425 | requestAnimationFrame(() => {
426 | this.drawPencil(e);
427 | });
428 | }
429 | else if (this.currShape === "diamond") {
430 | const currSize = Math.max(Math.abs(x - this.startX), Math.abs(y - this.startY));
431 | requestAnimationFrame(() => {
432 | this.clearCanvas();
433 | this.drawDiamond(this.startX, this.startY, currSize, true);
434 | });
435 | }
436 | else if (this.currShape === "arrow") {
437 | this.clearCanvas();
438 | this.drawLine(this.startX, this.startY, x, y, true);
439 | }
440 | else if (this.currShape === "line") {
441 | this.clearCanvas();
442 | this.drawLine(this.startX, this.startY, x, y, false);
443 | }
444 | else if (this.currShape === "eraser") {
445 | this.eraseShape(x, y);
446 | }
447 | }
448 | }
449 |
450 | eraseShape(x: number, y: number) {
451 | const eraserSize = 10;
452 | this.existingStrokes = eraseShape(this.existingStrokes, x, y, eraserSize, this.socket, this.roomId);
453 | this.clearCanvas();
454 | }
455 |
456 | drawCircle(e: MouseEvent) {
457 | const endX = e.clientX;
458 | const endY = e.clientY;
459 | const centerX = (this.startX + endX) / 2;
460 | const centerY = (this.startY + endY) / 2;
461 |
462 | const radiusX = Math.abs(endX - this.startX) / 2;
463 | const radiusY = Math.abs(endY - this.startY) / 2;
464 |
465 | this.clearCanvas();
466 | this.ctx.beginPath();
467 | this.ctx.ellipse(centerX, centerY, radiusX, radiusY, 0, 0, 2 * Math.PI);
468 | this.ctx.fill();
469 | this.ctx.stroke();
470 | }
471 |
472 | drawRect(e: MouseEvent) {
473 | const width = e.clientX - this.startX;
474 | const height = e.clientY - this.startY;
475 | this.clearCanvas();
476 | this.ctx.fillRect(this.startX, this.startY, width, height);
477 | this.ctx.strokeRect(this.startX, this.startY, width, height);
478 | }
479 |
480 | drawPencil(e: MouseEvent) {
481 | const newPoint = { x: e.clientX, y: e.clientY };
482 | this.tempPath.push(newPoint);
483 | this.ctx.lineTo(newPoint.x, newPoint.y);
484 | this.ctx.stroke();
485 | }
486 |
487 | drawDiamond(startX: number, startY: number, size: number, fill: boolean) {
488 | this.ctx.beginPath();
489 | this.ctx.moveTo(startX, startY - size);
490 | this.ctx.lineTo(startX + size, startY);
491 | this.ctx.lineTo(startX, startY + size);
492 | this.ctx.lineTo(startX - size, startY);
493 | this.ctx.closePath();
494 | if (fill) {
495 | this.ctx.fill();
496 | }
497 | this.ctx.stroke();
498 | }
499 |
500 | drawLine(startX: number, startY: number, endX: number, endY: number, arrow: boolean) {
501 | this.ctx.beginPath();
502 | this.ctx.moveTo(startX, startY);
503 | this.ctx.lineTo(endX, endY);
504 | this.ctx.stroke();
505 |
506 | if (arrow == false) return;
507 |
508 | const arrowLength = 10;
509 | const angle = Math.atan2(endY - startY, endX - startX);
510 |
511 | this.ctx.beginPath();
512 |
513 | this.ctx.moveTo(endX, endY);
514 | this.ctx.lineTo(
515 | endX - arrowLength * Math.cos(angle - Math.PI / 6),
516 | endY - arrowLength * Math.sin(angle - Math.PI / 6)
517 | );
518 |
519 | this.ctx.moveTo(endX, endY);
520 | this.ctx.lineTo(
521 | endX - arrowLength * Math.cos(angle + Math.PI / 6),
522 | endY - arrowLength * Math.sin(angle + Math.PI / 6)
523 | );
524 |
525 | this.ctx.stroke();
526 | }
527 |
528 | addInput(x: number, y: number) {
529 | const input = document.createElement("input");
530 | input.type = "text";
531 | input.style.position = "absolute";
532 | input.style.left = `${x}px`;
533 | input.style.top = `${y}px`;
534 | input.style.background = "transparent";
535 | input.style.color = this.currColor;
536 | input.style.border = "none";
537 | input.style.outline = "none";
538 | input.style.fontSize = "24px";
539 | input.style.fontFamily = "Comic Sans MS, cursive";
540 | input.style.maxWidth="100px"
541 | document.body.appendChild(input);
542 | setTimeout(() => input.focus(), 0);
543 |
544 | const handleSubmit = () => {
545 | if (input.value.trim() !== "") {
546 | this.drawText(input.value, x, y);
547 | this.selectedShape = {
548 | id: `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
549 | type: "text",
550 | x,
551 | y,
552 | endX: x,
553 | endY: y,
554 | text: input.value.trim(),
555 | color: this.currColor,
556 | bgColor: this.currColor,
557 | strokeWidth: this.currStrokeWidth,
558 | strokeStyle: "solid"
559 | };
560 | this.existingStrokes.push(this.selectedShape);
561 | this.socket.send(
562 | JSON.stringify({
563 | type: "chat",
564 | id: this.selectedShape.id,
565 | roomId: this.roomId,
566 | message: JSON.stringify(this.selectedShape),
567 | })
568 | );
569 | }
570 | document.body.removeChild(input);
571 | };
572 |
573 | input.addEventListener("keydown", (e) => {
574 | if (e.key === "Enter") {
575 | handleSubmit();
576 | }
577 | });
578 |
579 | const handleClickOutside = (e: MouseEvent) => {
580 | if (!input.contains(e.target as Node)) {
581 | handleSubmit();
582 | }
583 | };
584 |
585 | setTimeout(() => {
586 | document.addEventListener("mousedown", handleClickOutside);
587 | }, 10);
588 |
589 | input.addEventListener("blur", () => {
590 | document.removeEventListener("mousedown", handleClickOutside);
591 | });
592 | }
593 |
594 | drawText(text: string, x: number, y: number) {
595 | this.ctx.font = '24px Comic Sans MS, cursive';
596 | this.ctx.fillStyle=this.currColor;
597 | this.ctx.lineWidth = this.currStrokeWidth
598 | this.ctx.fillText(text, x, y + 24);
599 | }
600 | }
--------------------------------------------------------------------------------
/apps/coscribe-frontend/draw/eraser.ts:
--------------------------------------------------------------------------------
1 | // util.ts
2 | import { Tool } from "./draw";
3 | export function isNearPoint(x: number, y: number, px: number, py: number, eraserSize: number): boolean {
4 | return Math.sqrt((x - px) ** 2 + (y - py) ** 2) < eraserSize;
5 | }
6 |
7 | export function isPointNearLine(x: number, y: number, x1: number, y1: number, x2: number, y2: number, eraserSize: number): boolean {
8 | const d = Math.abs((y2 - y1) * x - (x2 - x1) * y + x2 * y1 - y2 * x1) /
9 | Math.sqrt((y2 - y1) ** 2 + (x2 - x1) ** 2);
10 | return d < eraserSize;
11 | }
12 |
13 | export function isNearRectangle(x: number, y: number, shape: any): boolean {
14 | return x >= shape.x &&
15 | x <= shape.x + (shape.width || 0) &&
16 | y >= shape.y &&
17 | y <= shape.y + (shape.height || 0);
18 | }
19 |
20 | export function isNearCircle(x: number, y: number, shape: any): boolean {
21 | if (shape.type !== "circle") return false;
22 |
23 | // Calculate the center of the circle
24 | const centerX = (shape.x + shape.endX) / 2;
25 | const centerY = (shape.y + shape.endY) / 2;
26 |
27 | // Calculate the distance from the point to the center
28 | const dx = x - centerX;
29 | const dy = y - centerY;
30 |
31 | // Get the radii
32 | const radiusX = shape.width || 0;
33 | const radiusY = shape.height || 0;
34 |
35 | // Check if the point is inside the ellipse
36 | return (dx * dx) / (radiusX * radiusX) + (dy * dy) / (radiusY * radiusY) <= 1;
37 | }
38 |
39 |
40 | export function isNearDiamond(x: number, y: number, shape: any, eraserSize: number): boolean {
41 | const startX = shape.x;
42 | const startY = shape.y;
43 | const size = shape.size;
44 |
45 | const top = { x: startX, y: startY - size };
46 | const right = { x: startX + size, y: startY };
47 | const bottom = { x: startX, y: startY + size };
48 | const left = { x: startX - size, y: startY };
49 |
50 |
51 | const isNearEdge =
52 | isPointNearLine(x, y, top.x, top.y, right.x, right.y, eraserSize) ||
53 | isPointNearLine(x, y, right.x, right.y, bottom.x, bottom.y, eraserSize) ||
54 | isPointNearLine(x, y, bottom.x, bottom.y, left.x, left.y, eraserSize) ||
55 | isPointNearLine(x, y, left.x, left.y, top.x, top.y, eraserSize);
56 |
57 | return isNearEdge;
58 | }
59 | export function isNearText(x: number, y: number, shape: any, eraserSize: number): boolean {
60 | // Fixed font size
61 | const fontSize = 24;
62 |
63 | // Estimate the bounding box dimensions
64 | const textWidth = shape.text.length * 10; // Approximate width based on text length
65 | const textHeight = fontSize; // Height is equal to the font size
66 |
67 | // Define the bounding box for the text
68 | const textX = shape.x;
69 | const textY = shape.y;
70 |
71 | // Check if the eraser position is inside or near the bounding box
72 | return (
73 | x >= textX - eraserSize &&
74 | x <= textX + textWidth + eraserSize &&
75 | y >= textY - eraserSize &&
76 | y <= textY + textHeight + eraserSize
77 | );
78 | }
79 |
80 | export function eraseShape(existingStrokes: Tool[], x: number, y: number, eraserSize: number, socket: WebSocket,roomId:string): any[] {
81 | return existingStrokes.filter((shape) => {
82 | let shouldKeep = true;
83 |
84 | if (shape.type === "rect") {
85 | shouldKeep = !isNearRectangle(x, y, shape);
86 | } else if (shape.type === "circle") {
87 | shouldKeep = !isNearCircle(x, y, shape);
88 | } else if (shape.type === "line" || shape.type === "arrow") {
89 | shouldKeep = !isPointNearLine(x, y, shape.x, shape.y, shape.endX, shape.endY, eraserSize);
90 | } else if (shape.type === "pencil" && shape.path) {
91 | //@ts-ignore
92 | shouldKeep = !shape.path.some((p) => isNearPoint(x, y, p.x, p.y, eraserSize));
93 | } else if (shape.type === "diamond") {
94 | shouldKeep = !isNearDiamond(x, y, shape, eraserSize);
95 | } else if (shape.type === "text") {
96 | shouldKeep = !isNearText(x, y, shape, eraserSize);
97 | }
98 |
99 | // If the shape is erased, notify others via WebSocket
100 | if (!shouldKeep) {
101 | socket.send(JSON.stringify({ type: "eraser", id: shape.id,roomId }));
102 | }
103 |
104 | return shouldKeep;
105 | });
106 | }
107 |
--------------------------------------------------------------------------------
/apps/coscribe-frontend/draw/http.ts:
--------------------------------------------------------------------------------
1 | import axios from "axios";
2 | import { HTTP_URL } from "@/config";
3 | export async function getExistingChats(roomId: string){
4 | const token = localStorage.getItem('token');
5 | const res = await axios.get(`${HTTP_URL}/chats/${roomId}`, {
6 | headers: {
7 | Authorization: `Bearer ${token}` // Set token here
8 | }
9 | });
10 |
11 | const messages = res.data.messages;
12 |
13 | const shapes = messages.map((x:{message:string})=>{
14 | const messageData = JSON.parse(x.message);
15 | return messageData;
16 | })
17 | return shapes
18 | }
19 | export async function clearCanvas(roomId: string){
20 | const res = await axios.post(`${HTTP_URL}/clear`,
21 | { roomId },
22 | {
23 | headers: {
24 | Authorization: `Bearer ${localStorage.getItem('token')}` // Set token here
25 | }
26 |
27 | })
28 | }
--------------------------------------------------------------------------------
/apps/coscribe-frontend/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import { dirname } from "path";
2 | import { fileURLToPath } from "url";
3 | import { FlatCompat } from "@eslint/eslintrc";
4 |
5 | const __filename = fileURLToPath(import.meta.url);
6 | const __dirname = dirname(__filename);
7 |
8 | const compat = new FlatCompat({
9 | baseDirectory: __dirname,
10 | });
11 |
12 | const eslintConfig = [
13 | ...compat.extends("next/core-web-vitals", "next/typescript"),
14 | ];
15 |
16 | export default eslintConfig;
17 |
--------------------------------------------------------------------------------
/apps/coscribe-frontend/lib/react-query.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4 | import { useState, ReactNode } from "react";
5 |
6 | export default function ReactQueryProvider({ children }: { children: ReactNode }) {
7 | const [queryClient] = useState(() => new QueryClient());
8 |
9 | return (
10 |
11 | {children}
12 |
13 | );
14 | }
--------------------------------------------------------------------------------
/apps/coscribe-frontend/next.config.ts:
--------------------------------------------------------------------------------
1 | import type { NextConfig } from "next";
2 |
3 | const nextConfig: NextConfig = {
4 | /* config options here */
5 | reactStrictMode: false,
6 | };
7 |
8 | export default nextConfig;
9 |
--------------------------------------------------------------------------------
/apps/coscribe-frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "coscribe-frontend",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@repo/common": "workspace:*",
13 | "@repo/ui": "workspace:*",
14 | "@tanstack/react-query": "^5.66.6",
15 | "@tanstack/react-query-devtools": "^5.66.6",
16 | "axios": "^1.7.9",
17 | "lucide-react": "^0.475.0",
18 | "next": "15.1.7",
19 | "react": "^19.0.0",
20 | "react-color-palette": "^7.3.0",
21 | "react-dom": "^19.0.0",
22 | "react-hot-toast": "^2.5.2",
23 | "react-spinners": "^0.15.0"
24 | },
25 | "devDependencies": {
26 | "@eslint/eslintrc": "^3",
27 | "@types/node": "^20",
28 | "@types/react": "^19",
29 | "@types/react-dom": "^19",
30 | "eslint": "^9",
31 | "eslint-config-next": "15.1.7",
32 | "postcss": "^8",
33 | "tailwindcss": "^3.4.1",
34 | "typescript": "^5"
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/apps/coscribe-frontend/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/apps/coscribe-frontend/public/abstract-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Devansh-Sabharwal/CoScribe/0481e5e43207527003cfca14d787e5c7016f114d/apps/coscribe-frontend/public/abstract-logo.png
--------------------------------------------------------------------------------
/apps/coscribe-frontend/public/eraser-cursor.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Devansh-Sabharwal/CoScribe/0481e5e43207527003cfca14d787e5c7016f114d/apps/coscribe-frontend/public/eraser-cursor.png
--------------------------------------------------------------------------------
/apps/coscribe-frontend/public/final.mp4:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Devansh-Sabharwal/CoScribe/0481e5e43207527003cfca14d787e5c7016f114d/apps/coscribe-frontend/public/final.mp4
--------------------------------------------------------------------------------
/apps/coscribe-frontend/public/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Devansh-Sabharwal/CoScribe/0481e5e43207527003cfca14d787e5c7016f114d/apps/coscribe-frontend/public/logo.png
--------------------------------------------------------------------------------
/apps/coscribe-frontend/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/apps/coscribe-frontend/public/window.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/apps/coscribe-frontend/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | export default {
4 | content: [
5 | "./pages/**/*.{js,ts,jsx,tsx,mdx}",
6 | "./components/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./app/**/*.{js,ts,jsx,tsx,mdx}",
8 | "../../packages/ui/src/**/*.{js,ts,jsx,tsx}"
9 | ],
10 | theme: {
11 | extend: {
12 | colors: {
13 | background: "var(--background)",
14 | foreground: "var(--foreground)",
15 | },
16 | fontFamily: {
17 | jakarta: 'var(--font-jakarta), sans-serif',
18 | },
19 | letterSpacing: {
20 | tightest: '-0.06em',
21 | },
22 | },
23 | },
24 | plugins: [],
25 | } satisfies Config;
26 |
--------------------------------------------------------------------------------
/apps/coscribe-frontend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "ES2017",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "components/toast/CustomToaster", "components/toast/CustomToaster"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------
/apps/http-backend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "http-backend",
3 | "version": "1.0.0",
4 | "main": "index.js",
5 | "scripts": {
6 | "test": "echo \"Error: no test specified\" && exit 1",
7 | "dev": "tsc -b && node dist/index.js",
8 | "build": "tsc -b",
9 | "start": "node dist/index.js"
10 | },
11 | "dependencies": {
12 | "@repo/db": "workspace:*",
13 | "@types/bcrypt": "^5.0.2",
14 | "@types/cors": "^2.8.17",
15 | "bcrypt": "^5.1.1",
16 | "cors": "^2.8.5",
17 | "dotenv": "^16.4.7",
18 | "express": "^4.21.2",
19 | "jsonwebtoken": "^9.0.2",
20 | "uuid": "^11.1.0",
21 | "@repo/common": "workspace:*"
22 | },
23 | "devDependencies": {
24 | "@repo/typescript-config": "workspace:*",
25 | "@types/express": "^5.0.0",
26 | "@types/jsonwebtoken": "^9.0.8"
27 | },
28 | "keywords": [],
29 | "author": "",
30 | "license": "ISC",
31 | "description": ""
32 | }
33 |
--------------------------------------------------------------------------------
/apps/http-backend/src/index.ts:
--------------------------------------------------------------------------------
1 | import express from "express"
2 | import jwt from "jsonwebtoken"
3 | import auth from "./middleware"
4 | import {CreateUserSchema,SigninSchema,CreateRoomSchema} from "@repo/common/types"
5 | const app = express()
6 | app.use(express.json());
7 | import db from "@repo/db/client"
8 | import cors from "cors";
9 | import bcrypt from "bcrypt"
10 | import { v4 as uuidv4 } from "uuid";
11 | import dotenv from "dotenv";
12 | dotenv.config();
13 |
14 | const JWT_SECRET = process.env.JWT_SECRET as string
15 | const generateRoomID = (): string => {
16 | return uuidv4().replace(/-/g, "").substring(0, 6).toUpperCase();
17 | };
18 | app.use(
19 | cors({
20 | origin: [
21 | "http://localhost:3000",
22 | "https://coscribe.onrender.com"
23 | ], // Allow both local and deployed frontend
24 | credentials: true, // Allow cookies/auth headers
25 | methods: ["GET", "POST", "PUT", "DELETE"], // Allowed HTTP methods
26 | allowedHeaders: ["Content-Type", "Authorization"], // Allowed headers
27 | })
28 | );
29 | app.post("/signup",async (req,res)=>{
30 |
31 | const parsedData = CreateUserSchema.safeParse(req.body);
32 | if(!parsedData.success){
33 | res.status(401).json({
34 | message:"Invalid Credentials"
35 | })
36 | return;
37 | }
38 | const { email, password, name, photo } = parsedData.data;
39 | try{
40 | const hashedPassword = await bcrypt.hash(password, 10);
41 | await db.user.create({
42 | data: {
43 | email,
44 | password: hashedPassword,
45 | name,
46 | photo,
47 | },
48 | });
49 | res.status(200).json({
50 | message:"User created successfully"
51 | })
52 | }
53 | catch(e){
54 | console.log(e);
55 | res.status(500).json({
56 | message:"email already exist"
57 | })
58 | }
59 | })
60 | app.post("/signin",async (req,res)=>{
61 | const parsedData = SigninSchema.safeParse(req.body);
62 | if(!parsedData.success){
63 | res.status(401).json({
64 | message:"Invalid Credentials"
65 | })
66 | return;
67 | }
68 | const email = parsedData.data.email;
69 | const password = parsedData.data.password;
70 | try{
71 | const user = await db.user.findFirst({ where: { email } });
72 |
73 | if (!user) {
74 | res.status(401).json({ message: "Incorrect Credentials" });
75 | return;
76 | }
77 |
78 | // Compare hashed password
79 | const isPasswordValid = await bcrypt.compare(password, user.password);
80 | if (!isPasswordValid) {
81 | res.status(401).json({ message: "Password is incorrect" });
82 | return;
83 | }
84 | const token = jwt.sign({ id: user.id }, JWT_SECRET, { expiresIn: "1d" });
85 | res.status(200).json({
86 | token
87 | })
88 | }
89 | catch(e){
90 | console.log(e);
91 | res.status(500).json({
92 | message:"server error"
93 | })
94 | }
95 | })
96 | app.use(auth);
97 |
98 | app.post("/create-room",async (req,res)=>{
99 | const parsedData = CreateRoomSchema.safeParse(req.body);
100 | if(!parsedData.success){
101 | res.status(401).json({
102 | message:"Invalid Credentials"
103 | })
104 | return;
105 | }
106 | const userId = req.userId;
107 | if(!userId) {
108 | res.status(401).json({
109 | message: "Unauthorized"
110 | });
111 | return;
112 | }
113 | try{
114 | const roomId = generateRoomID();
115 | const room = await db.room.create({
116 | data:{
117 | id:roomId,
118 | slug: parsedData.data.name,
119 | adminId: userId,
120 | users: {
121 | connect: [{ id: userId }]
122 | }
123 | }
124 | })
125 | res.status(200).json({
126 | message:"room created successfully",
127 | roomId:room.id
128 |
129 | })
130 | }
131 | catch(e){
132 | res.status(401).json({
133 | message: "room creation failed"
134 | });
135 | return;
136 | }
137 | })
138 | app.get('/join-room/:roomId',async(req,res)=>{
139 | const roomId = (req.params.roomId);
140 | try{
141 | const room = await db.room.findUnique({
142 | where:{
143 | id:roomId
144 | }
145 | })
146 | if(room)res.status(200).json({message:"Room exist"})
147 | else{
148 | res.status(404).json({message:"Room doesn't exist"})
149 | return;
150 | }
151 | }catch(e){
152 | console.log("doesnt exist");
153 | res.status(404).json({message:"Room doesn't exist"})
154 | }
155 | })
156 | app.get('/chats/:roomId',async (req,res)=>{
157 | const roomId = (req.params.roomId);
158 | try{
159 | const messages = await db.chat.findMany({
160 | where:{
161 | roomId
162 | },
163 | orderBy:{
164 | id:"asc"
165 | },
166 | take: 100
167 | })
168 | res.status(200).json({
169 | messages
170 | })
171 | }
172 | catch(e){
173 | console.log(e);
174 | }
175 |
176 | });
177 | app.get("/room/:roomId",async (req,res)=>{
178 | const roomId = (req.params.roomId);
179 | if(!roomId || roomId==""){
180 | res.status(404).json({message:"Room doesnt exist"});
181 | return;
182 | }
183 |
184 | try{
185 | const room = await db.room.findFirst({
186 | where:{id:roomId}
187 | });
188 | if(!room){
189 | res.status(404).json({message:"Room doesnt exist"});
190 | return;
191 | }
192 | res.status(200).json({message:"RoomExist"});
193 | }
194 | catch(e){
195 | console.log(e);
196 | }
197 | })
198 | app.get("/rooms", async (req, res) => {
199 | const userId = req.userId;
200 | try {
201 | const user = await db.user.findUnique({
202 | where: {
203 | id: userId
204 | },
205 | select: {
206 | id: true,
207 | name: true,
208 | rooms: {
209 | include: {
210 | users: {
211 | select: {
212 | id:true,
213 | name: true, // Include only the `name` of the participants
214 | },
215 | },
216 | },
217 | orderBy: { createdAt: "desc" }
218 | },
219 | },
220 | });
221 |
222 | if (!user) {
223 | res.status(404).json({ message: "User not found" });
224 | return;
225 | }
226 | const data = {
227 | userId: user.id,
228 | userName: user.name,
229 | rooms: user.rooms.map((room:any) => ({
230 | roomId: room.id,
231 | slug: room.slug,
232 | createdAt: room.createdAt.toISOString().slice(0, 10).split('-').reverse().join('-'),
233 | participants: room.users.map((participant:any) =>
234 | participant.id === user.id ? "You" : participant.name // Replace current user's name with "You"
235 | ),
236 | noOfParticipants: room.users.length
237 | })),
238 | };
239 | res.status(200).json({
240 | messages: data
241 | });
242 | } catch (e) {
243 | console.log("An error occurred");
244 | console.log(e);
245 | res.status(500).json({ message: "Internal server error" });
246 | }
247 | });
248 |
249 | app.post("/clear",async(req,res)=>{
250 | const roomId = (req.body.roomId);
251 |
252 | if (!roomId || roomId==="") {
253 | res.status(400).json({ message: "Invalid or missing roomId" });
254 | return
255 | }
256 |
257 | try{
258 | await db.chat.deleteMany({
259 | where:{
260 | roomId
261 | }})
262 | res.status(200).json({message:"Canvas Cleared Successfully"});
263 | }
264 | catch(e){
265 | res.status(500).json({message:"DB server error"});
266 | }
267 | })
268 | app.post("/leave-room",async(req,res)=>{
269 | const userId = req.userId;
270 | const roomId = (req.body.roomId);
271 | if (!roomId || roomId=="") {
272 | res.status(400).json({ message: "Invalid or missing roomId" });
273 | return
274 | }
275 | try{
276 | const room = await db.room.findUnique({
277 | where: { id: roomId },
278 | include: { users: true } // Include the users array
279 | });
280 |
281 | if (!room){
282 | res.status(404).json({ message: "Invalid or missing roomId" });
283 | return;
284 | }
285 |
286 | const updatedUsers = room.users.filter((u:any) => u.id !== userId);
287 | if (updatedUsers.length === 0) {
288 | await db.chat.deleteMany({
289 | where: { roomId }
290 | });
291 | await db.room.delete({
292 | where: { id: roomId }
293 | });
294 | res.status(200).json({message:"Room Deleted Successfully"});
295 | return;
296 | }
297 | await db.room.update({
298 | where: { id: roomId },
299 | data: { users: { set: updatedUsers.map((u:any )=> ({ id: u.id })) } }
300 | });
301 | res.status(200).json({message:"Room Deleted Successfully"})
302 | }catch(e){
303 | console.log(e)
304 | res.status(401).json({message:"Server error"});
305 | }
306 |
307 | })
308 | app.listen(8000, () => {
309 | console.log("server is listening ");
310 | });
311 |
312 |
313 |
--------------------------------------------------------------------------------
/apps/http-backend/src/middleware.ts:
--------------------------------------------------------------------------------
1 | import { NextFunction, Request, Response } from "express";
2 | import jwt, { JwtPayload } from "jsonwebtoken"
3 | import dotenv from "dotenv";
4 | dotenv.config();
5 | const JWT_SECRET = process.env.JWT_SECRET as string
6 | declare global {
7 | namespace Express {
8 | interface Request {
9 | userId?: string;
10 | }
11 | }
12 | }
13 | const auth = (req:Request,res:Response,next:NextFunction)=>{
14 | const authHeader = req.headers.authorization;
15 | if (!authHeader || !authHeader.startsWith('Bearer ')) {
16 | res.status(401).json({ message: 'Authorization token missing or malformed' });
17 | return;
18 | }
19 | const token = authHeader.split(' ')[1] as string;
20 | try{
21 | const decodedData = jwt.verify(token,JWT_SECRET) as JwtPayload;
22 | if(decodedData){
23 | req.userId = decodedData.id;
24 | next();
25 | }
26 | else{
27 | res.status(403).json({
28 | message:"Invalid Token"
29 | })
30 | }}
31 | catch(e){
32 | res.status(403).json({
33 | message:"Invalid Token"
34 | })
35 | }
36 | }
37 | export default auth;
--------------------------------------------------------------------------------
/apps/http-backend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@repo/typescript-config/base.json",
3 | "compilerOptions": {
4 | "rootDir": "./src",
5 | "outDir": "./dist"
6 | }
7 | }
--------------------------------------------------------------------------------
/apps/http-backend/tsconfig.tsbuildinfo:
--------------------------------------------------------------------------------
1 | {"root":["./src/index.ts","./src/middleware.ts"],"version":"5.7.3"}
--------------------------------------------------------------------------------
/apps/http-backend/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["//"],
3 | "tasks": {
4 | "build": {
5 | "outputs": ["dist/**"]
6 | }
7 | }
8 | }
--------------------------------------------------------------------------------
/apps/ws-backend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ws-backend",
3 | "version": "1.0.0",
4 | "main": "index.js",
5 | "scripts": {
6 | "test": "echo \"Error: no test specified\" && exit 1",
7 | "dev": "tsc -b && node dist/index.js",
8 | "build": "tsc -b",
9 | "start": "node dist/index.js"
10 | },
11 | "dependencies": {
12 | "@repo/db": "workspace:*",
13 | "@types/jsonwebtoken": "^9.0.8",
14 | "dotenv": "^16.4.7",
15 | "jsonwebtoken": "^9.0.2",
16 | "ws": "^8.18.0"
17 | },
18 | "devDependencies": {
19 | "@repo/typescript-config": "workspace:*",
20 | "@types/ws": "^8.5.14"
21 | },
22 | "keywords": [],
23 | "author": "",
24 | "license": "ISC",
25 | "description": ""
26 | }
27 |
--------------------------------------------------------------------------------
/apps/ws-backend/src/index.ts:
--------------------------------------------------------------------------------
1 | import WebSocket,{WebSocketServer} from "ws";
2 | import jwt from "jsonwebtoken"
3 | import dotenv from "dotenv";
4 | dotenv.config();
5 | const JWT_SECRET = process.env.JWT_SECRET as string
6 | import db from "@repo/db/client"
7 | interface User{
8 | ws: WebSocket,
9 | rooms: string[],
10 | userId: string
11 | }
12 | const users:User[] = [];
13 | const wss = new WebSocketServer({port:8080});
14 | function checkUser(token:string){
15 | try{
16 | const decoded = jwt.verify(token,JWT_SECRET);
17 | if(typeof decoded=="string"){
18 | return null;
19 | }
20 | if(!decoded || !decoded.id){
21 | return null;
22 | }
23 | return decoded.id;
24 | }
25 | catch(e){
26 | console.log(e);
27 | console.log("JWT didnt verify");
28 | }
29 | }
30 | wss.on('connection',(ws,req)=>{
31 |
32 | const url = req.url;
33 | if(!url) return;
34 | const queryParams = new URLSearchParams(url.split('?')[1]);
35 | const token = queryParams.get('token') || "";
36 | const userId = checkUser(token);
37 | if(!userId) {
38 | ws.close();
39 | return;
40 | }
41 | users.push({
42 | userId,
43 | rooms:[],
44 | ws
45 | })
46 | ws.on('message', async (data)=>{
47 | const parsedData = JSON.parse(data as unknown as string);
48 | // console.log(parsedData.type);
49 | if(parsedData.type==="join_room"){
50 |
51 | try{
52 | const roomId = (parsedData.roomId);
53 | if(!roomId || (roomId=="")){
54 | return;
55 | }
56 | const user = users.find(x=>x.ws === ws);
57 | const room = await db.room.findUnique({
58 | where: { id: roomId }
59 | });
60 | if(!room) {
61 | ws.close();
62 | return;
63 | }
64 | user?.rooms.push(parsedData.roomId);
65 | const updatedRoom = await db.room.update({
66 | where: { id: roomId }, // Room ID to update
67 | data: {
68 | users: {
69 | connect: { id: userId }, // Connect the user to the room
70 | },
71 | },
72 | });
73 | }
74 |
75 | catch(e){
76 | console.log("An error occured");
77 | console.log(e);
78 | return;
79 |
80 | }
81 | }
82 | if(parsedData.type==="leave_room"){
83 | const user = users.find(x=>x.ws==ws);
84 | if(!user) return;
85 | user.rooms = user?.rooms.filter(x=>x!==parsedData.roomId)
86 | const roomId = (parsedData.roomId);
87 |
88 | try{
89 |
90 | const room = await db.room.findUnique({
91 | where: { id: roomId },
92 | include: { users: true } // Include the users array
93 | });
94 |
95 | if (!room) return;
96 |
97 | const updatedUsers = room.users.filter(u => u.id !== userId);
98 | if (updatedUsers.length === 0) {
99 | await db.chat.deleteMany({
100 | where: { roomId }
101 | });
102 | await db.room.delete({
103 | where: { id: roomId }
104 | });
105 | return;
106 | }
107 | await db.room.update({
108 | where: { id: roomId },
109 | data: { users: { set: updatedUsers.map(u => ({ id: u.id })) } }
110 | });
111 | ws.send(JSON.stringify({
112 | status:"OK"
113 | }))
114 |
115 | }catch(e){
116 | console.log(e);
117 | ws.send(JSON.stringify({
118 | status:"Error"
119 | }))
120 | }
121 | }
122 | if(parsedData.type=="chat"){
123 | const roomId = parsedData.roomId;
124 | const id = parsedData.id;
125 | const message = parsedData.message;
126 | try{
127 | await db.chat.create({
128 | data:{
129 | id,
130 | roomId:(roomId),
131 | message,
132 | userId
133 | }
134 | });
135 | users.forEach(user=>{
136 | if(user.rooms.includes(roomId) && user.userId !== userId){
137 | user.ws.send(JSON.stringify({
138 | type:"chat",
139 | id,
140 | message:message,
141 | roomId
142 | }))
143 | }
144 | })
145 | }
146 | catch(e){
147 | console.log("An error occured");
148 | console.log(e);
149 | return;
150 | }
151 | }
152 | else if(parsedData.type=="eraser"){
153 | const roomId = parsedData.roomId;
154 | const id = parsedData.id;
155 | // console.log(id);
156 | try{
157 | const deleted = await db.chat.deleteMany({
158 | where: {
159 | id,
160 | roomId:(roomId),
161 | },
162 | });
163 | if (deleted.count > 0) {
164 | users.forEach(user => {
165 | if (user.rooms.includes(roomId) && user.userId !== userId) {
166 | user.ws.send(
167 | JSON.stringify({
168 | type: "eraser",
169 | id,
170 | roomId,
171 | })
172 | );
173 | }
174 | });
175 | }
176 | }catch(e){
177 | console.log("An error occured");
178 | console.log(e);
179 | return;
180 | }
181 | }
182 | else if(parsedData.type=="clean"){
183 | const roomId = parsedData.roomId;
184 | users.forEach(user => {
185 | if (user.rooms.includes(roomId) && user.userId !== userId) {
186 | user.ws.send(
187 | JSON.stringify({
188 | type: "clean",
189 | roomId
190 | })
191 | );
192 | }
193 | });
194 | }
195 | else if(parsedData.type=="update"){
196 | const roomId = parsedData.roomId;
197 | const id = parsedData.id;
198 | const message = parsedData.message;
199 | try{
200 | await db.chat.update({
201 | where:{
202 | id,
203 | roomId: (roomId),
204 | },
205 | data:{
206 | message
207 | }
208 | })
209 | users.forEach(user => {
210 | if (user.rooms.includes(roomId) && user.userId !== userId) {
211 | user.ws.send(
212 | JSON.stringify({
213 | type: "update",
214 | id,
215 | message,
216 | roomId
217 | })
218 | );
219 | }
220 | });
221 | }catch(e){
222 | console.log("An error occured");
223 | console.log(e);
224 | return;
225 | }
226 | }
227 | })
228 | });
--------------------------------------------------------------------------------
/apps/ws-backend/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@repo/typescript-config/base.json",
3 | "compilerOptions": {
4 | "rootDir": "./src",
5 | "outDir": "dist"
6 | }
7 | }
--------------------------------------------------------------------------------
/apps/ws-backend/tsconfig.tsbuildinfo:
--------------------------------------------------------------------------------
1 | {"root":["./src/index.ts"],"version":"5.7.3"}
--------------------------------------------------------------------------------
/apps/ws-backend/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends":["//"],
3 | "tasks":{
4 | "build":{
5 | "outputs":["dist/**"]
6 | }
7 | }
8 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "coscribe",
3 | "private": true,
4 | "scripts": {
5 | "build": "turbo build",
6 | "dev": "turbo dev",
7 | "lint": "turbo lint",
8 | "format": "prettier --write \"**/*.{ts,tsx,md}\""
9 | },
10 | "devDependencies": {
11 | "prettier": "^3.4.2",
12 | "turbo": "^2.4.0",
13 | "typescript": "5.7.3"
14 | },
15 | "packageManager": "pnpm@9.15.4",
16 | "engines": {
17 | "node": ">=18",
18 | "pnpm": ">=9.15.4"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/packages/common/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@repo/common",
3 | "version": "1.0.0",
4 | "main": "dist/index.js",
5 | "exports":{
6 | "./types":"./dist/types.js"
7 | },
8 | "scripts": {
9 | "build":"tsc -b",
10 | "test": "echo \"Error: no test specified\" && exit 1"
11 | },
12 | "devDependencies": {
13 | "@repo/typescript-config": "workspace:*"
14 | },
15 | "keywords": [],
16 | "author": "",
17 | "license": "ISC",
18 | "description": "",
19 | "dependencies": {
20 | "zod": "^3.24.1"
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/packages/common/src/types.ts:
--------------------------------------------------------------------------------
1 | import { z } from "zod";
2 |
3 | const emailSchema = z.string().email({ message: "Invalid email format" });
4 | const passwordSchema = z
5 | .string()
6 | .min(6, { message: "Password must be at least 6 characters long" })
7 | .max(30, { message: "Password cannot exceed 30 characters" })
8 | .refine(
9 | (value) => /[0-9]/.test(value), // At least one digit
10 | { message: "Password must contain at least one digit" }
11 | )
12 | .refine(
13 | (value) => /[!@#$%^&*()_+{}\[\]:;<>,.?~\\/-]/.test(value), // At least one special character
14 | { message: "Password must contain at least one special character" }
15 | );
16 | const nameSchema = z.string().min(2, { message: "Name must be at least 2 characters" }).max(30, { message: "Name cannot exceed 30 characters" });
17 | const photoSchema = z.string().url({ message: "Photo must be a valid URL" }).optional();
18 |
19 | export const CreateUserSchema = z.object({
20 | email: emailSchema,
21 | password: passwordSchema,
22 | name: nameSchema.optional(),
23 | photo: photoSchema
24 | });
25 |
26 | export const SigninSchema = z.object({
27 | email: emailSchema,
28 | password: passwordSchema,
29 | });
30 |
31 | export const CreateRoomSchema = z.object({
32 | name: z.string().min(1,{message:"name must be atleast 1 character long"}),
33 | });
34 |
35 | export const JWT_CODE = "random#";
36 |
--------------------------------------------------------------------------------
/packages/common/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends":"@repo/typescript-config/base.json",
3 | "compilerOptions": {
4 | "rootDir": "./src",
5 | "outDir": "./dist"
6 | }
7 | }
--------------------------------------------------------------------------------
/packages/common/tsconfig.tsbuildinfo:
--------------------------------------------------------------------------------
1 | {"root":["./src/types.ts"],"version":"5.7.3"}
--------------------------------------------------------------------------------
/packages/common/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["//"],
3 | "tasks": {
4 | "build": {
5 | "outputs": ["dist/**"]
6 | }
7 | }
8 | }
--------------------------------------------------------------------------------
/packages/db/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@repo/db",
3 | "version": "1.0.0",
4 | "main": "index.js",
5 | "scripts": {
6 | "test": "echo \"Error: no test specified\" && exit 1",
7 | "migrate": "prisma migrate dev",
8 | "generate": "prisma generate"
9 | },
10 | "keywords": [],
11 | "author": "",
12 | "license": "ISC",
13 | "description": "",
14 | "devDependencies": {
15 | "@repo/typescript-config":"workspace:*"
16 | },
17 | "dependencies": {
18 | "@prisma/client": "6.3.1",
19 | "prisma": "^6.3.1"
20 | },
21 | "exports": {
22 | "./client": "./src/index.ts"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/packages/db/prisma/migrations/20250208045612_init/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE "User" (
3 | "id" SERIAL NOT NULL,
4 | "username" TEXT NOT NULL,
5 | "password" TEXT NOT NULL,
6 |
7 | CONSTRAINT "User_pkey" PRIMARY KEY ("id")
8 | );
9 |
10 | -- CreateIndex
11 | CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
12 |
--------------------------------------------------------------------------------
/packages/db/prisma/migrations/20250208180508_added_further_models/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - The primary key for the `User` table will be changed. If it partially fails, the table could be left without primary key constraint.
5 | - You are about to drop the column `username` on the `User` table. All the data in the column will be lost.
6 | - Added the required column `email` to the `User` table without a default value. This is not possible if the table is not empty.
7 | - Added the required column `name` to the `User` table without a default value. This is not possible if the table is not empty.
8 | - Added the required column `photo` to the `User` table without a default value. This is not possible if the table is not empty.
9 |
10 | */
11 | -- DropIndex
12 | DROP INDEX "User_username_key";
13 |
14 | -- AlterTable
15 | ALTER TABLE "User" DROP CONSTRAINT "User_pkey",
16 | DROP COLUMN "username",
17 | ADD COLUMN "email" TEXT NOT NULL,
18 | ADD COLUMN "name" TEXT NOT NULL,
19 | ADD COLUMN "photo" TEXT NOT NULL,
20 | ALTER COLUMN "id" DROP DEFAULT,
21 | ALTER COLUMN "id" SET DATA TYPE TEXT,
22 | ADD CONSTRAINT "User_pkey" PRIMARY KEY ("id");
23 | DROP SEQUENCE "User_id_seq";
24 |
25 | -- CreateTable
26 | CREATE TABLE "Room" (
27 | "id" SERIAL NOT NULL,
28 | "slug" TEXT NOT NULL,
29 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
30 | "adminId" TEXT NOT NULL,
31 |
32 | CONSTRAINT "Room_pkey" PRIMARY KEY ("id")
33 | );
34 |
35 | -- CreateTable
36 | CREATE TABLE "Chat" (
37 | "id" SERIAL NOT NULL,
38 | "roomId" INTEGER NOT NULL,
39 | "message" TEXT NOT NULL,
40 | "userId" TEXT NOT NULL,
41 |
42 | CONSTRAINT "Chat_pkey" PRIMARY KEY ("id")
43 | );
44 |
45 | -- CreateIndex
46 | CREATE UNIQUE INDEX "Room_slug_key" ON "Room"("slug");
47 |
48 | -- AddForeignKey
49 | ALTER TABLE "Room" ADD CONSTRAINT "Room_adminId_fkey" FOREIGN KEY ("adminId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
50 |
51 | -- AddForeignKey
52 | ALTER TABLE "Chat" ADD CONSTRAINT "Chat_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
53 |
54 | -- AddForeignKey
55 | ALTER TABLE "Chat" ADD CONSTRAINT "Chat_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
56 |
--------------------------------------------------------------------------------
/packages/db/prisma/migrations/20250209055528_added_optional/migration.sql:
--------------------------------------------------------------------------------
1 | -- AlterTable
2 | ALTER TABLE "User" ALTER COLUMN "name" DROP NOT NULL,
3 | ALTER COLUMN "photo" DROP NOT NULL;
4 |
--------------------------------------------------------------------------------
/packages/db/prisma/migrations/20250209163559_unique/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - A unique constraint covering the columns `[email]` on the table `User` will be added. If there are existing duplicate values, this will fail.
5 |
6 | */
7 | -- CreateIndex
8 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
9 |
--------------------------------------------------------------------------------
/packages/db/prisma/migrations/20250215193156_message_id_chat_schema/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - Added the required column `messageId` to the `Chat` table without a default value. This is not possible if the table is not empty.
5 |
6 | */
7 | -- AlterTable
8 | ALTER TABLE "Chat" ADD COLUMN "messageId" TEXT NOT NULL;
9 |
--------------------------------------------------------------------------------
/packages/db/prisma/migrations/20250215193742_removed_message_id/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - The primary key for the `Chat` table will be changed. If it partially fails, the table could be left without primary key constraint.
5 | - You are about to drop the column `messageId` on the `Chat` table. All the data in the column will be lost.
6 |
7 | */
8 | -- AlterTable
9 | ALTER TABLE "Chat" DROP CONSTRAINT "Chat_pkey",
10 | DROP COLUMN "messageId",
11 | ALTER COLUMN "id" DROP DEFAULT,
12 | ALTER COLUMN "id" SET DATA TYPE TEXT,
13 | ADD CONSTRAINT "Chat_pkey" PRIMARY KEY ("id");
14 | DROP SEQUENCE "Chat_id_seq";
15 |
--------------------------------------------------------------------------------
/packages/db/prisma/migrations/20250219191655_added_users_array_to_rooms/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE "_RoomUsers" (
3 | "A" INTEGER NOT NULL,
4 | "B" TEXT NOT NULL,
5 |
6 | CONSTRAINT "_RoomUsers_AB_pkey" PRIMARY KEY ("A","B")
7 | );
8 |
9 | -- CreateIndex
10 | CREATE INDEX "_RoomUsers_B_index" ON "_RoomUsers"("B");
11 |
12 | -- AddForeignKey
13 | ALTER TABLE "_RoomUsers" ADD CONSTRAINT "_RoomUsers_A_fkey" FOREIGN KEY ("A") REFERENCES "Room"("id") ON DELETE CASCADE ON UPDATE CASCADE;
14 |
15 | -- AddForeignKey
16 | ALTER TABLE "_RoomUsers" ADD CONSTRAINT "_RoomUsers_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
17 |
--------------------------------------------------------------------------------
/packages/db/prisma/migrations/20250221055846_removed_unique_slug/migration.sql:
--------------------------------------------------------------------------------
1 | -- DropIndex
2 | DROP INDEX "Room_slug_key";
3 |
--------------------------------------------------------------------------------
/packages/db/prisma/migrations/20250308174257_room_id_string/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - The primary key for the `Room` table will be changed. If it partially fails, the table could be left without primary key constraint.
5 | - The primary key for the `_RoomUsers` table will be changed. If it partially fails, the table could be left without primary key constraint.
6 |
7 | */
8 | -- DropForeignKey
9 | ALTER TABLE "Chat" DROP CONSTRAINT "Chat_roomId_fkey";
10 |
11 | -- DropForeignKey
12 | ALTER TABLE "_RoomUsers" DROP CONSTRAINT "_RoomUsers_A_fkey";
13 |
14 | -- AlterTable
15 | ALTER TABLE "Chat" ALTER COLUMN "roomId" SET DATA TYPE TEXT;
16 |
17 | -- AlterTable
18 | ALTER TABLE "Room" DROP CONSTRAINT "Room_pkey",
19 | ALTER COLUMN "id" DROP DEFAULT,
20 | ALTER COLUMN "id" SET DATA TYPE TEXT,
21 | ADD CONSTRAINT "Room_pkey" PRIMARY KEY ("id");
22 | DROP SEQUENCE "Room_id_seq";
23 |
24 | -- AlterTable
25 | ALTER TABLE "_RoomUsers" DROP CONSTRAINT "_RoomUsers_AB_pkey",
26 | ALTER COLUMN "A" SET DATA TYPE TEXT,
27 | ADD CONSTRAINT "_RoomUsers_AB_pkey" PRIMARY KEY ("A", "B");
28 |
29 | -- AddForeignKey
30 | ALTER TABLE "Chat" ADD CONSTRAINT "Chat_roomId_fkey" FOREIGN KEY ("roomId") REFERENCES "Room"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
31 |
32 | -- AddForeignKey
33 | ALTER TABLE "_RoomUsers" ADD CONSTRAINT "_RoomUsers_A_fkey" FOREIGN KEY ("A") REFERENCES "Room"("id") ON DELETE CASCADE ON UPDATE CASCADE;
34 |
--------------------------------------------------------------------------------
/packages/db/prisma/migrations/migration_lock.toml:
--------------------------------------------------------------------------------
1 | # Please do not edit this file manually
2 | # It should be added in your version-control system (e.g., Git)
3 | provider = "postgresql"
--------------------------------------------------------------------------------
/packages/db/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | // This is your Prisma schema file,
2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema
3 |
4 | // Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
5 | // Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
6 |
7 | generator client {
8 | provider = "prisma-client-js"
9 | }
10 |
11 | datasource db {
12 | provider = "postgresql"
13 | url = env("DATABASE_URL")
14 | }
15 | model User {
16 | id String @id @default(uuid())
17 | email String @unique
18 | password String
19 | name String?
20 | photo String?
21 | rooms Room[] @relation("RoomUsers") // Many-to-many relationship
22 | chats Chat[]
23 | adminOf Room[] @relation("RoomAdmin") // Inverse of `admin` in `Room`
24 | }
25 |
26 | model Room {
27 | id String @id
28 | slug String
29 | createdAt DateTime @default(now())
30 | adminId String
31 | admin User @relation(fields: [adminId], references: [id], name: "RoomAdmin") // Relationship with User
32 | chats Chat[]
33 | users User[] @relation("RoomUsers") // Many-to-many relationship
34 | }
35 | model Chat{
36 | id String @id
37 | roomId String
38 | message String
39 | userId String
40 | user User @relation(fields: [userId],references: [id])
41 | room Room @relation(fields: [roomId],references: [id])
42 | }
--------------------------------------------------------------------------------
/packages/db/src/index.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "@prisma/client";
2 | const prismaClient = new PrismaClient();
3 | export default prismaClient
--------------------------------------------------------------------------------
/packages/db/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends":"@repo/typescript-config/base.json",
3 | "compilerOptions": {
4 | "rootDir": "./src",
5 | "outDir": "./dist"
6 | }
7 | }
--------------------------------------------------------------------------------
/packages/eslint-config/README.md:
--------------------------------------------------------------------------------
1 | # `@turbo/eslint-config`
2 |
3 | Collection of internal eslint configurations.
4 |
--------------------------------------------------------------------------------
/packages/eslint-config/base.js:
--------------------------------------------------------------------------------
1 | import js from "@eslint/js";
2 | import eslintConfigPrettier from "eslint-config-prettier";
3 | import turboPlugin from "eslint-plugin-turbo";
4 | import tseslint from "typescript-eslint";
5 | import onlyWarn from "eslint-plugin-only-warn";
6 |
7 | /**
8 | * A shared ESLint configuration for the repository.
9 | *
10 | * @type {import("eslint").Linter.Config}
11 | * */
12 | export const config = [
13 | js.configs.recommended,
14 | eslintConfigPrettier,
15 | ...tseslint.configs.recommended,
16 | {
17 | plugins: {
18 | turbo: turboPlugin,
19 | },
20 | rules: {
21 | "turbo/no-undeclared-env-vars": "warn",
22 | },
23 | },
24 | {
25 | plugins: {
26 | onlyWarn,
27 | },
28 | },
29 | {
30 | ignores: ["dist/**"],
31 | },
32 | ];
33 |
--------------------------------------------------------------------------------
/packages/eslint-config/next.js:
--------------------------------------------------------------------------------
1 | import js from "@eslint/js";
2 | import eslintConfigPrettier from "eslint-config-prettier";
3 | import tseslint from "typescript-eslint";
4 | import pluginReactHooks from "eslint-plugin-react-hooks";
5 | import pluginReact from "eslint-plugin-react";
6 | import globals from "globals";
7 | import pluginNext from "@next/eslint-plugin-next";
8 | import { config as baseConfig } from "./base.js";
9 |
10 | /**
11 | * A custom ESLint configuration for libraries that use Next.js.
12 | *
13 | * @type {import("eslint").Linter.Config}
14 | * */
15 | export const nextJsConfig = [
16 | ...baseConfig,
17 | js.configs.recommended,
18 | eslintConfigPrettier,
19 | ...tseslint.configs.recommended,
20 | {
21 | ...pluginReact.configs.flat.recommended,
22 | languageOptions: {
23 | ...pluginReact.configs.flat.recommended.languageOptions,
24 | globals: {
25 | ...globals.serviceworker,
26 | },
27 | },
28 | },
29 | {
30 | plugins: {
31 | "@next/next": pluginNext,
32 | },
33 | rules: {
34 | ...pluginNext.configs.recommended.rules,
35 | ...pluginNext.configs["core-web-vitals"].rules,
36 | },
37 | },
38 | {
39 | plugins: {
40 | "react-hooks": pluginReactHooks,
41 | },
42 | settings: { react: { version: "detect" } },
43 | rules: {
44 | ...pluginReactHooks.configs.recommended.rules,
45 | // React scope no longer necessary with new JSX transform.
46 | "react/react-in-jsx-scope": "off",
47 | },
48 | },
49 | ];
50 |
--------------------------------------------------------------------------------
/packages/eslint-config/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@repo/eslint-config",
3 | "version": "0.0.0",
4 | "type": "module",
5 | "private": true,
6 | "exports": {
7 | "./base": "./base.js",
8 | "./next-js": "./next.js",
9 | "./react-internal": "./react-internal.js"
10 | },
11 | "devDependencies": {
12 | "@eslint/js": "^9.19.0",
13 | "@next/eslint-plugin-next": "^15.1.6",
14 | "eslint": "^9.19.0",
15 | "eslint-config-prettier": "^10.0.1",
16 | "eslint-plugin-only-warn": "^1.1.0",
17 | "eslint-plugin-react": "^7.37.4",
18 | "eslint-plugin-react-hooks": "^5.1.0",
19 | "eslint-plugin-turbo": "^2.4.0",
20 | "globals": "^15.14.0",
21 | "typescript": "^5.7.3",
22 | "typescript-eslint": "^8.23.0"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/packages/eslint-config/react-internal.js:
--------------------------------------------------------------------------------
1 | import js from "@eslint/js";
2 | import eslintConfigPrettier from "eslint-config-prettier";
3 | import tseslint from "typescript-eslint";
4 | import pluginReactHooks from "eslint-plugin-react-hooks";
5 | import pluginReact from "eslint-plugin-react";
6 | import globals from "globals";
7 | import { config as baseConfig } from "./base.js";
8 |
9 | /**
10 | * A custom ESLint configuration for libraries that use React.
11 | *
12 | * @type {import("eslint").Linter.Config} */
13 | export const config = [
14 | ...baseConfig,
15 | js.configs.recommended,
16 | eslintConfigPrettier,
17 | ...tseslint.configs.recommended,
18 | pluginReact.configs.flat.recommended,
19 | {
20 | languageOptions: {
21 | ...pluginReact.configs.flat.recommended.languageOptions,
22 | globals: {
23 | ...globals.serviceworker,
24 | ...globals.browser,
25 | },
26 | },
27 | },
28 | {
29 | plugins: {
30 | "react-hooks": pluginReactHooks,
31 | },
32 | settings: { react: { version: "detect" } },
33 | rules: {
34 | ...pluginReactHooks.configs.recommended.rules,
35 | // React scope no longer necessary with new JSX transform.
36 | "react/react-in-jsx-scope": "off",
37 | },
38 | },
39 | ];
40 |
--------------------------------------------------------------------------------
/packages/typescript-config/base.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "compilerOptions": {
4 | "declaration": true,
5 | "declarationMap": true,
6 | "esModuleInterop": true,
7 | "incremental": false,
8 | "isolatedModules": true,
9 | "lib": ["es2022", "DOM", "DOM.Iterable"],
10 | "module": "NodeNext",
11 | "moduleDetection": "force",
12 | "moduleResolution": "NodeNext",
13 | "noUncheckedIndexedAccess": true,
14 | "resolveJsonModule": true,
15 | "skipLibCheck": true,
16 | "strict": true,
17 | "target": "ES2022"
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/packages/typescript-config/nextjs.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "extends": "./base.json",
4 | "compilerOptions": {
5 | "plugins": [{ "name": "next" }],
6 | "module": "ESNext",
7 | "moduleResolution": "Bundler",
8 | "allowJs": true,
9 | "jsx": "preserve",
10 | "noEmit": true
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/packages/typescript-config/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@repo/typescript-config",
3 | "version": "0.0.0",
4 | "private": true,
5 | "license": "MIT",
6 | "publishConfig": {
7 | "access": "public"
8 | }
9 | }
10 |
--------------------------------------------------------------------------------
/packages/typescript-config/react-library.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://json.schemastore.org/tsconfig",
3 | "extends": "./base.json",
4 | "compilerOptions": {
5 | "jsx": "react-jsx"
6 | }
7 | }
8 |
--------------------------------------------------------------------------------
/packages/ui/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import { config } from "@repo/eslint-config/react-internal";
2 |
3 | /** @type {import("eslint").Linter.Config} */
4 | export default config;
5 |
--------------------------------------------------------------------------------
/packages/ui/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@repo/ui",
3 | "version": "0.0.0",
4 | "type": "module",
5 | "private": true,
6 | "exports": {
7 | "./button": "./src/button.tsx",
8 | "./card": "./src/card.tsx",
9 | "./code": "./src/code.tsx",
10 | "./input": "./src/input.tsx"
11 | },
12 | "scripts": {
13 | "lint": "eslint . --max-warnings 0",
14 | "generate:component": "turbo gen react-component",
15 | "check-types": "tsc --noEmit"
16 | },
17 | "devDependencies": {
18 | "@repo/eslint-config": "workspace:*",
19 | "@repo/typescript-config": "workspace:*",
20 | "@turbo/gen": "^2.4.0",
21 | "@types/node": "^22.13.0",
22 | "@types/react": "19.0.8",
23 | "@types/react-dom": "19.0.3",
24 | "eslint": "^9.19.0",
25 | "typescript": "5.7.3"
26 | },
27 | "dependencies": {
28 | "autoprefixer": "^10.4.20",
29 | "lucide-react": "^0.475.0",
30 | "postcss": "^8",
31 | "react": "^19.0.0",
32 | "react-dom": "^19.0.0",
33 | "tailwindcss": "^3.4.1"
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/packages/ui/src/button.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | interface ButtonProps {
4 | text: React.ReactNode
5 | onClick: (e: React.FormEvent) => void
6 | type?: "submit"|"button"
7 | disabled:boolean
8 | }
9 |
10 | export const Button = (props: ButtonProps) => {
11 | return (
12 |
13 | {props.text}
14 |
15 | );
16 | };
17 |
--------------------------------------------------------------------------------
/packages/ui/src/card.tsx:
--------------------------------------------------------------------------------
1 | import { type JSX } from "react";
2 |
3 | export function Card({
4 | className,
5 | title,
6 | children,
7 | href,
8 | }: {
9 | className?: string;
10 | title: string;
11 | children: React.ReactNode;
12 | href: string;
13 | }): JSX.Element {
14 | return (
15 |
21 |
22 | {title} ->
23 |
24 | {children}
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/packages/ui/src/code.tsx:
--------------------------------------------------------------------------------
1 | import { type JSX } from "react";
2 |
3 | export function Code({
4 | children,
5 | className,
6 | }: {
7 | children: React.ReactNode;
8 | className?: string;
9 | }): JSX.Element {
10 | return {children}
;
11 | }
12 |
--------------------------------------------------------------------------------
/packages/ui/src/index.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
--------------------------------------------------------------------------------
/packages/ui/src/input.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { Eye, EyeOff } from "lucide-react";
3 | import "../src/index.css";
4 | interface InputProps {
5 | title: string
6 | placeholder: string
7 | type: string
8 | required: boolean
9 | value: string
10 | onChange: (e: React.ChangeEvent) => void
11 | error?:string
12 | }
13 |
14 | const Input = ({
15 | title,
16 | placeholder,
17 | required = false,
18 | type,
19 | value,
20 | onChange,
21 | error
22 | }: InputProps) => {
23 | return (
24 |
25 |
26 | {title}
27 | {required && * }
28 |
29 |
37 | {error &&
{error}
}
38 |
39 | );
40 | }
41 |
42 | interface PasswordInputProps {
43 | title: string;
44 | value: string;
45 | onChange: (e: React.ChangeEvent) => void;
46 | placeholder?: string;
47 | required?: boolean;
48 | error?:string
49 | }
50 |
51 | export function PasswordInput({
52 | title,
53 | value,
54 | onChange,
55 | placeholder = "Enter password",
56 | required = false,
57 | error
58 | }: PasswordInputProps) {
59 | const [showPassword, setShowPassword] = useState(false);
60 |
61 | const togglePasswordVisibility = () => {
62 | setShowPassword(!showPassword);
63 | };
64 |
65 | return (
66 |
67 |
68 | {title}
69 | {required && * }
70 |
71 |
72 |
80 |
86 | {showPassword ? (
87 |
88 | ) : (
89 |
90 | )}
91 |
92 |
93 | {error &&
{error}
}
94 |
95 |
96 | );
97 | }
98 | export default Input
--------------------------------------------------------------------------------
/packages/ui/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: ["./src/**/*.{js,ts,jsx,tsx}",],
4 | darkMode: 'media', // or 'class'
5 | theme: {
6 | accentColor: ({ theme }) => ({
7 | ...theme('colors'),
8 | auto: 'auto',
9 | }),
10 | animation: {
11 | none: 'none',
12 | spin: 'spin 1s linear infinite',
13 | ping: 'ping 1s cubic-bezier(0, 0, 0.2, 1) infinite',
14 | pulse: 'pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
15 | bounce: 'bounce 1s infinite',
16 | },
17 | aria: {
18 | busy: 'busy="true"',
19 | checked: 'checked="true"',
20 | disabled: 'disabled="true"',
21 | expanded: 'expanded="true"',
22 | hidden: 'hidden="true"',
23 | pressed: 'pressed="true"',
24 | readonly: 'readonly="true"',
25 | required: 'required="true"',
26 | selected: 'selected="true"',
27 | },
28 | aspectRatio: {
29 | auto: 'auto',
30 | square: '1 / 1',
31 | video: '16 / 9',
32 | },
33 | backdropBlur: ({ theme }) => theme('blur'),
34 | backdropBrightness: ({ theme }) => theme('brightness'),
35 | backdropContrast: ({ theme }) => theme('contrast'),
36 | backdropGrayscale: ({ theme }) => theme('grayscale'),
37 | backdropHueRotate: ({ theme }) => theme('hueRotate'),
38 | backdropInvert: ({ theme }) => theme('invert'),
39 | backdropOpacity: ({ theme }) => theme('opacity'),
40 | backdropSaturate: ({ theme }) => theme('saturate'),
41 | backdropSepia: ({ theme }) => theme('sepia'),
42 | backgroundColor: ({ theme }) => theme('colors'),
43 | backgroundImage: {
44 | none: 'none',
45 | 'gradient-to-t': 'linear-gradient(to top, var(--tw-gradient-stops))',
46 | 'gradient-to-tr': 'linear-gradient(to top right, var(--tw-gradient-stops))',
47 | 'gradient-to-r': 'linear-gradient(to right, var(--tw-gradient-stops))',
48 | 'gradient-to-br': 'linear-gradient(to bottom right, var(--tw-gradient-stops))',
49 | 'gradient-to-b': 'linear-gradient(to bottom, var(--tw-gradient-stops))',
50 | 'gradient-to-bl': 'linear-gradient(to bottom left, var(--tw-gradient-stops))',
51 | 'gradient-to-l': 'linear-gradient(to left, var(--tw-gradient-stops))',
52 | 'gradient-to-tl': 'linear-gradient(to top left, var(--tw-gradient-stops))',
53 | },
54 | backgroundOpacity: ({ theme }) => theme('opacity'),
55 | backgroundPosition: {
56 | bottom: 'bottom',
57 | center: 'center',
58 | left: 'left',
59 | 'left-bottom': 'left bottom',
60 | 'left-top': 'left top',
61 | right: 'right',
62 | 'right-bottom': 'right bottom',
63 | 'right-top': 'right top',
64 | top: 'top',
65 | },
66 | backgroundSize: {
67 | auto: 'auto',
68 | cover: 'cover',
69 | contain: 'contain',
70 | },
71 | blur: {
72 | 0: '0',
73 | none: '',
74 | sm: '4px',
75 | DEFAULT: '8px',
76 | md: '12px',
77 | lg: '16px',
78 | xl: '24px',
79 | '2xl': '40px',
80 | '3xl': '64px',
81 | },
82 | borderColor: ({ theme }) => ({
83 | ...theme('colors'),
84 | DEFAULT: theme('colors.gray.200', 'currentColor'),
85 | }),
86 | borderOpacity: ({ theme }) => theme('opacity'),
87 | borderRadius: {
88 | none: '0px',
89 | sm: '0.125rem',
90 | DEFAULT: '0.25rem',
91 | md: '0.375rem',
92 | lg: '0.5rem',
93 | xl: '0.75rem',
94 | '2xl': '1rem',
95 | '3xl': '1.5rem',
96 | full: '9999px',
97 | },
98 | borderSpacing: ({ theme }) => ({
99 | ...theme('spacing'),
100 | }),
101 | borderWidth: {
102 | DEFAULT: '1px',
103 | 0: '0px',
104 | 2: '2px',
105 | 4: '4px',
106 | 8: '8px',
107 | },
108 | boxShadow: {
109 | sm: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
110 | DEFAULT: '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
111 | md: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
112 | lg: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
113 | xl: '0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)',
114 | '2xl': '0 25px 50px -12px rgb(0 0 0 / 0.25)',
115 | inner: 'inset 0 2px 4px 0 rgb(0 0 0 / 0.05)',
116 | none: 'none',
117 | },
118 | boxShadowColor: ({ theme }) => theme('colors'),
119 | brightness: {
120 | 0: '0',
121 | 50: '.5',
122 | 75: '.75',
123 | 90: '.9',
124 | 95: '.95',
125 | 100: '1',
126 | 105: '1.05',
127 | 110: '1.1',
128 | 125: '1.25',
129 | 150: '1.5',
130 | 200: '2',
131 | },
132 | caretColor: ({ theme }) => theme('colors'),
133 | colors: ({ colors }) => ({
134 | inherit: colors.inherit,
135 | current: colors.current,
136 | transparent: colors.transparent,
137 | black: colors.black,
138 | white: colors.white,
139 | slate: colors.slate,
140 | gray: colors.gray,
141 | zinc: colors.zinc,
142 | neutral: colors.neutral,
143 | stone: colors.stone,
144 | red: colors.red,
145 | orange: colors.orange,
146 | amber: colors.amber,
147 | yellow: colors.yellow,
148 | lime: colors.lime,
149 | green: colors.green,
150 | emerald: colors.emerald,
151 | teal: colors.teal,
152 | cyan: colors.cyan,
153 | sky: colors.sky,
154 | blue: colors.blue,
155 | indigo: colors.indigo,
156 | violet: colors.violet,
157 | purple: colors.purple,
158 | fuchsia: colors.fuchsia,
159 | pink: colors.pink,
160 | rose: colors.rose,
161 | }),
162 | columns: {
163 | auto: 'auto',
164 | 1: '1',
165 | 2: '2',
166 | 3: '3',
167 | 4: '4',
168 | 5: '5',
169 | 6: '6',
170 | 7: '7',
171 | 8: '8',
172 | 9: '9',
173 | 10: '10',
174 | 11: '11',
175 | 12: '12',
176 | '3xs': '16rem',
177 | '2xs': '18rem',
178 | xs: '20rem',
179 | sm: '24rem',
180 | md: '28rem',
181 | lg: '32rem',
182 | xl: '36rem',
183 | '2xl': '42rem',
184 | '3xl': '48rem',
185 | '4xl': '56rem',
186 | '5xl': '64rem',
187 | '6xl': '72rem',
188 | '7xl': '80rem',
189 | },
190 | container: {},
191 | content: {
192 | none: 'none',
193 | },
194 | contrast: {
195 | 0: '0',
196 | 50: '.5',
197 | 75: '.75',
198 | 100: '1',
199 | 125: '1.25',
200 | 150: '1.5',
201 | 200: '2',
202 | },
203 | cursor: {
204 | auto: 'auto',
205 | default: 'default',
206 | pointer: 'pointer',
207 | wait: 'wait',
208 | text: 'text',
209 | move: 'move',
210 | help: 'help',
211 | 'not-allowed': 'not-allowed',
212 | none: 'none',
213 | 'context-menu': 'context-menu',
214 | progress: 'progress',
215 | cell: 'cell',
216 | crosshair: 'crosshair',
217 | 'vertical-text': 'vertical-text',
218 | alias: 'alias',
219 | copy: 'copy',
220 | 'no-drop': 'no-drop',
221 | grab: 'grab',
222 | grabbing: 'grabbing',
223 | 'all-scroll': 'all-scroll',
224 | 'col-resize': 'col-resize',
225 | 'row-resize': 'row-resize',
226 | 'n-resize': 'n-resize',
227 | 'e-resize': 'e-resize',
228 | 's-resize': 's-resize',
229 | 'w-resize': 'w-resize',
230 | 'ne-resize': 'ne-resize',
231 | 'nw-resize': 'nw-resize',
232 | 'se-resize': 'se-resize',
233 | 'sw-resize': 'sw-resize',
234 | 'ew-resize': 'ew-resize',
235 | 'ns-resize': 'ns-resize',
236 | 'nesw-resize': 'nesw-resize',
237 | 'nwse-resize': 'nwse-resize',
238 | 'zoom-in': 'zoom-in',
239 | 'zoom-out': 'zoom-out',
240 | },
241 | divideColor: ({ theme }) => theme('borderColor'),
242 | divideOpacity: ({ theme }) => theme('borderOpacity'),
243 | divideWidth: ({ theme }) => theme('borderWidth'),
244 | dropShadow: {
245 | sm: '0 1px 1px rgb(0 0 0 / 0.05)',
246 | DEFAULT: ['0 1px 2px rgb(0 0 0 / 0.1)', '0 1px 1px rgb(0 0 0 / 0.06)'],
247 | md: ['0 4px 3px rgb(0 0 0 / 0.07)', '0 2px 2px rgb(0 0 0 / 0.06)'],
248 | lg: ['0 10px 8px rgb(0 0 0 / 0.04)', '0 4px 3px rgb(0 0 0 / 0.1)'],
249 | xl: ['0 20px 13px rgb(0 0 0 / 0.03)', '0 8px 5px rgb(0 0 0 / 0.08)'],
250 | '2xl': '0 25px 25px rgb(0 0 0 / 0.15)',
251 | none: '0 0 #0000',
252 | },
253 | fill: ({ theme }) => ({
254 | none: 'none',
255 | ...theme('colors'),
256 | }),
257 | flex: {
258 | 1: '1 1 0%',
259 | auto: '1 1 auto',
260 | initial: '0 1 auto',
261 | none: 'none',
262 | },
263 | flexBasis: ({ theme }) => ({
264 | auto: 'auto',
265 | ...theme('spacing'),
266 | '1/2': '50%',
267 | '1/3': '33.333333%',
268 | '2/3': '66.666667%',
269 | '1/4': '25%',
270 | '2/4': '50%',
271 | '3/4': '75%',
272 | '1/5': '20%',
273 | '2/5': '40%',
274 | '3/5': '60%',
275 | '4/5': '80%',
276 | '1/6': '16.666667%',
277 | '2/6': '33.333333%',
278 | '3/6': '50%',
279 | '4/6': '66.666667%',
280 | '5/6': '83.333333%',
281 | '1/12': '8.333333%',
282 | '2/12': '16.666667%',
283 | '3/12': '25%',
284 | '4/12': '33.333333%',
285 | '5/12': '41.666667%',
286 | '6/12': '50%',
287 | '7/12': '58.333333%',
288 | '8/12': '66.666667%',
289 | '9/12': '75%',
290 | '10/12': '83.333333%',
291 | '11/12': '91.666667%',
292 | full: '100%',
293 | }),
294 | flexGrow: {
295 | 0: '0',
296 | DEFAULT: '1',
297 | },
298 | flexShrink: {
299 | 0: '0',
300 | DEFAULT: '1',
301 | },
302 | fontFamily: {
303 | sans: [
304 | 'ui-sans-serif',
305 | 'system-ui',
306 | 'sans-serif',
307 | '"Apple Color Emoji"',
308 | '"Segoe UI Emoji"',
309 | '"Segoe UI Symbol"',
310 | '"Noto Color Emoji"',
311 | ],
312 | serif: ['ui-serif', 'Georgia', 'Cambria', '"Times New Roman"', 'Times', 'serif'],
313 | mono: [
314 | 'ui-monospace',
315 | 'SFMono-Regular',
316 | 'Menlo',
317 | 'Monaco',
318 | 'Consolas',
319 | '"Liberation Mono"',
320 | '"Courier New"',
321 | 'monospace',
322 | ],
323 | },
324 | fontSize: {
325 | xs: ['0.75rem', { lineHeight: '1rem' }],
326 | sm: ['0.875rem', { lineHeight: '1.25rem' }],
327 | base: ['1rem', { lineHeight: '1.5rem' }],
328 | lg: ['1.125rem', { lineHeight: '1.75rem' }],
329 | xl: ['1.25rem', { lineHeight: '1.75rem' }],
330 | '2xl': ['1.5rem', { lineHeight: '2rem' }],
331 | '3xl': ['1.875rem', { lineHeight: '2.25rem' }],
332 | '4xl': ['2.25rem', { lineHeight: '2.5rem' }],
333 | '5xl': ['3rem', { lineHeight: '1' }],
334 | '6xl': ['3.75rem', { lineHeight: '1' }],
335 | '7xl': ['4.5rem', { lineHeight: '1' }],
336 | '8xl': ['6rem', { lineHeight: '1' }],
337 | '9xl': ['8rem', { lineHeight: '1' }],
338 | },
339 | fontWeight: {
340 | thin: '100',
341 | extralight: '200',
342 | light: '300',
343 | normal: '400',
344 | medium: '500',
345 | semibold: '600',
346 | bold: '700',
347 | extrabold: '800',
348 | black: '900',
349 | },
350 | gap: ({ theme }) => theme('spacing'),
351 | gradientColorStops: ({ theme }) => theme('colors'),
352 | gradientColorStopPositions: {
353 | '0%': '0%',
354 | '5%': '5%',
355 | '10%': '10%',
356 | '15%': '15%',
357 | '20%': '20%',
358 | '25%': '25%',
359 | '30%': '30%',
360 | '35%': '35%',
361 | '40%': '40%',
362 | '45%': '45%',
363 | '50%': '50%',
364 | '55%': '55%',
365 | '60%': '60%',
366 | '65%': '65%',
367 | '70%': '70%',
368 | '75%': '75%',
369 | '80%': '80%',
370 | '85%': '85%',
371 | '90%': '90%',
372 | '95%': '95%',
373 | '100%': '100%',
374 | },
375 | grayscale: {
376 | 0: '0',
377 | DEFAULT: '100%',
378 | },
379 | gridAutoColumns: {
380 | auto: 'auto',
381 | min: 'min-content',
382 | max: 'max-content',
383 | fr: 'minmax(0, 1fr)',
384 | },
385 | gridAutoRows: {
386 | auto: 'auto',
387 | min: 'min-content',
388 | max: 'max-content',
389 | fr: 'minmax(0, 1fr)',
390 | },
391 | gridColumn: {
392 | auto: 'auto',
393 | 'span-1': 'span 1 / span 1',
394 | 'span-2': 'span 2 / span 2',
395 | 'span-3': 'span 3 / span 3',
396 | 'span-4': 'span 4 / span 4',
397 | 'span-5': 'span 5 / span 5',
398 | 'span-6': 'span 6 / span 6',
399 | 'span-7': 'span 7 / span 7',
400 | 'span-8': 'span 8 / span 8',
401 | 'span-9': 'span 9 / span 9',
402 | 'span-10': 'span 10 / span 10',
403 | 'span-11': 'span 11 / span 11',
404 | 'span-12': 'span 12 / span 12',
405 | 'span-full': '1 / -1',
406 | },
407 | gridColumnEnd: {
408 | auto: 'auto',
409 | 1: '1',
410 | 2: '2',
411 | 3: '3',
412 | 4: '4',
413 | 5: '5',
414 | 6: '6',
415 | 7: '7',
416 | 8: '8',
417 | 9: '9',
418 | 10: '10',
419 | 11: '11',
420 | 12: '12',
421 | 13: '13',
422 | },
423 | gridColumnStart: {
424 | auto: 'auto',
425 | 1: '1',
426 | 2: '2',
427 | 3: '3',
428 | 4: '4',
429 | 5: '5',
430 | 6: '6',
431 | 7: '7',
432 | 8: '8',
433 | 9: '9',
434 | 10: '10',
435 | 11: '11',
436 | 12: '12',
437 | 13: '13',
438 | },
439 | gridRow: {
440 | auto: 'auto',
441 | 'span-1': 'span 1 / span 1',
442 | 'span-2': 'span 2 / span 2',
443 | 'span-3': 'span 3 / span 3',
444 | 'span-4': 'span 4 / span 4',
445 | 'span-5': 'span 5 / span 5',
446 | 'span-6': 'span 6 / span 6',
447 | 'span-7': 'span 7 / span 7',
448 | 'span-8': 'span 8 / span 8',
449 | 'span-9': 'span 9 / span 9',
450 | 'span-10': 'span 10 / span 10',
451 | 'span-11': 'span 11 / span 11',
452 | 'span-12': 'span 12 / span 12',
453 | 'span-full': '1 / -1',
454 | },
455 | gridRowEnd: {
456 | auto: 'auto',
457 | 1: '1',
458 | 2: '2',
459 | 3: '3',
460 | 4: '4',
461 | 5: '5',
462 | 6: '6',
463 | 7: '7',
464 | 8: '8',
465 | 9: '9',
466 | 10: '10',
467 | 11: '11',
468 | 12: '12',
469 | 13: '13',
470 | },
471 | gridRowStart: {
472 | auto: 'auto',
473 | 1: '1',
474 | 2: '2',
475 | 3: '3',
476 | 4: '4',
477 | 5: '5',
478 | 6: '6',
479 | 7: '7',
480 | 8: '8',
481 | 9: '9',
482 | 10: '10',
483 | 11: '11',
484 | 12: '12',
485 | 13: '13',
486 | },
487 | gridTemplateColumns: {
488 | none: 'none',
489 | subgrid: 'subgrid',
490 | 1: 'repeat(1, minmax(0, 1fr))',
491 | 2: 'repeat(2, minmax(0, 1fr))',
492 | 3: 'repeat(3, minmax(0, 1fr))',
493 | 4: 'repeat(4, minmax(0, 1fr))',
494 | 5: 'repeat(5, minmax(0, 1fr))',
495 | 6: 'repeat(6, minmax(0, 1fr))',
496 | 7: 'repeat(7, minmax(0, 1fr))',
497 | 8: 'repeat(8, minmax(0, 1fr))',
498 | 9: 'repeat(9, minmax(0, 1fr))',
499 | 10: 'repeat(10, minmax(0, 1fr))',
500 | 11: 'repeat(11, minmax(0, 1fr))',
501 | 12: 'repeat(12, minmax(0, 1fr))',
502 | },
503 | gridTemplateRows: {
504 | none: 'none',
505 | subgrid: 'subgrid',
506 | 1: 'repeat(1, minmax(0, 1fr))',
507 | 2: 'repeat(2, minmax(0, 1fr))',
508 | 3: 'repeat(3, minmax(0, 1fr))',
509 | 4: 'repeat(4, minmax(0, 1fr))',
510 | 5: 'repeat(5, minmax(0, 1fr))',
511 | 6: 'repeat(6, minmax(0, 1fr))',
512 | 7: 'repeat(7, minmax(0, 1fr))',
513 | 8: 'repeat(8, minmax(0, 1fr))',
514 | 9: 'repeat(9, minmax(0, 1fr))',
515 | 10: 'repeat(10, minmax(0, 1fr))',
516 | 11: 'repeat(11, minmax(0, 1fr))',
517 | 12: 'repeat(12, minmax(0, 1fr))',
518 | },
519 | height: ({ theme }) => ({
520 | auto: 'auto',
521 | ...theme('spacing'),
522 | '1/2': '50%',
523 | '1/3': '33.333333%',
524 | '2/3': '66.666667%',
525 | '1/4': '25%',
526 | '2/4': '50%',
527 | '3/4': '75%',
528 | '1/5': '20%',
529 | '2/5': '40%',
530 | '3/5': '60%',
531 | '4/5': '80%',
532 | '1/6': '16.666667%',
533 | '2/6': '33.333333%',
534 | '3/6': '50%',
535 | '4/6': '66.666667%',
536 | '5/6': '83.333333%',
537 | full: '100%',
538 | screen: '100vh',
539 | svh: '100svh',
540 | lvh: '100lvh',
541 | dvh: '100dvh',
542 | min: 'min-content',
543 | max: 'max-content',
544 | fit: 'fit-content',
545 | }),
546 | hueRotate: {
547 | 0: '0deg',
548 | 15: '15deg',
549 | 30: '30deg',
550 | 60: '60deg',
551 | 90: '90deg',
552 | 180: '180deg',
553 | },
554 | inset: ({ theme }) => ({
555 | auto: 'auto',
556 | ...theme('spacing'),
557 | '1/2': '50%',
558 | '1/3': '33.333333%',
559 | '2/3': '66.666667%',
560 | '1/4': '25%',
561 | '2/4': '50%',
562 | '3/4': '75%',
563 | full: '100%',
564 | }),
565 | invert: {
566 | 0: '0',
567 | DEFAULT: '100%',
568 | },
569 | keyframes: {
570 | spin: {
571 | to: {
572 | transform: 'rotate(360deg)',
573 | },
574 | },
575 | ping: {
576 | '75%, 100%': {
577 | transform: 'scale(2)',
578 | opacity: '0',
579 | },
580 | },
581 | pulse: {
582 | '50%': {
583 | opacity: '.5',
584 | },
585 | },
586 | bounce: {
587 | '0%, 100%': {
588 | transform: 'translateY(-25%)',
589 | animationTimingFunction: 'cubic-bezier(0.8,0,1,1)',
590 | },
591 | '50%': {
592 | transform: 'none',
593 | animationTimingFunction: 'cubic-bezier(0,0,0.2,1)',
594 | },
595 | },
596 | },
597 | letterSpacing: {
598 | tighter: '-0.05em',
599 | tight: '-0.025em',
600 | normal: '0em',
601 | wide: '0.025em',
602 | wider: '0.05em',
603 | widest: '0.1em',
604 | },
605 | lineHeight: {
606 | none: '1',
607 | tight: '1.25',
608 | snug: '1.375',
609 | normal: '1.5',
610 | relaxed: '1.625',
611 | loose: '2',
612 | 3: '.75rem',
613 | 4: '1rem',
614 | 5: '1.25rem',
615 | 6: '1.5rem',
616 | 7: '1.75rem',
617 | 8: '2rem',
618 | 9: '2.25rem',
619 | 10: '2.5rem',
620 | },
621 | listStyleType: {
622 | none: 'none',
623 | disc: 'disc',
624 | decimal: 'decimal',
625 | },
626 | listStyleImage: {
627 | none: 'none',
628 | },
629 | margin: ({ theme }) => ({
630 | auto: 'auto',
631 | ...theme('spacing'),
632 | }),
633 | lineClamp: {
634 | 1: '1',
635 | 2: '2',
636 | 3: '3',
637 | 4: '4',
638 | 5: '5',
639 | 6: '6',
640 | },
641 | maxHeight: ({ theme }) => ({
642 | ...theme('spacing'),
643 | none: 'none',
644 | full: '100%',
645 | screen: '100vh',
646 | svh: '100svh',
647 | lvh: '100lvh',
648 | dvh: '100dvh',
649 | min: 'min-content',
650 | max: 'max-content',
651 | fit: 'fit-content',
652 | }),
653 | maxWidth: ({ theme, breakpoints }) => ({
654 | ...theme('spacing'),
655 | none: 'none',
656 | xs: '20rem',
657 | sm: '24rem',
658 | md: '28rem',
659 | lg: '32rem',
660 | xl: '36rem',
661 | '2xl': '42rem',
662 | '3xl': '48rem',
663 | '4xl': '56rem',
664 | '5xl': '64rem',
665 | '6xl': '72rem',
666 | '7xl': '80rem',
667 | full: '100%',
668 | min: 'min-content',
669 | max: 'max-content',
670 | fit: 'fit-content',
671 | prose: '65ch',
672 | ...breakpoints(theme('screens')),
673 | }),
674 | minHeight: ({ theme }) => ({
675 | ...theme('spacing'),
676 | full: '100%',
677 | screen: '100vh',
678 | svh: '100svh',
679 | lvh: '100lvh',
680 | dvh: '100dvh',
681 | min: 'min-content',
682 | max: 'max-content',
683 | fit: 'fit-content',
684 | }),
685 | minWidth: ({ theme }) => ({
686 | ...theme('spacing'),
687 | full: '100%',
688 | min: 'min-content',
689 | max: 'max-content',
690 | fit: 'fit-content',
691 | }),
692 | objectPosition: {
693 | bottom: 'bottom',
694 | center: 'center',
695 | left: 'left',
696 | 'left-bottom': 'left bottom',
697 | 'left-top': 'left top',
698 | right: 'right',
699 | 'right-bottom': 'right bottom',
700 | 'right-top': 'right top',
701 | top: 'top',
702 | },
703 | opacity: {
704 | 0: '0',
705 | 5: '0.05',
706 | 10: '0.1',
707 | 15: '0.15',
708 | 20: '0.2',
709 | 25: '0.25',
710 | 30: '0.3',
711 | 35: '0.35',
712 | 40: '0.4',
713 | 45: '0.45',
714 | 50: '0.5',
715 | 55: '0.55',
716 | 60: '0.6',
717 | 65: '0.65',
718 | 70: '0.7',
719 | 75: '0.75',
720 | 80: '0.8',
721 | 85: '0.85',
722 | 90: '0.9',
723 | 95: '0.95',
724 | 100: '1',
725 | },
726 | order: {
727 | first: '-9999',
728 | last: '9999',
729 | none: '0',
730 | 1: '1',
731 | 2: '2',
732 | 3: '3',
733 | 4: '4',
734 | 5: '5',
735 | 6: '6',
736 | 7: '7',
737 | 8: '8',
738 | 9: '9',
739 | 10: '10',
740 | 11: '11',
741 | 12: '12',
742 | },
743 | outlineColor: ({ theme }) => theme('colors'),
744 | outlineOffset: {
745 | 0: '0px',
746 | 1: '1px',
747 | 2: '2px',
748 | 4: '4px',
749 | 8: '8px',
750 | },
751 | outlineWidth: {
752 | 0: '0px',
753 | 1: '1px',
754 | 2: '2px',
755 | 4: '4px',
756 | 8: '8px',
757 | },
758 | padding: ({ theme }) => theme('spacing'),
759 | placeholderColor: ({ theme }) => theme('colors'),
760 | placeholderOpacity: ({ theme }) => theme('opacity'),
761 | ringColor: ({ theme }) => ({
762 | DEFAULT: theme('colors.blue.500', '#3b82f6'),
763 | ...theme('colors'),
764 | }),
765 | ringOffsetColor: ({ theme }) => theme('colors'),
766 | ringOffsetWidth: {
767 | 0: '0px',
768 | 1: '1px',
769 | 2: '2px',
770 | 4: '4px',
771 | 8: '8px',
772 | },
773 | ringOpacity: ({ theme }) => ({
774 | DEFAULT: '0.5',
775 | ...theme('opacity'),
776 | }),
777 | ringWidth: {
778 | DEFAULT: '3px',
779 | 0: '0px',
780 | 1: '1px',
781 | 2: '2px',
782 | 4: '4px',
783 | 8: '8px',
784 | },
785 | rotate: {
786 | 0: '0deg',
787 | 1: '1deg',
788 | 2: '2deg',
789 | 3: '3deg',
790 | 6: '6deg',
791 | 12: '12deg',
792 | 45: '45deg',
793 | 90: '90deg',
794 | 180: '180deg',
795 | },
796 | saturate: {
797 | 0: '0',
798 | 50: '.5',
799 | 100: '1',
800 | 150: '1.5',
801 | 200: '2',
802 | },
803 | scale: {
804 | 0: '0',
805 | 50: '.5',
806 | 75: '.75',
807 | 90: '.9',
808 | 95: '.95',
809 | 100: '1',
810 | 105: '1.05',
811 | 110: '1.1',
812 | 125: '1.25',
813 | 150: '1.5',
814 | },
815 | screens: {
816 | sm: '640px',
817 | md: '768px',
818 | lg: '1024px',
819 | xl: '1280px',
820 | '2xl': '1536px',
821 | },
822 | scrollMargin: ({ theme }) => ({
823 | ...theme('spacing'),
824 | }),
825 | scrollPadding: ({ theme }) => theme('spacing'),
826 | sepia: {
827 | 0: '0',
828 | DEFAULT: '100%',
829 | },
830 | skew: {
831 | 0: '0deg',
832 | 1: '1deg',
833 | 2: '2deg',
834 | 3: '3deg',
835 | 6: '6deg',
836 | 12: '12deg',
837 | },
838 | space: ({ theme }) => ({
839 | ...theme('spacing'),
840 | }),
841 | spacing: {
842 | px: '1px',
843 | 0: '0px',
844 | 0.5: '0.125rem',
845 | 1: '0.25rem',
846 | 1.5: '0.375rem',
847 | 2: '0.5rem',
848 | 2.5: '0.625rem',
849 | 3: '0.75rem',
850 | 3.5: '0.875rem',
851 | 4: '1rem',
852 | 5: '1.25rem',
853 | 6: '1.5rem',
854 | 7: '1.75rem',
855 | 8: '2rem',
856 | 9: '2.25rem',
857 | 10: '2.5rem',
858 | 11: '2.75rem',
859 | 12: '3rem',
860 | 14: '3.5rem',
861 | 16: '4rem',
862 | 20: '5rem',
863 | 24: '6rem',
864 | 28: '7rem',
865 | 32: '8rem',
866 | 36: '9rem',
867 | 40: '10rem',
868 | 44: '11rem',
869 | 48: '12rem',
870 | 52: '13rem',
871 | 56: '14rem',
872 | 60: '15rem',
873 | 64: '16rem',
874 | 72: '18rem',
875 | 80: '20rem',
876 | 96: '24rem',
877 | },
878 | stroke: ({ theme }) => ({
879 | none: 'none',
880 | ...theme('colors'),
881 | }),
882 | strokeWidth: {
883 | 0: '0',
884 | 1: '1',
885 | 2: '2',
886 | },
887 | supports: {},
888 | data: {},
889 | textColor: ({ theme }) => theme('colors'),
890 | textDecorationColor: ({ theme }) => theme('colors'),
891 | textDecorationThickness: {
892 | auto: 'auto',
893 | 'from-font': 'from-font',
894 | 0: '0px',
895 | 1: '1px',
896 | 2: '2px',
897 | 4: '4px',
898 | 8: '8px',
899 | },
900 | textIndent: ({ theme }) => ({
901 | ...theme('spacing'),
902 | }),
903 | textOpacity: ({ theme }) => theme('opacity'),
904 | textUnderlineOffset: {
905 | auto: 'auto',
906 | 0: '0px',
907 | 1: '1px',
908 | 2: '2px',
909 | 4: '4px',
910 | 8: '8px',
911 | },
912 | transformOrigin: {
913 | center: 'center',
914 | top: 'top',
915 | 'top-right': 'top right',
916 | right: 'right',
917 | 'bottom-right': 'bottom right',
918 | bottom: 'bottom',
919 | 'bottom-left': 'bottom left',
920 | left: 'left',
921 | 'top-left': 'top left',
922 | },
923 | transitionDelay: {
924 | 0: '0s',
925 | 75: '75ms',
926 | 100: '100ms',
927 | 150: '150ms',
928 | 200: '200ms',
929 | 300: '300ms',
930 | 500: '500ms',
931 | 700: '700ms',
932 | 1000: '1000ms',
933 | },
934 | transitionDuration: {
935 | DEFAULT: '150ms',
936 | 0: '0s',
937 | 75: '75ms',
938 | 100: '100ms',
939 | 150: '150ms',
940 | 200: '200ms',
941 | 300: '300ms',
942 | 500: '500ms',
943 | 700: '700ms',
944 | 1000: '1000ms',
945 | },
946 | transitionProperty: {
947 | none: 'none',
948 | all: 'all',
949 | DEFAULT:
950 | 'color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter',
951 | colors: 'color, background-color, border-color, text-decoration-color, fill, stroke',
952 | opacity: 'opacity',
953 | shadow: 'box-shadow',
954 | transform: 'transform',
955 | },
956 | transitionTimingFunction: {
957 | DEFAULT: 'cubic-bezier(0.4, 0, 0.2, 1)',
958 | linear: 'linear',
959 | in: 'cubic-bezier(0.4, 0, 1, 1)',
960 | out: 'cubic-bezier(0, 0, 0.2, 1)',
961 | 'in-out': 'cubic-bezier(0.4, 0, 0.2, 1)',
962 | },
963 | translate: ({ theme }) => ({
964 | ...theme('spacing'),
965 | '1/2': '50%',
966 | '1/3': '33.333333%',
967 | '2/3': '66.666667%',
968 | '1/4': '25%',
969 | '2/4': '50%',
970 | '3/4': '75%',
971 | full: '100%',
972 | }),
973 | size: ({ theme }) => ({
974 | auto: 'auto',
975 | ...theme('spacing'),
976 | '1/2': '50%',
977 | '1/3': '33.333333%',
978 | '2/3': '66.666667%',
979 | '1/4': '25%',
980 | '2/4': '50%',
981 | '3/4': '75%',
982 | '1/5': '20%',
983 | '2/5': '40%',
984 | '3/5': '60%',
985 | '4/5': '80%',
986 | '1/6': '16.666667%',
987 | '2/6': '33.333333%',
988 | '3/6': '50%',
989 | '4/6': '66.666667%',
990 | '5/6': '83.333333%',
991 | '1/12': '8.333333%',
992 | '2/12': '16.666667%',
993 | '3/12': '25%',
994 | '4/12': '33.333333%',
995 | '5/12': '41.666667%',
996 | '6/12': '50%',
997 | '7/12': '58.333333%',
998 | '8/12': '66.666667%',
999 | '9/12': '75%',
1000 | '10/12': '83.333333%',
1001 | '11/12': '91.666667%',
1002 | full: '100%',
1003 | min: 'min-content',
1004 | max: 'max-content',
1005 | fit: 'fit-content',
1006 | }),
1007 | width: ({ theme }) => ({
1008 | auto: 'auto',
1009 | ...theme('spacing'),
1010 | '1/2': '50%',
1011 | '1/3': '33.333333%',
1012 | '2/3': '66.666667%',
1013 | '1/4': '25%',
1014 | '2/4': '50%',
1015 | '3/4': '75%',
1016 | '1/5': '20%',
1017 | '2/5': '40%',
1018 | '3/5': '60%',
1019 | '4/5': '80%',
1020 | '1/6': '16.666667%',
1021 | '2/6': '33.333333%',
1022 | '3/6': '50%',
1023 | '4/6': '66.666667%',
1024 | '5/6': '83.333333%',
1025 | '1/12': '8.333333%',
1026 | '2/12': '16.666667%',
1027 | '3/12': '25%',
1028 | '4/12': '33.333333%',
1029 | '5/12': '41.666667%',
1030 | '6/12': '50%',
1031 | '7/12': '58.333333%',
1032 | '8/12': '66.666667%',
1033 | '9/12': '75%',
1034 | '10/12': '83.333333%',
1035 | '11/12': '91.666667%',
1036 | full: '100%',
1037 | screen: '100vw',
1038 | svw: '100svw',
1039 | lvw: '100lvw',
1040 | dvw: '100dvw',
1041 | min: 'min-content',
1042 | max: 'max-content',
1043 | fit: 'fit-content',
1044 | }),
1045 | willChange: {
1046 | auto: 'auto',
1047 | scroll: 'scroll-position',
1048 | contents: 'contents',
1049 | transform: 'transform',
1050 | },
1051 | zIndex: {
1052 | auto: 'auto',
1053 | 0: '0',
1054 | 10: '10',
1055 | 20: '20',
1056 | 30: '30',
1057 | 40: '40',
1058 | 50: '50',
1059 | },
1060 | },
1061 | plugins: [],
1062 | }
1063 |
1064 |
--------------------------------------------------------------------------------
/packages/ui/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "@repo/typescript-config/react-library.json",
3 | "compilerOptions": {
4 | "outDir": "dist"
5 | },
6 | "include": ["src"],
7 | "exclude": ["node_modules", "dist"]
8 | }
9 |
--------------------------------------------------------------------------------
/packages/ui/turbo/generators/config.ts:
--------------------------------------------------------------------------------
1 | import type { PlopTypes } from "@turbo/gen";
2 |
3 | // Learn more about Turborepo Generators at https://turbo.build/repo/docs/core-concepts/monorepos/code-generation
4 |
5 | export default function generator(plop: PlopTypes.NodePlopAPI): void {
6 | // A simple generator to add a new React component to the internal UI library
7 | plop.setGenerator("react-component", {
8 | description: "Adds a new react component",
9 | prompts: [
10 | {
11 | type: "input",
12 | name: "name",
13 | message: "What is the name of the component?",
14 | },
15 | ],
16 | actions: [
17 | {
18 | type: "add",
19 | path: "src/{{kebabCase name}}.tsx",
20 | templateFile: "templates/component.hbs",
21 | },
22 | {
23 | type: "append",
24 | path: "package.json",
25 | pattern: /"exports": {(?)/g,
26 | template: ' "./{{kebabCase name}}": "./src/{{kebabCase name}}.tsx",',
27 | },
28 | ],
29 | });
30 | }
31 |
--------------------------------------------------------------------------------
/packages/ui/turbo/generators/templates/component.hbs:
--------------------------------------------------------------------------------
1 | export const {{ pascalCase name }} = ({ children }: { children: React.ReactNode }) => {
2 | return (
3 |
4 |
{{ pascalCase name }} Component
5 | {children}
6 |
7 | );
8 | };
9 |
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - "apps/*"
3 | - "packages/*"
4 |
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turbo.build/schema.json",
3 | "ui": "tui",
4 | "tasks": {
5 | "build": {
6 | "dependsOn": ["^build"],
7 | "inputs": ["$TURBO_DEFAULT$", ".env*"],
8 | "outputs": [".next/**", "!.next/cache/**"]
9 | },
10 | "lint": {
11 | "dependsOn": ["^lint"]
12 | },
13 | "check-types": {
14 | "dependsOn": ["^check-types"]
15 | },
16 | "dev": {
17 | "cache": false,
18 | "persistent": true
19 | }
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "buildCommand": "pnpm run build"
3 | }
--------------------------------------------------------------------------------