├── .eslintrc.json ├── app ├── favicon.ico ├── (dashboard) │ ├── _components │ │ ├── board-card │ │ │ ├── overlay.tsx │ │ │ ├── footer.tsx │ │ │ └── index.tsx │ │ ├── sidebar │ │ │ ├── index.tsx │ │ │ ├── list.tsx │ │ │ ├── new-button.tsx │ │ │ └── item.tsx │ │ ├── empty-search.tsx │ │ ├── empty-favorites.tsx │ │ ├── invite-button.tsx │ │ ├── empty-org.tsx │ │ ├── navbar.tsx │ │ ├── search-input.tsx │ │ ├── new-board-button.tsx │ │ ├── empty-boards.tsx │ │ ├── board-list.tsx │ │ └── org-sidebar.tsx │ ├── page.tsx │ └── layout.tsx ├── board │ └── [boardId] │ │ ├── page.tsx │ │ └── _components │ │ ├── loading.tsx │ │ ├── user-avatar.tsx │ │ ├── tool-button.tsx │ │ ├── ellipse.tsx │ │ ├── rectangle.tsx │ │ ├── path.tsx │ │ ├── cursor.tsx │ │ ├── reset-camera.tsx │ │ ├── cursors-presence.tsx │ │ ├── color-picker.tsx │ │ ├── text.tsx │ │ ├── participants.tsx │ │ ├── note.tsx │ │ ├── layer-preview.tsx │ │ ├── info.tsx │ │ ├── selection-tools.tsx │ │ ├── toolbar.tsx │ │ └── selection-box.tsx ├── layout.tsx ├── api │ └── liveblocks-auth │ │ └── route.ts └── globals.css ├── public ├── color-picker.png ├── logo.svg ├── vercel.svg ├── next.svg └── placeholders │ ├── 6.svg │ ├── 7.svg │ ├── 3.svg │ ├── 4.svg │ └── 2.svg ├── postcss.config.mjs ├── middleware.ts ├── convex ├── auth.config.js ├── _generated │ ├── api.js │ ├── api.d.ts │ ├── dataModel.d.ts │ ├── server.js │ └── server.d.ts ├── tsconfig.json ├── schema.ts ├── boards.ts ├── README.md └── board.ts ├── .env.example ├── next.config.mjs ├── components ├── ui │ ├── skeleton.tsx │ ├── input.tsx │ ├── sonner.tsx │ ├── tooltip.tsx │ ├── popover.tsx │ ├── avatar.tsx │ ├── button.tsx │ ├── dialog.tsx │ ├── alert-dialog.tsx │ └── dropdown-menu.tsx ├── auth │ └── loading.tsx ├── room.tsx ├── hint.tsx ├── icon.tsx ├── confirm-modal.tsx ├── modals │ └── rename-modal.tsx ├── custom-color-picker.tsx └── actions.tsx ├── components.json ├── hooks ├── use-disable-scroll-bounce.ts ├── use-forwarded-ref.ts ├── use-api-mutation.ts ├── use-delete-layers.ts └── use-selection-bounds.ts ├── providers ├── modal-provider.tsx └── convex-client-provider.tsx ├── .gitignore ├── store └── use-rename-modal.ts ├── tsconfig.json ├── package.json ├── tailwind.config.ts ├── types └── canvas.ts ├── README.md ├── lib └── utils.ts └── liveblocks.config.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jatin1510/miro-clone/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /public/color-picker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jatin1510/miro-clone/HEAD/public/color-picker.png -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { authMiddleware } from "@clerk/nextjs/server"; 2 | 3 | export default authMiddleware(); 4 | 5 | export const config = { 6 | matcher: ["/((?!.*\\..*|_next).*)", "/", "/(api|trpc)(.*)"], 7 | }; -------------------------------------------------------------------------------- /convex/auth.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | providers: [ 3 | { 4 | domain: "https://regular-humpback-73.clerk.accounts.dev", 5 | applicationID: "convex", 6 | }, 7 | ], 8 | }; -------------------------------------------------------------------------------- /app/(dashboard)/_components/board-card/overlay.tsx: -------------------------------------------------------------------------------- 1 | export const Overlay = () => { 2 | return ( 3 |
4 | ); 5 | }; 6 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Deployment used by `npx convex dev` 2 | CONVEX_DEPLOYMENT= 3 | NEXT_PUBLIC_CONVEX_URL= 4 | 5 | # Clerk configuration 6 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= 7 | CLERK_SECRET_KEY= 8 | 9 | # Liveblocks configuration 10 | NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY= 11 | NEXT_PUBLIC_LIVEBLOCKS_SECRET_KEY= -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | remotePatterns: [ 5 | { 6 | protocol: "https", 7 | hostname: "img.clerk.com", 8 | }, 9 | ], 10 | }, 11 | }; 12 | 13 | export default nextConfig; 14 | -------------------------------------------------------------------------------- /components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 |
12 | ) 13 | } 14 | 15 | export { Skeleton } 16 | -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /app/(dashboard)/_components/sidebar/index.tsx: -------------------------------------------------------------------------------- 1 | import { List } from "./list"; 2 | import { NewButton } from "./new-button"; 3 | 4 | export const Sidebar = () => { 5 | return ( 6 | 10 | ); 11 | }; 12 | 13 | export default Sidebar; 14 | -------------------------------------------------------------------------------- /hooks/use-disable-scroll-bounce.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | export const useDisableScrollBounce = () => { 4 | useEffect(() => { 5 | document.body.classList.add("overflow-hidden", "overscroll-none"); 6 | return () => { 7 | document.body.classList.remove( 8 | "overflow-hidden", 9 | "overscroll-none" 10 | ); 11 | }; 12 | }, []); 13 | }; 14 | -------------------------------------------------------------------------------- /components/auth/loading.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | export const Loading = () => { 4 | return ( 5 |
6 | Logo 13 |
14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /hooks/use-forwarded-ref.ts: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | import { useEffect, useRef } from "react"; 3 | 4 | export function useForwardedRef(ref: React.ForwardedRef) { 5 | const innerRef = useRef(null); 6 | 7 | useEffect(() => { 8 | if (!ref) return; 9 | if (typeof ref === "function") { 10 | ref(innerRef.current); 11 | } else { 12 | ref.current = innerRef.current; 13 | } 14 | }); 15 | 16 | return innerRef; 17 | } -------------------------------------------------------------------------------- /providers/modal-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { RenameModal } from "@/components/modals/rename-modal"; 4 | import { useEffect, useState } from "react"; 5 | 6 | export const ModalProvider = () => { 7 | const [isMounted, setIsMounted] = useState(false); 8 | 9 | useEffect(() => { 10 | setIsMounted(true); 11 | }, []); 12 | 13 | if (!isMounted) { 14 | return null; 15 | } 16 | 17 | return ( 18 | <> 19 | 20 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /convex/_generated/api.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated `api` utility. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * Generated by convex@1.12.0. 8 | * To regenerate, run `npx convex dev`. 9 | * @module 10 | */ 11 | 12 | import { anyApi } from "convex/server"; 13 | 14 | /** 15 | * A utility for referencing Convex functions in your app's API. 16 | * 17 | * Usage: 18 | * ```js 19 | * const myFunctionReference = api.myModule.myFunction; 20 | * ``` 21 | */ 22 | export const api = anyApi; 23 | export const internal = anyApi; 24 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /store/use-rename-modal.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | const defaultValues = { 4 | id: "", 5 | title: "", 6 | }; 7 | 8 | interface IRenameModal { 9 | isOpen: boolean; 10 | initialValues: typeof defaultValues; 11 | onOpen: (id: string, title: string) => void; 12 | onClose: () => void; 13 | } 14 | 15 | export const useRenameModal = create((set) => ({ 16 | isOpen: false, 17 | onOpen: (id, title) => set({ isOpen: true, initialValues: { id, title } }), 18 | onClose: () => set({ isOpen: false, initialValues: defaultValues }), 19 | initialValues: defaultValues, 20 | })); 21 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/empty-search.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | export const EmptySearch = () => { 4 | return ( 5 |
6 | Empty 12 |

No results found

13 |

14 | Try searching for something else 15 |

16 |
17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/empty-favorites.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | export const EmptyFavorites = () => { 4 | return ( 5 |
6 | Empty Favorites 12 |

No favorite boards!

13 |

14 | Try favoriting a board 15 |

16 |
17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /app/board/[boardId]/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Canvas } from "./_components/canvas"; 4 | import { Room } from "@/components/room"; 5 | import { Loading } from "./_components/loading"; 6 | import { useEffect } from "react"; 7 | interface BoardIdPageProps { 8 | params: { boardId: string }; 9 | } 10 | 11 | const BoardIdPage = ({ params }: BoardIdPageProps) => { 12 | useEffect(() => { 13 | document.title = `Board - Miro Clone`; 14 | }, []); 15 | 16 | return ( 17 | }> 18 | 19 | 20 | ); 21 | }; 22 | 23 | export default BoardIdPage; 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /hooks/use-api-mutation.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useMutation } from "convex/react"; 3 | 4 | export const useApiMutation = (mutationFunction: any) => { 5 | const [pending, setPending] = useState(false); 6 | const apiMutation = useMutation(mutationFunction); 7 | 8 | const mutate = async (payload: any) => { 9 | setPending(true); 10 | return await apiMutation(payload) 11 | .then((result) => { 12 | return result; 13 | }) 14 | .catch((error) => { 15 | throw error; 16 | }) 17 | .finally(() => setPending(false)); 18 | }; 19 | 20 | return { mutate, pending }; 21 | }; 22 | -------------------------------------------------------------------------------- /app/board/[boardId]/_components/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Loader } from "lucide-react"; 2 | import { ParticipantsSkeleton } from "./participants"; 3 | import { InfoSkeleton } from "./info"; 4 | import { ToolbarSkeleton } from "./toolbar"; 5 | import { ResetCameraSkeleton } from "./reset-camera"; 6 | 7 | export const Loading = () => { 8 | return ( 9 |
13 | 14 | 15 | 16 | 17 | 18 |
19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/invite-button.tsx: -------------------------------------------------------------------------------- 1 | import { Plus } from "lucide-react"; 2 | import { OrganizationProfile } from "@clerk/nextjs"; 3 | import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; 4 | import { Button } from "@/components/ui/button"; 5 | 6 | export const InviteButton = () => { 7 | return ( 8 | 9 | 10 | 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | }; -------------------------------------------------------------------------------- /app/(dashboard)/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useOrganization } from "@clerk/nextjs"; 4 | import { EmptyOrg } from "./_components/empty-org"; 5 | import { BoardList } from "./_components/board-list"; 6 | 7 | interface DashboardPageProps { 8 | searchParams: { 9 | search?: string; 10 | favorites?: string; 11 | }; 12 | } 13 | 14 | const DashboardPage = ({ searchParams }: DashboardPageProps) => { 15 | const { organization } = useOrganization(); 16 | return ( 17 |
18 | {!organization ? ( 19 | 20 | ) : ( 21 | 22 | )} 23 |
24 | ); 25 | }; 26 | 27 | export default DashboardPage; 28 | -------------------------------------------------------------------------------- /hooks/use-delete-layers.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useSelf } from "@/liveblocks.config"; 2 | 3 | export const useDeleteLayers = () => { 4 | const selection = useSelf((me) => me.presence.selection); 5 | 6 | return useMutation( 7 | ({ storage, setMyPresence }) => { 8 | const liveLayers = storage.get("layers"); 9 | const liveLayerIds = storage.get("layerIds"); 10 | 11 | for (const id of selection) { 12 | liveLayers.delete(id); 13 | 14 | const index = liveLayerIds.indexOf(id); 15 | 16 | if (index !== -1) { 17 | liveLayerIds.delete(index); 18 | } 19 | } 20 | 21 | setMyPresence({ selection: [] }, { addToHistory: true }); 22 | }, 23 | [selection] 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /convex/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | /* This TypeScript project config describes the environment that 3 | * Convex functions run in and is used to typecheck them. 4 | * You can modify it, but some settings required to use Convex. 5 | */ 6 | "compilerOptions": { 7 | /* These settings are not required by Convex and can be modified. */ 8 | "allowJs": true, 9 | "strict": true, 10 | 11 | /* These compiler options are required by Convex */ 12 | "target": "ESNext", 13 | "lib": ["ES2021", "dom"], 14 | "forceConsistentCasingInFileNames": true, 15 | "allowSyntheticDefaultImports": true, 16 | "module": "ESNext", 17 | "moduleResolution": "Node", 18 | "isolatedModules": true, 19 | "skipLibCheck": true, 20 | "noEmit": true 21 | }, 22 | "include": ["./**/*"], 23 | "exclude": ["./_generated"] 24 | } 25 | -------------------------------------------------------------------------------- /app/board/[boardId]/_components/user-avatar.tsx: -------------------------------------------------------------------------------- 1 | import { Hint } from "@/components/hint"; 2 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; 3 | 4 | interface UserAvatarProps { 5 | src?: string; 6 | name?: string; 7 | fallback?: string; 8 | borderColor?: string; 9 | } 10 | 11 | export const UserAvatar = ({ 12 | src, 13 | name, 14 | fallback, 15 | borderColor, 16 | }: UserAvatarProps) => { 17 | return ( 18 | 19 | 20 | 21 | 22 | {fallback} 23 | 24 | 25 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /convex/schema.ts: -------------------------------------------------------------------------------- 1 | import { v } from "convex/values"; 2 | import { defineTable, defineSchema } from "convex/server"; 3 | 4 | export default defineSchema({ 5 | boards: defineTable({ 6 | title: v.string(), 7 | orgId: v.string(), 8 | authorId: v.string(), 9 | authorName: v.string(), 10 | imageUrl: v.string(), 11 | }) 12 | .index("by_org", ["orgId"]) 13 | .searchIndex("search_title", { 14 | searchField: "title", 15 | filterFields: ["orgId"], 16 | }), 17 | 18 | userFavorites: defineTable({ 19 | orgId: v.string(), 20 | userId: v.string(), 21 | boardId: v.id("boards"), 22 | }) 23 | .index("by_board", ["boardId"]) 24 | .index("by_user_org", ["userId", "orgId"]) 25 | .index("by_user_board", ["userId", "boardId"]) 26 | .index("by_user_board_org", ["userId", "boardId", "orgId"]), 27 | }); 28 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/sidebar/list.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useOrganizationList } from "@clerk/nextjs"; 4 | import { Item } from "./item"; 5 | 6 | export const List = () => { 7 | const { userMemberships } = useOrganizationList({ 8 | userMemberships: { 9 | infinite: true, 10 | }, 11 | }); 12 | 13 | if (!userMemberships.data?.length) { 14 | return null; 15 | } 16 | 17 | return ( 18 |
    19 | {userMemberships.data.map((mem) => { 20 | return ( 21 | 27 | ); 28 | })} 29 |
30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter } from "next/font/google"; 3 | import "./globals.css"; 4 | import { ConvexClientProvider } from "@/providers/convex-client-provider"; 5 | import { Toaster } from "@/components/ui/sonner"; 6 | import { ModalProvider } from "@/providers/modal-provider"; 7 | const inter = Inter({ subsets: ["latin"] }); 8 | 9 | export const metadata: Metadata = { 10 | title: "Miro Clone", 11 | }; 12 | 13 | export default function RootLayout({ 14 | children, 15 | }: Readonly<{ 16 | children: React.ReactNode; 17 | }>) { 18 | return ( 19 | 20 | 21 | 22 | 23 | 24 | {children} 25 | 26 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ) 21 | } 22 | ) 23 | Input.displayName = "Input" 24 | 25 | export { Input } 26 | -------------------------------------------------------------------------------- /app/board/[boardId]/_components/tool-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Hint } from "@/components/hint"; 4 | import { Button } from "@/components/ui/button"; 5 | import { LucideIcon } from "lucide-react"; 6 | 7 | interface ToolButtonProps { 8 | label: string; 9 | icon: LucideIcon; 10 | onClick: () => void; 11 | isActive?: boolean; 12 | isDisabled?: boolean; 13 | } 14 | 15 | export const ToolButton = ({ 16 | label, 17 | icon: Icon, 18 | onClick, 19 | isActive, 20 | isDisabled, 21 | }: ToolButtonProps) => { 22 | return ( 23 | 24 | 32 | 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /app/(dashboard)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Navbar } from "./_components/navbar"; 2 | import { OrgSidebar } from "./_components/org-sidebar"; 3 | import Sidebar from "./_components/sidebar"; 4 | 5 | interface DashBoardLayoutProps { 6 | children?: React.ReactNode; 7 | } 8 | 9 | const DashBoardLayout = ({ children }: DashBoardLayoutProps) => { 10 | return ( 11 |
12 | 13 |
14 |
15 | 16 | {/* Separator */} 17 |
18 |
19 | 20 | {children} 21 |
22 |
23 |
24 |
25 | ); 26 | }; 27 | 28 | export default DashBoardLayout; 29 | -------------------------------------------------------------------------------- /convex/_generated/api.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated `api` utility. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * Generated by convex@1.12.0. 8 | * To regenerate, run `npx convex dev`. 9 | * @module 10 | */ 11 | 12 | import type { 13 | ApiFromModules, 14 | FilterApi, 15 | FunctionReference, 16 | } from "convex/server"; 17 | import type * as board from "../board.js"; 18 | import type * as boards from "../boards.js"; 19 | 20 | /** 21 | * A utility for referencing Convex functions in your app's API. 22 | * 23 | * Usage: 24 | * ```js 25 | * const myFunctionReference = api.myModule.myFunction; 26 | * ``` 27 | */ 28 | declare const fullApi: ApiFromModules<{ 29 | board: typeof board; 30 | boards: typeof boards; 31 | }>; 32 | export declare const api: FilterApi< 33 | typeof fullApi, 34 | FunctionReference 35 | >; 36 | export declare const internal: FilterApi< 37 | typeof fullApi, 38 | FunctionReference 39 | >; 40 | -------------------------------------------------------------------------------- /providers/convex-client-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ClerkProvider, useAuth } from "@clerk/nextjs"; 4 | import { ConvexProviderWithClerk } from "convex/react-clerk"; 5 | import { AuthLoading, Authenticated, ConvexReactClient } from "convex/react"; 6 | import { Loading } from "@/components/auth/loading"; 7 | 8 | interface ConvexClientProviderProps { 9 | children: React.ReactNode; 10 | } 11 | 12 | const convexUrl = process.env.NEXT_PUBLIC_CONVEX_URL!; 13 | 14 | const convex = new ConvexReactClient(convexUrl); 15 | 16 | export const ConvexClientProvider = ({ 17 | children, 18 | }: ConvexClientProviderProps) => { 19 | return ( 20 | 21 | 22 | {children} 23 | 24 | 25 | 26 | 27 | 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useTheme } from "next-themes" 4 | import { Toaster as Sonner } from "sonner" 5 | 6 | type ToasterProps = React.ComponentProps 7 | 8 | const Toaster = ({ ...props }: ToasterProps) => { 9 | const { theme = "system" } = useTheme() 10 | 11 | return ( 12 | 28 | ) 29 | } 30 | 31 | export { Toaster } 32 | -------------------------------------------------------------------------------- /app/board/[boardId]/_components/ellipse.tsx: -------------------------------------------------------------------------------- 1 | import { colorToCss } from "@/lib/utils"; 2 | import { EllipseLayer } from "@/types/canvas"; 3 | 4 | interface EllipseProps { 5 | id: string; 6 | layer: EllipseLayer; 7 | onPointerDown: (e: React.PointerEvent, id: string) => void; 8 | selectionColor?: string; 9 | } 10 | 11 | export const Ellipse = ({ 12 | id, 13 | layer, 14 | onPointerDown, 15 | selectionColor, 16 | }: EllipseProps) => { 17 | const { x, y, width, height, fill } = layer; 18 | return ( 19 | onPointerDown(e, id)} 22 | style={{ 23 | transform: `translate(${x}px, ${y}px)`, 24 | }} 25 | cx={width / 2} 26 | cy={height / 2} 27 | rx={width / 2} 28 | ry={height / 2} 29 | fill={fill ? colorToCss(fill) : "#000"} 30 | stroke={selectionColor || "transparent"} 31 | strokeWidth={1} 32 | /> 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /app/board/[boardId]/_components/rectangle.tsx: -------------------------------------------------------------------------------- 1 | import { colorToCss } from "@/lib/utils"; 2 | import { RectangleLayer } from "@/types/canvas"; 3 | 4 | interface RectangleProps { 5 | id: string; 6 | layer: RectangleLayer; 7 | onPointerDown: (e: React.PointerEvent, id: string) => void; 8 | selectionColor?: string; 9 | } 10 | 11 | export const Rectangle = ({ 12 | id, 13 | layer, 14 | onPointerDown, 15 | selectionColor, 16 | }: RectangleProps) => { 17 | const { x, y, width, height, fill } = layer; 18 | 19 | return ( 20 | onPointerDown(e, id)} 23 | style={{ 24 | transform: `translate(${x}px, ${y}px)`, 25 | position: "absolute", 26 | }} 27 | x={0} 28 | y={0} 29 | width={width} 30 | height={height} 31 | strokeWidth={1} 32 | fill={fill ? colorToCss(fill) : "#000"} 33 | stroke={selectionColor || "transparent"} 34 | /> 35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /components/room.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ClientSideSuspense } from "@liveblocks/react"; 4 | import { ReactNode } from "react"; 5 | 6 | import { RoomProvider } from "@/liveblocks.config"; 7 | import { LiveList, LiveMap, LiveObject } from "@liveblocks/client"; 8 | import { Layer } from "@/types/canvas"; 9 | 10 | interface RoomProps { 11 | children: ReactNode; 12 | roomId: string; 13 | fallback: NonNullable | null; 14 | } 15 | export const Room = ({ children, roomId, fallback }: RoomProps) => { 16 | return ( 17 | >(), 27 | layerIds: new LiveList(), 28 | }} 29 | > 30 | 31 | {() => children} 32 | 33 | 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /app/board/[boardId]/_components/path.tsx: -------------------------------------------------------------------------------- 1 | import { getSvgPathFromStroke } from "@/lib/utils"; 2 | import getStroke from "perfect-freehand"; 3 | 4 | interface PathProps { 5 | x: number; 6 | y: number; 7 | points: number[][]; 8 | fill: string; 9 | onPointerDown?: (e: React.PointerEvent) => void; 10 | stroke?: string; 11 | } 12 | 13 | export const Path = ({ 14 | x, 15 | y, 16 | points, 17 | fill, 18 | onPointerDown, 19 | stroke, 20 | }: PathProps) => { 21 | return ( 22 | 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/empty-org.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; 3 | import { CreateOrganization } from "@clerk/nextjs"; 4 | import Image from "next/image"; 5 | 6 | export const EmptyOrg = () => { 7 | return ( 8 |
9 | Empty 10 |

Welcome to Board

11 |

12 | Create an organization to get started 13 |

14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 |
25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /components/hint.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Tooltip, 3 | TooltipContent, 4 | TooltipProvider, 5 | TooltipTrigger, 6 | } from "@/components/ui/tooltip"; 7 | 8 | export interface HintProps { 9 | label: string; 10 | children: React.ReactNode; 11 | side?: "top" | "bottom" | "left" | "right"; 12 | align?: "start" | "center" | "end"; 13 | sideOffset?: number; 14 | alignOffset?: number; 15 | } 16 | 17 | export function Hint({ 18 | label, 19 | children, 20 | side, 21 | align, 22 | sideOffset, 23 | alignOffset, 24 | }: HintProps) { 25 | return ( 26 | 27 | 28 | {children} 29 | 36 |

{label}

37 |
38 |
39 |
40 | ); 41 | } -------------------------------------------------------------------------------- /components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const TooltipProvider = TooltipPrimitive.Provider 9 | 10 | const Tooltip = TooltipPrimitive.Root 11 | 12 | const TooltipTrigger = TooltipPrimitive.Trigger 13 | 14 | const TooltipContent = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef 17 | >(({ className, sideOffset = 4, ...props }, ref) => ( 18 | 27 | )) 28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName 29 | 30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 31 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/sidebar/new-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Hint } from "@/components/hint"; 4 | // import { Hint } from "@/components/hint"; 5 | import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; 6 | import { CreateOrganization } from "@clerk/nextjs"; 7 | import { Plus } from "lucide-react"; 8 | 9 | export const NewButton = () => { 10 | return ( 11 | 12 | 13 |
14 | 20 |
21 | 22 |
23 |
24 |
25 |
26 | 27 | 28 | 29 |
30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/sidebar/item.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | 5 | import { useOrganization, useOrganizationList } from "@clerk/nextjs"; 6 | import { cn } from "@/lib/utils"; 7 | import { Hint } from "@/components/hint"; 8 | 9 | interface ItemProps { 10 | id: string; 11 | name: string; 12 | imageUrl: string; 13 | } 14 | 15 | export const Item = ({ id, name, imageUrl }: ItemProps) => { 16 | const { organization } = useOrganization(); 17 | const { setActive } = useOrganizationList(); 18 | const isActive = organization?.id === id; 19 | const onClick = () => { 20 | if (!setActive) return; 21 | setActive({ organization: id }); 22 | }; 23 | return ( 24 |
25 | 26 | {name} 36 | 37 |
38 | ); 39 | }; 40 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as PopoverPrimitive from "@radix-ui/react-popover" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Popover = PopoverPrimitive.Root 9 | 10 | const PopoverTrigger = PopoverPrimitive.Trigger 11 | 12 | const PopoverContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 16 | 17 | 27 | 28 | )) 29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName 30 | 31 | export { Popover, PopoverTrigger, PopoverContent } 32 | -------------------------------------------------------------------------------- /app/api/liveblocks-auth/route.ts: -------------------------------------------------------------------------------- 1 | import { api } from "@/convex/_generated/api"; 2 | import { auth, currentUser } from "@clerk/nextjs"; 3 | import { Liveblocks } from "@liveblocks/node"; 4 | 5 | import { ConvexHttpClient } from "convex/browser"; 6 | 7 | const convex = new ConvexHttpClient(process.env.NEXT_PUBLIC_CONVEX_URL!); 8 | 9 | const liveblocks = new Liveblocks({ 10 | secret: process.env.NEXT_PUBLIC_LIVEBLOCKS_SECRET_KEY!, 11 | }); 12 | 13 | export async function POST(request: Request) { 14 | const authorization = await auth(); 15 | const user = await currentUser(); 16 | 17 | if (!authorization || !user) { 18 | return new Response("Unauthorized", { status: 401 }); 19 | } 20 | 21 | const { room } = await request.json(); 22 | const board = await convex.query(api.board.get, { id: room }); 23 | 24 | if (board?.orgId !== authorization.orgId) { 25 | return new Response("Unauthorized", { status: 401 }); 26 | } 27 | 28 | const userInfo = { 29 | name: user.firstName || "Anonymous", 30 | picture: user.imageUrl!, 31 | }; 32 | 33 | const session = liveblocks.prepareSession(user.id, { 34 | userInfo, 35 | }); 36 | 37 | if (room) { 38 | session.allow(room, session.FULL_ACCESS); 39 | } 40 | 41 | const { status, body } = await session.authorize(); 42 | return new Response(body, { status }); 43 | } 44 | -------------------------------------------------------------------------------- /hooks/use-selection-bounds.ts: -------------------------------------------------------------------------------- 1 | import { useSelf, useStorage } from "@/liveblocks.config"; 2 | import type { Layer, XYWH } from "@/types/canvas"; 3 | import { shallow } from "@liveblocks/react"; 4 | 5 | const boundingBox = (layers: Layer[]): XYWH | null => { 6 | const first = layers[0]; 7 | 8 | if (!first) return null; 9 | 10 | let left = first.x; 11 | let right = first.x + first.width; 12 | let top = first.y; 13 | let bottom = first.y + first.height; 14 | 15 | for (let i = 1; i < layers.length; i++) { 16 | const { x, y, width, height } = layers[i]; 17 | 18 | if (left > x) { 19 | left = x; 20 | } 21 | 22 | if (right < x + width) { 23 | right = x + width; 24 | } 25 | 26 | if (top > y) { 27 | top = y; 28 | } 29 | 30 | if (bottom < y + height) { 31 | bottom = y + height; 32 | } 33 | } 34 | 35 | return { 36 | x: left, 37 | y: top, 38 | width: right - left, 39 | height: bottom - top, 40 | }; 41 | }; 42 | 43 | export const useSelectionBounds = () => { 44 | const selection = useSelf((me) => me.presence.selection); 45 | 46 | return useStorage((root) => { 47 | const selectedLayers = selection 48 | .map((layerId) => root.layers.get(layerId)!) 49 | .filter(Boolean); 50 | 51 | return boundingBox(selectedLayers); 52 | }, shallow); 53 | }; 54 | -------------------------------------------------------------------------------- /app/board/[boardId]/_components/cursor.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { connectionIdToColor } from "@/lib/utils"; 4 | import { useOther } from "@/liveblocks.config"; 5 | import { MousePointer2 } from "lucide-react"; 6 | import { memo } from "react"; 7 | 8 | interface CursorProps { 9 | connectionId: number; 10 | } 11 | 12 | export const Cursor = memo(({ connectionId }: CursorProps) => { 13 | const info = useOther(connectionId, (user) => user?.info); 14 | const cursor = useOther(connectionId, (user) => user.presence.cursor); 15 | 16 | const name = info?.name || "Anonymous"; 17 | 18 | if (!cursor) return null; 19 | 20 | const { x, y } = cursor; 21 | 22 | return ( 23 | 29 | 36 |
40 | {name} 41 |
42 |
43 | ); 44 | }); 45 | 46 | Cursor.displayName = "Cursor"; 47 | -------------------------------------------------------------------------------- /app/board/[boardId]/_components/reset-camera.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { Locate, LocateFixed } from "lucide-react"; 3 | import { Hint } from "@/components/hint"; 4 | import { Button } from "@/components/ui/button"; 5 | import { Skeleton } from "@/components/ui/skeleton"; 6 | 7 | interface ResetCameraProps { 8 | resetCamera: () => void; 9 | } 10 | 11 | export const ResetCamera = ({ resetCamera }: ResetCameraProps) => { 12 | const [isHovered, setIsHovered] = useState(false); 13 | 14 | return ( 15 | 16 |
setIsHovered(true)} 19 | onMouseLeave={() => setIsHovered(false)} 20 | > 21 | 28 |
29 |
30 | ); 31 | }; 32 | 33 | export const ResetCameraSkeleton = () => { 34 | return ( 35 |
36 | 37 |
38 | ); 39 | }; -------------------------------------------------------------------------------- /components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | Avatar.displayName = AvatarPrimitive.Root.displayName 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )) 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )) 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 49 | 50 | export { Avatar, AvatarImage, AvatarFallback } 51 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/board-card/footer.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import { Star } from "lucide-react"; 3 | 4 | interface FooterProps { 5 | title: string; 6 | authorLabel: string; 7 | createdAtLabel: string; 8 | isFavorite: boolean; 9 | onClick: () => void; 10 | disabled: boolean; 11 | } 12 | 13 | export const Footer = ({ 14 | isFavorite, 15 | title, 16 | authorLabel, 17 | createdAtLabel, 18 | onClick, 19 | disabled, 20 | }: FooterProps) => { 21 | const handleClick = (e: React.MouseEvent) => { 22 | e.stopPropagation(); 23 | e.preventDefault(); 24 | onClick(); 25 | }; 26 | return ( 27 |
28 |

29 | {title} 30 |

31 |

32 | {authorLabel}, {createdAtLabel} 33 |

34 | 49 |
50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/navbar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | OrganizationSwitcher, 5 | UserButton, 6 | useOrganization, 7 | } from "@clerk/nextjs"; 8 | import { SearchInput } from "./search-input"; 9 | import { InviteButton } from "./invite-button"; 10 | 11 | export const Navbar = () => { 12 | const { organization } = useOrganization(); 13 | 14 | return ( 15 |
16 |
17 | 18 |
19 |
20 | 42 |
43 | {organization && } 44 | 45 |
46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "miro-clone", 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 | "@clerk/clerk-react": "^5.2.0", 13 | "@clerk/nextjs": "^4.29.5", 14 | "@liveblocks/client": "^1.12.0", 15 | "@liveblocks/node": "^1.12.0", 16 | "@liveblocks/react": "^1.12.0", 17 | "@radix-ui/react-alert-dialog": "^1.0.5", 18 | "@radix-ui/react-avatar": "^1.0.4", 19 | "@radix-ui/react-dialog": "^1.0.5", 20 | "@radix-ui/react-dropdown-menu": "^2.0.6", 21 | "@radix-ui/react-popover": "^1.0.7", 22 | "@radix-ui/react-separator": "^1.0.3", 23 | "@radix-ui/react-slot": "^1.0.2", 24 | "@radix-ui/react-tooltip": "^1.0.7", 25 | "class-variance-authority": "^0.7.0", 26 | "clsx": "^2.1.1", 27 | "convex": "^1.12.0", 28 | "convex-helpers": "^0.1.38", 29 | "date-fns": "^3.6.0", 30 | "html-to-image": "^1.11.11", 31 | "lucide-react": "^0.379.0", 32 | "nanoid": "^5.0.7", 33 | "next": "14.2.3", 34 | "next-themes": "^0.3.0", 35 | "perfect-freehand": "^1.2.2", 36 | "query-string": "^9.0.0", 37 | "react": "^18", 38 | "react-colorful": "^5.6.1", 39 | "react-contenteditable": "^3.3.7", 40 | "react-dom": "^18", 41 | "sonner": "^1.4.41", 42 | "tailwind-merge": "^2.3.0", 43 | "tailwindcss-animate": "^1.0.7", 44 | "use-debouncy": "^5.0.1", 45 | "usehooks-ts": "^3.1.0", 46 | "zustand": "^4.5.2" 47 | }, 48 | "devDependencies": { 49 | "@types/node": "^20", 50 | "@types/react": "^18", 51 | "@types/react-dom": "^18", 52 | "eslint": "^8", 53 | "eslint-config-next": "14.2.3", 54 | "postcss": "^8", 55 | "tailwindcss": "^3.4.1", 56 | "typescript": "^5" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/search-input.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import qs from "query-string"; 4 | import { useRouter } from "next/navigation"; 5 | import { ChangeEvent, useEffect } from "react"; 6 | import { Input } from "@/components/ui/input"; 7 | import { Search } from "lucide-react"; 8 | import { useDebounceValue } from "usehooks-ts"; 9 | 10 | export const SearchInput = () => { 11 | const router = useRouter(); 12 | const [value, setValue] = useDebounceValue("", 500); 13 | 14 | const handleChange = (e: ChangeEvent) => { 15 | setValue(e.target.value); 16 | }; 17 | 18 | useEffect(() => { 19 | const handleKeyDown = (e: KeyboardEvent) => { 20 | if (e.key === "f" && (e.metaKey || e.ctrlKey)) { 21 | e.preventDefault(); 22 | const input = document.querySelector("input"); 23 | input?.focus(); 24 | } 25 | }; 26 | 27 | document.addEventListener("keydown", handleKeyDown); 28 | 29 | return () => { 30 | document.removeEventListener("keydown", handleKeyDown); 31 | }; 32 | }, []); 33 | 34 | useEffect(() => { 35 | const url = qs.stringifyUrl( 36 | { 37 | url: "/", 38 | query: { 39 | search: value, 40 | }, 41 | }, 42 | { 43 | skipEmptyString: true, 44 | skipNull: true, 45 | } 46 | ); 47 | 48 | router.push(url); 49 | }, [value, router]); 50 | 51 | return ( 52 |
53 | 54 | 59 |
60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html, 6 | body, 7 | :root { 8 | height: 100%; 9 | } 10 | 11 | @layer base { 12 | :root { 13 | --background: 0 0% 100%; 14 | --foreground: 222.2 84% 4.9%; 15 | 16 | --card: 0 0% 100%; 17 | --card-foreground: 222.2 84% 4.9%; 18 | 19 | --popover: 0 0% 100%; 20 | --popover-foreground: 222.2 84% 4.9%; 21 | 22 | --primary: 222.2 47.4% 11.2%; 23 | --primary-foreground: 210 40% 98%; 24 | 25 | --secondary: 210 40% 96.1%; 26 | --secondary-foreground: 222.2 47.4% 11.2%; 27 | 28 | --muted: 210 40% 96.1%; 29 | --muted-foreground: 215.4 16.3% 46.9%; 30 | 31 | --accent: 210 40% 96.1%; 32 | --accent-foreground: 222.2 47.4% 11.2%; 33 | 34 | --destructive: 0 84.2% 60.2%; 35 | --destructive-foreground: 210 40% 98%; 36 | 37 | --border: 214.3 31.8% 91.4%; 38 | --input: 214.3 31.8% 91.4%; 39 | --ring: 222.2 84% 4.9%; 40 | 41 | --radius: 0.5rem; 42 | } 43 | 44 | .dark { 45 | --background: 222.2 84% 4.9%; 46 | --foreground: 210 40% 98%; 47 | 48 | --card: 222.2 84% 4.9%; 49 | --card-foreground: 210 40% 98%; 50 | 51 | --popover: 222.2 84% 4.9%; 52 | --popover-foreground: 210 40% 98%; 53 | 54 | --primary: 210 40% 98%; 55 | --primary-foreground: 222.2 47.4% 11.2%; 56 | 57 | --secondary: 217.2 32.6% 17.5%; 58 | --secondary-foreground: 210 40% 98%; 59 | 60 | --muted: 217.2 32.6% 17.5%; 61 | --muted-foreground: 215 20.2% 65.1%; 62 | 63 | --accent: 217.2 32.6% 17.5%; 64 | --accent-foreground: 210 40% 98%; 65 | 66 | --destructive: 0 62.8% 30.6%; 67 | --destructive-foreground: 210 40% 98%; 68 | 69 | --border: 217.2 32.6% 17.5%; 70 | --input: 217.2 32.6% 17.5%; 71 | --ring: 212.7 26.8% 83.9%; 72 | } 73 | } 74 | 75 | @layer base { 76 | * { 77 | @apply border-border; 78 | } 79 | 80 | body { 81 | @apply bg-background text-foreground; 82 | } 83 | } -------------------------------------------------------------------------------- /app/board/[boardId]/_components/cursors-presence.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { memo } from "react"; 4 | 5 | import { useOthersConnectionIds, useOthersMapped } from "@/liveblocks.config"; 6 | import { Cursor } from "./cursor"; 7 | import { shallow } from "@liveblocks/client"; 8 | import { Path } from "./path"; 9 | import { colorToCss } from "@/lib/utils"; 10 | 11 | const Cursors = () => { 12 | const ids = useOthersConnectionIds(); 13 | return ( 14 | <> 15 | {ids.map((connectionId) => { 16 | return ( 17 | 18 | ); 19 | })} 20 | 21 | ); 22 | }; 23 | 24 | const Drafts = () => { 25 | const others = useOthersMapped( 26 | (other) => ({ 27 | pencilDraft: other.presence.pencilDraft, 28 | pencilColor: other.presence.penColor, 29 | }), 30 | shallow 31 | ); 32 | 33 | return ( 34 | <> 35 | {others.map(([key, other]) => { 36 | if (other.pencilDraft) { 37 | return ( 38 | 49 | ); 50 | } 51 | return null; 52 | })} 53 | 54 | ); 55 | }; 56 | 57 | export const CursorsPresence = memo(() => { 58 | return ( 59 | <> 60 | 61 | 62 | 63 | ); 64 | }); 65 | 66 | CursorsPresence.displayName = "CursorsPresence"; 67 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/new-board-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { api } from "@/convex/_generated/api"; 4 | import { useApiMutation } from "@/hooks/use-api-mutation"; 5 | import { cn } from "@/lib/utils"; 6 | import { useQuery } from "convex/react"; 7 | import { Plus } from "lucide-react"; 8 | import { useRouter } from "next/navigation"; 9 | import { toast } from "sonner"; 10 | 11 | interface NewBoardButtonProps { 12 | orgId: string; 13 | disabled?: boolean; 14 | } 15 | 16 | const MAX_BOARDS_WITHIN_ORG = 5; 17 | 18 | export const NewBoardButton = ({ orgId, disabled }: NewBoardButtonProps) => { 19 | const router = useRouter(); 20 | const { mutate, pending } = useApiMutation(api.board.create); 21 | 22 | const data = useQuery(api.board.getTotalBoardCountOfOrg, { 23 | orgId, 24 | }); 25 | 26 | const onClick = () => { 27 | if (data && data >= MAX_BOARDS_WITHIN_ORG) { 28 | toast.error( 29 | `You can only have ${MAX_BOARDS_WITHIN_ORG} boards within an organization` 30 | ); 31 | return; 32 | } 33 | 34 | mutate({ orgId, title: "Untitled" }) 35 | .then((id) => { 36 | toast.success("Board created!"); 37 | router.push(`/board/${id}`); 38 | }) 39 | .catch(() => toast.error("Failed to create board")); 40 | }; 41 | 42 | return ( 43 | 55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /convex/_generated/dataModel.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated data model types. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * Generated by convex@1.12.0. 8 | * To regenerate, run `npx convex dev`. 9 | * @module 10 | */ 11 | 12 | import type { 13 | DataModelFromSchemaDefinition, 14 | DocumentByName, 15 | TableNamesInDataModel, 16 | SystemTableNames, 17 | } from "convex/server"; 18 | import type { GenericId } from "convex/values"; 19 | import schema from "../schema.js"; 20 | 21 | /** 22 | * The names of all of your Convex tables. 23 | */ 24 | export type TableNames = TableNamesInDataModel; 25 | 26 | /** 27 | * The type of a document stored in Convex. 28 | * 29 | * @typeParam TableName - A string literal type of the table name (like "users"). 30 | */ 31 | export type Doc = DocumentByName< 32 | DataModel, 33 | TableName 34 | >; 35 | 36 | /** 37 | * An identifier for a document in Convex. 38 | * 39 | * Convex documents are uniquely identified by their `Id`, which is accessible 40 | * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). 41 | * 42 | * Documents can be loaded using `db.get(id)` in query and mutation functions. 43 | * 44 | * IDs are just strings at runtime, but this type can be used to distinguish them from other 45 | * strings when type checking. 46 | * 47 | * @typeParam TableName - A string literal type of the table name (like "users"). 48 | */ 49 | export type Id = 50 | GenericId; 51 | 52 | /** 53 | * A type describing your Convex data model. 54 | * 55 | * This type includes information about what tables you have, the type of 56 | * documents stored in those tables, and the indexes defined on them. 57 | * 58 | * This type is used to parameterize methods like `queryGeneric` and 59 | * `mutationGeneric` to make them type-safe. 60 | */ 61 | export type DataModel = DataModelFromSchemaDefinition; 62 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/empty-boards.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { api } from "@/convex/_generated/api"; 5 | import { useApiMutation } from "@/hooks/use-api-mutation"; 6 | import { useOrganization } from "@clerk/nextjs"; 7 | import Image from "next/image"; 8 | import { useRouter } from "next/navigation"; 9 | import { toast } from "sonner"; 10 | 11 | export const EmptyBoards = () => { 12 | const { organization } = useOrganization(); 13 | const { pending, mutate } = useApiMutation(api.board.create); 14 | const router = useRouter(); 15 | 16 | const onClick = async () => { 17 | if (!organization) { 18 | return; 19 | } 20 | try { 21 | await mutate({ orgId: organization.id, title: "Untitled" }) 22 | .then((id) => { 23 | toast.success("Board created"); 24 | router.push(`/board/${id}`); 25 | }) 26 | .catch((error) => { 27 | toast.error("Failed to create board"); 28 | console.error(error); 29 | }); 30 | } catch (error) { 31 | console.error(error); 32 | } 33 | }; 34 | return ( 35 |
36 | Empty Boards 42 |

43 | Create your first board! 44 |

45 |

46 | Start by creating a board for your organization 47 |

48 |
49 | 52 |
53 |
54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /components/icon.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface IconProps { 4 | color?: string; 5 | height?: number; 6 | width?: number; 7 | } 8 | 9 | const BringToFrontIcon: React.FC = ({ 10 | color = "white", 11 | height = 24, 12 | width = 24, 13 | }) => ( 14 | 15 | 21 | 22 | ); 23 | 24 | const SendToBackIcon: React.FC = ({ 25 | color = "white", 26 | height = 24, 27 | width = 24, 28 | }) => ( 29 | 30 | 36 | 37 | ); 38 | 39 | export { BringToFrontIcon, SendToBackIcon }; 40 | -------------------------------------------------------------------------------- /app/board/[boardId]/_components/color-picker.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { colorToCss, cssToColor } from "@/lib/utils"; 4 | import { Color } from "@/types/canvas"; 5 | import { CustomColorPicker } from "@/components/custom-color-picker"; 6 | import { useState } from "react"; 7 | interface ColorPickerProps { 8 | onChange: (color: Color) => void; 9 | lastUsedColor: Color; 10 | } 11 | 12 | export const ColorPicker = ({ onChange, lastUsedColor }: ColorPickerProps) => { 13 | const [color, setColor] = useState(colorToCss(lastUsedColor)); 14 | return ( 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | { 28 | setColor(color); 29 | onChange(cssToColor(color)); 30 | }} 31 | /> 32 |
33 | ); 34 | }; 35 | 36 | interface ColorButtonProps { 37 | color: Color; 38 | onClick: (color: Color) => void; 39 | } 40 | 41 | const ColorButton = ({ color, onClick }: ColorButtonProps) => { 42 | return ( 43 | 52 | ); 53 | }; 54 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | board: "hover:bg-blue-500/20 hover:text-blue-800", 22 | boardActive: "bg-blue-500/20 text-blue-800", 23 | boardDestructive: "text-destructive hover:bg-destructive/20", 24 | }, 25 | size: { 26 | default: "h-10 px-4 py-2", 27 | sm: "h-9 rounded-md px-3", 28 | lg: "h-11 rounded-md px-8", 29 | icon: "h-10 w-10", 30 | }, 31 | }, 32 | defaultVariants: { 33 | variant: "default", 34 | size: "default", 35 | }, 36 | } 37 | ) 38 | 39 | export interface ButtonProps 40 | extends React.ButtonHTMLAttributes, 41 | VariantProps { 42 | asChild?: boolean 43 | } 44 | 45 | const Button = React.forwardRef( 46 | ({ className, variant, size, asChild = false, ...props }, ref) => { 47 | const Comp = asChild ? Slot : "button" 48 | return ( 49 | 54 | ) 55 | } 56 | ) 57 | Button.displayName = "Button" 58 | 59 | export { Button, buttonVariants } 60 | -------------------------------------------------------------------------------- /components/confirm-modal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ChangeEvent, useState } from "react"; 4 | import { 5 | AlertDialog, 6 | AlertDialogAction, 7 | AlertDialogCancel, 8 | AlertDialogContent, 9 | AlertDialogDescription, 10 | AlertDialogFooter, 11 | AlertDialogHeader, 12 | AlertDialogTitle, 13 | AlertDialogTrigger, 14 | } from "./ui/alert-dialog"; 15 | import { Input } from "./ui/input"; 16 | 17 | interface ConfirmModalProps { 18 | children: React.ReactNode; 19 | onConfirm: () => void; 20 | disabled?: boolean; 21 | header: string; 22 | description?: string | React.ReactNode; 23 | title: string; 24 | } 25 | 26 | export const ConfirmModal = ({ 27 | children, 28 | onConfirm, 29 | disabled, 30 | header, 31 | description, 32 | title, 33 | }: ConfirmModalProps) => { 34 | const [boardName, setBoardName] = useState(""); 35 | const handleChange = (e: ChangeEvent) => { 36 | setBoardName(e.target.value); 37 | }; 38 | 39 | const handleKeyDown = (e: React.KeyboardEvent) => { 40 | if (e.key === "Enter" && boardName === title) { 41 | onConfirm(); 42 | } 43 | }; 44 | 45 | return ( 46 | 47 | {children} 48 | 49 | 50 | {header} 51 | 52 | {description} 53 | 54 | 55 | {/* TODO: Automatically focus the input tag */} 56 | 60 | 61 | Cancel 62 | 66 | Confirm 67 | 68 | 69 | 70 | 71 | ); 72 | }; 73 | -------------------------------------------------------------------------------- /app/board/[boardId]/_components/text.tsx: -------------------------------------------------------------------------------- 1 | import { Kalam } from "next/font/google"; 2 | import ContentEditable, { ContentEditableEvent } from "react-contenteditable"; 3 | 4 | import { cn, colorToCss } from "@/lib/utils"; 5 | import { TextLayer } from "@/types/canvas"; 6 | import { useMutation } from "@/liveblocks.config"; 7 | 8 | const font = Kalam({ 9 | subsets: ["latin"], 10 | weight: ["400"], 11 | }); 12 | 13 | interface TextProps { 14 | id: string; 15 | layer: TextLayer; 16 | onPointerDown: (e: React.PointerEvent, id: string) => void; 17 | selectionColor?: string; 18 | } 19 | 20 | const calculateFontSize = (width: number, height: number) => { 21 | const maxFontSize = 96; 22 | const scaleFactor = 0.5; 23 | const fontSizeBasedOnHeight = height * scaleFactor; 24 | const fontSizeBasedOnWidth = width * scaleFactor; 25 | 26 | return Math.min(maxFontSize, fontSizeBasedOnHeight, fontSizeBasedOnWidth); 27 | }; 28 | 29 | export const Text = ({ 30 | id, 31 | layer, 32 | onPointerDown, 33 | selectionColor, 34 | }: TextProps) => { 35 | const { x, y, width, height, fill, value } = layer; 36 | 37 | const updateValue = useMutation(({ storage }, newValue: string) => { 38 | const liveLayers = storage.get("layers"); 39 | 40 | liveLayers.get(id)?.set("value", newValue); 41 | }, []); 42 | 43 | const handleContentChange = (e: ContentEditableEvent) => { 44 | updateValue(e.target.value); 45 | }; 46 | 47 | return ( 48 | onPointerDown(e, id)} 54 | style={{ 55 | outline: selectionColor 56 | ? `1px solid ${selectionColor}` 57 | : "none", 58 | }} 59 | > 60 | 72 | 73 | ); 74 | }; 75 | -------------------------------------------------------------------------------- /convex/boards.ts: -------------------------------------------------------------------------------- 1 | import { v } from "convex/values"; 2 | import { getAllOrThrow } from "convex-helpers/server/relationships"; 3 | import { query } from "./_generated/server"; 4 | 5 | export const get = query({ 6 | args: { 7 | orgId: v.string(), 8 | search: v.optional(v.string()), 9 | favorites: v.optional(v.string()), 10 | }, 11 | handler: async (ctx, args) => { 12 | const identity = await ctx.auth.getUserIdentity(); 13 | if (!identity) { 14 | throw new Error("Unauthorized"); 15 | } 16 | 17 | if (args.favorites) { 18 | const favoriteBoards = await ctx.db 19 | .query("userFavorites") 20 | .withIndex("by_user_org", (q) => 21 | q.eq("userId", identity.subject).eq("orgId", args.orgId) 22 | ) 23 | .order("desc") 24 | .collect(); 25 | 26 | const ids = favoriteBoards.map((b) => b.boardId); 27 | 28 | const boards = await getAllOrThrow(ctx.db, ids); 29 | 30 | return boards.map((board) => ({ 31 | ...board, 32 | isFavorite: true, 33 | })); 34 | } 35 | 36 | const title = args.search as string; 37 | 38 | let boards = []; 39 | 40 | if (title) { 41 | boards = await ctx.db 42 | .query("boards") 43 | .withSearchIndex("search_title", (q) => 44 | q.search("title", title).eq("orgId", args.orgId) 45 | ) 46 | .collect(); 47 | } else { 48 | boards = await ctx.db 49 | .query("boards") 50 | .withIndex("by_org", (q) => q.eq("orgId", args.orgId)) 51 | .order("desc") 52 | .collect(); 53 | } 54 | 55 | const boardWithFavoriteRelation = boards.map((board) => { 56 | return ctx.db 57 | .query("userFavorites") 58 | .withIndex("by_user_board", (q) => 59 | q.eq("userId", identity.subject).eq("boardId", board._id) 60 | ) 61 | .unique() 62 | .then((favorite) => { 63 | return { ...board, isFavorite: !!favorite }; 64 | }); 65 | }); 66 | 67 | const boardWithFavoriteBoolean = Promise.all(boardWithFavoriteRelation); 68 | return boardWithFavoriteBoolean; 69 | }, 70 | }); 71 | -------------------------------------------------------------------------------- /public/placeholders/6.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/board/[boardId]/_components/participants.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Skeleton } from "@/components/ui/skeleton"; 4 | import { UserAvatar } from "./user-avatar"; 5 | import { useOthers, useSelf } from "@/liveblocks.config"; 6 | import { connectionIdToColor } from "@/lib/utils"; 7 | 8 | const MAX_SHOWN_OTHER_USERS = 1; 9 | 10 | const Participants = () => { 11 | const users = useOthers(); 12 | const currentUser = useSelf(); 13 | const hasMoreUsers = users.length > MAX_SHOWN_OTHER_USERS; 14 | 15 | return ( 16 |
17 |
18 | {users 19 | .slice(0, MAX_SHOWN_OTHER_USERS) 20 | .map(({ connectionId, info }) => { 21 | return ( 22 | 29 | ); 30 | })} 31 | 32 | {currentUser && ( 33 | 41 | )} 42 | 43 | {hasMoreUsers && ( 44 | 49 | )} 50 |
51 |
52 | ); 53 | }; 54 | 55 | export const ParticipantsSkeleton = () => { 56 | return ( 57 |
58 | 59 |
60 | ); 61 | }; 62 | 63 | export default Participants; 64 | -------------------------------------------------------------------------------- /app/board/[boardId]/_components/note.tsx: -------------------------------------------------------------------------------- 1 | import { Kalam } from "next/font/google"; 2 | import ContentEditable, { ContentEditableEvent } from "react-contenteditable"; 3 | 4 | import { cn, colorToCss, getContrastingTextColor } from "@/lib/utils"; 5 | import { NoteLayer } from "@/types/canvas"; 6 | import { useMutation } from "@/liveblocks.config"; 7 | 8 | const font = Kalam({ 9 | subsets: ["latin"], 10 | weight: ["400"], 11 | }); 12 | 13 | interface NoteProps { 14 | id: string; 15 | layer: NoteLayer; 16 | onPointerDown: (e: React.PointerEvent, id: string) => void; 17 | selectionColor?: string; 18 | } 19 | 20 | const calculateFontSize = (width: number, height: number) => { 21 | const maxFontSize = 96; 22 | const scaleFactor = 0.15; 23 | const fontSizeBasedOnHeight = height * scaleFactor; 24 | const fontSizeBasedOnWidth = width * scaleFactor; 25 | 26 | return Math.min(maxFontSize, fontSizeBasedOnHeight, fontSizeBasedOnWidth); 27 | }; 28 | 29 | export const Note = ({ 30 | id, 31 | layer, 32 | onPointerDown, 33 | selectionColor, 34 | }: NoteProps) => { 35 | const { x, y, width, height, fill, value } = layer; 36 | 37 | const updateValue = useMutation(({ storage }, newValue: string) => { 38 | const liveLayers = storage.get("layers"); 39 | 40 | liveLayers.get(id)?.set("value", newValue); 41 | }, []); 42 | 43 | const handleContentChange = (e: ContentEditableEvent) => { 44 | updateValue(e.target.value); 45 | }; 46 | 47 | return ( 48 | onPointerDown(e, id)} 54 | style={{ 55 | outline: selectionColor 56 | ? `1px solid ${selectionColor}` 57 | : "none", 58 | backgroundColor: fill ? colorToCss(fill) : "#000", 59 | }} 60 | className="shadow-md drop-shadow-xl" 61 | > 62 | 74 | 75 | ); 76 | }; 77 | -------------------------------------------------------------------------------- /components/modals/rename-modal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Dialog, 5 | DialogClose, 6 | DialogContent, 7 | DialogDescription, 8 | DialogFooter, 9 | DialogHeader, 10 | } from "@/components/ui/dialog"; 11 | import { api } from "@/convex/_generated/api"; 12 | import { useApiMutation } from "@/hooks/use-api-mutation"; 13 | import { useRenameModal } from "@/store/use-rename-modal"; 14 | import { FormEventHandler, useEffect, useState } from "react"; 15 | import { toast } from "sonner"; 16 | import { Button } from "@/components/ui/button"; 17 | import { Input } from "@/components/ui/input"; 18 | 19 | export const RenameModal = () => { 20 | const { mutate, pending } = useApiMutation(api.board.update); 21 | 22 | const { isOpen, onClose, initialValues } = useRenameModal(); 23 | 24 | const [title, setTitle] = useState(initialValues.title); 25 | 26 | useEffect(() => { 27 | setTitle(initialValues.title); 28 | }, [initialValues.title]); 29 | 30 | const onSubmit: FormEventHandler = (e) => { 31 | e.preventDefault(); 32 | mutate({ id: initialValues.id, title }) 33 | .then(() => { 34 | toast.success("Board renamed"); 35 | }) 36 | .catch(() => toast.error("Failed to rename board")) 37 | .finally(onClose); 38 | }; 39 | 40 | return ( 41 | 42 | 43 | Edit board title 44 | 45 | Enter a new title for this board 46 | 47 |
48 | setTitle(e.target.value)} 54 | placeholder="Board title" 55 | /> 56 | 57 | 58 | 61 | 62 | 65 | 66 |
67 |
68 |
69 | ); 70 | }; 71 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss" 2 | 3 | const config = { 4 | darkMode: ["class"], 5 | content: [ 6 | './pages/**/*.{ts,tsx}', 7 | './components/**/*.{ts,tsx}', 8 | './app/**/*.{ts,tsx}', 9 | './src/**/*.{ts,tsx}', 10 | ], 11 | prefix: "", 12 | theme: { 13 | container: { 14 | center: true, 15 | padding: "2rem", 16 | screens: { 17 | "2xl": "1400px", 18 | }, 19 | }, 20 | extend: { 21 | colors: { 22 | border: "hsl(var(--border))", 23 | input: "hsl(var(--input))", 24 | ring: "hsl(var(--ring))", 25 | background: "hsl(var(--background))", 26 | foreground: "hsl(var(--foreground))", 27 | primary: { 28 | DEFAULT: "hsl(var(--primary))", 29 | foreground: "hsl(var(--primary-foreground))", 30 | }, 31 | secondary: { 32 | DEFAULT: "hsl(var(--secondary))", 33 | foreground: "hsl(var(--secondary-foreground))", 34 | }, 35 | destructive: { 36 | DEFAULT: "hsl(var(--destructive))", 37 | foreground: "hsl(var(--destructive-foreground))", 38 | }, 39 | muted: { 40 | DEFAULT: "hsl(var(--muted))", 41 | foreground: "hsl(var(--muted-foreground))", 42 | }, 43 | accent: { 44 | DEFAULT: "hsl(var(--accent))", 45 | foreground: "hsl(var(--accent-foreground))", 46 | }, 47 | popover: { 48 | DEFAULT: "hsl(var(--popover))", 49 | foreground: "hsl(var(--popover-foreground))", 50 | }, 51 | card: { 52 | DEFAULT: "hsl(var(--card))", 53 | foreground: "hsl(var(--card-foreground))", 54 | }, 55 | }, 56 | borderRadius: { 57 | lg: "var(--radius)", 58 | md: "calc(var(--radius) - 2px)", 59 | sm: "calc(var(--radius) - 4px)", 60 | }, 61 | keyframes: { 62 | "accordion-down": { 63 | from: { height: "0" }, 64 | to: { height: "var(--radix-accordion-content-height)" }, 65 | }, 66 | "accordion-up": { 67 | from: { height: "var(--radix-accordion-content-height)" }, 68 | to: { height: "0" }, 69 | }, 70 | "shimmer": { 71 | '0%': { backgroundPosition: '200% 0' }, 72 | '100%': { backgroundPosition: '-200% 0' }, 73 | }, 74 | }, 75 | animation: { 76 | "accordion-down": "accordion-down 0.2s ease-out", 77 | "accordion-up": "accordion-up 0.2s ease-out", 78 | "shimmer": "shimmer 1.5s infinite", 79 | }, 80 | }, 81 | }, 82 | plugins: [require("tailwindcss-animate")], 83 | } satisfies Config 84 | 85 | export default config -------------------------------------------------------------------------------- /convex/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to your Convex functions directory! 2 | 3 | Write your Convex functions here. 4 | See https://docs.convex.dev/functions for more. 5 | 6 | A query function that takes two arguments looks like: 7 | 8 | ```ts 9 | // functions.js 10 | import { query } from "./_generated/server"; 11 | import { v } from "convex/values"; 12 | 13 | export const myQueryFunction = query({ 14 | // Validators for arguments. 15 | args: { 16 | first: v.number(), 17 | second: v.string(), 18 | }, 19 | 20 | // Function implementation. 21 | handler: async (ctx, args) => { 22 | // Read the database as many times as you need here. 23 | // See https://docs.convex.dev/database/reading-data. 24 | const documents = await ctx.db.query("tablename").collect(); 25 | 26 | // Arguments passed from the client are properties of the args object. 27 | console.log(args.first, args.second); 28 | 29 | // Write arbitrary JavaScript here: filter, aggregate, build derived data, 30 | // remove non-public properties, or create new objects. 31 | return documents; 32 | }, 33 | }); 34 | ``` 35 | 36 | Using this query function in a React component looks like: 37 | 38 | ```ts 39 | const data = useQuery(api.functions.myQueryFunction, { 40 | first: 10, 41 | second: "hello", 42 | }); 43 | ``` 44 | 45 | A mutation function looks like: 46 | 47 | ```ts 48 | // functions.js 49 | import { mutation } from "./_generated/server"; 50 | import { v } from "convex/values"; 51 | 52 | export const myMutationFunction = mutation({ 53 | // Validators for arguments. 54 | args: { 55 | first: v.string(), 56 | second: v.string(), 57 | }, 58 | 59 | // Function implementation. 60 | handler: async (ctx, args) => { 61 | // Insert or modify documents in the database here. 62 | // Mutations can also read from the database like queries. 63 | // See https://docs.convex.dev/database/writing-data. 64 | const message = { body: args.first, author: args.second }; 65 | const id = await ctx.db.insert("messages", message); 66 | 67 | // Optionally, return a value from your mutation. 68 | return await ctx.db.get(id); 69 | }, 70 | }); 71 | ``` 72 | 73 | Using this mutation function in a React component looks like: 74 | 75 | ```ts 76 | const mutation = useMutation(api.functions.myMutationFunction); 77 | function handleButtonPress() { 78 | // fire and forget, the most common way to use mutations 79 | mutation({ first: "Hello!", second: "me" }); 80 | // OR 81 | // use the result once the mutation has completed 82 | mutation({ first: "Hello!", second: "me" }).then((result) => 83 | console.log(result), 84 | ); 85 | } 86 | ``` 87 | 88 | Use the Convex CLI to push your functions to a deployment. See everything 89 | the Convex CLI can do by running `npx convex -h` in your project root 90 | directory. To learn more, launch the docs with `npx convex docs`. 91 | -------------------------------------------------------------------------------- /app/board/[boardId]/_components/layer-preview.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useStorage } from "@/liveblocks.config"; 4 | import { LayerType } from "@/types/canvas"; 5 | import { memo } from "react"; 6 | import { Rectangle } from "./rectangle"; 7 | import { Ellipse } from "./ellipse"; 8 | import { Text } from "./text"; 9 | import { Note } from "./note"; 10 | import { Path } from "./path"; 11 | import { colorToCss } from "@/lib/utils"; 12 | 13 | interface LayerPreviewProps { 14 | id: string; 15 | onLayerPointerDown: (e: React.PointerEvent, layerId: string) => void; 16 | selectionColor?: string; 17 | } 18 | 19 | export const LayerPreview = memo( 20 | ({ id, onLayerPointerDown, selectionColor }: LayerPreviewProps) => { 21 | const layer = useStorage((root) => root.layers.get(id)); 22 | if (!layer) return null; 23 | 24 | switch (layer.type) { 25 | case LayerType.Path: 26 | return ( 27 | onLayerPointerDown(e, id)} 30 | x={layer.x} 31 | y={layer.y} 32 | fill={layer.fill ? colorToCss(layer.fill) : "#000"} 33 | stroke={selectionColor} 34 | /> 35 | ); 36 | case LayerType.Note: 37 | return ( 38 | 44 | ); 45 | case LayerType.Text: 46 | return ( 47 | 53 | ); 54 | case LayerType.Ellipse: 55 | return ( 56 | 62 | ); 63 | case LayerType.Rectangle: 64 | return ( 65 | 71 | ); 72 | 73 | default: 74 | console.warn("Unsupported layer type"); 75 | } 76 | } 77 | ); 78 | 79 | LayerPreview.displayName = "LayerPreview"; 80 | -------------------------------------------------------------------------------- /types/canvas.ts: -------------------------------------------------------------------------------- 1 | export type Color = { 2 | r: number; 3 | g: number; 4 | b: number; 5 | }; 6 | 7 | export type Camera = { 8 | x: number; 9 | y: number; 10 | }; 11 | 12 | export enum LayerType { 13 | Rectangle, 14 | Ellipse, 15 | Path, 16 | Text, 17 | Note, 18 | } 19 | 20 | export type RectangleLayer = { 21 | type: LayerType.Rectangle; 22 | x: number; 23 | y: number; 24 | height: number; 25 | width: number; 26 | fill: Color; 27 | value?: string; 28 | }; 29 | 30 | export type EllipseLayer = { 31 | type: LayerType.Ellipse; 32 | x: number; 33 | y: number; 34 | height: number; 35 | width: number; 36 | fill: Color; 37 | value?: string; 38 | }; 39 | 40 | export type PathLayer = { 41 | type: LayerType.Path; 42 | x: number; 43 | y: number; 44 | height: number; 45 | width: number; 46 | fill: Color; 47 | points: number[][]; 48 | value?: string; 49 | }; 50 | 51 | export type TextLayer = { 52 | type: LayerType.Text; 53 | x: number; 54 | y: number; 55 | height: number; 56 | width: number; 57 | fill: Color; 58 | value?: string; 59 | }; 60 | 61 | export type NoteLayer = { 62 | type: LayerType.Note; 63 | x: number; 64 | y: number; 65 | height: number; 66 | width: number; 67 | fill: Color; 68 | value?: string; 69 | }; 70 | 71 | export type Point = { 72 | x: number; 73 | y: number; 74 | }; 75 | 76 | export type XYWH = { 77 | x: number; 78 | y: number; 79 | width: number; 80 | height: number; 81 | }; 82 | 83 | export enum Side { 84 | Top = 1, 85 | Bottom = 2, 86 | Left = 4, 87 | Right = 8, 88 | } 89 | 90 | export type CanvasState = 91 | | { 92 | mode: CanvasMode.None; 93 | } 94 | | { 95 | mode: CanvasMode.SelectionNet; 96 | origin: Point; 97 | current?: Point; 98 | } 99 | | { 100 | mode: CanvasMode.Translating; 101 | current: Point; 102 | } 103 | | { 104 | mode: CanvasMode.Inserting; 105 | layerType: 106 | | LayerType.Ellipse 107 | | LayerType.Rectangle 108 | | LayerType.Text 109 | | LayerType.Note; 110 | } 111 | | { 112 | mode: CanvasMode.Pencil; 113 | } 114 | | { 115 | mode: CanvasMode.Pressing; 116 | origin: Point; 117 | } 118 | | { 119 | mode: CanvasMode.Resizing; 120 | initialBounds: XYWH; 121 | corner: Side; 122 | }; 123 | 124 | export enum CanvasMode { 125 | None, 126 | SelectionNet, 127 | Translating, 128 | Inserting, 129 | Pencil, 130 | Pressing, 131 | Resizing, 132 | } 133 | 134 | export type Layer = 135 | | RectangleLayer 136 | | EllipseLayer 137 | | PathLayer 138 | | TextLayer 139 | | NoteLayer; 140 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/board-list.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useQuery } from "convex/react"; 4 | import { EmptyBoards } from "./empty-boards"; 5 | import { EmptyFavorites } from "./empty-favorites"; 6 | import { EmptySearch } from "./empty-search"; 7 | import { api } from "@/convex/_generated/api"; 8 | import { BoardCard } from "./board-card"; 9 | import { NewBoardButton } from "./new-board-button"; 10 | import { useParams, useSearchParams } from "next/navigation"; 11 | 12 | interface BoardListProps { 13 | orgId: string; 14 | query: { 15 | search?: string; 16 | favorites?: string; 17 | }; 18 | } 19 | 20 | export const BoardList = ({ orgId, query }: BoardListProps) => { 21 | const params = useSearchParams(); 22 | const favorites = params.get("favorites"); 23 | const search = params.get("search"); 24 | 25 | query.favorites = favorites ? favorites : ""; 26 | query.search = search ? search : ""; 27 | 28 | const data = useQuery(api.boards.get, { 29 | orgId, 30 | ...query 31 | }); 32 | 33 | if (data === undefined) { 34 | return ( 35 |
36 |

37 | {favorites ? "Favorite boards" : "Team boards"} 38 |

39 |
40 | 41 | {[...Array(9)].map((_, index) => ( 42 | 43 | ))} 44 |
45 |
46 | ); 47 | } 48 | 49 | if (!data.length && search) { 50 | return ; 51 | } 52 | if (!data.length && favorites) { 53 | return ; 54 | } 55 | if (!data.length) { 56 | return ; 57 | } 58 | 59 | return ( 60 |
61 |

62 | {favorites ? "Favorite boards" : "Team boards"} 63 |

64 |
65 | 66 | {data.map((board) => { 67 | return ( 68 | 79 | ); 80 | })} 81 |
82 |
83 | ); 84 | }; 85 | -------------------------------------------------------------------------------- /components/custom-color-picker.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { forwardRef, useMemo, useState } from "react"; 4 | import { HexColorPicker } from "react-colorful"; 5 | import { cn, colorToCss } from "@/lib/utils"; 6 | import { useForwardedRef } from "@/hooks/use-forwarded-ref"; 7 | import type { ButtonProps } from "@/components/ui/button"; 8 | import { Button } from "@/components/ui/button"; 9 | import { 10 | Popover, 11 | PopoverContent, 12 | PopoverTrigger, 13 | } from "@/components/ui/popover"; 14 | import { Input } from "@/components/ui/input"; 15 | import Image from "next/image"; 16 | import { useDebouncyEffect } from "use-debouncy"; 17 | import { Color } from "@/types/canvas"; 18 | 19 | interface ColorPickerProps { 20 | value: string; 21 | onChange: (value: string) => void; 22 | onBlur?: () => void; 23 | lastUsedColor: Color; 24 | } 25 | 26 | const CustomColorPicker = forwardRef< 27 | HTMLInputElement, 28 | Omit & ColorPickerProps 29 | >( 30 | ( 31 | { disabled, value, lastUsedColor, onChange, onBlur, name, className, ...props }, 32 | forwardedRef 33 | ) => { 34 | const ref = useForwardedRef(forwardedRef); 35 | const [open, setOpen] = useState(false); 36 | 37 | const parsedValue = useMemo(() => { 38 | return value || colorToCss(lastUsedColor); 39 | }, [value]); 40 | 41 | return ( 42 | 43 | 44 | 62 | 63 | 64 | 65 | { 69 | onChange(e?.currentTarget?.value); 70 | }} 71 | ref={ref} 72 | value={parsedValue} 73 | /> 74 | 75 | 76 | ); 77 | } 78 | ); 79 | 80 | const DebouncedPicker = ({ color, onChange }: any) => { 81 | const [value, setValue] = useState(color); 82 | 83 | useDebouncyEffect(() => onChange(value), 200, [value]); 84 | 85 | return ; 86 | }; 87 | 88 | 89 | CustomColorPicker.displayName = "ColorPicker"; 90 | 91 | export { CustomColorPicker }; 92 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/org-sidebar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { cn } from "@/lib/utils"; 5 | import { OrganizationSwitcher } from "@clerk/nextjs"; 6 | import { LayoutDashboard, Star } from "lucide-react"; 7 | import { Poppins } from "next/font/google"; 8 | import Image from "next/image"; 9 | import Link from "next/link"; 10 | import { useSearchParams } from "next/navigation"; 11 | 12 | const font = Poppins({ 13 | subsets: ["latin"], 14 | weight: ["600"], 15 | }); 16 | 17 | export const OrgSidebar = () => { 18 | const searchParams = useSearchParams(); 19 | const favorite = searchParams.get("favorites"); 20 | return ( 21 |
22 | 23 |
24 | Logo 25 | 28 | Board 29 | 30 |
31 | 32 | 53 | 54 |
55 | 66 | 84 |
85 |
86 | ); 87 | }; 88 | -------------------------------------------------------------------------------- /convex/_generated/server.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated utilities for implementing server-side Convex query and mutation functions. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * Generated by convex@1.12.0. 8 | * To regenerate, run `npx convex dev`. 9 | * @module 10 | */ 11 | 12 | import { 13 | actionGeneric, 14 | httpActionGeneric, 15 | queryGeneric, 16 | mutationGeneric, 17 | internalActionGeneric, 18 | internalMutationGeneric, 19 | internalQueryGeneric, 20 | } from "convex/server"; 21 | 22 | /** 23 | * Define a query in this Convex app's public API. 24 | * 25 | * This function will be allowed to read your Convex database and will be accessible from the client. 26 | * 27 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 28 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 29 | */ 30 | export const query = queryGeneric; 31 | 32 | /** 33 | * Define a query that is only accessible from other Convex functions (but not from the client). 34 | * 35 | * This function will be allowed to read from your Convex database. It will not be accessible from the client. 36 | * 37 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 38 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 39 | */ 40 | export const internalQuery = internalQueryGeneric; 41 | 42 | /** 43 | * Define a mutation in this Convex app's public API. 44 | * 45 | * This function will be allowed to modify your Convex database and will be accessible from the client. 46 | * 47 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 48 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 49 | */ 50 | export const mutation = mutationGeneric; 51 | 52 | /** 53 | * Define a mutation that is only accessible from other Convex functions (but not from the client). 54 | * 55 | * This function will be allowed to modify your Convex database. It will not be accessible from the client. 56 | * 57 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 58 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 59 | */ 60 | export const internalMutation = internalMutationGeneric; 61 | 62 | /** 63 | * Define an action in this Convex app's public API. 64 | * 65 | * An action is a function which can execute any JavaScript code, including non-deterministic 66 | * code and code with side-effects, like calling third-party services. 67 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. 68 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. 69 | * 70 | * @param func - The action. It receives an {@link ActionCtx} as its first argument. 71 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible. 72 | */ 73 | export const action = actionGeneric; 74 | 75 | /** 76 | * Define an action that is only accessible from other Convex functions (but not from the client). 77 | * 78 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 79 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible. 80 | */ 81 | export const internalAction = internalActionGeneric; 82 | 83 | /** 84 | * Define a Convex HTTP action. 85 | * 86 | * @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object 87 | * as its second. 88 | * @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`. 89 | */ 90 | export const httpAction = httpActionGeneric; 91 | -------------------------------------------------------------------------------- /components/actions.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { DropdownMenuContentProps } from "@radix-ui/react-dropdown-menu"; 4 | 5 | import { 6 | DropdownMenu, 7 | DropdownMenuTrigger, 8 | DropdownMenuContent, 9 | DropdownMenuItem, 10 | DropdownMenuSeparator, 11 | } from "@/components/ui/dropdown-menu"; 12 | import { Link2, Pencil, Trash2 } from "lucide-react"; 13 | import { toast } from "sonner"; 14 | import { useApiMutation } from "@/hooks/use-api-mutation"; 15 | import { api } from "@/convex/_generated/api"; 16 | import { ConfirmModal } from "./confirm-modal"; 17 | import { Button } from "./ui/button"; 18 | import { useRenameModal } from "@/store/use-rename-modal"; 19 | 20 | interface ActionProps { 21 | children: React.ReactNode; 22 | side?: DropdownMenuContentProps["side"]; 23 | sideOffset?: DropdownMenuContentProps["sideOffset"]; 24 | alignOffset?: DropdownMenuContentProps["alignOffset"]; 25 | id: string; 26 | title: string; 27 | } 28 | 29 | export const Actions = ({ 30 | children, 31 | side, 32 | sideOffset, 33 | id, 34 | title, 35 | alignOffset, 36 | }: ActionProps) => { 37 | const { onOpen } = useRenameModal(); 38 | 39 | const { mutate, pending } = useApiMutation(api.board.remove); 40 | 41 | const onDelete = () => { 42 | mutate({ id }) 43 | .then(() => toast.success("Board deleted!")) 44 | .catch(() => toast.error("Failed to delete board")); 45 | }; 46 | 47 | const onCopyLink = () => { 48 | navigator.clipboard 49 | .writeText(`${window.location.origin}/board/${id}`) 50 | .then(() => toast.success("Link copied!")) 51 | .catch(() => toast.error("Failed to copy link")); 52 | }; 53 | return ( 54 | 55 | {children} 56 | e.stopPropagation()} 58 | side={side} 59 | sideOffset={sideOffset} 60 | align="end" 61 | className="w-50" 62 | alignOffset={alignOffset} 63 | > 64 | 68 | 69 | Copy board link 70 | 71 | onOpen(id, title)} 74 | > 75 | 76 | Rename 77 | 78 | 79 | 83 | To confirm, type{" "} 84 | {title} in 85 | the box below 86 |
87 | } 88 | disabled={pending} 89 | onConfirm={onDelete} 90 | title={title} 91 | > 92 | 99 | 100 | 101 | 102 | ); 103 | }; 104 | -------------------------------------------------------------------------------- /public/placeholders/7.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/board/[boardId]/_components/info.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Actions } from "@/components/actions"; 4 | import { Hint } from "@/components/hint"; 5 | import { Button } from "@/components/ui/button"; 6 | import { Skeleton } from "@/components/ui/skeleton"; 7 | import { api } from "@/convex/_generated/api"; 8 | import { Id } from "@/convex/_generated/dataModel"; 9 | import { cn } from "@/lib/utils"; 10 | import { useRenameModal } from "@/store/use-rename-modal"; 11 | import { useQuery } from "convex/react"; 12 | import { ImageDown, Menu } from "lucide-react"; 13 | import { Poppins } from "next/font/google"; 14 | import Image from "next/image"; 15 | import Link from "next/link"; 16 | 17 | interface InfoProps { 18 | boardId: string; 19 | exportAsPng?: () => void; 20 | } 21 | 22 | const font = Poppins({ 23 | subsets: ["latin"], 24 | weight: ["600"], 25 | }); 26 | 27 | const TabSeparator = () => { 28 | return
|
; 29 | }; 30 | 31 | const Info = ({ boardId, exportAsPng }: InfoProps) => { 32 | const { onOpen } = useRenameModal(); 33 | const data = useQuery(api.board.get, { id: boardId as Id<"boards"> }); 34 | 35 | if (!data) return ; 36 | 37 | return ( 38 |
39 | 40 | 58 | 59 | 60 | 61 | 68 | 69 | 70 | 71 | 74 | 75 | 76 | 83 |
84 | 85 | 88 | 89 |
90 |
91 |
92 | ); 93 | }; 94 | 95 | export const InfoSkeleton = () => { 96 | return ( 97 |
98 | 99 |
100 | ); 101 | }; 102 | 103 | export default Info; 104 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/board-card/index.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | import Link from "next/link"; 5 | import { formatDistanceToNow } from "date-fns"; 6 | import { Overlay } from "./overlay"; 7 | import { useAuth } from "@clerk/nextjs"; 8 | import { Footer } from "./footer"; 9 | import { Skeleton } from "@/components/ui/skeleton"; 10 | import { Actions } from "@/components/actions"; 11 | import { MoreHorizontal } from "lucide-react"; 12 | import { useApiMutation } from "@/hooks/use-api-mutation"; 13 | import { api } from "@/convex/_generated/api"; 14 | import { toast } from "sonner"; 15 | 16 | interface BoardCardProps { 17 | id: string; 18 | title: string; 19 | authorName: string; 20 | authorId: string; 21 | createdAt: number; 22 | imageUrl: string; 23 | orgId: string; 24 | isFavorite: boolean; 25 | } 26 | 27 | export const BoardCard = ({ 28 | id, 29 | title, 30 | imageUrl, 31 | authorId, 32 | authorName, 33 | createdAt, 34 | orgId, 35 | isFavorite, 36 | }: BoardCardProps) => { 37 | const { userId } = useAuth(); 38 | 39 | const authorLabel = authorId === userId ? "You" : authorName; 40 | const createdAtLabel = formatDistanceToNow(createdAt, { addSuffix: true }); 41 | 42 | const { mutate: onFavorite, pending: pendingFavorite } = useApiMutation( 43 | api.board.favorite 44 | ); 45 | 46 | const { mutate: onUnFavorite, pending: pendingUnFavorite } = useApiMutation( 47 | api.board.unfavorite 48 | ); 49 | 50 | const toggleFavorite = async () => { 51 | if (isFavorite) { 52 | await onUnFavorite({ id }).catch(() => 53 | toast.error("Failed to unfavorite") 54 | ); 55 | } else { 56 | await onFavorite({ id, orgId }).catch(() => 57 | toast.error("Failed to favorite") 58 | ); 59 | } 60 | }; 61 | 62 | return ( 63 | 64 |
65 |
66 | {title} 72 | 73 | 80 | 89 | 90 |
91 |
99 |
100 | 101 | ); 102 | }; 103 | 104 | BoardCard.Skeleton = function BoardCardSkeleton() { 105 | return ( 106 |
107 | 108 |
109 | ); 110 | }; 111 | -------------------------------------------------------------------------------- /components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { X } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Dialog = DialogPrimitive.Root 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger 12 | 13 | const DialogPortal = DialogPrimitive.Portal 14 | 15 | const DialogClose = DialogPrimitive.Close 16 | 17 | const DialogOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )) 30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 31 | 32 | const DialogContent = React.forwardRef< 33 | React.ElementRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, children, ...props }, ref) => ( 36 | 37 | 38 | 46 | {children} 47 | 48 | 49 | Close 50 | 51 | 52 | 53 | )) 54 | DialogContent.displayName = DialogPrimitive.Content.displayName 55 | 56 | const DialogHeader = ({ 57 | className, 58 | ...props 59 | }: React.HTMLAttributes) => ( 60 |
67 | ) 68 | DialogHeader.displayName = "DialogHeader" 69 | 70 | const DialogFooter = ({ 71 | className, 72 | ...props 73 | }: React.HTMLAttributes) => ( 74 |
81 | ) 82 | DialogFooter.displayName = "DialogFooter" 83 | 84 | const DialogTitle = React.forwardRef< 85 | React.ElementRef, 86 | React.ComponentPropsWithoutRef 87 | >(({ className, ...props }, ref) => ( 88 | 96 | )) 97 | DialogTitle.displayName = DialogPrimitive.Title.displayName 98 | 99 | const DialogDescription = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | DialogDescription.displayName = DialogPrimitive.Description.displayName 110 | 111 | export { 112 | Dialog, 113 | DialogPortal, 114 | DialogOverlay, 115 | DialogClose, 116 | DialogTrigger, 117 | DialogContent, 118 | DialogHeader, 119 | DialogFooter, 120 | DialogTitle, 121 | DialogDescription, 122 | } 123 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Miro Clone 2 | 3 | This project is a clone of the popular Miro whiteboard application, built using modern web technologies and tools. Follow the tutorial by [Code with Antonio](https://www.youtube.com/@codewithantonio) to create your own collaborative whiteboard app. 4 | 5 | |image| 6 | |-| 7 | |image| 8 | |image| 9 | 10 | ## Tech Stack 11 | 12 | - **Framework**: [Next.js](https://nextjs.org/) 13 | - **UI Components**: [shadcn/ui](https://ui.shadcn.com/) 14 | - **Backend**: [convex](https://www.convex.dev/) 15 | - **Real-time Collaboration**: [liveblocks](https://liveblocks.io/) 16 | 17 | ## Deployment 18 | 19 | The application is deployed on Vercel. Check it out [here](https://miro-clone-chi.vercel.app). 20 | 21 | ## Getting Started 22 | 23 | Follow these instructions to set up the project locally. 24 | 25 | ### Prerequisites 26 | 27 | Make sure you have the following installed on your system: 28 | 29 | - Node.js (>= 14.x) 30 | - npm 31 | 32 | ### Installation 33 | 34 | 1. **Clone the repository:** 35 | 36 | ```sh 37 | git clone https://github.com/jatin1510/miro-clone 38 | cd miro-clone 39 | ``` 40 | 41 | 2. **Install dependencies:** 42 | 43 | ```sh 44 | npm install 45 | ``` 46 | 47 | 3. **Configure environment variables:** 48 | 49 | Create a `.env.local` file in the root directory and add your configuration variables. You can explore the `.env.example` file for more information. 50 | 51 | 4. **Clerk Setup** 52 | - Enable Organization from the "Organization settings" 53 | - Add JWT Template named "convex" image 54 | - Make sure to have `org_id` and `org_role` inside **Claims** image 55 | - Don't forget to add issuer into the `auth.config.js` inside /convex. 56 | 57 | 5. **Prepare the convex functions:** 58 | ```sh 59 | npx convex dev 60 | ``` 61 | 62 | 6. **Run the development server:** 63 | 64 | ```sh 65 | npm run dev 66 | ``` 67 | 68 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 69 | 70 | ## Features 71 | 72 | - **Real-time collaboration**: Multiple users can interact on the whiteboard simultaneously. 73 | - **Interactive UI**: Intuitive and responsive user interface for a seamless experience. 74 | - **Scalable backend**: Powered by Convex for managing backend logic and data storage. 75 | - **Live updates**: Instant updates using Liveblocks for real-time synchronization. 76 | 77 | ### New Features 78 | 79 | - **Keyboard Shortcuts**: 80 | - **Move Selected Layers**: Use keyboard shortcuts to move selected layers within the Canvas component. 81 | - **Duplicate Layers**: Duplicate selected layers with `Ctrl + D`. 82 | - **Focus Search Input**: Keyboard shortcut to focus on the search input field. 83 | 84 | 85 | - **Enhanced Selection Tool**: 86 | - **Improved Layout and Functionality**: Added a duplicate icon in the selection box for better usability. 87 | - **Select Fully Inside Rectangle**: Layers are only selected if they are fully inside the selection rectangle. 88 | - **Shortcuts for Layer Insertion**: Added keyboard shortcuts for selection and insertion in the toolbar 89 | 90 | - **Board Creation Limit**: 91 | - User can make only 5 boards within an organization 92 | 93 | - **Reset Camera**: 94 | - When the user scrolls through the canvas, a button at the right bottom appears through which the user can reset the camera position 95 | 96 | - **Color Picker**: 97 | - User now has infinite possible combinations of the layer they want. Color picker also has the debouncing technique to prevent the numerous undo/redo actions 98 | 99 | - **Export as a PNG**: 100 | - Users can now export their board as a PNG image file. This functionality allows users to save their work and share it with others easily. 101 | 102 | - **Bug Fixes**: 103 | - **Search and Favorite Functionality**: Fixed the search and favorite functionality by using `useSearchParams`. 104 | 105 | ## Tutorial 106 | 107 | This project follows the tutorial by [Code with Antonio](https://www.youtube.com/@codewithantonio). Watch the full tutorial on [YouTube](https://www.youtube.com/watch?v=ADJKbuayubE). 108 | 109 | ## Contributing 110 | 111 | Contributions are welcome! Please open an issue or submit a pull request for any improvements or bug fixes. 112 | 113 | --- 114 | -------------------------------------------------------------------------------- /components/ui/alert-dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" 5 | 6 | import { cn } from "@/lib/utils" 7 | import { buttonVariants } from "@/components/ui/button" 8 | 9 | const AlertDialog = AlertDialogPrimitive.Root 10 | 11 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger 12 | 13 | const AlertDialogPortal = AlertDialogPrimitive.Portal 14 | 15 | const AlertDialogOverlay = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, ...props }, ref) => ( 19 | 27 | )) 28 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName 29 | 30 | const AlertDialogContent = React.forwardRef< 31 | React.ElementRef, 32 | React.ComponentPropsWithoutRef 33 | >(({ className, ...props }, ref) => ( 34 | 35 | 36 | 44 | 45 | )) 46 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName 47 | 48 | const AlertDialogHeader = ({ 49 | className, 50 | ...props 51 | }: React.HTMLAttributes) => ( 52 |
59 | ) 60 | AlertDialogHeader.displayName = "AlertDialogHeader" 61 | 62 | const AlertDialogFooter = ({ 63 | className, 64 | ...props 65 | }: React.HTMLAttributes) => ( 66 |
73 | ) 74 | AlertDialogFooter.displayName = "AlertDialogFooter" 75 | 76 | const AlertDialogTitle = React.forwardRef< 77 | React.ElementRef, 78 | React.ComponentPropsWithoutRef 79 | >(({ className, ...props }, ref) => ( 80 | 85 | )) 86 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName 87 | 88 | const AlertDialogDescription = React.forwardRef< 89 | React.ElementRef, 90 | React.ComponentPropsWithoutRef 91 | >(({ className, ...props }, ref) => ( 92 | 97 | )) 98 | AlertDialogDescription.displayName = 99 | AlertDialogPrimitive.Description.displayName 100 | 101 | const AlertDialogAction = React.forwardRef< 102 | React.ElementRef, 103 | React.ComponentPropsWithoutRef 104 | >(({ className, ...props }, ref) => ( 105 | 110 | )) 111 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName 112 | 113 | const AlertDialogCancel = React.forwardRef< 114 | React.ElementRef, 115 | React.ComponentPropsWithoutRef 116 | >(({ className, ...props }, ref) => ( 117 | 126 | )) 127 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName 128 | 129 | export { 130 | AlertDialog, 131 | AlertDialogPortal, 132 | AlertDialogOverlay, 133 | AlertDialogTrigger, 134 | AlertDialogContent, 135 | AlertDialogHeader, 136 | AlertDialogFooter, 137 | AlertDialogTitle, 138 | AlertDialogDescription, 139 | AlertDialogAction, 140 | AlertDialogCancel, 141 | } 142 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Camera, 3 | Color, 4 | Layer, 5 | LayerType, 6 | PathLayer, 7 | Point, 8 | Side, 9 | XYWH, 10 | } from "@/types/canvas"; 11 | import { type ClassValue, clsx } from "clsx"; 12 | import { twMerge } from "tailwind-merge"; 13 | 14 | const COLORS = [ 15 | "#DC2626", 16 | "#D97706", 17 | "#059669", 18 | "#7C3AED", 19 | "#DB2777", 20 | "#F87171", 21 | "#FBBF24", 22 | ]; 23 | 24 | export function cn(...inputs: ClassValue[]) { 25 | return twMerge(clsx(inputs)); 26 | } 27 | 28 | export function connectionIdToColor(connectionId: number): string { 29 | return COLORS[connectionId % COLORS.length]; 30 | } 31 | 32 | export function pointerEventToCanvasPoint( 33 | e: React.PointerEvent, 34 | camera: Camera 35 | ) { 36 | return { 37 | x: Math.round(e.clientX - camera.x), 38 | y: Math.round(e.clientY - camera.y), 39 | }; 40 | } 41 | 42 | export function colorToCss(color: Color) { 43 | return `#${color.r.toString(16).padStart(2, "0")}${color.g.toString(16).padStart(2, "0")}${color.b.toString(16).padStart(2, "0")}`; 44 | } 45 | 46 | export function cssToColor(css_color: string) { 47 | if (!css_color.startsWith("#") || css_color.length !== 7) { 48 | return { r: 255, g: 255, b: 255 }; 49 | } 50 | 51 | const hex_color = css_color.slice(1); 52 | 53 | const r = parseInt(hex_color.substring(0, 2), 16); 54 | const g = parseInt(hex_color.substring(2, 4), 16); 55 | const b = parseInt(hex_color.substring(4), 16); 56 | 57 | return { r, g, b }; 58 | } 59 | 60 | export function resizeBounds(bounds: XYWH, corner: Side, point: Point): XYWH { 61 | const result = { ...bounds }; 62 | 63 | if ((corner & Side.Left) === Side.Left) { 64 | result.x = Math.min(bounds.x + bounds.width, point.x); 65 | result.width = Math.abs(bounds.x + bounds.width - point.x); 66 | } 67 | 68 | if ((corner & Side.Right) === Side.Right) { 69 | result.x = Math.min(bounds.x, point.x); 70 | result.width = Math.abs(bounds.x - point.x); 71 | } 72 | 73 | if ((corner & Side.Top) === Side.Top) { 74 | result.y = Math.min(bounds.y + bounds.height, point.y); 75 | result.height = Math.abs(bounds.y + bounds.height - point.y); 76 | } 77 | 78 | if ((corner & Side.Bottom) === Side.Bottom) { 79 | result.y = Math.min(bounds.y, point.y); 80 | result.height = Math.abs(bounds.y - point.y); 81 | } 82 | 83 | return result; 84 | } 85 | 86 | export function findIntersectingLayersWithRectangle( 87 | layerIds: readonly string[], 88 | layers: ReadonlyMap, 89 | a: Point, 90 | b: Point 91 | ) { 92 | const rect = { 93 | x: Math.min(a.x, b.x), 94 | y: Math.min(a.y, b.y), 95 | width: Math.abs(a.x - b.x), 96 | height: Math.abs(a.y - b.y), 97 | }; 98 | 99 | const ids = []; 100 | 101 | for (const layerId of layerIds) { 102 | const layer = layers.get(layerId); 103 | 104 | if (layer == null) { 105 | continue; 106 | } 107 | 108 | const { x, y, height, width } = layer; 109 | 110 | if ( 111 | rect.x <= x && 112 | rect.x + rect.width >= x + width && 113 | rect.y <= y && 114 | rect.y + rect.height >= y + height 115 | ) { 116 | ids.push(layerId); 117 | } 118 | } 119 | 120 | return ids; 121 | } 122 | 123 | export function getContrastingTextColor(color: Color) { 124 | const luminance = 0.299 * color.r + 0.587 * color.g + 0.114 * color.b; 125 | return luminance > 182 ? "black" : "white"; 126 | } 127 | 128 | export function penPointsToPathLayer( 129 | points: number[][], 130 | color: Color 131 | ): PathLayer { 132 | if (points.length < 2) { 133 | throw new Error("Cannot transform points with less than 2 points"); 134 | } 135 | 136 | let left = Number.POSITIVE_INFINITY; 137 | let top = Number.POSITIVE_INFINITY; 138 | let right = Number.NEGATIVE_INFINITY; 139 | let bottom = Number.NEGATIVE_INFINITY; 140 | 141 | for (const point of points) { 142 | const [x, y] = point; 143 | 144 | if (left > x) { 145 | left = x; 146 | } 147 | 148 | if (top > y) { 149 | top = y; 150 | } 151 | 152 | if (right < x) { 153 | right = x; 154 | } 155 | 156 | if (bottom < y) { 157 | bottom = y; 158 | } 159 | } 160 | 161 | return { 162 | type: LayerType.Path, 163 | x: left, 164 | y: top, 165 | width: right - left, 166 | height: bottom - top, 167 | fill: color, 168 | points: points.map(([x, y, pressure]) => [x - left, y - top, pressure]), 169 | }; 170 | } 171 | 172 | export function getSvgPathFromStroke(stroke: number[][]) { 173 | if (!stroke.length) return ""; 174 | 175 | const d = stroke.reduce( 176 | (acc, [x0, y0], i, arr) => { 177 | const [x1, y1] = arr[(i + 1) % arr.length]; 178 | acc.push(x0, y0, (x0 + x1) / 2, (y0 + y1) / 2); 179 | return acc; 180 | }, 181 | ["M", ...stroke[0], "Q"] 182 | ); 183 | 184 | d.push("Z"); 185 | return d.join(" "); 186 | } 187 | -------------------------------------------------------------------------------- /convex/board.ts: -------------------------------------------------------------------------------- 1 | import { v } from "convex/values"; 2 | import { mutation, query } from "./_generated/server"; 3 | const images = [ 4 | "/placeholders/1.svg", 5 | "/placeholders/2.svg", 6 | "/placeholders/3.svg", 7 | "/placeholders/4.svg", 8 | "/placeholders/5.svg", 9 | "/placeholders/6.svg", 10 | "/placeholders/7.svg", 11 | "/placeholders/8.svg", 12 | "/placeholders/9.svg", 13 | "/placeholders/10.svg", 14 | ]; 15 | 16 | export const create = mutation({ 17 | args: { 18 | orgId: v.string(), 19 | title: v.string(), 20 | }, 21 | handler: async (ctx, args) => { 22 | const identity = await ctx.auth.getUserIdentity(); 23 | if (!identity) { 24 | throw new Error("Unauthorized"); 25 | } 26 | 27 | const randomImage = images[Math.floor(Math.random() * images.length)]; 28 | const board = await ctx.db.insert("boards", { 29 | title: args.title, 30 | orgId: args.orgId, 31 | authorId: identity.subject, 32 | authorName: identity.name!, 33 | imageUrl: randomImage, 34 | }); 35 | 36 | return board; 37 | }, 38 | }); 39 | 40 | export const remove = mutation({ 41 | args: { 42 | id: v.id("boards"), 43 | }, 44 | handler: async (ctx, args) => { 45 | const identity = await ctx.auth.getUserIdentity(); 46 | if (!identity) { 47 | throw new Error("Unauthorized"); 48 | } 49 | const userId = identity.subject; 50 | const existingFavorite = await ctx.db 51 | .query("userFavorites") 52 | .withIndex("by_user_board", (q) => 53 | q.eq("userId", userId).eq("boardId", args.id) 54 | ) 55 | .unique(); 56 | 57 | if (existingFavorite) { 58 | await ctx.db.delete(existingFavorite._id); 59 | } 60 | 61 | const board = await ctx.db.get(args.id); 62 | if (board?.authorId !== userId) throw new Error("Unauthorized"); 63 | 64 | await ctx.db.delete(args.id); 65 | }, 66 | }); 67 | 68 | export const update = mutation({ 69 | args: { 70 | id: v.id("boards"), 71 | title: v.string(), 72 | }, 73 | handler: async (ctx, args) => { 74 | const identity = await ctx.auth.getUserIdentity(); 75 | if (!identity) { 76 | throw new Error("Unauthorized"); 77 | } 78 | 79 | const title = args.title.trim(); 80 | if (!title) throw new Error("Title is required"); 81 | 82 | if (title.length > 60) 83 | throw new Error("Title cannot be longer than 60 characters"); 84 | 85 | return await ctx.db.patch(args.id, { title: args.title }); 86 | }, 87 | }); 88 | 89 | export const favorite = mutation({ 90 | args: { 91 | id: v.id("boards"), 92 | orgId: v.string(), 93 | }, 94 | handler: async (ctx, args) => { 95 | const identity = await ctx.auth.getUserIdentity(); 96 | if (!identity) { 97 | throw new Error("Unauthorized"); 98 | } 99 | 100 | const board = await ctx.db.get(args.id); 101 | if (!board) { 102 | throw new Error("Board not found"); 103 | } 104 | 105 | const userId = identity.subject; 106 | 107 | const existingFavorite = await ctx.db 108 | .query("userFavorites") 109 | .withIndex("by_user_board", (q) => 110 | q.eq("userId", userId).eq("boardId", board._id) 111 | ) 112 | .unique(); 113 | 114 | if (existingFavorite) throw new Error("Board already favorited"); 115 | await ctx.db.insert("userFavorites", { 116 | userId, 117 | boardId: board._id, 118 | orgId: args.orgId, 119 | }); 120 | 121 | return board; 122 | }, 123 | }); 124 | 125 | export const unfavorite = mutation({ 126 | args: { 127 | id: v.id("boards"), 128 | }, 129 | handler: async (ctx, args) => { 130 | const identity = await ctx.auth.getUserIdentity(); 131 | if (!identity) { 132 | throw new Error("Unauthorized"); 133 | } 134 | 135 | const board = await ctx.db.get(args.id); 136 | if (!board) { 137 | throw new Error("Board not found"); 138 | } 139 | 140 | const userId = identity.subject; 141 | 142 | const existingFavorite = await ctx.db 143 | .query("userFavorites") 144 | .withIndex("by_user_board", (q) => 145 | q.eq("userId", userId).eq("boardId", board._id) 146 | ) 147 | .unique(); 148 | 149 | if (!existingFavorite) throw new Error("Favorited board not found"); 150 | 151 | await ctx.db.delete(existingFavorite._id); 152 | 153 | return board; 154 | }, 155 | }); 156 | 157 | export const get = query({ 158 | args: { 159 | id: v.id("boards"), 160 | }, 161 | handler: async (ctx, args) => { 162 | return await ctx.db.get(args.id); 163 | }, 164 | }); 165 | 166 | export const getTotalBoardCountOfOrg = query({ 167 | args: { 168 | orgId: v.string(), 169 | }, 170 | handler: async (ctx, args) => { 171 | return await ctx.db 172 | .query("boards") 173 | .withIndex("by_org", (q) => q.eq("orgId", args.orgId)) 174 | .collect() 175 | .then((boards) => boards.length); 176 | }, 177 | }); 178 | -------------------------------------------------------------------------------- /liveblocks.config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createClient, 3 | LiveList, 4 | LiveMap, 5 | LiveObject, 6 | } from "@liveblocks/client"; 7 | import { createRoomContext, createLiveblocksContext } from "@liveblocks/react"; 8 | import { Layer, Color } from "./types/canvas"; 9 | 10 | const client = createClient({ 11 | // publicApiKey: process.env.NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY!, 12 | authEndpoint: "/api/liveblocks-auth", 13 | throttle: 16, 14 | async resolveUsers({ userIds }) { 15 | // Used only for Comments and Notifications. Return a list of user information 16 | // retrieved from `userIds`. This info is used in comments, mentions etc. 17 | 18 | // const usersData = await __fetchUsersFromDB__(userIds); 19 | // 20 | // return usersData.map((userData) => ({ 21 | // name: userData.name, 22 | // avatar: userData.avatar.src, 23 | // })); 24 | 25 | return []; 26 | }, 27 | async resolveMentionSuggestions({ text }) { 28 | // Used only for Comments. Return a list of userIds that match `text`. 29 | // These userIds are used to create a mention list when typing in the 30 | // composer. 31 | // 32 | // For example when you type "@jo", `text` will be `"jo"`, and 33 | // you should to return an array with John and Joanna's userIds: 34 | // ["john@example.com", "joanna@example.com"] 35 | 36 | // const users = await getUsers({ search: text }); 37 | // return users.map((user) => user.id); 38 | 39 | return []; 40 | }, 41 | async resolveRoomsInfo({ roomIds }) { 42 | // Used only for Comments and Notifications. Return a list of room information 43 | // retrieved from `roomIds`. 44 | 45 | // const roomsData = await __fetchRoomsFromDB__(roomIds); 46 | // 47 | // return roomsData.map((roomData) => ({ 48 | // name: roomData.name, 49 | // url: roomData.url, 50 | // })); 51 | 52 | return []; 53 | }, 54 | }); 55 | 56 | // Presence represents the properties that exist on every user in the Room 57 | // and that will automatically be kept in sync. Accessible through the 58 | // `user.presence` property. Must be JSON-serializable. 59 | type Presence = { 60 | cursor: { x: number; y: number } | null; 61 | selection: string[]; 62 | pencilDraft: [x: number, y: number, pressure: number][] | null; 63 | penColor: Color | null; 64 | // ... 65 | }; 66 | 67 | // Optionally, Storage represents the shared document that persists in the 68 | // Room, even after all users leave. Fields under Storage typically are 69 | // LiveList, LiveMap, LiveObject instances, for which updates are 70 | // automatically persisted and synced to all connected clients. 71 | type Storage = { 72 | layers: LiveMap>; 73 | layerIds: LiveList; 74 | }; 75 | 76 | // Optionally, UserMeta represents static/readonly metadata on each user, as 77 | // provided by your own custom auth back end (if used). Useful for data that 78 | // will not change during a session, like a user's name or avatar. 79 | type UserMeta = { 80 | id?: string; // Accessible through `user.id` 81 | info?: { 82 | name?: string; 83 | picture?: string; 84 | }; // Accessible through `user.info` 85 | }; 86 | 87 | // Optionally, the type of custom events broadcast and listened to in this 88 | // room. Use a union for multiple events. Must be JSON-serializable. 89 | type RoomEvent = { 90 | // type: "NOTIFICATION", 91 | // ... 92 | }; 93 | 94 | // Optionally, when using Comments, ThreadMetadata represents metadata on 95 | // each thread. Can only contain booleans, strings, and numbers. 96 | export type ThreadMetadata = { 97 | // resolved: boolean; 98 | // quote: string; 99 | // time: number; 100 | }; 101 | 102 | // Room-level hooks, use inside `RoomProvider` 103 | export const { 104 | suspense: { 105 | RoomProvider, 106 | useRoom, 107 | useMyPresence, 108 | useUpdateMyPresence, 109 | useSelf, 110 | useOthers, 111 | useOthersMapped, 112 | useOthersListener, 113 | useOthersConnectionIds, 114 | useOther, 115 | useBroadcastEvent, 116 | useEventListener, 117 | useErrorListener, 118 | useStorage, 119 | useObject, 120 | useMap, 121 | useList, 122 | useBatch, 123 | useHistory, 124 | useUndo, 125 | useRedo, 126 | useCanUndo, 127 | useCanRedo, 128 | useMutation, 129 | useStatus, 130 | useLostConnectionListener, 131 | useThreads, 132 | useCreateThread, 133 | useEditThreadMetadata, 134 | useCreateComment, 135 | useEditComment, 136 | useDeleteComment, 137 | useAddReaction, 138 | useRemoveReaction, 139 | useThreadSubscription, 140 | useMarkThreadAsRead, 141 | useRoomNotificationSettings, 142 | useUpdateRoomNotificationSettings, 143 | 144 | // These hooks can be exported from either context 145 | // useUser, 146 | // useRoomInfo 147 | }, 148 | } = createRoomContext( 149 | client 150 | ); 151 | 152 | // Project-level hooks, use inside `LiveblocksProvider` 153 | export const { 154 | suspense: { 155 | LiveblocksProvider, 156 | useMarkInboxNotificationAsRead, 157 | useMarkAllInboxNotificationsAsRead, 158 | useInboxNotifications, 159 | useUnreadInboxNotificationsCount, 160 | 161 | // These hooks can be exported from either context 162 | useUser, 163 | useRoomInfo, 164 | }, 165 | } = createLiveblocksContext(client); 166 | -------------------------------------------------------------------------------- /app/board/[boardId]/_components/selection-tools.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useSelectionBounds } from "@/hooks/use-selection-bounds"; 4 | import { useMutation, useSelf } from "@/liveblocks.config"; 5 | import { Camera, Color } from "@/types/canvas"; 6 | import { memo } from "react"; 7 | import { ColorPicker } from "./color-picker"; 8 | import { useDeleteLayers } from "@/hooks/use-delete-layers"; 9 | import { Button } from "@/components/ui/button"; 10 | import { Hint } from "@/components/hint"; 11 | import { BringToFront, Copy, SendToBack, Trash2 } from "lucide-react"; 12 | import { BringToFrontIcon, SendToBackIcon } from "@/components/icon"; 13 | 14 | interface SelectionToolsProps { 15 | camera: Camera; 16 | setLastUsedColor: (color: Color) => void; 17 | onDuplicate: () => void; 18 | lastUsedColor: Color; 19 | } 20 | 21 | export const SelectionTools = memo( 22 | ({ camera, setLastUsedColor, onDuplicate, lastUsedColor }: SelectionToolsProps) => { 23 | const selection = useSelf((me) => me.presence.selection); 24 | 25 | const moveToFront = useMutation( 26 | ({ storage }) => { 27 | const liveLayerIds = storage.get("layerIds"); 28 | const indices: number[] = []; 29 | const arr = liveLayerIds.toImmutable(); 30 | 31 | for (let i = 0; i < arr.length; i++) { 32 | if (selection.includes(arr[i])) { 33 | indices.push(i); 34 | } 35 | } 36 | 37 | for (let i = indices.length - 1; i >= 0; i--) { 38 | liveLayerIds.move( 39 | indices[i], 40 | arr.length - 1 - (indices.length - 1 - i) 41 | ); 42 | } 43 | }, 44 | [selection] 45 | ); 46 | 47 | const moveToBack = useMutation( 48 | ({ storage }) => { 49 | const liveLayerIds = storage.get("layerIds"); 50 | const indices: number[] = []; 51 | const arr = liveLayerIds.toImmutable(); 52 | 53 | for (let i = 0; i < arr.length; i++) { 54 | if (selection.includes(arr[i])) { 55 | indices.push(i); 56 | } 57 | } 58 | 59 | for (let i = 0; i < indices.length; i++) { 60 | liveLayerIds.move(indices[i], i); 61 | } 62 | }, 63 | [selection] 64 | ); 65 | 66 | const setFill = useMutation( 67 | ({ storage }, fill: Color) => { 68 | const liveLayers = storage.get("layers"); 69 | setLastUsedColor(fill); 70 | if (!selection) { 71 | return; 72 | } 73 | selection.forEach((id) => 74 | liveLayers.get(id)?.set("fill", fill) 75 | ); 76 | }, 77 | [selection, setLastUsedColor] 78 | ); 79 | 80 | const deleteLayers = useDeleteLayers(); 81 | 82 | const selectionBounds = useSelectionBounds(); 83 | 84 | if (!selectionBounds) { 85 | return null; 86 | } 87 | 88 | const x = selectionBounds.width / 2 + selectionBounds.x + camera.x; 89 | const y = selectionBounds.y + camera.y; 90 | 91 | return ( 92 |
101 | 102 |
103 | 104 | 115 | 116 | 117 | 128 | 129 |
130 |
131 | 132 | 139 | 140 | 141 | 148 | 149 |
150 |
151 | ); 152 | } 153 | ); 154 | 155 | SelectionTools.displayName = "SelectionTools"; 156 | -------------------------------------------------------------------------------- /convex/_generated/server.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | /** 3 | * Generated utilities for implementing server-side Convex query and mutation functions. 4 | * 5 | * THIS CODE IS AUTOMATICALLY GENERATED. 6 | * 7 | * Generated by convex@1.12.0. 8 | * To regenerate, run `npx convex dev`. 9 | * @module 10 | */ 11 | 12 | import { 13 | ActionBuilder, 14 | HttpActionBuilder, 15 | MutationBuilder, 16 | QueryBuilder, 17 | GenericActionCtx, 18 | GenericMutationCtx, 19 | GenericQueryCtx, 20 | GenericDatabaseReader, 21 | GenericDatabaseWriter, 22 | } from "convex/server"; 23 | import type { DataModel } from "./dataModel.js"; 24 | 25 | /** 26 | * Define a query in this Convex app's public API. 27 | * 28 | * This function will be allowed to read your Convex database and will be accessible from the client. 29 | * 30 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 31 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 32 | */ 33 | export declare const query: QueryBuilder; 34 | 35 | /** 36 | * Define a query that is only accessible from other Convex functions (but not from the client). 37 | * 38 | * This function will be allowed to read from your Convex database. It will not be accessible from the client. 39 | * 40 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 41 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 42 | */ 43 | export declare const internalQuery: QueryBuilder; 44 | 45 | /** 46 | * Define a mutation in this Convex app's public API. 47 | * 48 | * This function will be allowed to modify your Convex database and will be accessible from the client. 49 | * 50 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 51 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 52 | */ 53 | export declare const mutation: MutationBuilder; 54 | 55 | /** 56 | * Define a mutation that is only accessible from other Convex functions (but not from the client). 57 | * 58 | * This function will be allowed to modify your Convex database. It will not be accessible from the client. 59 | * 60 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 61 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 62 | */ 63 | export declare const internalMutation: MutationBuilder; 64 | 65 | /** 66 | * Define an action in this Convex app's public API. 67 | * 68 | * An action is a function which can execute any JavaScript code, including non-deterministic 69 | * code and code with side-effects, like calling third-party services. 70 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. 71 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. 72 | * 73 | * @param func - The action. It receives an {@link ActionCtx} as its first argument. 74 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible. 75 | */ 76 | export declare const action: ActionBuilder; 77 | 78 | /** 79 | * Define an action that is only accessible from other Convex functions (but not from the client). 80 | * 81 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 82 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible. 83 | */ 84 | export declare const internalAction: ActionBuilder; 85 | 86 | /** 87 | * Define an HTTP action. 88 | * 89 | * This function will be used to respond to HTTP requests received by a Convex 90 | * deployment if the requests matches the path and method where this action 91 | * is routed. Be sure to route your action in `convex/http.js`. 92 | * 93 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 94 | * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. 95 | */ 96 | export declare const httpAction: HttpActionBuilder; 97 | 98 | /** 99 | * A set of services for use within Convex query functions. 100 | * 101 | * The query context is passed as the first argument to any Convex query 102 | * function run on the server. 103 | * 104 | * This differs from the {@link MutationCtx} because all of the services are 105 | * read-only. 106 | */ 107 | export type QueryCtx = GenericQueryCtx; 108 | 109 | /** 110 | * A set of services for use within Convex mutation functions. 111 | * 112 | * The mutation context is passed as the first argument to any Convex mutation 113 | * function run on the server. 114 | */ 115 | export type MutationCtx = GenericMutationCtx; 116 | 117 | /** 118 | * A set of services for use within Convex action functions. 119 | * 120 | * The action context is passed as the first argument to any Convex action 121 | * function run on the server. 122 | */ 123 | export type ActionCtx = GenericActionCtx; 124 | 125 | /** 126 | * An interface to read from the database within Convex query functions. 127 | * 128 | * The two entry points are {@link DatabaseReader.get}, which fetches a single 129 | * document by its {@link Id}, or {@link DatabaseReader.query}, which starts 130 | * building a query. 131 | */ 132 | export type DatabaseReader = GenericDatabaseReader; 133 | 134 | /** 135 | * An interface to read from and write to the database within Convex mutation 136 | * functions. 137 | * 138 | * Convex guarantees that all writes within a single mutation are 139 | * executed atomically, so you never have to worry about partial writes leaving 140 | * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) 141 | * for the guarantees Convex provides your functions. 142 | */ 143 | export type DatabaseWriter = GenericDatabaseWriter; 144 | -------------------------------------------------------------------------------- /public/placeholders/3.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/board/[boardId]/_components/toolbar.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@/components/ui/skeleton"; 2 | import { ToolButton } from "./tool-button"; 3 | import { 4 | Circle, 5 | MousePointer2, 6 | Pencil, 7 | Redo2, 8 | Square, 9 | StickyNote, 10 | TypeIcon, 11 | Undo2, 12 | } from "lucide-react"; 13 | import { CanvasMode, CanvasState, LayerType } from "@/types/canvas"; 14 | import { useEffect } from "react"; 15 | import { useSelf } from "@/liveblocks.config"; 16 | 17 | interface ToolbarProps { 18 | canvasState: CanvasState; 19 | setCanvasState: (newState: CanvasState) => void; 20 | undo: () => void; 21 | redo: () => void; 22 | canUndo: boolean; 23 | canRedo: boolean; 24 | } 25 | 26 | const Toolbar = ({ 27 | canvasState, 28 | setCanvasState, 29 | undo, 30 | redo, 31 | canUndo, 32 | canRedo, 33 | }: ToolbarProps) => { 34 | const selection = useSelf((me) => me.presence.selection); 35 | 36 | useEffect(() => { 37 | const onKeyDown = (e: KeyboardEvent) => { 38 | if (selection?.length > 0) return; 39 | switch (e.key) { 40 | case "a": 41 | if (e.ctrlKey) setCanvasState({ mode: CanvasMode.None }); 42 | break; 43 | 44 | case "t": 45 | if (e.ctrlKey) 46 | setCanvasState({ 47 | layerType: LayerType.Text, 48 | mode: CanvasMode.Inserting, 49 | }); 50 | break; 51 | 52 | case "n": 53 | if (e.ctrlKey) 54 | setCanvasState({ 55 | mode: CanvasMode.Inserting, 56 | layerType: LayerType.Note, 57 | }); 58 | break; 59 | 60 | case "r": 61 | if (e.ctrlKey) 62 | setCanvasState({ 63 | mode: CanvasMode.Inserting, 64 | layerType: LayerType.Rectangle, 65 | }); 66 | break; 67 | 68 | case "e": 69 | if (e.ctrlKey) 70 | setCanvasState({ 71 | mode: CanvasMode.Inserting, 72 | layerType: LayerType.Ellipse, 73 | }); 74 | break; 75 | 76 | default: 77 | break; 78 | } 79 | }; 80 | 81 | document.addEventListener("keydown", onKeyDown); 82 | 83 | return () => { 84 | document.removeEventListener("keydown", onKeyDown); 85 | }; 86 | }, [selection, setCanvasState]); 87 | 88 | return ( 89 |
90 |
91 | setCanvasState({ mode: CanvasMode.None })} 95 | isActive={ 96 | canvasState.mode === CanvasMode.None || 97 | canvasState.mode === CanvasMode.Translating || 98 | canvasState.mode === CanvasMode.SelectionNet || 99 | canvasState.mode === CanvasMode.Pressing || 100 | canvasState.mode === CanvasMode.Resizing 101 | } 102 | /> 103 | 107 | setCanvasState({ 108 | layerType: LayerType.Text, 109 | mode: CanvasMode.Inserting, 110 | }) 111 | } 112 | isActive={ 113 | canvasState.mode === CanvasMode.Inserting && 114 | canvasState.layerType === LayerType.Text 115 | } 116 | /> 117 | 121 | setCanvasState({ 122 | mode: CanvasMode.Inserting, 123 | layerType: LayerType.Note, 124 | }) 125 | } 126 | isActive={ 127 | canvasState.mode === CanvasMode.Inserting && 128 | canvasState.layerType === LayerType.Note 129 | } 130 | /> 131 | 135 | setCanvasState({ 136 | mode: CanvasMode.Inserting, 137 | layerType: LayerType.Rectangle, 138 | }) 139 | } 140 | isActive={ 141 | canvasState.mode === CanvasMode.Inserting && 142 | canvasState.layerType === LayerType.Rectangle 143 | } 144 | /> 145 | 149 | setCanvasState({ 150 | mode: CanvasMode.Inserting, 151 | layerType: LayerType.Ellipse, 152 | }) 153 | } 154 | isActive={ 155 | canvasState.mode === CanvasMode.Inserting && 156 | canvasState.layerType === LayerType.Ellipse 157 | } 158 | /> 159 | 163 | setCanvasState({ 164 | mode: CanvasMode.Pencil, 165 | }) 166 | } 167 | isActive={canvasState.mode === CanvasMode.Pencil} 168 | /> 169 |
170 |
171 | 177 | 183 |
184 |
185 | ); 186 | }; 187 | 188 | export const ToolbarSkeleton = () => { 189 | return ( 190 |
191 | 192 |
193 | ); 194 | }; 195 | 196 | export default Toolbar; 197 | -------------------------------------------------------------------------------- /public/placeholders/4.svg: -------------------------------------------------------------------------------- 1 | happy_music -------------------------------------------------------------------------------- /components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" 5 | import { Check, ChevronRight, Circle } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const DropdownMenu = DropdownMenuPrimitive.Root 10 | 11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger 12 | 13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group 14 | 15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal 16 | 17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub 18 | 19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup 20 | 21 | const DropdownMenuSubTrigger = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef & { 24 | inset?: boolean 25 | } 26 | >(({ className, inset, children, ...props }, ref) => ( 27 | 36 | {children} 37 | 38 | 39 | )) 40 | DropdownMenuSubTrigger.displayName = 41 | DropdownMenuPrimitive.SubTrigger.displayName 42 | 43 | const DropdownMenuSubContent = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef 46 | >(({ className, ...props }, ref) => ( 47 | 55 | )) 56 | DropdownMenuSubContent.displayName = 57 | DropdownMenuPrimitive.SubContent.displayName 58 | 59 | const DropdownMenuContent = React.forwardRef< 60 | React.ElementRef, 61 | React.ComponentPropsWithoutRef 62 | >(({ className, sideOffset = 4, ...props }, ref) => ( 63 | 64 | 73 | 74 | )) 75 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName 76 | 77 | const DropdownMenuItem = React.forwardRef< 78 | React.ElementRef, 79 | React.ComponentPropsWithoutRef & { 80 | inset?: boolean 81 | } 82 | >(({ className, inset, ...props }, ref) => ( 83 | 92 | )) 93 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName 94 | 95 | const DropdownMenuCheckboxItem = React.forwardRef< 96 | React.ElementRef, 97 | React.ComponentPropsWithoutRef 98 | >(({ className, children, checked, ...props }, ref) => ( 99 | 108 | 109 | 110 | 111 | 112 | 113 | {children} 114 | 115 | )) 116 | DropdownMenuCheckboxItem.displayName = 117 | DropdownMenuPrimitive.CheckboxItem.displayName 118 | 119 | const DropdownMenuRadioItem = React.forwardRef< 120 | React.ElementRef, 121 | React.ComponentPropsWithoutRef 122 | >(({ className, children, ...props }, ref) => ( 123 | 131 | 132 | 133 | 134 | 135 | 136 | {children} 137 | 138 | )) 139 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName 140 | 141 | const DropdownMenuLabel = React.forwardRef< 142 | React.ElementRef, 143 | React.ComponentPropsWithoutRef & { 144 | inset?: boolean 145 | } 146 | >(({ className, inset, ...props }, ref) => ( 147 | 156 | )) 157 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName 158 | 159 | const DropdownMenuSeparator = React.forwardRef< 160 | React.ElementRef, 161 | React.ComponentPropsWithoutRef 162 | >(({ className, ...props }, ref) => ( 163 | 168 | )) 169 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName 170 | 171 | const DropdownMenuShortcut = ({ 172 | className, 173 | ...props 174 | }: React.HTMLAttributes) => { 175 | return ( 176 | 180 | ) 181 | } 182 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut" 183 | 184 | export { 185 | DropdownMenu, 186 | DropdownMenuTrigger, 187 | DropdownMenuContent, 188 | DropdownMenuItem, 189 | DropdownMenuCheckboxItem, 190 | DropdownMenuRadioItem, 191 | DropdownMenuLabel, 192 | DropdownMenuSeparator, 193 | DropdownMenuShortcut, 194 | DropdownMenuGroup, 195 | DropdownMenuPortal, 196 | DropdownMenuSub, 197 | DropdownMenuSubContent, 198 | DropdownMenuSubTrigger, 199 | DropdownMenuRadioGroup, 200 | } 201 | -------------------------------------------------------------------------------- /app/board/[boardId]/_components/selection-box.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useSelectionBounds } from "@/hooks/use-selection-bounds"; 4 | import { useSelf, useStorage } from "@/liveblocks.config"; 5 | import { LayerType, Side, XYWH } from "@/types/canvas"; 6 | import { memo } from "react"; 7 | 8 | interface SelectionBoxProps { 9 | onResizeHandlePointerDown: (corner: Side, initialBounds: XYWH) => void; 10 | } 11 | 12 | const HANDLE_WIDTH = 6; 13 | 14 | export const SelectionBox = memo( 15 | ({ onResizeHandlePointerDown }: SelectionBoxProps) => { 16 | const soleLayerId = useSelf((me) => 17 | me.presence.selection.length === 1 ? me.presence.selection[0] : null 18 | ); 19 | 20 | const isShowingHandles = useStorage( 21 | (root) => 22 | soleLayerId && 23 | root.layers.get(soleLayerId)?.type !== LayerType.Path 24 | ); 25 | 26 | const bounds = useSelectionBounds(); 27 | 28 | if (!bounds) { 29 | return null; 30 | } 31 | 32 | return ( 33 | <> 34 | 44 | {isShowingHandles && ( 45 | <> 46 | { 57 | e.stopPropagation(); 58 | onResizeHandlePointerDown(Side.Top + Side.Left, bounds); 59 | }} 60 | /> 61 | { 72 | e.stopPropagation(); 73 | onResizeHandlePointerDown(Side.Top, bounds); 74 | }} 75 | /> 76 | { 87 | e.stopPropagation(); 88 | onResizeHandlePointerDown(Side.Top + Side.Right, bounds); 89 | }} 90 | /> 91 | { 102 | e.stopPropagation(); 103 | onResizeHandlePointerDown(Side.Right, bounds); 104 | }} 105 | /> 106 | { 117 | e.stopPropagation(); 118 | onResizeHandlePointerDown(Side.Right + Side.Bottom, bounds); 119 | }} 120 | /> 121 | { 132 | e.stopPropagation(); 133 | onResizeHandlePointerDown(Side.Bottom, bounds); 134 | }} 135 | /> 136 | { 147 | e.stopPropagation(); 148 | onResizeHandlePointerDown(Side.Left + Side.Bottom, bounds); 149 | }} 150 | /> 151 | { 162 | e.stopPropagation(); 163 | onResizeHandlePointerDown(Side.Left, bounds); 164 | }} 165 | /> 166 | 167 | )} 168 | 169 | ); 170 | } 171 | ); 172 | 173 | SelectionBox.displayName = "SelectionBox"; 174 | -------------------------------------------------------------------------------- /public/placeholders/2.svg: -------------------------------------------------------------------------------- 1 | --------------------------------------------------------------------------------