├── .eslintrc.json ├── postcss.config.js ├── 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 ├── app ├── (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 │ │ ├── search-input.tsx │ │ ├── navbar.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 │ │ ├── rectangle.tsx │ │ ├── ellipse.tsx │ │ ├── path.tsx │ │ ├── cursor.tsx │ │ ├── cursors-presence.tsx │ │ ├── color-picker.tsx │ │ ├── participants.tsx │ │ ├── text.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 ├── next.config.mjs ├── components ├── ui │ ├── skeleton.tsx │ ├── input.tsx │ ├── sonner.tsx │ ├── tooltip.tsx │ ├── avatar.tsx │ ├── button.tsx │ ├── dialog.tsx │ ├── alert-dialog.tsx │ └── dropdown-menu.tsx ├── auth │ └── loading.tsx ├── room.tsx ├── hint.tsx ├── confirm-modal.tsx ├── modals │ └── rename-modal.tsx └── actions.tsx ├── hooks ├── use-disable-scroll-bounce.ts ├── use-api-mutation.ts ├── use-delete-layers.ts └── use-selection-bounds.ts ├── components.json ├── middleware.ts ├── .gitignore ├── public ├── vercel.svg ├── next.svg ├── logo.svg └── placeholders │ ├── 6.svg │ ├── 7.svg │ ├── 3.svg │ ├── 4.svg │ ├── 2.svg │ ├── 5.svg │ └── 9.svg ├── store └── use-rename-modal.ts ├── tsconfig.json ├── providers ├── modal-provider.tsx └── convex-client-provider.tsx ├── package.json ├── tailwind.config.ts ├── types └── canvas.ts ├── README.md ├── liveblocks.config.ts └── lib └── utils.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /convex/auth.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | providers: [ 3 | { 4 | domain: "https://quiet-llama-40.clerk.accounts.dev", 5 | applicationID: "convex", 6 | }, 7 | ], 8 | }; 9 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/board-card/overlay.tsx: -------------------------------------------------------------------------------- 1 | export const Overlay = () => { 2 | return ( 3 |
4 | ); 5 | }; 6 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | remotePatterns: [{ protocol: "https", hostname: "img.clerk.com" }], 5 | }, 6 | }; 7 | 8 | export default nextConfig; 9 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /hooks/use-disable-scroll-bounce.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | export const useDisabledScrollBounce = () => { 4 | useEffect(() => { 5 | document.body.classList.add("overflow-hidden", "overscroll-none"); 6 | 7 | return () => { 8 | document.body.classList.remove("overflow-hidden", "overscroll-none"); 9 | }; 10 | }, []); 11 | }; 12 | -------------------------------------------------------------------------------- /components/auth/loading.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | export const Loading = () => { 4 | return ( 5 |
6 | Logo 13 |
14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { authMiddleware } from "@clerk/nextjs"; 2 | 3 | // This example protects all routes including api/trpc routes 4 | // Please edit this to allow other routes to be public as needed. 5 | // See https://clerk.com/docs/references/nextjs/auth-middleware for more information about configuring your Middleware 6 | export default authMiddleware({}); 7 | 8 | export const config = { 9 | matcher: ["/((?!.+\\.[\\w]+$|_next).*)", "/", "/(api|trpc)(.*)"], 10 | }; 11 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/empty-search.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | export const EmptySearch = () => { 4 | return ( 5 |
6 | Empty 7 |

No results found!

8 |

9 | Try searching for something else 10 |

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

No favorite boards!

8 |

9 | Try favoriting a board 10 |

11 |
12 | ); 13 | }; 14 | -------------------------------------------------------------------------------- /app/board/[boardId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { Room } from "@/components/room"; 2 | import { Canvas } from "./_components/canvas"; 3 | import { Loading } from "./_components/loading"; 4 | 5 | interface BoardIdPageProps { 6 | params: { 7 | boardId: string; 8 | }; 9 | } 10 | 11 | const BoardIdPage = ({ params }: BoardIdPageProps) => { 12 | return ( 13 | }> 14 | 15 | 16 | ); 17 | }; 18 | 19 | export default BoardIdPage; 20 | -------------------------------------------------------------------------------- /.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.9.1. 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 | -------------------------------------------------------------------------------- /app/board/[boardId]/_components/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Loader } from "lucide-react"; 2 | import { InfoSkeleton } from "./info"; 3 | import { ParticipantsSkeleton } from "./participants"; 4 | import { ToolbarSkeleton } from "./toolbar"; 5 | 6 | export const Loading = () => { 7 | return ( 8 |
9 | 10 | 11 | 12 | 13 |
14 | ); 15 | }; 16 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /hooks/use-api-mutation.ts: -------------------------------------------------------------------------------- 1 | import { useMutation } from "convex/react"; 2 | import { useState } from "react"; 3 | 4 | export const useApiMutation = (mutationFunction: any) => { 5 | const [pending, setPending] = useState(false); 6 | const apiMutation = useMutation(mutationFunction); 7 | 8 | const mutate = (payload: any) => { 9 | setPending(true); 10 | return apiMutation(payload) 11 | .finally(() => setPending(false)) 12 | .then((result) => { 13 | return result; 14 | }) 15 | .catch((e) => { 16 | throw e; 17 | }); 18 | }; 19 | 20 | return { mutate, pending }; 21 | }; 22 | -------------------------------------------------------------------------------- /store/use-rename-modal.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | const defaultValues = { id: "", title: "" }; 4 | 5 | interface IRenameModal { 6 | isOpen: boolean; 7 | initialValues: typeof defaultValues; 8 | onOpen: (id: string, title: string) => void; 9 | onClose: () => void; 10 | } 11 | 12 | export const useRenameModal = create((set) => ({ 13 | isOpen: false, 14 | onOpen: (id, title) => 15 | set({ 16 | isOpen: true, 17 | initialValues: { id, title }, 18 | }), 19 | onClose: () => 20 | set({ 21 | isOpen: false, 22 | initialValues: defaultValues, 23 | }), 24 | initialValues: defaultValues, 25 | })); 26 | -------------------------------------------------------------------------------- /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-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(({ storage, setMyPresence }) => { 7 | const liveLayers = storage.get("layers"); 8 | const liveLayersIds = storage.get("layerIds"); 9 | 10 | for (const id of selection) { 11 | liveLayers.delete(id); 12 | const index = liveLayersIds.indexOf(id); 13 | 14 | if (index !== -1) { 15 | liveLayersIds.delete(index); 16 | } 17 | } 18 | 19 | setMyPresence({ selection: [] }, { addToHistory: true }); 20 | }, [selection]); 21 | }; 22 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/invite-button.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; 3 | import { OrganizationProfile } from "@clerk/nextjs"; 4 | import { Plus } from "lucide-react"; 5 | 6 | export const InviteButton = () => { 7 | return ( 8 | 9 | 10 | 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /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) return null; 14 | 15 | return ( 16 |
    17 | {userMemberships.data?.map((mem) => ( 18 | 24 | ))} 25 |
26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /providers/modal-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { RenameModal } from "@/components/modals/rename-modal"; 4 | import { useEffect, useState } from "react"; 5 | 6 | /** 7 | * @name ModalProvider 8 | * @description The ModalProvider component avoid hydration error when displaying modals 9 | * @returns JSX Elements (Modals) 10 | */ 11 | export const ModalProvider = () => { 12 | const [isMounted, setIsMounted] = useState(false); 13 | 14 | useEffect(() => { 15 | setIsMounted(true); 16 | }, []); 17 | 18 | // if it is not mounted, it is not in the client side 19 | // therefore, do not render anything 20 | if (!isMounted) return null; 21 | 22 | return ( 23 | <> 24 | 25 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /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 | 17 | return ( 18 |
19 | {!organization ? ( 20 | 21 | ) : ( 22 | 23 | )} 24 |
25 | ); 26 | }; 27 | 28 | export default DashboardPage; 29 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /convex/schema.ts: -------------------------------------------------------------------------------- 1 | import { v } from "convex/values"; 2 | import { defineSchema, defineTable } 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 | userFavorites: defineTable({ 18 | orgId: v.string(), 19 | userId: v.string(), 20 | boardId: v.id("boards"), 21 | }) 22 | .index("by_board", ["boardId"]) 23 | .index("by_user_org", ["userId", "orgId"]) 24 | .index("by_user_board", ["userId", "boardId"]) 25 | .index("by_user_board_org", ["userId", "boardId", "orgId"]), 26 | }); 27 | -------------------------------------------------------------------------------- /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 | /** 10 | * @description Layout structure for Dashboard 11 | * @param param0 children accepts React.ReactNode elements 12 | * @returns JSX Elements 13 | */ 14 | const DashboardLayout = ({ children }: DashboardLayoutProps) => { 15 | return ( 16 |
17 | 18 |
19 |
20 | 21 |
22 | 23 | {children} 24 |
25 |
26 |
27 |
28 | ); 29 | }; 30 | 31 | export default DashboardLayout; 32 | -------------------------------------------------------------------------------- /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, fill, height, width } = layer; 18 | 19 | return ( 20 | onPointerDown(e, id)} 23 | style={{ transform: `translate(${x}px, ${y}px)` }} 24 | x={0} 25 | y={0} 26 | width={width} 27 | height={height} 28 | strokeWidth={1} 29 | fill={fill ? colorToCss(fill) : "#000"} 30 | stroke={selectionColor || "transparent"} 31 | /> 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /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 | return ( 18 | onPointerDown(e, id)} 21 | style={{ transform: `translate(${layer.x}px, ${layer.y}px)` }} 22 | cx={layer.width / 2} 23 | cy={layer.height / 2} 24 | rx={layer.width / 2} 25 | ry={layer.height / 2} 26 | fill={layer.fill ? colorToCss(layer.fill) : "#000"} 27 | stroke={selectionColor || "transparent"} 28 | strokeWidth="1" 29 | /> 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /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.9.1. 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 | -------------------------------------------------------------------------------- /components/room.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { RoomProvider } from "@/liveblocks.config"; 4 | import { Layer } from "@/types/canvas"; 5 | import { LiveList, LiveMap, LiveObject } from "@liveblocks/client"; 6 | import { ClientSideSuspense } from "@liveblocks/react"; 7 | 8 | interface RoomProps { 9 | children: React.ReactNode; 10 | roomId: string; 11 | fallback: NonNullable | null; 12 | } 13 | 14 | export const Room = ({ children, roomId, fallback }: RoomProps) => { 15 | return ( 16 | >(), 26 | layerIds: new LiveList(), 27 | }} 28 | > 29 | 30 | {() => children} 31 | 32 | 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /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/(dashboard)/_components/sidebar/new-button.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Plus } from "lucide-react"; 4 | import { CreateOrganization } from "@clerk/nextjs"; 5 | import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; 6 | import { Hint } from "@/components/hint"; 7 | 8 | export const NewButton = () => { 9 | return ( 10 | 11 | 12 |
13 | 19 | 22 | 23 |
24 |
25 | 26 | 27 | 28 |
29 | ); 30 | }; 31 | -------------------------------------------------------------------------------- /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 const 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 | }; 42 | -------------------------------------------------------------------------------- /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 Miro Clone

11 |

12 | Create an organization to get started 13 |

14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 |
25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /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 | import { Suspense } from "react"; 8 | import { Loading } from "@/components/auth/loading"; 9 | 10 | const inter = Inter({ subsets: ["latin"] }); 11 | 12 | export const metadata: Metadata = { 13 | title: "Miro Clone", 14 | description: "Miro Clone web app created with Nextjs.", 15 | icons: { 16 | icon: [{ url: "/logo.svg", href: "/logo.svg" }], 17 | }, 18 | }; 19 | 20 | export default function RootLayout({ 21 | children, 22 | }: Readonly<{ 23 | children: React.ReactNode; 24 | }>) { 25 | return ( 26 | 27 | 28 | }> 29 | 30 | 31 | 32 | {children} 33 | 34 | 35 | 36 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/sidebar/item.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | import { useOrganization, useOrganizationList } from "@clerk/nextjs"; 5 | import { cn } from "@/lib/utils"; 6 | import { Hint } from "@/components/hint"; 7 | 8 | interface ItemProps { 9 | id: string; 10 | name: string; 11 | imageUrl: string; 12 | } 13 | 14 | export const Item = ({ id, name, imageUrl }: ItemProps) => { 15 | const { organization } = useOrganization(); 16 | const { setActive } = useOrganizationList(); 17 | 18 | const isActive = organization?.id === id; 19 | 20 | const onClick = () => { 21 | if (!setActive) return; 22 | setActive({ organization: id }); 23 | }; 24 | 25 | return ( 26 |
27 | 28 | {name} 38 | 39 |
40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /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/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 | import { ConvexHttpClient } from "convex/browser"; 5 | 6 | const convex = new ConvexHttpClient(process.env.NEXT_PUBLIC_CONVEX_URL!); 7 | 8 | const liveblocks = new Liveblocks({ 9 | secret: process.env.LIVEBLOCKS_SECRET_KEY!, 10 | }); 11 | 12 | export async function POST(request: Request) { 13 | const authorization = auth(); 14 | const user = await currentUser(); 15 | 16 | if (!authorization || !user) { 17 | return new Response("Unauthorized", { status: 403 }); 18 | } 19 | 20 | const { room } = await request.json(); 21 | const board = await convex.query(api.board.get, { id: room }); 22 | 23 | if (board?.orgId !== authorization.orgId) { 24 | return new Response("Unauthorized", { status: 403 }); 25 | } 26 | 27 | const userInfo = { 28 | name: user.firstName || "Teammate", 29 | picture: user.imageUrl, 30 | }; 31 | 32 | const session = liveblocks.prepareSession(user.id, { userInfo }); 33 | 34 | if (room) { 35 | session.allow(room, session.FULL_ACCESS); 36 | } 37 | 38 | const { status, body } = await session.authorize(); 39 | 40 | return new Response(body, { status }); 41 | } 42 | -------------------------------------------------------------------------------- /hooks/use-selection-bounds.ts: -------------------------------------------------------------------------------- 1 | import { shallow } from "@liveblocks/react"; 2 | import { Layer, XYWH } from "@/types/canvas"; 3 | import { useSelf, useStorage } from "@/liveblocks.config"; 4 | 5 | const boundingBox = (layers: Layer[]): XYWH | null => { 6 | const first = layers[0]; 7 | 8 | if (!first) { 9 | return null; 10 | } 11 | 12 | let left = first.x; 13 | let right = first.x + first.width; 14 | let top = first.y; 15 | let bottom = first.y + first.height; 16 | 17 | for (let i = 1; i < layers.length; i++) { 18 | const { x, y, width, height } = layers[i]; 19 | 20 | if (left > x) { 21 | left = x; 22 | } 23 | 24 | if (right < x + width) { 25 | right = x + width; 26 | } 27 | 28 | if (top > y) { 29 | top = y; 30 | } 31 | 32 | if (bottom < y + height) { 33 | bottom = y + height; 34 | } 35 | } 36 | return { x: left, y: top, width: right - left, height: bottom - top }; 37 | }; 38 | 39 | export const useSelectionBounds = () => { 40 | const selection = useSelf((me) => me.presence.selection); 41 | 42 | return useStorage((root) => { 43 | const selectedLayers = selection 44 | .map((layerId) => root.layers.get(layerId)!) 45 | .filter(Boolean); 46 | 47 | return boundingBox(selectedLayers); 48 | }, shallow); 49 | }; 50 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/search-input.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Input } from "@/components/ui/input"; 4 | import { Search } from "lucide-react"; 5 | import { useRouter } from "next/navigation"; 6 | import qs from "query-string"; 7 | import { ChangeEvent, useEffect, useState } from "react"; 8 | import { useDebounceValue } from "usehooks-ts"; 9 | 10 | export const SearchInput = () => { 11 | const router = useRouter(); 12 | const [value, setValue] = useState(""); 13 | const debouncedValue = useDebounceValue(value, 500); 14 | 15 | const handleChange = (e: ChangeEvent) => { 16 | setValue(e.target.value); 17 | }; 18 | 19 | useEffect(() => { 20 | const url = qs.stringifyUrl( 21 | { 22 | url: "/", 23 | query: { 24 | search: debouncedValue[0], 25 | }, 26 | }, 27 | { skipEmptyString: true, skipNull: true } 28 | ); 29 | 30 | router.push(url); 31 | }, [debouncedValue, router]); 32 | 33 | return ( 34 |
35 | 36 | 42 |
43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /components/confirm-modal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | AlertDialog, 5 | AlertDialogAction, 6 | AlertDialogCancel, 7 | AlertDialogContent, 8 | AlertDialogDescription, 9 | AlertDialogFooter, 10 | AlertDialogHeader, 11 | AlertDialogTitle, 12 | AlertDialogTrigger, 13 | } from "@/components/ui/alert-dialog"; 14 | 15 | interface ConfirmModalProps { 16 | children: React.ReactNode; 17 | onConfirm: () => void; 18 | disabled?: boolean; 19 | header: string; 20 | description?: string; 21 | } 22 | 23 | export const ConfirmModal = ({ 24 | children, 25 | onConfirm, 26 | disabled, 27 | header, 28 | description, 29 | }: ConfirmModalProps) => { 30 | const handleConfirm = () => { 31 | onConfirm(); 32 | }; 33 | 34 | return ( 35 | 36 | {children} 37 | 38 | 39 | {header} 40 | {description} 41 | 42 | 43 | Cancel 44 | 45 | Confirm 46 | 47 | 48 | 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 | -------------------------------------------------------------------------------- /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 | title, 15 | authorLabel, 16 | createdAtLabel, 17 | isFavorite, 18 | onClick, 19 | disabled, 20 | }: FooterProps) => { 21 | const handleClick = (e: React.MouseEvent) => { 22 | e.stopPropagation(); 23 | e.preventDefault(); 24 | onClick(); 25 | }; 26 | 27 | return ( 28 |
29 |

{title}

30 |

31 | {authorLabel}, {createdAtLabel} 32 |

33 | 45 |
46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /app/board/[boardId]/_components/cursor.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { memo } from "react"; 4 | import { MousePointer2 } from "lucide-react"; 5 | 6 | import { useOther } from "@/liveblocks.config"; 7 | import { connectionIdToColor } from "@/lib/utils"; 8 | 9 | interface CursorProps { 10 | connectionId: number; 11 | } 12 | 13 | export const Cursor = memo(({ connectionId }: CursorProps) => { 14 | const info = useOther(connectionId, (user) => user?.info); 15 | const cursor = useOther(connectionId, (user) => user.presence.cursor); 16 | 17 | const name = info?.name || "Teammate"; 18 | 19 | if (!cursor) { 20 | return null; 21 | } 22 | 23 | const { x, y } = cursor; 24 | 25 | return ( 26 | 34 | 41 |
45 | {name} 46 |
47 |
48 | ); 49 | }); 50 | 51 | Cursor.displayName = "Cursor"; 52 | -------------------------------------------------------------------------------- /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 { Plus } from "lucide-react"; 7 | import { useRouter } from "next/navigation"; 8 | import { toast } from "sonner"; 9 | 10 | interface NewBoardButtonProps { 11 | orgId: string; 12 | disabled?: boolean; 13 | } 14 | 15 | export const NewBoardButton = ({ orgId, disabled }: NewBoardButtonProps) => { 16 | const router = useRouter(); 17 | const { mutate, pending } = useApiMutation(api.board.create); 18 | 19 | const onClick = () => { 20 | mutate({ 21 | orgId, 22 | title: "Untitled", 23 | }) 24 | .then((id) => { 25 | toast.success("Board created"); 26 | router.push(`/board/${id}`); 27 | }) 28 | .catch(() => toast.error("Failed to create board")); 29 | }; 30 | 31 | return ( 32 | 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /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 | 7 | import { Cursor } from "./cursor"; 8 | import { shallow } from "@liveblocks/client"; 9 | import { Path } from "./path"; 10 | import { colorToCss } from "@/lib/utils"; 11 | 12 | const Cursors = () => { 13 | const ids = useOthersConnectionIds(); 14 | 15 | return ( 16 | <> 17 | {ids.map((connectionId) => ( 18 | 19 | ))} 20 | 21 | ); 22 | }; 23 | 24 | const Drafts = () => { 25 | const others = useOthersMapped( 26 | (other) => ({ 27 | pencilDraft: other.presence.pencilDraft, 28 | penColor: other.presence.penColor, 29 | }), 30 | shallow 31 | ); 32 | 33 | return ( 34 | <> 35 | {others.map(([key, other]) => { 36 | if (other.pencilDraft) { 37 | return ( 38 | 45 | ); 46 | } 47 | 48 | return null; 49 | })} 50 | 51 | ); 52 | }; 53 | 54 | export const CursorsPresence = memo(() => { 55 | return ( 56 | <> 57 | 58 | 59 | 60 | ); 61 | }); 62 | 63 | CursorsPresence.displayName = "CursorsPresence"; 64 | -------------------------------------------------------------------------------- /app/board/[boardId]/_components/color-picker.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { colorToCss } from "@/lib/utils"; 4 | import { Color } from "@/types/canvas"; 5 | 6 | interface ColorPickerProps { 7 | onChange: (color: Color) => void; 8 | } 9 | 10 | export const ColorPicker = ({ onChange }: ColorPickerProps) => { 11 | return ( 12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | ); 23 | }; 24 | 25 | interface ColorButtonProps { 26 | onClick: (color: Color) => void; 27 | color: Color; 28 | } 29 | 30 | const ColorButton = ({ onClick, color }: ColorButtonProps) => { 31 | return ( 32 | 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /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/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 router = useRouter(); 13 | const { mutate, pending } = useApiMutation(api.board.create); 14 | const { organization } = useOrganization(); 15 | 16 | const onClick = () => { 17 | if (!organization) return; 18 | mutate({ 19 | title: "Untitled", 20 | orgId: organization.id, 21 | }) 22 | .then((id) => { 23 | toast.success("Board created"); 24 | router.push(`/board/${id}`); 25 | }) 26 | .catch(() => toast.error("Failed to create board")); 27 | }; 28 | 29 | return ( 30 |
31 | Empty 32 |

Create your first board!

33 |

34 | Start by creating a board for your organization 35 |

36 |
37 | 45 |
46 |
47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "miro-clone-nextjs", 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/nextjs": "^4.29.7", 13 | "@liveblocks/client": "^1.10.0", 14 | "@liveblocks/node": "^1.10.0", 15 | "@liveblocks/react": "^1.10.0", 16 | "@radix-ui/react-alert-dialog": "^1.0.5", 17 | "@radix-ui/react-avatar": "^1.0.4", 18 | "@radix-ui/react-dialog": "^1.0.5", 19 | "@radix-ui/react-dropdown-menu": "^2.0.6", 20 | "@radix-ui/react-slot": "^1.0.2", 21 | "@radix-ui/react-tooltip": "^1.0.7", 22 | "class-variance-authority": "^0.7.0", 23 | "clsx": "^2.1.0", 24 | "convex": "^1.9.0", 25 | "convex-helpers": "^0.1.22", 26 | "date-fns": "^3.3.1", 27 | "lucide-react": "^0.323.0", 28 | "nanoid": "^5.0.6", 29 | "next": "14.1.0", 30 | "next-themes": "^0.2.1", 31 | "perfect-freehand": "^1.2.2", 32 | "query-string": "^8.2.0", 33 | "react": "^18", 34 | "react-contenteditable": "^3.3.7", 35 | "react-dom": "^18", 36 | "sonner": "^1.4.0", 37 | "tailwind-merge": "^2.2.1", 38 | "tailwindcss-animate": "^1.0.7", 39 | "usehooks-ts": "^2.14.0", 40 | "zustand": "^4.5.0" 41 | }, 42 | "devDependencies": { 43 | "@types/node": "^20", 44 | "@types/react": "^18", 45 | "@types/react-dom": "^18", 46 | "autoprefixer": "^10.0.1", 47 | "eslint": "^8", 48 | "eslint-config-next": "14.1.0", 49 | "postcss": "^8", 50 | "tailwindcss": "^3.3.0", 51 | "typescript": "^5" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /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_USERS = 2; 9 | 10 | export const Participants = () => { 11 | const users = useOthers(); 12 | const currentUser = useSelf(); 13 | const hasMoreUsers = users.length > MAX_SHOWN_USERS; 14 | 15 | return ( 16 |
17 |
18 | {users.slice(0, MAX_SHOWN_USERS).map(({ connectionId, info }) => { 19 | return ( 20 | 27 | ); 28 | })} 29 | 30 | {currentUser && ( 31 | 37 | )} 38 | 39 | {hasMoreUsers && ( 40 | 44 | )} 45 |
46 |
47 | ); 48 | }; 49 | 50 | export const ParticipantsSkeleton = () => { 51 | return ( 52 |
53 | 54 |
55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /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 | body { 80 | @apply bg-background text-foreground; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /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.9.1. 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 | -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /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 | 14 | if (!identity) { 15 | throw new Error("Unauthorized"); 16 | } 17 | 18 | if (args.favorites) { 19 | const favoritedBoards = await ctx.db 20 | .query("userFavorites") 21 | .withIndex("by_user_org", (q) => 22 | q.eq("userId", identity.subject).eq("orgId", args.orgId) 23 | ) 24 | .order("desc") 25 | .collect(); 26 | 27 | const ids = favoritedBoards.map((b) => b.boardId); 28 | 29 | const boards = await getAllOrThrow(ctx.db, ids); 30 | 31 | return boards.map((board) => ({ 32 | ...board, 33 | isFavorite: true, 34 | })); 35 | } 36 | 37 | const title = args.search as string; 38 | let boards = []; 39 | 40 | if (title) { 41 | boards = await ctx.db 42 | .query("boards") 43 | .withSearchIndex("search_title", (q) => q.search("title", title)) 44 | .collect(); 45 | } else { 46 | boards = await ctx.db 47 | .query("boards") 48 | .withIndex("by_org", (q) => q.eq("orgId", args.orgId)) 49 | .order("desc") 50 | .collect(); 51 | } 52 | 53 | const boardsWithFavoriteRelation = boards.map((board) => { 54 | return ctx.db 55 | .query("userFavorites") 56 | .withIndex("by_user_board", (q) => 57 | q.eq("userId", identity.subject).eq("boardId", board._id) 58 | ) 59 | .unique() 60 | .then((favorite) => { 61 | return { ...board, isFavorite: !!favorite }; 62 | }); 63 | }); 64 | 65 | const boardsWithFavoriteBoolean = Promise.all(boardsWithFavoriteRelation); 66 | 67 | return boardsWithFavoriteBoolean; 68 | }, 69 | }); 70 | -------------------------------------------------------------------------------- /app/board/[boardId]/_components/text.tsx: -------------------------------------------------------------------------------- 1 | import { cn, colorToCss } from "@/lib/utils"; 2 | import { TextLayer } from "@/types/canvas"; 3 | import { useMutation } from "@/liveblocks.config"; 4 | import { Kalam } from "next/font/google"; 5 | import ContentEditable, { ContentEditableEvent } from "react-contenteditable"; 6 | 7 | const font = Kalam({ subsets: ["latin"], weight: "400" }); 8 | 9 | const calculateFontSize = (width: number, height: number) => { 10 | const maxFontSize = 96; 11 | const scaleFactor = 0.5; 12 | const fontSizeBasedOnHeight = height * scaleFactor; 13 | const fontSizeBasedOnWidth = width * scaleFactor; 14 | 15 | return Math.min(fontSizeBasedOnHeight, fontSizeBasedOnWidth, maxFontSize); 16 | }; 17 | 18 | interface TextProps { 19 | id: string; 20 | layer: TextLayer; 21 | onPointerDown: (e: React.PointerEvent, id: string) => void; 22 | selectionColor?: string; 23 | } 24 | 25 | export const Text = ({ 26 | layer, 27 | onPointerDown, 28 | id, 29 | selectionColor, 30 | }: TextProps) => { 31 | const { x, y, width, height, fill, value } = layer; 32 | 33 | const updateValue = useMutation(({ storage }, newValue: string) => { 34 | const liveLayers = storage.get("layers"); 35 | 36 | liveLayers.get(id)?.set("value", newValue); 37 | }, []); 38 | 39 | const handleContentChange = (e: ContentEditableEvent) => { 40 | updateValue(e.target.value); 41 | }; 42 | 43 | return ( 44 | onPointerDown(e, id)} 50 | style={{ 51 | outline: selectionColor ? `1px solid ${selectionColor}` : "none", 52 | }} 53 | > 54 | 66 | 67 | ); 68 | }; 69 | -------------------------------------------------------------------------------- /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 | }, 24 | size: { 25 | default: "h-10 px-4 py-2", 26 | sm: "h-9 rounded-md px-3", 27 | lg: "h-11 rounded-md px-8", 28 | icon: "h-10 w-10", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "default", 33 | size: "default", 34 | }, 35 | } 36 | ); 37 | 38 | export interface ButtonProps 39 | extends React.ButtonHTMLAttributes, 40 | VariantProps { 41 | asChild?: boolean; 42 | } 43 | 44 | const Button = React.forwardRef( 45 | ({ className, variant, size, asChild = false, ...props }, ref) => { 46 | const Comp = asChild ? Slot : "button"; 47 | return ( 48 | 53 | ); 54 | } 55 | ); 56 | Button.displayName = "Button"; 57 | 58 | export { Button, buttonVariants }; 59 | -------------------------------------------------------------------------------- /app/board/[boardId]/_components/note.tsx: -------------------------------------------------------------------------------- 1 | import { cn, colorToCss, getContrastingTextColor } from "@/lib/utils"; 2 | import { NoteLayer } from "@/types/canvas"; 3 | import { useMutation } from "@/liveblocks.config"; 4 | import { Kalam } from "next/font/google"; 5 | import ContentEditable, { ContentEditableEvent } from "react-contenteditable"; 6 | 7 | const font = Kalam({ subsets: ["latin"], weight: "400" }); 8 | 9 | const calculateFontSize = (width: number, height: number) => { 10 | const maxFontSize = 96; 11 | const scaleFactor = 0.15; 12 | const fontSizeBasedOnHeight = height * scaleFactor; 13 | const fontSizeBasedOnWidth = width * scaleFactor; 14 | 15 | return Math.min(fontSizeBasedOnHeight, fontSizeBasedOnWidth, maxFontSize); 16 | }; 17 | 18 | interface NoteProps { 19 | id: string; 20 | layer: NoteLayer; 21 | onPointerDown: (e: React.PointerEvent, id: string) => void; 22 | selectionColor?: string; 23 | } 24 | 25 | export const Note = ({ 26 | layer, 27 | onPointerDown, 28 | id, 29 | selectionColor, 30 | }: NoteProps) => { 31 | const { x, y, width, height, fill, value } = layer; 32 | 33 | const updateValue = useMutation(({ storage }, newValue: string) => { 34 | const liveLayers = storage.get("layers"); 35 | 36 | liveLayers.get(id)?.set("value", newValue); 37 | }, []); 38 | 39 | const handleContentChange = (e: ContentEditableEvent) => { 40 | updateValue(e.target.value); 41 | }; 42 | 43 | return ( 44 | onPointerDown(e, id)} 50 | style={{ 51 | outline: selectionColor ? `1px solid ${selectionColor}` : "none", 52 | backgroundColor: fill ? colorToCss(fill) : "#000", 53 | }} 54 | className="shadow-md drop-shadow-xl" 55 | > 56 | 68 | 69 | ); 70 | }; 71 | -------------------------------------------------------------------------------- /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 | 23 | if (!layer) { 24 | return null; 25 | } 26 | 27 | switch (layer.type) { 28 | case LayerType.Path: 29 | return ( 30 | onLayerPointerDown(e, id)} 37 | stroke={selectionColor} 38 | /> 39 | ); 40 | case LayerType.Note: 41 | return ( 42 | 48 | ); 49 | case LayerType.Text: 50 | return ( 51 | 57 | ); 58 | case LayerType.Ellipse: 59 | return ( 60 | 66 | ); 67 | case LayerType.Rectangle: 68 | return ( 69 | 75 | ); 76 | default: 77 | console.warn("Unknown layer type"); 78 | return null; 79 | } 80 | } 81 | ); 82 | 83 | LayerPreview.displayName = "LayerPreview"; 84 | -------------------------------------------------------------------------------- /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 | 11 | interface BoardListProps { 12 | orgId: string; 13 | query: { 14 | search?: string; 15 | favorites?: string; 16 | }; 17 | } 18 | 19 | export const BoardList = ({ orgId, query }: BoardListProps) => { 20 | const data = useQuery(api.boards.get, { orgId, ...query }); 21 | 22 | // data will never be undefined even if there is an error or is empty 23 | // if it is empty, convex will return null 24 | if (data === undefined) { 25 | return ( 26 |
27 |

28 | {query.favorites ? "Favorite boards" : "Team boards"} 29 |

30 |
31 | 32 | 33 | 34 | 35 | 36 | 37 |
38 |
39 | ); 40 | } 41 | 42 | if (!data?.length && query.search) { 43 | return ; 44 | } 45 | 46 | if (!data?.length && query.favorites) { 47 | return ; 48 | } 49 | 50 | if (!data?.length) { 51 | return ; 52 | } 53 | 54 | return ( 55 |
56 |

57 | {query.favorites ? "Favorite boards" : "Team boards"} 58 |

59 |
60 | 61 | {data.map((board) => ( 62 | 73 | ))} 74 |
75 |
76 | ); 77 | }; 78 | -------------------------------------------------------------------------------- /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 | }, 71 | animation: { 72 | "accordion-down": "accordion-down 0.2s ease-out", 73 | "accordion-up": "accordion-up 0.2s ease-out", 74 | }, 75 | }, 76 | }, 77 | plugins: [require("tailwindcss-animate")], 78 | } satisfies Config 79 | 80 | export default config -------------------------------------------------------------------------------- /public/placeholders/6.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /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 | | { mode: CanvasMode.None } 92 | | { mode: CanvasMode.Pressing; origin: Point } 93 | | { mode: CanvasMode.SelectionNet; origin: Point; current?: Point } 94 | | { mode: CanvasMode.Translating; current: Point } 95 | | { 96 | mode: CanvasMode.Inserting; 97 | layerType: 98 | | LayerType.Ellipse 99 | | LayerType.Rectangle 100 | | LayerType.Text 101 | | LayerType.Note; 102 | } 103 | | { mode: CanvasMode.Resizing; initialBounds: XYWH; corner: Side } 104 | | { mode: CanvasMode.Pencil }; 105 | 106 | export enum CanvasMode { 107 | None, 108 | Pressing, 109 | SelectionNet, 110 | Translating, 111 | Inserting, 112 | Resizing, 113 | Pencil, 114 | } 115 | 116 | export type Layer = 117 | | RectangleLayer 118 | | EllipseLayer 119 | | PathLayer 120 | | TextLayer 121 | | NoteLayer; 122 | -------------------------------------------------------------------------------- /components/modals/rename-modal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { 5 | Dialog, 6 | DialogClose, 7 | DialogContent, 8 | DialogDescription, 9 | DialogFooter, 10 | DialogHeader, 11 | DialogTitle, 12 | } from "@/components/ui/dialog"; 13 | import { Input } from "@/components/ui/input"; 14 | import { api } from "@/convex/_generated/api"; 15 | import { useApiMutation } from "@/hooks/use-api-mutation"; 16 | import { useRenameModal } from "@/store/use-rename-modal"; 17 | import { FormEventHandler, useEffect, useState } from "react"; 18 | import { toast } from "sonner"; 19 | 20 | /** 21 | * @name RenameModal 22 | * @description RenameModal component allows users to rename the title of a board 23 | * @returns JSX Element 24 | */ 25 | export const RenameModal = () => { 26 | const { mutate, pending } = useApiMutation(api.board.update); // a custom hook for updating the convex database 27 | const { isOpen, onClose, initialValues } = useRenameModal(); 28 | const [title, setTitle] = useState(initialValues.title); 29 | 30 | useEffect(() => { 31 | setTitle(initialValues.title); 32 | }, [initialValues.title]); 33 | 34 | const onSubmit: FormEventHandler = (e) => { 35 | e.preventDefault(); 36 | 37 | // update the database 38 | mutate({ 39 | id: initialValues.id, 40 | title, 41 | }) 42 | .then(() => { 43 | toast.success("Board renamed"); 44 | onClose(); 45 | }) 46 | .catch(() => { 47 | toast.error("Failed to rename board"); 48 | }); 49 | }; 50 | 51 | return ( 52 | 53 | 54 | 55 | Edit board title 56 | 57 | Enter a new title for this board 58 |
59 | setTitle(e.target.value)} 65 | placeholder="Board title" 66 | /> 67 | 68 | 69 | 72 | 73 | 76 | 77 |
78 |
79 |
80 | ); 81 | }; 82 | -------------------------------------------------------------------------------- /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 favorites = searchParams.get("favorites"); 20 | 21 | return ( 22 |
23 | 24 |
25 | Logo 26 | 27 | Miro Clone 28 | 29 |
30 | 31 | 52 |
53 | 64 | 80 |
81 |
82 | ); 83 | }; 84 | -------------------------------------------------------------------------------- /convex/README.md: -------------------------------------------------------------------------------- 1 | # Welcome to your Convex functions directory! 2 | 3 | Write your Convex functions here. See 4 | https://docs.convex.dev/using/writing-convex-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/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 { 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 | const font = Poppins({ 18 | subsets: ["latin"], 19 | weight: ["600"], 20 | }); 21 | 22 | const TabSeparator = () => { 23 | return
|
; 24 | }; 25 | 26 | interface InfoProps { 27 | boardId: string; 28 | } 29 | 30 | export const Info = ({ boardId }: InfoProps) => { 31 | const { onOpen } = useRenameModal(); 32 | 33 | const data = useQuery(api.board.get, { 34 | id: boardId as Id<"boards">, 35 | }); 36 | 37 | if (!data) return ; 38 | 39 | return ( 40 |
41 | 42 | 60 | 61 | 62 | 63 | 70 | 71 | 72 | 73 |
74 | 75 | 78 | 79 |
80 |
81 |
82 | ); 83 | }; 84 | 85 | export const InfoSkeleton = () => { 86 | return ( 87 |
88 | 89 |
90 | ); 91 | }; 92 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/board-card/index.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { formatDistanceToNow } from "date-fns"; 4 | import Image from "next/image"; 5 | import Link from "next/link"; 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 | authorId, 31 | authorName, 32 | createdAt, 33 | imageUrl, 34 | orgId, 35 | isFavorite, 36 | }: BoardCardProps) => { 37 | const { userId } = useAuth(); 38 | 39 | const authorLabel = userId === authorId ? "You" : authorName; 40 | const createdAtLabel = formatDistanceToNow(createdAt, { addSuffix: true }); 41 | 42 | const { mutate: onFavorite, pending: pendingFavorite } = useApiMutation( 43 | api.board.favorite 44 | ); 45 | const { mutate: onUnfavorite, pending: pendingUnfavorite } = useApiMutation( 46 | api.board.unfavorite 47 | ); 48 | 49 | const toggleFavorite = () => { 50 | if (isFavorite) { 51 | onUnfavorite({ id }).catch(() => toast.error("Failed to unfavorite")); 52 | } else { 53 | onFavorite({ id, orgId }).catch(() => toast.error("Failed to favorite")); 54 | } 55 | }; 56 | 57 | return ( 58 | 59 |
60 |
61 | {title} 62 | 63 | 64 | 67 | 68 |
69 |
77 |
78 | 79 | ); 80 | }; 81 | 82 | BoardCard.Skeleton = function BoardCardSkeleton() { 83 | return ( 84 |
85 | 86 |
87 | ); 88 | }; 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # miro-clone-nextjs 2 | 3 | ![GitHub repo size](https://img.shields.io/github/repo-size/evanch98/miro-clone-nextjs) 4 | ![GitHub stars](https://img.shields.io/github/stars/evanch98/miro-clone-nextjs?style=social) 5 | ![GitHub forks](https://img.shields.io/github/forks/evanch98/miro-clone-nextjs?style=social) 6 | 7 |
8 | February, 2024.
9 | Full-stack Miro Clone project built by using Next JS 14, React, TypeScript, Shadcn UI, Tailwind CSS, Convex Database, Zustand, liveblocks, and Clerk Auth. 10 | 11 | ## Features 12 | 13 | - Create organizations and invite team members seamlessly. 14 | - Collaborate with team members seamlessly in Miro-like whiteboard interface. 15 | - Real-time collaboration using the real-time Convex database. 16 | - User authentication, organization creation, and management with Clerk Auth. 17 | - Responsive design with TailwindCSS. 18 | - State management using Zustand. 19 | 20 | ## Getting Started 21 | 22 | These instructions will help you set up and run the project on your local machine for development and testing purposes. 23 | 24 | 1. **Clone the repository:** 25 | 26 | ```bash 27 | git clone https://github.com/evanch98/notion-clone-nextjs.git 28 | cd your-repo-name 29 | ``` 30 | 31 | 2. **Install the required dependencies:** 32 | 33 | ```bash 34 | npm install 35 | ``` 36 | 37 | 3. **Configure environmental variables:** 38 | Create a `.env.local` file in the project root and set the necessary environment variables. 39 | 40 | ``` 41 | CONVEX_DEPLOYMENT= 42 | NEXT_PUBLIC_CONVEX_URL= 43 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= 44 | CLERK_SECRET_KEY= 45 | LIVEBLOCKS_SECRET_KEY= 46 | ``` 47 | 48 | 4. **Run the development server:** 49 | 50 | ```bash 51 | npm run dev 52 | ``` 53 | 54 | 5. **Start building and customizing your Miro Clone!** 55 | 56 | ## Technologies Used 57 | 58 | - [Next.js](https://nextjs.org/) 59 | - [React](https://react.dev/) 60 | - [TailwindCSS](https://tailwindcss.com/) 61 | - [TypeScript](https://www.typescriptlang.org/) 62 | - [Convex](https://www.convex.dev/) 63 | - [Clerk Auth](https://clerk.com/) 64 | - [Zustand](https://docs.pmnd.rs/zustand/getting-started/introduction) 65 | - [Shadcn UI](https://ui.shadcn.com/) 66 | - [Liveblocks](https://liveblocks.io/) 67 | 68 | ## Acknowledgements 69 | 70 | - [class-variance-authority](https://www.npmjs.com/package/class-variance-authority) 71 | - [clsx](https://www.npmjs.com/package/clsx) 72 | - [cmdk](https://www.npmjs.com/package/cmdk) 73 | - [date-fns](https://www.npmjs.com/package/date-fns) 74 | - [lucide-react](https://www.npmjs.com/package/lucide-react) 75 | - [nanoid](https://www.npmjs.com/package/nanoid) 76 | - [perfect-freehand](https://www.npmjs.com/package/perfect-freehand) 77 | - [query-string](https://www.npmjs.com/package/query-string) 78 | - [react-contenteditable](https://www.npmjs.com/package/react-contenteditable) 79 | - [sonner](https://www.npmjs.com/package/sonner) 80 | - [tailwind-merge](https://www.npmjs.com/package/tailwind-merge) 81 | - [usehooks-ts](https://www.npmjs.com/package/usehooks-ts) 82 | -------------------------------------------------------------------------------- /components/actions.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ConfirmModal } from "@/components/confirm-modal"; 4 | import { Button } from "@/components/ui/button"; 5 | import { 6 | DropdownMenu, 7 | DropdownMenuContent, 8 | DropdownMenuItem, 9 | DropdownMenuSeparator, 10 | DropdownMenuTrigger, 11 | } from "@/components/ui/dropdown-menu"; 12 | import { api } from "@/convex/_generated/api"; 13 | import { useApiMutation } from "@/hooks/use-api-mutation"; 14 | import { useRenameModal } from "@/store/use-rename-modal"; 15 | import { DropdownMenuContentProps } from "@radix-ui/react-dropdown-menu"; 16 | import { Link2, Pencil, Trash2 } from "lucide-react"; 17 | import { toast } from "sonner"; 18 | 19 | interface ActionsProps { 20 | children: React.ReactNode; 21 | side?: DropdownMenuContentProps["side"]; 22 | sideOffset?: DropdownMenuContentProps["sideOffset"]; 23 | id: string; 24 | title: string; 25 | } 26 | 27 | /** 28 | * @name Actions 29 | * @description Actions component is built on top of shadcn DropdownMenu component to allow users to do various actions (copy link url, rename the board, and delete the board) on the board they created 30 | * @param param0 children accepts React elements (JSX Elements) to let user open the dropdown menu (required) 31 | * @param param1 side accepts an argument of type DropdownMenuContentProps["side"] (optional) 32 | * @param param2 sideOffset accepts an argument of type DropdownMenuContentProps["sideOffset"] (optional) 33 | * @param param3 id accepts an argument of type string (id of the board) (required) 34 | * @param param4 title accepts an argument of type title (title of the board) (required) 35 | * @returns JSX Element 36 | */ 37 | export const Actions = ({ 38 | children, 39 | side, 40 | sideOffset, 41 | id, 42 | title, 43 | }: ActionsProps) => { 44 | const { onOpen } = useRenameModal(); 45 | const { mutate, pending } = useApiMutation(api.board.remove); 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 | 54 | const onDelete = () => { 55 | mutate({ id }) 56 | .then(() => toast.success("Board deleted")) 57 | .catch(() => toast.error("Failed to delete board")); 58 | }; 59 | 60 | return ( 61 | 62 | {children} 63 | e.stopPropagation()} 65 | side={side} 66 | sideOffset={sideOffset} 67 | className="w-60" 68 | > 69 | 70 | 71 | Copy board link 72 | 73 | onOpen(id, title)} 75 | className="p-3 cursor-pointer" 76 | > 77 | 78 | Rename 79 | 80 | 86 | 93 | 94 | 95 | 96 | ); 97 | }; 98 | -------------------------------------------------------------------------------- /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.9.1. 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 | -------------------------------------------------------------------------------- /liveblocks.config.ts: -------------------------------------------------------------------------------- 1 | import { Color, Layer } from "@/types/canvas"; 2 | import { 3 | createClient, 4 | LiveList, 5 | LiveMap, 6 | LiveObject, 7 | } from "@liveblocks/client"; 8 | import { createRoomContext, createLiveblocksContext } from "@liveblocks/react"; 9 | 10 | const client = createClient({ 11 | throttle: 16, 12 | authEndpoint: "/api/liveblocks-auth", 13 | }); 14 | 15 | // Presence represents the properties that exist on every user in the Room 16 | // and that will automatically be kept in sync. Accessible through the 17 | // `user.presence` property. Must be JSON-serializable. 18 | type Presence = { 19 | cursor: { x: number; y: number } | null; 20 | selection: string[]; 21 | pencilDraft: [x: number, y: number, pressure: number][] | null; 22 | penColor: Color | null; 23 | // ... 24 | }; 25 | 26 | // Optionally, Storage represents the shared document that persists in the 27 | // Room, even after all users leave. Fields under Storage typically are 28 | // LiveList, LiveMap, LiveObject instances, for which updates are 29 | // automatically persisted and synced to all connected clients. 30 | type Storage = { 31 | layers: LiveMap>; 32 | layerIds: LiveList; 33 | }; 34 | 35 | // Optionally, UserMeta represents static/readonly metadata on each user, as 36 | // provided by your own custom auth back end (if used). Useful for data that 37 | // will not change during a session, like a user's name or avatar. 38 | type UserMeta = { 39 | id?: string; 40 | info?: { 41 | name?: string; 42 | picture?: string; 43 | }; 44 | }; 45 | 46 | // Optionally, the type of custom events broadcast and listened to in this 47 | // room. Use a union for multiple events. Must be JSON-serializable. 48 | type RoomEvent = { 49 | // type: "NOTIFICATION", 50 | // ... 51 | }; 52 | 53 | // Optionally, when using Comments, ThreadMetadata represents metadata on 54 | // each thread. Can only contain booleans, strings, and numbers. 55 | export type ThreadMetadata = { 56 | // resolved: boolean; 57 | // quote: string; 58 | // time: number; 59 | }; 60 | 61 | // Room-level hooks, use inside `RoomProvider` 62 | export const { 63 | suspense: { 64 | RoomProvider, 65 | useRoom, 66 | useMyPresence, 67 | useUpdateMyPresence, 68 | useSelf, 69 | useOthers, 70 | useOthersMapped, 71 | useOthersListener, 72 | useOthersConnectionIds, 73 | useOther, 74 | useBroadcastEvent, 75 | useEventListener, 76 | useErrorListener, 77 | useStorage, 78 | useObject, 79 | useMap, 80 | useList, 81 | useBatch, 82 | useHistory, 83 | useUndo, 84 | useRedo, 85 | useCanUndo, 86 | useCanRedo, 87 | useMutation, 88 | useStatus, 89 | useLostConnectionListener, 90 | useThreads, 91 | useCreateThread, 92 | useEditThreadMetadata, 93 | useCreateComment, 94 | useEditComment, 95 | useDeleteComment, 96 | useAddReaction, 97 | useRemoveReaction, 98 | useThreadSubscription, 99 | useMarkThreadAsRead, 100 | useRoomNotificationSettings, 101 | useUpdateRoomNotificationSettings, 102 | 103 | // These hooks can be exported from either context 104 | // useUser, 105 | // useRoomInfo 106 | }, 107 | } = createRoomContext( 108 | client 109 | ); 110 | 111 | // Project-level hooks, use inside `LiveblocksProvider` 112 | export const { 113 | suspense: { 114 | LiveblocksProvider, 115 | useMarkInboxNotificationAsRead, 116 | useMarkAllInboxNotificationsAsRead, 117 | useInboxNotifications, 118 | useUnreadInboxNotificationsCount, 119 | 120 | // These hooks can be exported from either context 121 | useUser, 122 | useRoomInfo, 123 | }, 124 | } = createLiveblocksContext(client); 125 | -------------------------------------------------------------------------------- /public/placeholders/7.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /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, SendToBack, Trash2 } from "lucide-react"; 12 | 13 | interface SelectionToolsProps { 14 | camera: Camera; 15 | setLastUsedColor: (color: Color) => void; 16 | } 17 | 18 | export const SelectionTools = memo( 19 | ({ camera, setLastUsedColor }: SelectionToolsProps) => { 20 | const selection = useSelf((me) => me.presence.selection); 21 | 22 | const moveToBack = useMutation( 23 | ({ storage }) => { 24 | const liveLayerIds = storage.get("layerIds"); 25 | const indices: number[] = []; 26 | 27 | const arr = liveLayerIds.toImmutable(); 28 | 29 | for (let i = 0; i < arr.length; i++) { 30 | if (selection.includes(arr[i])) { 31 | indices.push(i); 32 | } 33 | } 34 | 35 | for (let i = 0; i < indices.length; i++) { 36 | liveLayerIds.move(indices[i], i); 37 | } 38 | }, 39 | [selection] 40 | ); 41 | 42 | const moveToFront = useMutation( 43 | ({ storage }) => { 44 | const liveLayerIds = storage.get("layerIds"); 45 | const indices: number[] = []; 46 | 47 | const arr = liveLayerIds.toImmutable(); 48 | 49 | for (let i = 0; i < arr.length; i++) { 50 | if (selection.includes(arr[i])) { 51 | indices.push(i); 52 | } 53 | } 54 | 55 | for (let i = indices.length - 1; i >= 0; i--) { 56 | liveLayerIds.move( 57 | indices[i], 58 | arr.length - 1 - (indices.length - 1 - i) 59 | ); 60 | } 61 | }, 62 | [selection] 63 | ); 64 | 65 | const setFill = useMutation( 66 | ({ storage }, fill: Color) => { 67 | const liveLayers = storage.get("layers"); 68 | setLastUsedColor(fill); 69 | 70 | selection.forEach((id) => { 71 | liveLayers.get(id)?.set("fill", fill); 72 | }); 73 | }, 74 | [selection, setLastUsedColor] 75 | ); 76 | 77 | const deleteLayers = useDeleteLayers(); 78 | 79 | const selectionBounds = useSelectionBounds(); 80 | 81 | if (!selectionBounds) { 82 | return null; 83 | } 84 | 85 | const x = selectionBounds.width / 2 + selectionBounds.x + camera.x; 86 | const y = selectionBounds.y + camera.y; 87 | 88 | return ( 89 |
95 | 96 |
97 | 98 | 101 | 102 | 103 | 106 | 107 |
108 |
109 | 110 | 113 | 114 |
115 |
116 | ); 117 | } 118 | ); 119 | 120 | SelectionTools.displayName = "SelectionTools"; 121 | -------------------------------------------------------------------------------- /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 | Type, 11 | Undo2, 12 | } from "lucide-react"; 13 | import { CanvasMode, CanvasState, LayerType } from "@/types/canvas"; 14 | 15 | interface ToolbarProps { 16 | canvasState: CanvasState; 17 | setCanvasState: (newState: CanvasState) => void; 18 | undo: () => void; 19 | redo: () => void; 20 | canUndo: boolean; 21 | canRedo: boolean; 22 | } 23 | 24 | export const Toolbar = ({ 25 | canvasState, 26 | setCanvasState, 27 | undo, 28 | redo, 29 | canRedo, 30 | canUndo, 31 | }: ToolbarProps) => { 32 | return ( 33 |
34 |
35 | setCanvasState({ mode: CanvasMode.None })} 39 | isActive={ 40 | canvasState.mode === CanvasMode.None || 41 | canvasState.mode === CanvasMode.Translating || 42 | canvasState.mode === CanvasMode.SelectionNet || 43 | canvasState.mode === CanvasMode.Pressing || 44 | canvasState.mode === CanvasMode.Resizing 45 | } 46 | /> 47 | 51 | setCanvasState({ 52 | mode: CanvasMode.Inserting, 53 | layerType: LayerType.Text, 54 | }) 55 | } 56 | isActive={ 57 | canvasState.mode === CanvasMode.Inserting && 58 | canvasState.layerType === LayerType.Text 59 | } 60 | /> 61 | { 65 | setCanvasState({ 66 | mode: CanvasMode.Inserting, 67 | layerType: LayerType.Note, 68 | }); 69 | }} 70 | isActive={ 71 | canvasState.mode === CanvasMode.Inserting && 72 | canvasState.layerType === LayerType.Note 73 | } 74 | /> 75 | { 79 | setCanvasState({ 80 | mode: CanvasMode.Inserting, 81 | layerType: LayerType.Rectangle, 82 | }); 83 | }} 84 | isActive={ 85 | canvasState.mode === CanvasMode.Inserting && 86 | canvasState.layerType === LayerType.Rectangle 87 | } 88 | /> 89 | { 93 | setCanvasState({ 94 | mode: CanvasMode.Inserting, 95 | layerType: LayerType.Ellipse, 96 | }); 97 | }} 98 | isActive={ 99 | canvasState.mode === CanvasMode.Inserting && 100 | canvasState.layerType === LayerType.Ellipse 101 | } 102 | /> 103 | setCanvasState({ mode: CanvasMode.Pencil })} 107 | isActive={canvasState.mode === CanvasMode.Pencil} 108 | /> 109 |
110 |
111 | 117 | 123 |
124 |
125 | ); 126 | }; 127 | 128 | export const ToolbarSkeleton = () => { 129 | return ( 130 |
131 | 132 |
133 | ); 134 | }; 135 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 = ["#DC2626", "#D97706", "#059669", "#7C3AED", "#DB2777"]; 15 | 16 | export function cn(...inputs: ClassValue[]) { 17 | return twMerge(clsx(inputs)); 18 | } 19 | 20 | export function connectionIdToColor(connectionId: number): string { 21 | return COLORS[connectionId % COLORS.length]; 22 | } 23 | 24 | export function pointerEventToCanvasPoint( 25 | e: React.PointerEvent, 26 | camera: Camera 27 | ) { 28 | return { 29 | x: Math.round(e.clientX) - camera.x, 30 | y: Math.round(e.clientY) - camera.y, 31 | }; 32 | } 33 | 34 | export function colorToCss(color: Color) { 35 | return `#${color.r.toString(16).padStart(2, "0")}${color.g 36 | .toString(16) 37 | .padStart(2, "0")}${color.b.toString(16).padStart(2, "0")}`; 38 | } 39 | 40 | export function resizeBounds(bounds: XYWH, corner: Side, point: Point): XYWH { 41 | const result = { 42 | x: bounds.x, 43 | y: bounds.y, 44 | width: bounds.width, 45 | height: bounds.height, 46 | }; 47 | 48 | if ((corner & Side.Left) === Side.Left) { 49 | result.x = Math.min(point.x, bounds.x + bounds.width); 50 | result.width = Math.abs(bounds.x + bounds.width - point.x); 51 | } 52 | 53 | if ((corner & Side.Right) === Side.Right) { 54 | result.x = Math.min(point.x, bounds.x); 55 | result.width = Math.abs(point.x - bounds.x); 56 | } 57 | 58 | if ((corner & Side.Top) === Side.Top) { 59 | result.y = Math.min(point.y, bounds.y + bounds.height); 60 | result.height = Math.abs(bounds.y + bounds.height - point.y); 61 | } 62 | 63 | if ((corner & Side.Bottom) === Side.Bottom) { 64 | result.y = Math.min(point.y, bounds.y); 65 | result.height = Math.abs(point.y - bounds.y); 66 | } 67 | 68 | return result; 69 | } 70 | 71 | export function findIntersectingLayersWithRectangle( 72 | layerIds: readonly string[], 73 | layers: ReadonlyMap, 74 | a: Point, 75 | b: Point 76 | ) { 77 | const rect = { 78 | x: Math.min(a.x, b.x), 79 | y: Math.min(a.y, b.y), 80 | width: Math.abs(a.x - b.x), 81 | height: Math.abs(a.y - b.y), 82 | }; 83 | 84 | const ids = []; 85 | 86 | for (const layerId of layerIds) { 87 | const layer = layers.get(layerId); 88 | 89 | if (layer == null) { 90 | continue; 91 | } 92 | 93 | const { x, y, height, width } = layer; 94 | 95 | if ( 96 | rect.x + rect.width > x && 97 | rect.x < x + width && 98 | rect.y + rect.height > y && 99 | rect.y < y + height 100 | ) { 101 | ids.push(layerId); 102 | } 103 | } 104 | 105 | return ids; 106 | } 107 | 108 | export function getContrastingTextColor(color: Color) { 109 | const luminance = 0.299 * color.r + 0.587 * color.g + 0.114 * color.b; 110 | 111 | return luminance > 182 ? "black" : "white"; 112 | } 113 | 114 | export function penPointsToPathLayer( 115 | points: number[][], 116 | color: Color 117 | ): PathLayer { 118 | if (points.length < 2) { 119 | throw new Error("Cannot transform points with less than 2 points"); 120 | } 121 | 122 | let left = Number.POSITIVE_INFINITY; 123 | let top = Number.POSITIVE_INFINITY; 124 | let right = Number.NEGATIVE_INFINITY; 125 | let bottom = Number.NEGATIVE_INFINITY; 126 | 127 | for (const point of points) { 128 | const [x, y] = point; 129 | 130 | if (left > x) { 131 | left = x; 132 | } 133 | 134 | if (top > y) { 135 | top = y; 136 | } 137 | 138 | if (right < x) { 139 | right = x; 140 | } 141 | 142 | if (bottom < y) { 143 | bottom = y; 144 | } 145 | } 146 | 147 | return { 148 | type: LayerType.Path, 149 | x: left, 150 | y: top, 151 | width: right - left, 152 | height: bottom - top, 153 | fill: color, 154 | points: points.map(([x, y, pressure]) => [x - left, y - top, pressure]), 155 | }; 156 | } 157 | 158 | export function getSvgPathFromStroke(stroke: number[][]) { 159 | if (!stroke.length) return ""; 160 | 161 | const d = stroke.reduce( 162 | (acc, [x0, y0], i, arr) => { 163 | const [x1, y1] = arr[(i + 1) % arr.length]; 164 | acc.push(x0, y0, (x0 + x1) / 2, (y0 + y1) / 2); 165 | return acc; 166 | }, 167 | ["M", ...stroke[0], "Q"] 168 | ); 169 | 170 | d.push("Z"); 171 | return d.join(" "); 172 | }; 173 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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.9.1. 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 | -------------------------------------------------------------------------------- /convex/board.ts: -------------------------------------------------------------------------------- 1 | import { v } from "convex/values"; 2 | import { mutation, query } from "./_generated/server"; 3 | 4 | const images = [ 5 | "/placeholders/1.svg", 6 | "/placeholders/2.svg", 7 | "/placeholders/3.svg", 8 | "/placeholders/4.svg", 9 | "/placeholders/5.svg", 10 | "/placeholders/6.svg", 11 | "/placeholders/7.svg", 12 | "/placeholders/8.svg", 13 | "/placeholders/9.svg", 14 | "/placeholders/10.svg", 15 | ]; 16 | 17 | /** 18 | * Api endpoint to create a board. 19 | * The function requires two arguments, 20 | * orgId of type string and 21 | * title of type string. 22 | * The function 'create' will check if the user has logged in or not. 23 | * If the use has not logged in, it will throw an Error. 24 | * Otherwise, it will create a board. 25 | */ 26 | export const create = mutation({ 27 | args: { 28 | orgId: v.string(), 29 | title: v.string(), 30 | }, 31 | handler: async (ctx, args) => { 32 | const identity = await ctx.auth.getUserIdentity(); 33 | 34 | if (!identity) { 35 | throw new Error("Unauthorized"); 36 | } 37 | 38 | const randomImage = images[Math.floor(Math.random() * images.length)]; 39 | 40 | const board = await ctx.db.insert("boards", { 41 | title: args.title, 42 | orgId: args.orgId, 43 | authorId: identity.subject, 44 | authorName: identity.name!, 45 | imageUrl: randomImage, 46 | }); 47 | 48 | return board; 49 | }, 50 | }); 51 | 52 | /** 53 | * Api endpoint for removing a board. 54 | * The function requires one argument 55 | * board id 56 | * The function 'remove' will check if the user has logged in. 57 | * If the user has not logged in, it will throw an error. 58 | * Otherwise, it will remove the board. 59 | */ 60 | export const remove = mutation({ 61 | args: { id: v.id("boards") }, 62 | handler: async (ctx, args) => { 63 | const identity = await ctx.auth.getUserIdentity(); 64 | 65 | if (!identity) { 66 | throw new Error("Unauthorized"); 67 | } 68 | 69 | // To delete favorite relation 70 | const userId = identity.subject; 71 | 72 | const existingFavorite = await ctx.db 73 | .query("userFavorites") 74 | .withIndex("by_user_board", (q) => 75 | q.eq("userId", userId).eq("boardId", args.id) 76 | ) 77 | .unique(); 78 | 79 | if (existingFavorite) { 80 | await ctx.db.delete(existingFavorite._id); 81 | } 82 | 83 | await ctx.db.delete(args.id); 84 | }, 85 | }); 86 | 87 | /** 88 | * Api endpoint for updating the title of a board. 89 | * The function requires two arguments 90 | * board id and 91 | * title of type string 92 | * The function 'update' will check if the user has logged in. 93 | * If the user has not logged in, it will throw an error. 94 | * The function 'update' will also check if the title is provided. 95 | * If the title is not provided, it will throw an error. 96 | * The function 'update' will also check if the characters of the title is more than 60. 97 | * If the characters are more than 60, it will throw an error. 98 | * Otherwise, it will update the title of the board. 99 | */ 100 | export const update = mutation({ 101 | args: { id: v.id("boards"), title: v.string() }, 102 | handler: async (ctx, args) => { 103 | const identity = await ctx.auth.getUserIdentity(); 104 | 105 | if (!identity) { 106 | throw new Error("Unauthorized"); 107 | } 108 | 109 | const title = args.title.trim(); 110 | 111 | if (!title) { 112 | throw new Error("Title is required"); 113 | } 114 | 115 | if (title.length > 60) { 116 | throw new Error("Title cannot be longer than 60 characters."); 117 | } 118 | 119 | const board = await ctx.db.patch(args.id, { title: args.title }); 120 | 121 | return board; 122 | }, 123 | }); 124 | 125 | /** 126 | * Api endpoint for favoriting a board. 127 | * The function requires two arguments 128 | * board id and 129 | * orgId of type string 130 | * The function 'favorite' will check if the user has logged in. 131 | * If the user has not logged in, it will throw an error. 132 | * The function 'favorite' will also check if the board exists. 133 | * If the board does not exist, it will throw an error. 134 | * The function 'favorite' will also check if the board has already been favorited. 135 | * If the board has already been favorited, it will throw an error. 136 | * Otherwise, it will favorite the board. 137 | */ 138 | export const favorite = mutation({ 139 | args: { id: v.id("boards"), orgId: v.string() }, 140 | handler: async (ctx, arg) => { 141 | const identity = await ctx.auth.getUserIdentity(); 142 | 143 | if (!identity) { 144 | throw new Error("Unauthorized"); 145 | } 146 | 147 | const board = await ctx.db.get(arg.id); 148 | 149 | if (!board) { 150 | throw new Error("Board not found"); 151 | } 152 | 153 | const userId = identity.subject; 154 | 155 | // To check if the user has already favorited the board 156 | const existingFavorite = await ctx.db 157 | .query("userFavorites") 158 | .withIndex("by_user_board", (q) => 159 | q.eq("userId", userId).eq("boardId", board._id) 160 | ) 161 | .unique(); 162 | 163 | if (existingFavorite) { 164 | throw new Error("Board already favorited"); 165 | } 166 | 167 | await ctx.db.insert("userFavorites", { 168 | userId, 169 | boardId: board._id, 170 | orgId: arg.orgId, 171 | }); 172 | 173 | return board; 174 | }, 175 | }); 176 | 177 | /** 178 | * Api endpoint to unfavorite a board. 179 | * The function requires two arguments 180 | * board id and 181 | * orgId of type string 182 | * The function 'unfavorite' will check if the user has logged in. 183 | * If the user has not logged in, it will throw an error. 184 | * The function 'unfavorite' will also check if the board exists. 185 | * If the board does not exist, it will throw an error. 186 | * The function 'unfavorite' will also check if the board has already been favorited. 187 | * If the board has not already been favorited, it will throw an error. 188 | * Otherwise, it will unfavorite the board. 189 | */ 190 | export const unfavorite = mutation({ 191 | args: { id: v.id("boards") }, 192 | handler: async (ctx, arg) => { 193 | const identity = await ctx.auth.getUserIdentity(); 194 | 195 | if (!identity) { 196 | throw new Error("Unauthorized"); 197 | } 198 | 199 | const board = await ctx.db.get(arg.id); 200 | 201 | if (!board) { 202 | throw new Error("Board not found"); 203 | } 204 | 205 | const userId = identity.subject; 206 | 207 | // To check if the user has already favorited the board 208 | const existingFavorite = await ctx.db 209 | .query("userFavorites") 210 | .withIndex("by_user_board", (q) => 211 | q.eq("userId", userId).eq("boardId", board._id) 212 | ) 213 | .unique(); 214 | 215 | if (!existingFavorite) { 216 | throw new Error("Favorited board not found"); 217 | } 218 | 219 | await ctx.db.delete(existingFavorite._id); 220 | 221 | return board; 222 | }, 223 | }); 224 | 225 | /** 226 | * Api endpoint to get a board with the given id. 227 | * The function 'get' requires one argument. 228 | * board id. 229 | * The function will return the board with the given id. 230 | */ 231 | export const get = query({ 232 | args: { id: v.id("boards") }, 233 | handler: async (ctx, args) => { 234 | const board = await ctx.db.get(args.id); 235 | 236 | return board; 237 | }, 238 | }); 239 | -------------------------------------------------------------------------------- /app/board/[boardId]/_components/selection-box.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { memo } from "react"; 4 | 5 | import { LayerType, Side, XYWH } from "@/types/canvas"; 6 | import { useSelf, useStorage } from "@/liveblocks.config"; 7 | import { useSelectionBounds } from "@/hooks/use-selection-bounds"; 8 | 9 | interface SelectionBoxProps { 10 | onResizeHandlePointerDown: (corner: Side, initialBounds: XYWH) => void; 11 | } 12 | 13 | const HANDLE_WIDTH = 8; 14 | 15 | export const SelectionBox = memo( 16 | ({ onResizeHandlePointerDown }: SelectionBoxProps) => { 17 | const soleLayerId = useSelf((me) => 18 | me.presence.selection.length === 1 ? me.presence.selection[0] : null 19 | ); 20 | 21 | const isShowingHandles = useStorage( 22 | (root) => 23 | soleLayerId && 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 | { 62 | e.stopPropagation(); 63 | onResizeHandlePointerDown(Side.Top + Side.Left, bounds); 64 | }} 65 | /> 66 | { 82 | e.stopPropagation(); 83 | onResizeHandlePointerDown(Side.Top, bounds); 84 | }} 85 | /> 86 | { 101 | e.stopPropagation(); 102 | onResizeHandlePointerDown(Side.Top + Side.Right, bounds); 103 | }} 104 | /> 105 | { 120 | e.stopPropagation(); 121 | onResizeHandlePointerDown(Side.Right, bounds); 122 | }} 123 | /> 124 | { 139 | e.stopPropagation(); 140 | onResizeHandlePointerDown(Side.Bottom + Side.Right, bounds); 141 | }} 142 | /> 143 | { 158 | e.stopPropagation(); 159 | onResizeHandlePointerDown(Side.Bottom, bounds); 160 | }} 161 | /> 162 | { 177 | e.stopPropagation(); 178 | onResizeHandlePointerDown(Side.Bottom + Side.Left, bounds); 179 | }} 180 | /> 181 | { 196 | e.stopPropagation(); 197 | onResizeHandlePointerDown(Side.Left, bounds); 198 | }} 199 | /> 200 | 201 | )} 202 | 203 | ); 204 | } 205 | ); 206 | 207 | SelectionBox.displayName = "SelectionBox"; 208 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /public/placeholders/2.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/placeholders/5.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/placeholders/9.svg: -------------------------------------------------------------------------------- 1 | --------------------------------------------------------------------------------