├── .eslintrc.json ├── .gitignore ├── README.md ├── app ├── (dashboard) │ ├── _components │ │ ├── boardCard │ │ │ ├── footer.tsx │ │ │ ├── index.tsx │ │ │ └── overlay.tsx │ │ ├── boardList.tsx │ │ ├── emptyBoards.tsx │ │ ├── emptyFavourites.tsx │ │ ├── emptyOrg.tsx │ │ ├── emptySearch.tsx │ │ ├── inviteButton.tsx │ │ ├── navbar.tsx │ │ ├── newBoardButton.tsx │ │ ├── orgSidebar.tsx │ │ └── sidebar │ │ │ ├── index.tsx │ │ │ ├── item.tsx │ │ │ ├── list.tsx │ │ │ ├── newButton.tsx │ │ │ └── searchInput.tsx │ ├── layout.tsx │ └── page.tsx ├── api │ └── liveblocks-auth │ │ └── route.ts ├── boards │ └── [boardId] │ │ ├── _components │ │ ├── canvas.tsx │ │ ├── colorPicker.tsx │ │ ├── cursor.tsx │ │ ├── cursorPresence.tsx │ │ ├── ellipse.tsx │ │ ├── info.tsx │ │ ├── layerPreview.tsx │ │ ├── loading.tsx │ │ ├── note.tsx │ │ ├── participants.tsx │ │ ├── path.tsx │ │ ├── rectangle.tsx │ │ ├── selectionBox.tsx │ │ ├── selectionTools.tsx │ │ ├── text.tsx │ │ ├── toolButton.tsx │ │ ├── toolbar.tsx │ │ └── userAvatar.tsx │ │ └── page.tsx ├── favicon.ico ├── globals.css └── layout.tsx ├── bun.lockb ├── components.json ├── components ├── actions.tsx ├── auth │ └── loading.tsx ├── confirmModal.tsx ├── hint.tsx ├── modals │ └── renameModal.tsx ├── room.tsx └── ui │ ├── alert-dialog.tsx │ ├── avatar.tsx │ ├── button.tsx │ ├── dialog.tsx │ ├── dropdown-menu.tsx │ ├── input.tsx │ ├── skeleton.tsx │ ├── sonner.tsx │ └── tooltip.tsx ├── convex ├── README.md ├── _generated │ ├── api.d.ts │ ├── api.js │ ├── dataModel.d.ts │ ├── server.d.ts │ └── server.js ├── auth.config.js ├── board.ts ├── boards.ts ├── schema.ts └── tsconfig.json ├── hooks ├── useApiMutation.ts ├── useDeleteLayers.ts ├── useDisableScrollBounce.ts └── useSelectionBounds.ts ├── lib └── utils.ts ├── liveblocks.config.ts ├── middleware.ts ├── next.config.mjs ├── package.json ├── postcss.config.js ├── provders ├── convexClientProvider.tsx └── modalProvider.tsx ├── public ├── elements.svg ├── empty-favourites.svg ├── empty-search.svg ├── logo.svg ├── next.svg ├── note.svg ├── placeholders │ ├── 1.svg │ ├── 10.svg │ ├── 2.svg │ ├── 3.svg │ ├── 4.svg │ ├── 5.svg │ ├── 6.svg │ ├── 7.svg │ ├── 8.svg │ └── 9.svg └── vercel.svg ├── store └── useRenameModal.ts ├── tailwind.config.ts ├── tsconfig.json └── types └── canvas.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Miro Clone 2 | 3 | This is a Miro clone, built with [Next.js](https://nextjs.org/), [shadcn/ui](https://ui.shadcn.com/), [convex](https://www.convex.dev/), and [liveblocks](https://liveblocks.io/), following the [tutorial](https://www.youtube.com/watch?v=ADJKbuayubE) by [Code with Antonio](https://www.youtube.com/@codewithantonio). 4 | 5 | The application is deployed on Vercel [here](https://miro-clone-ten.vercel.app/), though it is by invitation only for now. 6 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/boardCard/footer.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import { Star } from "lucide-react"; 3 | 4 | interface FooterProps { 5 | isFavourite: boolean; 6 | title: string; 7 | authorLabel: string; 8 | createdAtLabel: string; 9 | onClick: () => void; 10 | disabled: boolean; 11 | } 12 | 13 | export function Footer({ 14 | isFavourite, 15 | title, 16 | authorLabel, 17 | createdAtLabel, 18 | onClick, 19 | disabled, 20 | }: FooterProps) { 21 | const handleClick = (e: React.MouseEvent) => { 22 | e.stopPropagation(); 23 | e.preventDefault(); 24 | onClick(); 25 | }; 26 | 27 | return ( 28 |
29 |

{title}

30 |

31 | {authorLabel}, {createdAtLabel} 32 |

33 | 48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/boardCard/index.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Actions } from "@/components/actions"; 4 | import { Skeleton } from "@/components/ui/skeleton"; 5 | import { api } from "@/convex/_generated/api"; 6 | import { Id } from "@/convex/_generated/dataModel"; 7 | import { useApiMutation } from "@/hooks/useApiMutation"; 8 | import { useAuth } from "@clerk/nextjs"; 9 | import { formatDistanceToNow } from "date-fns"; 10 | import { MoreHorizontal } from "lucide-react"; 11 | import Image from "next/image"; 12 | import Link from "next/link"; 13 | import { toast } from "sonner"; 14 | import { Footer } from "./footer"; 15 | import { Overlay } from "./overlay"; 16 | 17 | interface BoardCardProps { 18 | id: string; 19 | title: string; 20 | imageUrl: string; 21 | authorId: string; 22 | authorName: string; 23 | createdAt: number; 24 | orgId: string; 25 | isFavourite: boolean; 26 | } 27 | 28 | export function BoardCard({ 29 | id, 30 | title, 31 | imageUrl, 32 | authorId, 33 | authorName, 34 | createdAt, 35 | orgId, 36 | isFavourite, 37 | }: BoardCardProps) { 38 | const { mutate: favourite, isLoading: isFavouriting } = useApiMutation( 39 | api.board.favourite 40 | ); 41 | const { mutate: unfavourite, isLoading: isUnfavouriting } = useApiMutation( 42 | api.board.unfavourite 43 | ); 44 | 45 | const { userId } = useAuth(); 46 | const authorLabel = userId === authorId ? "You" : authorName; 47 | const createdAtLabel = formatDistanceToNow(createdAt, { 48 | addSuffix: true, 49 | }); 50 | 51 | const toggleFavourite = () => { 52 | if (isFavourite) { 53 | unfavourite({ id: id as Id<"boards"> }).catch(() => 54 | toast.error("Failed to unfavourite board") 55 | ); 56 | } else { 57 | favourite({ id: id as Id<"boards">, orgId }).catch(() => 58 | toast.error("Failed to favourite board") 59 | ); 60 | } 61 | }; 62 | 63 | return ( 64 | 65 |
66 |
67 | {title} 68 | 69 | 70 | 73 | 74 |
75 |
83 |
84 | 85 | ); 86 | } 87 | 88 | BoardCard.Skeleton = function BoardCardSkeleton() { 89 | return ( 90 |
91 | 92 |
93 | ); 94 | }; 95 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/boardCard/overlay.tsx: -------------------------------------------------------------------------------- 1 | export function Overlay() { 2 | return ( 3 |
4 | ); 5 | } 6 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/boardList.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useQuery } from "convex/react"; 4 | import { EmptyBoards } from "./emptyBoards"; 5 | import { EmptyFavourites } from "./emptyFavourites"; 6 | import { EmptySearch } from "./emptySearch"; 7 | import { api } from "@/convex/_generated/api"; 8 | import { BoardCard } from "./boardCard"; 9 | import { NewBoardButton } from "./newBoardButton"; 10 | 11 | interface BoardListProps { 12 | orgId: string; 13 | query: { 14 | search?: string; 15 | favourites?: string; 16 | }; 17 | } 18 | 19 | export function BoardList({ orgId, query }: BoardListProps) { 20 | const data = useQuery(api.boards.get, { orgId, ...query }); 21 | 22 | if (data === undefined) { 23 | return ( 24 |
25 |

26 | {query.favourites ? "Favourite Boards" : "Team boards"} 27 |

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

54 | {query.favourites ? "Favourite Boards" : "Team boards"} 55 |

56 |
57 | 58 | {data?.map((board) => ( 59 | 70 | ))} 71 |
72 |
73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/emptyBoards.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { api } from "@/convex/_generated/api"; 5 | import { useApiMutation } from "@/hooks/useApiMutation"; 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 function EmptyBoards() { 12 | const router = useRouter(); 13 | const { organization } = useOrganization(); 14 | const { mutate: create, isLoading } = useApiMutation(api.board.create); 15 | 16 | const handleClick = () => { 17 | if (!organization) return; 18 | 19 | create({ 20 | title: "Untitled", 21 | orgId: organization.id, 22 | }) 23 | .then((id) => { 24 | toast.success("Board created"); 25 | router.push(`/board/${id}`); 26 | }) 27 | .catch(() => { 28 | toast.error("Failed to create board"); 29 | }); 30 | }; 31 | 32 | return ( 33 |
34 | Empty 35 |

Create your first board

36 |

37 | Start by creating a board for your organization 38 |

39 |
40 | 43 |
44 |
45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/emptyFavourites.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | export function EmptyFavourites() { 4 | return ( 5 |
6 | Empty 7 |

No favourite boards!

8 |

9 | Try favouriting a board 10 |

11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/emptyOrg.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 function EmptyOrg() { 7 | return ( 8 |
9 | Empty 10 |

Welcome to Board

11 |

12 | Create an organization to get started 13 |

14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/emptySearch.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | export function 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/inviteButton.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 function InviteButton() { 7 | return ( 8 | 9 | 10 | 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/navbar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | OrganizationSwitcher, 5 | UserButton, 6 | useOrganization, 7 | } from "@clerk/nextjs"; 8 | import { InviteButton } from "./inviteButton"; 9 | import { SearchInput } from "./sidebar/searchInput"; 10 | 11 | export function 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/newBoardButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { api } from "@/convex/_generated/api"; 4 | import { useApiMutation } from "@/hooks/useApiMutation"; 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 function NewBoardButton({ orgId, disabled }: NewBoardButtonProps) { 16 | const router = useRouter(); 17 | const { mutate: create, isLoading } = useApiMutation(api.board.create); 18 | 19 | const handleClick = () => { 20 | create({ orgId, title: "Untitled" }) 21 | .then((id) => { 22 | toast.success("Board created!"); 23 | router.push(`/board/${id}`); 24 | }) 25 | .catch(() => toast.error("Failed to create board")); 26 | }; 27 | 28 | return ( 29 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/orgSidebar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import Image from "next/image"; 5 | import { Poppins } from "next/font/google"; 6 | import { cn } from "@/lib/utils"; 7 | import { OrganizationSwitcher } from "@clerk/nextjs"; 8 | import { LayoutDashboard, Star } from "lucide-react"; 9 | import { Button } from "@/components/ui/button"; 10 | import { useSearchParams } from "next/navigation"; 11 | 12 | const font = Poppins({ 13 | subsets: ["latin"], 14 | weight: ["600"], 15 | }); 16 | 17 | export function OrgSidebar() { 18 | const searchParams = useSearchParams(); 19 | 20 | const favourites = searchParams.get("favourites"); 21 | 22 | return ( 23 |
24 | 25 |
26 | Logo 27 | 28 | Board 29 | 30 |
31 | 32 | 53 |
54 | 65 | 83 |
84 |
85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/sidebar/index.tsx: -------------------------------------------------------------------------------- 1 | import { List } from "./list"; 2 | import { NewButton } from "./newButton"; 3 | 4 | export function Sidebar() { 5 | return ( 6 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /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 | 7 | interface ItemProps { 8 | id: string; 9 | name: string; 10 | imageUrl: string; 11 | } 12 | 13 | export function Item({ id, imageUrl, name }: ItemProps) { 14 | const { organization } = useOrganization(); 15 | const { setActive } = useOrganizationList(); 16 | 17 | const isActive = organization?.id === id; 18 | 19 | const onClick = () => { 20 | if (!setActive) return; 21 | 22 | setActive({ 23 | organization: id, 24 | }); 25 | }; 26 | 27 | return ( 28 |
29 | {name} 39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/sidebar/list.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useOrganizationList } from "@clerk/nextjs"; 4 | import { Item } from "./item"; 5 | 6 | export function List() { 7 | const { userMemberships } = useOrganizationList({ 8 | userMemberships: { 9 | infinite: true, 10 | }, 11 | }); 12 | 13 | if (userMemberships.data?.length === 0) return null; 14 | 15 | return ( 16 |
    17 | {userMemberships.data?.map((membership) => { 18 | return ( 19 | 25 | ); 26 | })} 27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/sidebar/newButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Hint } from "@/components/hint"; 4 | import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; 5 | import { CreateOrganization } from "@clerk/nextjs"; 6 | import { Plus } from "lucide-react"; 7 | 8 | export function NewButton() { 9 | return ( 10 | 11 | 12 |
13 | 19 |
20 | 21 |
22 |
23 |
24 |
25 | 26 | 27 | 28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/sidebar/searchInput.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 { useEffect } from "react"; 8 | import { useDebounceValue } from "usehooks-ts"; 9 | 10 | export function SearchInput() { 11 | const router = useRouter(); 12 | const [debounceValue, setValue] = useDebounceValue("", 500); 13 | 14 | const handleChange: React.ChangeEventHandler = (e) => { 15 | setValue(e.target.value); 16 | }; 17 | 18 | useEffect(() => { 19 | const url = qs.stringifyUrl( 20 | { 21 | url: "/", 22 | query: { 23 | search: debounceValue, 24 | }, 25 | }, 26 | { 27 | skipEmptyString: true, 28 | skipNull: true, 29 | } 30 | ); 31 | 32 | router.push(url); 33 | }, [debounceValue, router]); 34 | 35 | return ( 36 |
37 | 38 | 43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /app/(dashboard)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Navbar } from "./_components/navbar"; 2 | import { OrgSidebar } from "./_components/orgSidebar"; 3 | import { Sidebar } from "./_components/sidebar"; 4 | 5 | interface DashboardLayoutProps { 6 | children: React.ReactNode; 7 | } 8 | 9 | export default function DashboardLayout({ children }: DashboardLayoutProps) { 10 | return ( 11 |
12 | 13 |
14 |
15 | 16 |
17 | 18 | {children} 19 |
20 |
21 |
22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /app/(dashboard)/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useOrganization } from "@clerk/nextjs"; 4 | import { EmptyOrg } from "./_components/emptyOrg"; 5 | import { BoardList } from "./_components/boardList"; 6 | 7 | interface DashboardPageProps { 8 | searchParams: { 9 | search?: string; 10 | favourites?: string; 11 | }; 12 | } 13 | 14 | export default function DashboardPage({ searchParams }: DashboardPageProps) { 15 | const { organization } = useOrganization(); 16 | 17 | return ( 18 |
19 | {!organization ? ( 20 | 21 | ) : ( 22 | 23 | )} 24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /app/api/liveblocks-auth/route.ts: -------------------------------------------------------------------------------- 1 | import { auth, currentUser } from "@clerk/nextjs"; 2 | import { Liveblocks } from "@liveblocks/node"; 3 | import { api } from "@/convex/_generated/api"; 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 | 22 | const board = await convex.query(api.board.get, { id: room }); 23 | 24 | if (board?.orgId !== authorization.orgId) { 25 | return new Response("Unauthorized", { status: 403 }); 26 | } 27 | 28 | const userInfo = { 29 | name: user.firstName || "Teammate", 30 | picture: user.imageUrl, 31 | }; 32 | 33 | const session = liveBlocks.prepareSession(user.id, { userInfo }); 34 | 35 | if (room) { 36 | session.allow(room, session.FULL_ACCESS); 37 | } 38 | 39 | const { status, body } = await session.authorize(); 40 | 41 | return new Response(body, { status }); 42 | } 43 | -------------------------------------------------------------------------------- /app/boards/[boardId]/_components/canvas.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useDeleteLayers } from "@/hooks/useDeleteLayers"; 4 | import { useDisableScrollBounce } from "@/hooks/useDisableScrollBounce"; 5 | import { 6 | colorToCss, 7 | connectionIdToColor, 8 | findIntersectingLayersWithRectangle, 9 | penPointsToPathLayer, 10 | pointerEventToCanvasPoint, 11 | resizeBounds, 12 | } from "@/lib/utils"; 13 | import { 14 | useCanRedo, 15 | useCanUndo, 16 | useHistory, 17 | useMutation, 18 | useOthersMapped, 19 | useSelf, 20 | useStorage, 21 | } from "@/liveblocks.config"; 22 | import type { Side, XYWH } from "@/types/canvas"; 23 | import { 24 | Camera, 25 | CanvasMode, 26 | CanvasState, 27 | Color, 28 | LayerType, 29 | Point, 30 | } from "@/types/canvas"; 31 | import { LiveObject } from "@liveblocks/client"; 32 | import { nanoid } from "nanoid"; 33 | import { useCallback, useEffect, useMemo, useState } from "react"; 34 | import { CursorPresence } from "./cursorPresence"; 35 | import { Info } from "./info"; 36 | import { LayerPreview } from "./layerPreview"; 37 | import { Participants } from "./participants"; 38 | import { Path } from "./path"; 39 | import { SelectionBox } from "./selectionBox"; 40 | import { SelectionTools } from "./selectionTools"; 41 | import { Toolbar } from "./toolbar"; 42 | 43 | const MAX_LAYERS = 100; 44 | 45 | interface CanvasProps { 46 | boardId: string; 47 | } 48 | 49 | export const Canvas = ({ boardId }: CanvasProps) => { 50 | const layerIds = useStorage((root) => root.layerIds); 51 | const pencilDraft = useSelf((self) => self.presence.pencilDraft); 52 | 53 | const selections = useOthersMapped((other) => other.presence.selection); 54 | 55 | const layerIdsToColorSelection = useMemo(() => { 56 | const layerIdsToColorSelection: Record = {}; 57 | 58 | for (const user of selections) { 59 | const [connectionId, selection] = user; 60 | 61 | for (const layerId of selection) { 62 | layerIdsToColorSelection[layerId] = connectionIdToColor(connectionId); 63 | } 64 | } 65 | 66 | return layerIdsToColorSelection; 67 | }, [selections]); 68 | 69 | const [canvasState, setCanvasState] = useState({ 70 | mode: CanvasMode.None, 71 | }); 72 | const [camera, setCamera] = useState({ x: 0, y: 0 }); 73 | const [lastUsedColor, setLastUsedColor] = useState({ 74 | r: 255, 75 | g: 255, 76 | b: 255, 77 | }); 78 | 79 | useDisableScrollBounce(); 80 | const history = useHistory(); 81 | const canUndo = useCanUndo(); 82 | const canRedo = useCanRedo(); 83 | 84 | const insertLayer = useMutation( 85 | ( 86 | { storage, setMyPresence }, 87 | layerType: 88 | | LayerType.Ellipse 89 | | LayerType.Rectangle 90 | | LayerType.Text 91 | | LayerType.Note, 92 | position: Point 93 | ) => { 94 | const liveLayers = storage.get("layers"); 95 | if (liveLayers.size >= MAX_LAYERS) { 96 | return; 97 | } 98 | 99 | const liveLayerIds = storage.get("layerIds"); 100 | 101 | const layerId = nanoid(); 102 | 103 | const layer = new LiveObject({ 104 | type: layerType, 105 | x: position.x, 106 | y: position.y, 107 | width: 100, 108 | height: 100, 109 | fill: lastUsedColor, 110 | }); 111 | 112 | liveLayerIds.push(layerId); 113 | liveLayers.set(layerId, layer); 114 | 115 | setMyPresence({ selection: [layerId] }, { addToHistory: true }); 116 | setCanvasState({ 117 | mode: CanvasMode.None, 118 | }); 119 | }, 120 | [lastUsedColor] 121 | ); 122 | 123 | const translateSelectedLayer = useMutation( 124 | ({ storage, self }, point: Point) => { 125 | if (canvasState.mode !== CanvasMode.Translating) return; 126 | 127 | const offset = { 128 | x: point.x - canvasState.current.x, 129 | y: point.y - canvasState.current.y, 130 | }; 131 | 132 | const liveLayers = storage.get("layers"); 133 | 134 | for (const id of self.presence.selection) { 135 | const layer = liveLayers.get(id); 136 | if (layer) { 137 | layer.update({ 138 | x: layer.get("x") + offset.x, 139 | y: layer.get("y") + offset.y, 140 | }); 141 | } 142 | } 143 | 144 | setCanvasState({ 145 | mode: CanvasMode.Translating, 146 | current: point, 147 | }); 148 | }, 149 | [canvasState] 150 | ); 151 | 152 | const unselectLayer = useMutation(({ self, setMyPresence }) => { 153 | if (self.presence.selection.length > 0) { 154 | setMyPresence({ selection: [] }, { addToHistory: true }); 155 | } 156 | }, []); 157 | 158 | const updateSelectionNet = useMutation( 159 | ({ storage, setMyPresence }, current: Point, origin: Point) => { 160 | const layers = storage.get("layers").toImmutable(); 161 | setCanvasState({ 162 | mode: CanvasMode.SelectionNet, 163 | origin, 164 | current, 165 | }); 166 | 167 | const ids = findIntersectingLayersWithRectangle( 168 | layerIds, 169 | layers, 170 | origin, 171 | current 172 | ); 173 | 174 | setMyPresence({ selection: ids }); 175 | }, 176 | [layerIds] 177 | ); 178 | 179 | const startMultiSelection = useCallback((current: Point, origin: Point) => { 180 | if (Math.abs(current.x - origin.x) + Math.abs(current.y - origin.y) > 5) { 181 | setCanvasState({ 182 | mode: CanvasMode.SelectionNet, 183 | origin, 184 | current, 185 | }); 186 | } 187 | }, []); 188 | 189 | const continueDrawing = useMutation( 190 | ({ self, setMyPresence }, point: Point, event: React.PointerEvent) => { 191 | const { pencilDraft } = self.presence; 192 | 193 | if ( 194 | canvasState.mode !== CanvasMode.Pencil || 195 | event.buttons !== 1 || 196 | !pencilDraft 197 | ) 198 | return; 199 | 200 | setMyPresence({ 201 | cursor: point, 202 | pencilDraft: 203 | pencilDraft.length === 1 && 204 | pencilDraft[0][0] === point.x && 205 | pencilDraft[0][1] === point.y 206 | ? pencilDraft 207 | : [...pencilDraft, [point.x, point.y, event.pressure]], 208 | }); 209 | }, 210 | [canvasState.mode] 211 | ); 212 | 213 | const insertPath = useMutation( 214 | ({ storage, self, setMyPresence }) => { 215 | const liveLayers = storage.get("layers"); 216 | const { pencilDraft } = self.presence; 217 | 218 | if ( 219 | !pencilDraft || 220 | pencilDraft.length < 2 || 221 | liveLayers.size >= MAX_LAYERS 222 | ) { 223 | setMyPresence({ pencilDraft: null }); 224 | return; 225 | } 226 | 227 | const id = nanoid(); 228 | liveLayers.set( 229 | id, 230 | new LiveObject(penPointsToPathLayer(pencilDraft, lastUsedColor)) 231 | ); 232 | 233 | const liveLayerIds = storage.get("layerIds"); 234 | liveLayerIds.push(id); 235 | 236 | setMyPresence({ pencilDraft: null }); 237 | setCanvasState({ 238 | mode: CanvasMode.Pencil, 239 | }); 240 | }, 241 | [lastUsedColor] 242 | ); 243 | 244 | const startDrawing = useMutation( 245 | ({ setMyPresence }, point: Point, pressure: number) => { 246 | setMyPresence({ 247 | pencilDraft: [[point.x, point.y, pressure]], 248 | pencilColor: lastUsedColor, 249 | }); 250 | }, 251 | [lastUsedColor] 252 | ); 253 | 254 | const resizeSelectedLayer = useMutation( 255 | ({ self, storage }, point: Point) => { 256 | if (canvasState.mode !== CanvasMode.Resizing) { 257 | return; 258 | } 259 | 260 | const bounds = resizeBounds( 261 | canvasState.initialBounds, 262 | canvasState.corner, 263 | point 264 | ); 265 | const liveLayers = storage.get("layers"); 266 | const layer = liveLayers.get(self.presence.selection[0]); 267 | 268 | if (layer) { 269 | layer.update(bounds); 270 | } 271 | }, 272 | [canvasState] 273 | ); 274 | 275 | const handleResizeHandlePointerDown = useCallback( 276 | (corner: Side, initialBounds: XYWH) => { 277 | history.pause(); 278 | setCanvasState({ 279 | mode: CanvasMode.Resizing, 280 | initialBounds, 281 | corner, 282 | }); 283 | }, 284 | [history] 285 | ); 286 | 287 | const handleWheel = useCallback((e: React.WheelEvent) => { 288 | setCamera((camera) => ({ 289 | x: camera.x - e.deltaX, 290 | y: camera.y - e.deltaY, 291 | })); 292 | }, []); 293 | 294 | const handlePointerMove = useMutation( 295 | ({ setMyPresence }, e: React.PointerEvent) => { 296 | e.preventDefault(); 297 | 298 | const current = pointerEventToCanvasPoint(e, camera); 299 | 300 | if (canvasState.mode == CanvasMode.Pressing) { 301 | startMultiSelection(current, canvasState.origin); 302 | } else if (canvasState.mode === CanvasMode.SelectionNet) { 303 | updateSelectionNet(current, canvasState.origin); 304 | } else if (canvasState.mode === CanvasMode.Translating) { 305 | translateSelectedLayer(current); 306 | } else if (canvasState.mode === CanvasMode.Resizing) { 307 | resizeSelectedLayer(current); 308 | } else if (canvasState.mode === CanvasMode.Pencil) { 309 | continueDrawing(current, e); 310 | } 311 | 312 | setMyPresence({ cursor: current }); 313 | }, 314 | [ 315 | canvasState, 316 | resizeSelectedLayer, 317 | camera, 318 | translateSelectedLayer, 319 | continueDrawing, 320 | startMultiSelection, 321 | updateSelectionNet, 322 | ] 323 | ); 324 | 325 | const handlePointerLeave = useMutation(({ setMyPresence }) => { 326 | setMyPresence({ cursor: null }); 327 | }, []); 328 | 329 | const handlePointerDown = useCallback( 330 | (e: React.PointerEvent) => { 331 | const point = pointerEventToCanvasPoint(e, camera); 332 | 333 | if (canvasState.mode === CanvasMode.Inserting) return; 334 | 335 | if (canvasState.mode === CanvasMode.Pencil) { 336 | startDrawing(point, e.pressure); 337 | return; 338 | } 339 | 340 | setCanvasState({ 341 | origin: point, 342 | mode: CanvasMode.Pressing, 343 | }); 344 | }, 345 | [camera, canvasState.mode, setCanvasState, startDrawing] 346 | ); 347 | 348 | const handlePointerUp = useMutation( 349 | ({}, e) => { 350 | const point = pointerEventToCanvasPoint(e, camera); 351 | 352 | if ( 353 | canvasState.mode === CanvasMode.None || 354 | canvasState.mode === CanvasMode.Pressing 355 | ) { 356 | unselectLayer(); 357 | setCanvasState({ 358 | mode: CanvasMode.None, 359 | }); 360 | } else if (canvasState.mode === CanvasMode.Pencil) { 361 | insertPath(); 362 | } else if (canvasState.mode === CanvasMode.Inserting) { 363 | insertLayer(canvasState.layerType, point); 364 | } else { 365 | setCanvasState({ 366 | mode: CanvasMode.None, 367 | }); 368 | } 369 | history.resume(); 370 | }, 371 | [ 372 | camera, 373 | canvasState, 374 | history, 375 | insertLayer, 376 | unselectLayer, 377 | setCanvasState, 378 | insertPath, 379 | ] 380 | ); 381 | 382 | const handleLayerPointerDown = useMutation( 383 | ({ self, setMyPresence }, e: React.PointerEvent, layerId: string) => { 384 | if ( 385 | canvasState.mode === CanvasMode.Pencil || 386 | canvasState.mode === CanvasMode.Inserting 387 | ) { 388 | return; 389 | } 390 | 391 | history.pause(); 392 | e.stopPropagation(); 393 | 394 | const point = pointerEventToCanvasPoint(e, camera); 395 | 396 | if (!self.presence.selection.includes(layerId)) { 397 | setMyPresence({ selection: [layerId] }, { addToHistory: true }); 398 | } 399 | 400 | setCanvasState({ 401 | mode: CanvasMode.Translating, 402 | current: point, 403 | }); 404 | }, 405 | [setCanvasState, camera, history, canvasState.mode] 406 | ); 407 | 408 | const deleteLayers = useDeleteLayers(); 409 | 410 | useEffect(() => { 411 | function onKeyDown(e: KeyboardEvent) { 412 | switch (e.key) { 413 | case "z": { 414 | if (e.ctrlKey || e.metaKey) { 415 | if (e.shiftKey) { 416 | history.redo(); 417 | } else { 418 | history.undo(); 419 | } 420 | break; 421 | } 422 | } 423 | } 424 | } 425 | 426 | document.addEventListener("keydown", onKeyDown); 427 | 428 | return () => { 429 | document.removeEventListener("keydown", onKeyDown); 430 | }; 431 | }, [history]); 432 | 433 | return ( 434 |
435 | 436 | 437 | 445 | 446 | 454 | 459 | {layerIds.map((layerId) => ( 460 | 466 | ))} 467 | 470 | {canvasState.mode === CanvasMode.SelectionNet && 471 | canvasState.current && ( 472 | 479 | )} 480 | 481 | {pencilDraft && pencilDraft.length > 0 && ( 482 | 488 | )} 489 | 490 | 491 |
492 | ); 493 | }; 494 | -------------------------------------------------------------------------------- /app/boards/[boardId]/_components/colorPicker.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 | color: Color; 27 | onClick: (color: Color) => void; 28 | } 29 | 30 | export const ColorButton = ({ color, onClick }: ColorButtonProps) => { 31 | return ( 32 | 43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /app/boards/[boardId]/_components/cursor.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { memo } from "react"; 4 | import { Mouse, MousePointer2 } from "lucide-react"; 5 | import { useOther } from "@/liveblocks.config"; 6 | import { connectionIdToColor } from "@/lib/utils"; 7 | 8 | interface CursorProps { 9 | connectionId: number; 10 | } 11 | 12 | export const Cursor = memo(({ connectionId }: CursorProps) => { 13 | const info = useOther(connectionId, (user) => user?.info); 14 | 15 | const cursor = useOther(connectionId, (user) => user.presence.cursor); 16 | 17 | const name = info?.name || "Teammate"; 18 | 19 | if (!cursor) return null; 20 | 21 | const { x, y } = cursor; 22 | 23 | return ( 24 | 32 | 39 |
45 | {name} 46 |
47 |
48 | ); 49 | }); 50 | 51 | Cursor.displayName = "Cursor"; 52 | -------------------------------------------------------------------------------- /app/boards/[boardId]/_components/cursorPresence.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { colorToCss } from "@/lib/utils"; 4 | import { useOthersConnectionIds, useOthersMapped } from "@/liveblocks.config"; 5 | import { shallow } from "@liveblocks/client"; 6 | import { memo } from "react"; 7 | import { Cursor } from "./cursor"; 8 | import { Path } from "./path"; 9 | 10 | const Cursors = () => { 11 | const ids = useOthersConnectionIds(); 12 | 13 | return ( 14 | <> 15 | {ids.map((id) => ( 16 | 17 | ))} 18 | 19 | ); 20 | }; 21 | 22 | const Draft = () => { 23 | const others = useOthersMapped( 24 | (other) => ({ 25 | pencilDraft: other.presence.pencilDraft, 26 | pencilColor: other.presence.pencilColor, 27 | }), 28 | shallow 29 | ); 30 | 31 | return ( 32 | <> 33 | {others.map(([key, other]) => { 34 | if (other.pencilDraft) { 35 | return ( 36 | 43 | ); 44 | } 45 | return null; 46 | })} 47 | 48 | ); 49 | }; 50 | 51 | export const CursorPresence = memo(() => { 52 | return ( 53 | <> 54 | 55 | 56 | 57 | ); 58 | }); 59 | 60 | CursorPresence.displayName = "CursorPresence"; 61 | -------------------------------------------------------------------------------- /app/boards/[boardId]/_components/ellipse.tsx: -------------------------------------------------------------------------------- 1 | import { colorToCss } from "@/lib/utils"; 2 | import { EllipseLayer } from "@/types/canvas"; 3 | 4 | interface EllipseProps { 5 | id: string; 6 | layer: EllipseLayer; 7 | onPointerDown: (e: React.PointerEvent, id: string) => void; 8 | selectionColor?: string; 9 | } 10 | 11 | export const Ellipse = ({ 12 | id, 13 | layer, 14 | onPointerDown, 15 | selectionColor, 16 | }: EllipseProps) => { 17 | const { x, y, width, height, fill } = layer; 18 | return ( 19 | onPointerDown(e, id)} 22 | style={{ 23 | transform: `translate(${x}px, ${y}px)`, 24 | }} 25 | cx={width / 2} 26 | cy={height / 2} 27 | rx={width / 2} 28 | ry={height / 2} 29 | fill={fill ? colorToCss(fill) : "#000"} 30 | stroke={selectionColor || "transparent"} 31 | strokeWidth={1} 32 | /> 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /app/boards/[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 { api } from "@/convex/_generated/api"; 7 | import { Id } from "@/convex/_generated/dataModel"; 8 | import { cn } from "@/lib/utils"; 9 | import { useRenameModal } from "@/store/useRenameModal"; 10 | import { useQuery } from "convex/react"; 11 | import { Menu } from "lucide-react"; 12 | import { Poppins } from "next/font/google"; 13 | import Image from "next/image"; 14 | import Link from "next/link"; 15 | 16 | interface InfoProps { 17 | boardId: string; 18 | } 19 | 20 | const font = Poppins({ 21 | subsets: ["latin"], 22 | weight: ["600"], 23 | }); 24 | 25 | const TabSeparator = () => { 26 | return
|
; 27 | }; 28 | 29 | export const Info = ({ boardId }: InfoProps) => { 30 | const { onOpen } = useRenameModal(); 31 | 32 | const data = useQuery(api.board.get, { 33 | id: boardId as Id<"boards">, 34 | }); 35 | 36 | if (!data) return ; 37 | 38 | return ( 39 |
40 | 41 | 54 | 55 | 56 | 57 | 64 | 65 | 66 | 67 |
68 | 69 | 72 | 73 |
74 |
75 |
76 | ); 77 | }; 78 | 79 | export function InfoSkeleton() { 80 | return ( 81 |
82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /app/boards/[boardId]/_components/layerPreview.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useStorage } from "@/liveblocks.config"; 4 | import { LayerType } from "@/types/canvas"; 5 | import { memo } from "react"; 6 | import { Ellipse } from "./ellipse"; 7 | import { Rectangle } from "./rectangle"; 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) return null; 24 | 25 | switch (layer.type) { 26 | case LayerType.Path: 27 | return ( 28 | onLayerPointerDown(e, id)} 31 | x={layer.x} 32 | y={layer.y} 33 | fill={layer.fill ? colorToCss(layer.fill) : "#000"} 34 | stroke={selectionColor} 35 | /> 36 | ); 37 | case LayerType.Note: 38 | return ( 39 | 45 | ); 46 | case LayerType.Text: 47 | return ( 48 | 54 | ); 55 | case LayerType.Ellipse: 56 | return ( 57 | 63 | ); 64 | case LayerType.Rectangle: 65 | return ( 66 | 72 | ); 73 | 74 | default: 75 | console.warn("Unknown layer type", layer); 76 | return null; 77 | } 78 | } 79 | ); 80 | 81 | LayerPreview.displayName = "LayerPreview"; 82 | -------------------------------------------------------------------------------- /app/boards/[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 |
12 | 13 | 14 | 15 | 16 |
17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /app/boards/[boardId]/_components/note.tsx: -------------------------------------------------------------------------------- 1 | import { cn, colorToCss, getContrastingTextColor } from "@/lib/utils"; 2 | import { useMutation } from "@/liveblocks.config"; 3 | import { NoteLayer } from "@/types/canvas"; 4 | import { Kalam } from "next/font/google"; 5 | import ContentEditable, { ContentEditableEvent } from "react-contenteditable"; 6 | 7 | const font = Kalam({ 8 | subsets: ["latin"], 9 | weight: ["400"], 10 | }); 11 | 12 | interface NoteProps { 13 | id: string; 14 | layer: NoteLayer; 15 | onPointerDown: (e: React.PointerEvent, id: string) => void; 16 | selectionColor?: string; 17 | } 18 | 19 | const calculateFontSize = (width: number, height: number) => { 20 | const maxFontSize = 96; 21 | const scaleFactor = 0.15; 22 | const fontSizeBasedOnHeight = height * scaleFactor; 23 | const fontSizeBasedOnWidth = width * scaleFactor; 24 | 25 | return Math.min(maxFontSize, fontSizeBasedOnHeight, fontSizeBasedOnWidth); 26 | }; 27 | 28 | export const Note = ({ 29 | id, 30 | layer, 31 | onPointerDown, 32 | selectionColor, 33 | }: NoteProps) => { 34 | const { x, y, width, height, fill, value } = layer; 35 | 36 | const updateValue = useMutation(({ storage }, newValue: string) => { 37 | const liveLayers = storage.get("layers"); 38 | 39 | liveLayers.get(id)?.set("value", newValue); 40 | }, []); 41 | 42 | const hanldeContentChange = (e: ContentEditableEvent) => { 43 | updateValue(e.target.value); 44 | }; 45 | 46 | return ( 47 | onPointerDown(e, id)} 53 | style={{ 54 | outline: selectionColor ? `1px solid ${selectionColor}` : "none", 55 | backgroundColor: fill ? colorToCss(fill) : "#000", 56 | }} 57 | className="shadow-md drop-shadow-xl" 58 | > 59 | 71 | 72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /app/boards/[boardId]/_components/participants.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useOthers, useSelf } from "@/liveblocks.config"; 4 | import { UserAvatar } from "./userAvatar"; 5 | import { connectionIdToColor } from "@/lib/utils"; 6 | 7 | const MAX_SHOWN_OTHER_USERS = 2; 8 | 9 | export const Participants = () => { 10 | const otherUsers = useOthers(); 11 | const currentUser = useSelf(); 12 | const hasMoreUsers = otherUsers.length > MAX_SHOWN_OTHER_USERS; 13 | 14 | return ( 15 |
16 |
17 | {otherUsers 18 | .slice(0, MAX_SHOWN_OTHER_USERS) 19 | .map(({ connectionId, info }) => ( 20 | 27 | ))} 28 | {currentUser && ( 29 | 35 | )} 36 | {hasMoreUsers && ( 37 | 41 | )} 42 |
43 |
44 | ); 45 | }; 46 | 47 | export function ParticipantsSkeleton() { 48 | return ( 49 |
50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /app/boards/[boardId]/_components/path.tsx: -------------------------------------------------------------------------------- 1 | import { getSvgPathFromStroke } from "@/lib/utils"; 2 | import getStroke from "perfect-freehand"; 3 | 4 | interface PathProps { 5 | x: number; 6 | y: number; 7 | points: number[][]; 8 | fill: string; 9 | onPointerDown?: (e: React.PointerEvent) => void; 10 | stroke?: string; 11 | } 12 | 13 | export const Path = ({ 14 | x, 15 | y, 16 | points, 17 | fill, 18 | onPointerDown, 19 | stroke, 20 | }: PathProps) => { 21 | return ( 22 | 42 | ); 43 | }; 44 | -------------------------------------------------------------------------------- /app/boards/[boardId]/_components/rectangle.tsx: -------------------------------------------------------------------------------- 1 | import { colorToCss } from "@/lib/utils"; 2 | import { RectangleLayer } from "@/types/canvas"; 3 | 4 | interface RectangleProps { 5 | id: string; 6 | layer: RectangleLayer; 7 | onPointerDown: (e: React.PointerEvent, id: string) => void; 8 | selectionColor?: string; 9 | } 10 | 11 | export const Rectangle = ({ 12 | id, 13 | layer, 14 | onPointerDown, 15 | selectionColor, 16 | }: RectangleProps) => { 17 | const { x, y, width, height, fill } = layer; 18 | return ( 19 | onPointerDown(e, id)} 22 | style={{ 23 | transform: `translate(${x}px, ${y}px)`, 24 | }} 25 | x={0} 26 | y={0} 27 | width={width} 28 | height={height} 29 | strokeWidth={1} 30 | fill={fill ? colorToCss(fill) : "#000"} 31 | stroke={selectionColor || "transparent"} 32 | /> 33 | ); 34 | }; 35 | -------------------------------------------------------------------------------- /app/boards/[boardId]/_components/selectionBox.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useSelectionBounds } from "@/hooks/useSelectionBounds"; 4 | import { useSelf, useStorage } from "@/liveblocks.config"; 5 | import { LayerType, Side, XYWH } from "@/types/canvas"; 6 | import { memo } from "react"; 7 | 8 | interface SelectionBoxProps { 9 | onResizeHandlePointerDown: (corner: Side, initialBounds: XYWH) => void; 10 | } 11 | 12 | const HANDLE_WIDTH = 8; 13 | 14 | export const SelectionBox = memo( 15 | ({ onResizeHandlePointerDown }: SelectionBoxProps) => { 16 | const soleLayerId = useSelf((me) => 17 | me.presence.selection.length === 1 ? me.presence.selection[0] : null 18 | ); 19 | 20 | const isShowingHandles = useStorage( 21 | (root) => 22 | soleLayerId && root.layers.get(soleLayerId)?.type !== LayerType.Path 23 | ); 24 | 25 | const bounds = useSelectionBounds(); 26 | 27 | if (!bounds) return null; 28 | 29 | return ( 30 | <> 31 | 41 | {isShowingHandles && ( 42 | <> 43 | { 56 | e.stopPropagation(); 57 | onResizeHandlePointerDown(Side.Top + Side.Left, bounds); 58 | }} 59 | /> 60 | { 73 | e.stopPropagation(); 74 | onResizeHandlePointerDown(Side.Top, bounds); 75 | }} 76 | /> 77 | { 90 | e.stopPropagation(); 91 | onResizeHandlePointerDown(Side.Top + Side.Right, bounds); 92 | }} 93 | /> 94 | { 108 | e.stopPropagation(); 109 | onResizeHandlePointerDown(Side.Right, bounds); 110 | }} 111 | /> 112 | { 126 | e.stopPropagation(); 127 | onResizeHandlePointerDown(Side.Bottom + Side.Right, bounds); 128 | }} 129 | /> 130 | { 144 | e.stopPropagation(); 145 | onResizeHandlePointerDown(Side.Bottom, bounds); 146 | }} 147 | /> 148 | { 162 | e.stopPropagation(); 163 | onResizeHandlePointerDown(Side.Bottom + Side.Left, bounds); 164 | }} 165 | /> 166 | { 180 | e.stopPropagation(); 181 | onResizeHandlePointerDown(Side.Left, bounds); 182 | }} 183 | /> 184 | 185 | )} 186 | 187 | ); 188 | } 189 | ); 190 | 191 | SelectionBox.displayName = "SelectionBox"; 192 | -------------------------------------------------------------------------------- /app/boards/[boardId]/_components/selectionTools.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Hint } from "@/components/hint"; 4 | import { Button } from "@/components/ui/button"; 5 | import { useDeleteLayers } from "@/hooks/useDeleteLayers"; 6 | import { useSelectionBounds } from "@/hooks/useSelectionBounds"; 7 | import { useMutation, useSelf } from "@/liveblocks.config"; 8 | import { Camera, Color } from "@/types/canvas"; 9 | import { BringToFront, SendToBack, Trash2 } from "lucide-react"; 10 | import { memo } from "react"; 11 | import { ColorPicker } from "./colorPicker"; 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((self) => self.presence.selection); 21 | 22 | const deleteLayers = useDeleteLayers(); 23 | const selectionBounds = useSelectionBounds(); 24 | 25 | const handleMoveToBack = useMutation( 26 | ({ storage }) => { 27 | const liveLayerIds = storage.get("layerIds"); 28 | 29 | const indices: number[] = []; 30 | 31 | const arr = liveLayerIds.toImmutable(); 32 | 33 | for (let i = 0; i < arr.length; i++) { 34 | if (selection.includes(arr[i])) { 35 | indices.push(i); 36 | } 37 | } 38 | 39 | for (let i = 0; i < indices.length; i++) { 40 | liveLayerIds.move(indices[i], i); 41 | } 42 | }, 43 | [selection] 44 | ); 45 | 46 | const handleMoveToFront = useMutation( 47 | ({ storage }) => { 48 | const liveLayerIds = storage.get("layerIds"); 49 | 50 | const indices: number[] = []; 51 | 52 | const arr = liveLayerIds.toImmutable(); 53 | 54 | for (let i = 0; i < arr.length; i++) { 55 | if (selection.includes(arr[i])) { 56 | indices.push(i); 57 | } 58 | } 59 | 60 | for (let i = indices.length - 1; i >= 0; i--) { 61 | liveLayerIds.move( 62 | indices[i], 63 | arr.length - 1 - (indices.length - 1 - i) 64 | ); 65 | } 66 | }, 67 | [selection] 68 | ); 69 | 70 | const handleColorChange = useMutation( 71 | ({ storage }, fill: Color) => { 72 | const liveLayers = storage.get("layers"); 73 | setLastUsedColor(fill); 74 | 75 | selection.forEach((id) => { 76 | liveLayers.get(id)?.set("fill", fill); 77 | }); 78 | }, 79 | [selection, setLastUsedColor] 80 | ); 81 | 82 | if (!selectionBounds) return null; 83 | 84 | const x = selectionBounds.width / 2 + selectionBounds.x - camera.x; 85 | const y = selectionBounds.y + camera.y; 86 | 87 | return ( 88 |
97 | 98 |
99 | 100 | 103 | 104 | 105 | 108 | 109 |
110 |
111 | 112 | 115 | 116 |
117 |
118 | ); 119 | } 120 | ); 121 | 122 | SelectionTools.displayName = "SelectionTools"; 123 | -------------------------------------------------------------------------------- /app/boards/[boardId]/_components/text.tsx: -------------------------------------------------------------------------------- 1 | import { cn, colorToCss } from "@/lib/utils"; 2 | import { useMutation } from "@/liveblocks.config"; 3 | import { TextLayer } from "@/types/canvas"; 4 | import { Kalam } from "next/font/google"; 5 | import ContentEditable, { ContentEditableEvent } from "react-contenteditable"; 6 | 7 | const font = Kalam({ 8 | subsets: ["latin"], 9 | weight: ["400"], 10 | }); 11 | 12 | interface TextProps { 13 | id: string; 14 | layer: TextLayer; 15 | onPointerDown: (e: React.PointerEvent, id: string) => void; 16 | selectionColor?: string; 17 | } 18 | 19 | const calculateFontSize = (width: number, height: number) => { 20 | const maxFontSize = 96; 21 | const scaleFactor = 0.5; 22 | const fontSizeBasedOnHeight = height * scaleFactor; 23 | const fontSizeBasedOnWidth = width * scaleFactor; 24 | 25 | return Math.min(maxFontSize, fontSizeBasedOnHeight, fontSizeBasedOnWidth); 26 | }; 27 | 28 | export const Text = ({ 29 | id, 30 | layer, 31 | onPointerDown, 32 | selectionColor, 33 | }: TextProps) => { 34 | const { x, y, width, height, fill, value } = layer; 35 | 36 | const updateValue = useMutation(({ storage }, newValue: string) => { 37 | const liveLayers = storage.get("layers"); 38 | 39 | liveLayers.get(id)?.set("value", newValue); 40 | }, []); 41 | 42 | const hanldeContentChange = (e: ContentEditableEvent) => { 43 | updateValue(e.target.value); 44 | }; 45 | 46 | return ( 47 | onPointerDown(e, id)} 53 | style={{ 54 | outline: selectionColor ? `1px solid ${selectionColor}` : "none", 55 | }} 56 | > 57 | 69 | 70 | ); 71 | }; 72 | -------------------------------------------------------------------------------- /app/boards/[boardId]/_components/toolButton.tsx: -------------------------------------------------------------------------------- 1 | import { Hint } from "@/components/hint"; 2 | import { Button } from "@/components/ui/button"; 3 | import { LucideIcon } from "lucide-react"; 4 | 5 | interface ToolButtonProps { 6 | label: string; 7 | icon: LucideIcon; 8 | onClick: () => void; 9 | isActive?: boolean; 10 | disabled?: boolean; 11 | } 12 | 13 | export const ToolButton = ({ 14 | label, 15 | icon: Icon, 16 | onClick, 17 | isActive, 18 | disabled, 19 | }: ToolButtonProps) => { 20 | return ( 21 | 22 | 30 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /app/boards/[boardId]/_components/toolbar.tsx: -------------------------------------------------------------------------------- 1 | import { CanvasMode, CanvasState, LayerType } from "@/types/canvas"; 2 | import { 3 | Circle, 4 | MousePointer2, 5 | Pencil, 6 | Redo2, 7 | Square, 8 | StickyNote, 9 | Type, 10 | Undo2, 11 | } from "lucide-react"; 12 | import { ToolButton } from "./toolButton"; 13 | 14 | interface ToolbarProps { 15 | canvasState: CanvasState; 16 | setCanvasState: (newState: CanvasState) => void; 17 | undo: () => void; 18 | redo: () => void; 19 | canUndo: boolean; 20 | canRedo: boolean; 21 | } 22 | 23 | export const Toolbar = ({ 24 | canvasState, 25 | setCanvasState, 26 | undo, 27 | redo, 28 | canUndo, 29 | canRedo, 30 | }: ToolbarProps) => { 31 | return ( 32 |
33 |
34 | setCanvasState({ mode: CanvasMode.None })} 38 | isActive={ 39 | canvasState.mode === CanvasMode.None || 40 | canvasState.mode === CanvasMode.Translating || 41 | canvasState.mode === CanvasMode.SelectionNet || 42 | canvasState.mode === CanvasMode.Pressing || 43 | canvasState.mode === CanvasMode.Resizing 44 | } 45 | /> 46 | 50 | setCanvasState({ 51 | mode: CanvasMode.Inserting, 52 | layerType: LayerType.Text, 53 | }) 54 | } 55 | isActive={ 56 | canvasState.mode === CanvasMode.Inserting && 57 | canvasState.layerType === LayerType.Text 58 | } 59 | /> 60 | 64 | setCanvasState({ 65 | mode: CanvasMode.Inserting, 66 | layerType: LayerType.Note, 67 | }) 68 | } 69 | isActive={ 70 | canvasState.mode === CanvasMode.Inserting && 71 | canvasState.layerType === LayerType.Note 72 | } 73 | /> 74 | 78 | setCanvasState({ 79 | mode: CanvasMode.Inserting, 80 | layerType: LayerType.Rectangle, 81 | }) 82 | } 83 | isActive={ 84 | canvasState.mode === CanvasMode.Inserting && 85 | canvasState.layerType === LayerType.Rectangle 86 | } 87 | /> 88 | 92 | setCanvasState({ 93 | mode: CanvasMode.Inserting, 94 | layerType: LayerType.Ellipse, 95 | }) 96 | } 97 | isActive={ 98 | canvasState.mode === CanvasMode.Inserting && 99 | canvasState.layerType === LayerType.Ellipse 100 | } 101 | /> 102 | 106 | setCanvasState({ 107 | mode: CanvasMode.Pencil, 108 | }) 109 | } 110 | isActive={canvasState.mode === CanvasMode.Pencil} 111 | /> 112 |
113 |
114 | 120 | 126 |
127 |
128 | ); 129 | }; 130 | 131 | export function ToolbarSkeleton() { 132 | return ( 133 |
134 | ); 135 | } 136 | -------------------------------------------------------------------------------- /app/boards/[boardId]/_components/userAvatar.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 | 25 | 26 | 27 | {fallback} 28 | 29 | 30 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /app/boards/[boardId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { Canvas } from "./_components/canvas"; 2 | import { Room } from "@/components/room"; 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 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chunkangwong/miro-clone/5ae907b4267cd1074910e576ad387392aae307e8/app/favicon.ico -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Toaster } from "@/components/ui/sonner"; 2 | import ConvexClientProvider from "@/provders/convexClientProvider"; 3 | import { ModalProvider } from "@/provders/modalProvider"; 4 | import type { Metadata } from "next"; 5 | import { Inter } from "next/font/google"; 6 | import "./globals.css"; 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: "Digital collaboration whiteboard", 15 | }; 16 | 17 | export default function RootLayout({ 18 | children, 19 | }: Readonly<{ 20 | children: React.ReactNode; 21 | }>) { 22 | return ( 23 | 24 | 25 | }> 26 | 27 | 28 | 29 | {children} 30 | 31 | 32 | 33 | 34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chunkangwong/miro-clone/5ae907b4267cd1074910e576ad387392aae307e8/bun.lockb -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /components/actions.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { api } from "@/convex/_generated/api"; 4 | import { Id } from "@/convex/_generated/dataModel"; 5 | import { useApiMutation } from "@/hooks/useApiMutation"; 6 | import { useRenameModal } from "@/store/useRenameModal"; 7 | import type { DropdownMenuContentProps } from "@radix-ui/react-dropdown-menu"; 8 | import { Link2, Pencil, Trash2 } from "lucide-react"; 9 | import { toast } from "sonner"; 10 | import { ConfirmModal } from "./confirmModal"; 11 | import { Button } from "./ui/button"; 12 | import { 13 | DropdownMenu, 14 | DropdownMenuContent, 15 | DropdownMenuItem, 16 | DropdownMenuTrigger, 17 | } from "./ui/dropdown-menu"; 18 | 19 | interface ActionsProp { 20 | children: React.ReactNode; 21 | side?: DropdownMenuContentProps["side"]; 22 | sideOffset?: DropdownMenuContentProps["sideOffset"]; 23 | id: string; 24 | title: string; 25 | } 26 | 27 | export function Actions({ 28 | children, 29 | side, 30 | sideOffset, 31 | id, 32 | title, 33 | }: ActionsProp) { 34 | const { onOpen } = useRenameModal(); 35 | const { mutate: remove, isLoading } = useApiMutation(api.board.remove); 36 | 37 | const handleCopyLink = () => { 38 | navigator.clipboard 39 | .writeText(`${window.location.origin}/boards/${id}`) 40 | .then(() => toast.success("Link copied!")) 41 | .catch(() => toast.error("Failed to copy link")); 42 | }; 43 | 44 | const handleDelete = () => { 45 | remove({ id: id as Id<"boards"> }) 46 | .then(() => toast.success("Board deleted!")) 47 | .catch(() => toast.error("Failed to delete board")); 48 | }; 49 | 50 | return ( 51 | 52 | {children} 53 | e.stopPropagation()} 55 | side={side} 56 | sideOffset={sideOffset} 57 | className="w-60" 58 | > 59 | 63 | 64 | Copy board link 65 | 66 | onOpen(id, title)} 69 | > 70 | 71 | Rename 72 | 73 | 79 | 86 | 87 | 88 | 89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /components/auth/loading.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | export default function Loading() { 4 | return ( 5 |
6 | Logo 13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /components/confirmModal.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 "./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 | return ( 31 | 32 | {children} 33 | 34 | 35 | {header} 36 | {description} 37 | 38 | 39 | Cancel 40 | 41 | Confirm 42 | 43 | 44 | 45 | 46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /components/hint.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Tooltip, 3 | TooltipContent, 4 | TooltipProvider, 5 | TooltipTrigger, 6 | } from "@/components/ui/tooltip"; 7 | 8 | export interface HintProps { 9 | label: string; 10 | children: React.ReactNode; 11 | side?: "top" | "bottom" | "left" | "right"; 12 | align?: "start" | "center" | "end"; 13 | sideOffset?: number; 14 | alignOffset?: number; 15 | } 16 | 17 | export function Hint({ 18 | label, 19 | children, 20 | side, 21 | align, 22 | sideOffset, 23 | alignOffset, 24 | }: HintProps) { 25 | return ( 26 | 27 | 28 | {children} 29 | 36 |

{label}

37 |
38 |
39 |
40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /components/modals/renameModal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Dialog, 5 | DialogClose, 6 | DialogContent, 7 | DialogDescription, 8 | DialogFooter, 9 | DialogHeader, 10 | } from "@/components/ui/dialog"; 11 | import { api } from "@/convex/_generated/api"; 12 | import { useApiMutation } from "@/hooks/useApiMutation"; 13 | import { useRenameModal } from "@/store/useRenameModal"; 14 | import { useEffect, useState } from "react"; 15 | import { toast } from "sonner"; 16 | import { Button } from "../ui/button"; 17 | import { Input } from "../ui/input"; 18 | import { Id } from "@/convex/_generated/dataModel"; 19 | 20 | export const RenameModal = () => { 21 | const { mutate: update, isLoading } = useApiMutation(api.board.update); 22 | 23 | const { isOpen, onClose, initialValues } = useRenameModal(); 24 | 25 | const [title, setTitle] = useState(initialValues.title); 26 | 27 | useEffect(() => { 28 | setTitle(initialValues.title); 29 | }, [initialValues.title]); 30 | 31 | const handleSubmit = (e: React.FormEvent) => { 32 | e.preventDefault(); 33 | update({ id: initialValues.id as Id<"boards">, title }) 34 | .then(() => { 35 | toast.success("Board title updated"); 36 | }) 37 | .catch(() => toast.error("Failed to update board title")) 38 | .finally(onClose); 39 | }; 40 | 41 | return ( 42 | 43 | 44 | Edit board title 45 | Enter a new title for this board 46 |
47 | setTitle(e.target.value)} 53 | placeholder="Board title" 54 | /> 55 | 56 | 57 | 60 | 61 | 64 | 65 |
66 |
67 |
68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /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 | import React from "react"; 8 | 9 | interface RoomProps { 10 | children: React.ReactNode; 11 | roomId: string; 12 | fallback: NonNullable | null; 13 | } 14 | 15 | export const Room = ({ children, roomId, fallback }: RoomProps) => { 16 | return ( 17 | >(), 27 | layerIds: new LiveList(), 28 | }} 29 | > 30 | 31 | {() => children} 32 | 33 | 34 | ); 35 | }; 36 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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.0. 8 | * To regenerate, run `npx convex dev`. 9 | * @module 10 | */ 11 | 12 | import type { 13 | ApiFromModules, 14 | FilterApi, 15 | FunctionReference, 16 | } from "convex/server"; 17 | import type * as board from "../board.js"; 18 | import type * as boards from "../boards.js"; 19 | 20 | /** 21 | * A utility for referencing Convex functions in your app's API. 22 | * 23 | * Usage: 24 | * ```js 25 | * const myFunctionReference = api.myModule.myFunction; 26 | * ``` 27 | */ 28 | declare const fullApi: ApiFromModules<{ 29 | board: typeof board; 30 | boards: typeof boards; 31 | }>; 32 | export declare const api: FilterApi< 33 | typeof fullApi, 34 | FunctionReference 35 | >; 36 | export declare const internal: FilterApi< 37 | typeof fullApi, 38 | FunctionReference 39 | >; 40 | -------------------------------------------------------------------------------- /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.0. 8 | * To regenerate, run `npx convex dev`. 9 | * @module 10 | */ 11 | 12 | import { anyApi } from "convex/server"; 13 | 14 | /** 15 | * A utility for referencing Convex functions in your app's API. 16 | * 17 | * Usage: 18 | * ```js 19 | * const myFunctionReference = api.myModule.myFunction; 20 | * ``` 21 | */ 22 | export const api = anyApi; 23 | export const internal = anyApi; 24 | -------------------------------------------------------------------------------- /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.0. 8 | * To regenerate, run `npx convex dev`. 9 | * @module 10 | */ 11 | 12 | import type { 13 | DataModelFromSchemaDefinition, 14 | DocumentByName, 15 | TableNamesInDataModel, 16 | SystemTableNames, 17 | } from "convex/server"; 18 | import type { GenericId } from "convex/values"; 19 | import schema from "../schema.js"; 20 | 21 | /** 22 | * The names of all of your Convex tables. 23 | */ 24 | export type TableNames = TableNamesInDataModel; 25 | 26 | /** 27 | * The type of a document stored in Convex. 28 | * 29 | * @typeParam TableName - A string literal type of the table name (like "users"). 30 | */ 31 | export type Doc = DocumentByName< 32 | DataModel, 33 | TableName 34 | >; 35 | 36 | /** 37 | * An identifier for a document in Convex. 38 | * 39 | * Convex documents are uniquely identified by their `Id`, which is accessible 40 | * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). 41 | * 42 | * Documents can be loaded using `db.get(id)` in query and mutation functions. 43 | * 44 | * IDs are just strings at runtime, but this type can be used to distinguish them from other 45 | * strings when type checking. 46 | * 47 | * @typeParam TableName - A string literal type of the table name (like "users"). 48 | */ 49 | export type Id = 50 | GenericId; 51 | 52 | /** 53 | * A type describing your Convex data model. 54 | * 55 | * This type includes information about what tables you have, the type of 56 | * documents stored in those tables, and the indexes defined on them. 57 | * 58 | * This type is used to parameterize methods like `queryGeneric` and 59 | * `mutationGeneric` to make them type-safe. 60 | */ 61 | export type DataModel = DataModelFromSchemaDefinition; 62 | -------------------------------------------------------------------------------- /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.0. 8 | * To regenerate, run `npx convex dev`. 9 | * @module 10 | */ 11 | 12 | import { 13 | ActionBuilder, 14 | HttpActionBuilder, 15 | MutationBuilder, 16 | QueryBuilder, 17 | GenericActionCtx, 18 | GenericMutationCtx, 19 | GenericQueryCtx, 20 | GenericDatabaseReader, 21 | GenericDatabaseWriter, 22 | } from "convex/server"; 23 | import type { DataModel } from "./dataModel.js"; 24 | 25 | /** 26 | * Define a query in this Convex app's public API. 27 | * 28 | * This function will be allowed to read your Convex database and will be accessible from the client. 29 | * 30 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 31 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 32 | */ 33 | export declare const query: QueryBuilder; 34 | 35 | /** 36 | * Define a query that is only accessible from other Convex functions (but not from the client). 37 | * 38 | * This function will be allowed to read from your Convex database. It will not be accessible from the client. 39 | * 40 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 41 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 42 | */ 43 | export declare const internalQuery: QueryBuilder; 44 | 45 | /** 46 | * Define a mutation in this Convex app's public API. 47 | * 48 | * This function will be allowed to modify your Convex database and will be accessible from the client. 49 | * 50 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 51 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 52 | */ 53 | export declare const mutation: MutationBuilder; 54 | 55 | /** 56 | * Define a mutation that is only accessible from other Convex functions (but not from the client). 57 | * 58 | * This function will be allowed to modify your Convex database. It will not be accessible from the client. 59 | * 60 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 61 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 62 | */ 63 | export declare const internalMutation: MutationBuilder; 64 | 65 | /** 66 | * Define an action in this Convex app's public API. 67 | * 68 | * An action is a function which can execute any JavaScript code, including non-deterministic 69 | * code and code with side-effects, like calling third-party services. 70 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. 71 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. 72 | * 73 | * @param func - The action. It receives an {@link ActionCtx} as its first argument. 74 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible. 75 | */ 76 | export declare const action: ActionBuilder; 77 | 78 | /** 79 | * Define an action that is only accessible from other Convex functions (but not from the client). 80 | * 81 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 82 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible. 83 | */ 84 | export declare const internalAction: ActionBuilder; 85 | 86 | /** 87 | * Define an HTTP action. 88 | * 89 | * This function will be used to respond to HTTP requests received by a Convex 90 | * deployment if the requests matches the path and method where this action 91 | * is routed. Be sure to route your action in `convex/http.js`. 92 | * 93 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 94 | * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. 95 | */ 96 | export declare const httpAction: HttpActionBuilder; 97 | 98 | /** 99 | * A set of services for use within Convex query functions. 100 | * 101 | * The query context is passed as the first argument to any Convex query 102 | * function run on the server. 103 | * 104 | * This differs from the {@link MutationCtx} because all of the services are 105 | * read-only. 106 | */ 107 | export type QueryCtx = GenericQueryCtx; 108 | 109 | /** 110 | * A set of services for use within Convex mutation functions. 111 | * 112 | * The mutation context is passed as the first argument to any Convex mutation 113 | * function run on the server. 114 | */ 115 | export type MutationCtx = GenericMutationCtx; 116 | 117 | /** 118 | * A set of services for use within Convex action functions. 119 | * 120 | * The action context is passed as the first argument to any Convex action 121 | * function run on the server. 122 | */ 123 | export type ActionCtx = GenericActionCtx; 124 | 125 | /** 126 | * An interface to read from the database within Convex query functions. 127 | * 128 | * The two entry points are {@link DatabaseReader.get}, which fetches a single 129 | * document by its {@link Id}, or {@link DatabaseReader.query}, which starts 130 | * building a query. 131 | */ 132 | export type DatabaseReader = GenericDatabaseReader; 133 | 134 | /** 135 | * An interface to read from and write to the database within Convex mutation 136 | * functions. 137 | * 138 | * Convex guarantees that all writes within a single mutation are 139 | * executed atomically, so you never have to worry about partial writes leaving 140 | * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) 141 | * for the guarantees Convex provides your functions. 142 | */ 143 | export type DatabaseWriter = GenericDatabaseWriter; 144 | -------------------------------------------------------------------------------- /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.0. 8 | * To regenerate, run `npx convex dev`. 9 | * @module 10 | */ 11 | 12 | import { 13 | actionGeneric, 14 | httpActionGeneric, 15 | queryGeneric, 16 | mutationGeneric, 17 | internalActionGeneric, 18 | internalMutationGeneric, 19 | internalQueryGeneric, 20 | } from "convex/server"; 21 | 22 | /** 23 | * Define a query in this Convex app's public API. 24 | * 25 | * This function will be allowed to read your Convex database and will be accessible from the client. 26 | * 27 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 28 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 29 | */ 30 | export const query = queryGeneric; 31 | 32 | /** 33 | * Define a query that is only accessible from other Convex functions (but not from the client). 34 | * 35 | * This function will be allowed to read from your Convex database. It will not be accessible from the client. 36 | * 37 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 38 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 39 | */ 40 | export const internalQuery = internalQueryGeneric; 41 | 42 | /** 43 | * Define a mutation in this Convex app's public API. 44 | * 45 | * This function will be allowed to modify your Convex database and will be accessible from the client. 46 | * 47 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 48 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 49 | */ 50 | export const mutation = mutationGeneric; 51 | 52 | /** 53 | * Define a mutation that is only accessible from other Convex functions (but not from the client). 54 | * 55 | * This function will be allowed to modify your Convex database. It will not be accessible from the client. 56 | * 57 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 58 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 59 | */ 60 | export const internalMutation = internalMutationGeneric; 61 | 62 | /** 63 | * Define an action in this Convex app's public API. 64 | * 65 | * An action is a function which can execute any JavaScript code, including non-deterministic 66 | * code and code with side-effects, like calling third-party services. 67 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. 68 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. 69 | * 70 | * @param func - The action. It receives an {@link ActionCtx} as its first argument. 71 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible. 72 | */ 73 | export const action = actionGeneric; 74 | 75 | /** 76 | * Define an action that is only accessible from other Convex functions (but not from the client). 77 | * 78 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 79 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible. 80 | */ 81 | export const internalAction = internalActionGeneric; 82 | 83 | /** 84 | * Define a Convex HTTP action. 85 | * 86 | * @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object 87 | * as its second. 88 | * @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`. 89 | */ 90 | export const httpAction = httpActionGeneric; 91 | -------------------------------------------------------------------------------- /convex/auth.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | providers: [ 3 | { 4 | domain: "https://skilled-termite-54.clerk.accounts.dev", 5 | applicationID: "convex", 6 | }, 7 | ], 8 | }; 9 | -------------------------------------------------------------------------------- /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 | export const create = mutation({ 18 | args: { 19 | orgId: v.string(), 20 | title: v.string(), 21 | }, 22 | handler: async (ctx, args) => { 23 | const identity = await ctx.auth.getUserIdentity(); 24 | 25 | if (!identity) { 26 | throw new Error("Unauthorized"); 27 | } 28 | 29 | const randomImage = images[Math.floor(Math.random() * images.length)]; 30 | 31 | const board = await ctx.db.insert("boards", { 32 | orgId: args.orgId, 33 | title: args.title, 34 | authorId: identity.subject, 35 | authorName: identity.name!, 36 | imageUrl: randomImage, 37 | }); 38 | 39 | return board; 40 | }, 41 | }); 42 | 43 | export const remove = mutation({ 44 | args: { 45 | id: v.id("boards"), 46 | }, 47 | handler: async (ctx, args) => { 48 | const identity = await ctx.auth.getUserIdentity(); 49 | 50 | if (!identity) { 51 | throw new Error("Unauthorized"); 52 | } 53 | 54 | const userId = identity.subject; 55 | 56 | const existingFavourite = await ctx.db 57 | .query("userFavourites") 58 | .withIndex("by_user_board", (q) => 59 | q.eq("userId", userId).eq("boardId", args.id) 60 | ) 61 | .unique(); 62 | 63 | if (existingFavourite) { 64 | await ctx.db.delete(existingFavourite._id); 65 | } 66 | 67 | await ctx.db.delete(args.id); 68 | }, 69 | }); 70 | 71 | export const update = mutation({ 72 | args: { 73 | id: v.id("boards"), 74 | title: v.string(), 75 | }, 76 | handler: async (ctx, args) => { 77 | const identity = await ctx.auth.getUserIdentity(); 78 | 79 | if (!identity) { 80 | throw new Error("Unauthorized"); 81 | } 82 | 83 | const title = args.title.trim(); 84 | 85 | if (!title) { 86 | throw new Error("Title is required"); 87 | } 88 | 89 | if (title.length > 60) { 90 | throw new Error("Title cannot be longer than 60 characters"); 91 | } 92 | 93 | const board = await ctx.db.patch(args.id, { 94 | title: args.title, 95 | }); 96 | 97 | return board; 98 | }, 99 | }); 100 | 101 | export const favourite = mutation({ 102 | args: { id: v.id("boards"), orgId: v.string() }, 103 | handler: async (ctx, args) => { 104 | const identity = await ctx.auth.getUserIdentity(); 105 | 106 | if (!identity) { 107 | throw new Error("Unauthorized"); 108 | } 109 | 110 | const board = await ctx.db.get(args.id); 111 | 112 | if (!board) { 113 | throw new Error("Board not found"); 114 | } 115 | 116 | const userId = identity.subject; 117 | 118 | const existingFavourite = await ctx.db 119 | .query("userFavourites") 120 | .withIndex("by_user_board", (q) => 121 | q.eq("userId", userId).eq("boardId", board._id) 122 | ) 123 | .unique(); 124 | 125 | if (existingFavourite) { 126 | throw new Error("Board already favourited"); 127 | } 128 | 129 | await ctx.db.insert("userFavourites", { 130 | orgId: args.orgId, 131 | userId: userId, 132 | boardId: board._id, 133 | }); 134 | 135 | return board; 136 | }, 137 | }); 138 | 139 | export const unfavourite = mutation({ 140 | args: { id: v.id("boards") }, 141 | handler: async (ctx, args) => { 142 | const identity = await ctx.auth.getUserIdentity(); 143 | 144 | if (!identity) { 145 | throw new Error("Unauthorized"); 146 | } 147 | 148 | const board = await ctx.db.get(args.id); 149 | 150 | if (!board) { 151 | throw new Error("Board not found"); 152 | } 153 | 154 | const userId = identity.subject; 155 | 156 | const existingFavourite = await ctx.db 157 | .query("userFavourites") 158 | .withIndex("by_user_board", (q) => 159 | q.eq("userId", userId).eq("boardId", board._id) 160 | ) 161 | .unique(); 162 | 163 | if (!existingFavourite) { 164 | throw new Error("Favourited board not found"); 165 | } 166 | 167 | await ctx.db.delete(existingFavourite._id); 168 | 169 | return board; 170 | }, 171 | }); 172 | 173 | export const get = query({ 174 | args: { id: v.id("boards") }, 175 | handler: async (ctx, args) => { 176 | const board = await ctx.db.get(args.id); 177 | 178 | if (!board) { 179 | throw new Error("Board not found"); 180 | } 181 | 182 | return board; 183 | }, 184 | }); 185 | -------------------------------------------------------------------------------- /convex/boards.ts: -------------------------------------------------------------------------------- 1 | import { v } from "convex/values"; 2 | import { query } from "./_generated/server"; 3 | import { getAllOrThrow } from "convex-helpers/server/relationships"; 4 | 5 | export const get = query({ 6 | args: { 7 | orgId: v.string(), 8 | search: v.optional(v.string()), 9 | favourites: 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.favourites) { 19 | const favouriteBoards = await ctx.db 20 | .query("userFavourites") 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 = favouriteBoards.map((favourite) => favourite.boardId); 28 | 29 | const boards = await getAllOrThrow(ctx.db, ids); 30 | 31 | return boards.map((board) => ({ 32 | ...board, 33 | isFavourite: true, 34 | })); 35 | } 36 | 37 | const title = args.search as string; 38 | 39 | let boards = []; 40 | 41 | if (title) { 42 | boards = await ctx.db 43 | .query("boards") 44 | .withSearchIndex("search_title", (q) => 45 | q.search("title", title).eq("orgId", args.orgId) 46 | ) 47 | .collect(); 48 | } else { 49 | boards = await ctx.db 50 | .query("boards") 51 | .withIndex("by_org", (q) => q.eq("orgId", args.orgId)) 52 | .order("desc") 53 | .collect(); 54 | } 55 | 56 | const boardsWithFavouriteRelation = boards.map((board) => { 57 | return ctx.db 58 | .query("userFavourites") 59 | .withIndex("by_user_board", (q) => 60 | q.eq("userId", identity.subject).eq("boardId", board._id) 61 | ) 62 | .unique() 63 | .then((favourite) => { 64 | return { ...board, isFavourite: !!favourite }; 65 | }); 66 | }); 67 | 68 | const boardsWithFavouriteBoolean = Promise.all(boardsWithFavouriteRelation); 69 | 70 | return boardsWithFavouriteBoolean; 71 | }, 72 | }); 73 | -------------------------------------------------------------------------------- /convex/schema.ts: -------------------------------------------------------------------------------- 1 | import { v } from "convex/values"; 2 | import { defineTable, defineSchema } from "convex/server"; 3 | 4 | export default defineSchema({ 5 | boards: defineTable({ 6 | title: v.string(), 7 | orgId: v.string(), 8 | authorId: v.string(), 9 | authorName: v.string(), 10 | imageUrl: v.string(), 11 | }) 12 | .index("by_org", ["orgId"]) 13 | .searchIndex("search_title", { 14 | searchField: "title", 15 | filterFields: ["orgId"], 16 | }), 17 | userFavourites: 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /hooks/useApiMutation.ts: -------------------------------------------------------------------------------- 1 | import { useMutation } from "convex/react"; 2 | import type { 3 | DefaultFunctionArgs, 4 | FunctionReference, 5 | OptionalRestArgs, 6 | } from "convex/server"; 7 | import { useState } from "react"; 8 | 9 | export const useApiMutation = < 10 | Args extends DefaultFunctionArgs = any, 11 | ReturnType = any 12 | >( 13 | mutationFn: FunctionReference<"mutation", "public", Args, ReturnType> 14 | ) => { 15 | const [isLoading, setIsLoading] = useState(false); 16 | 17 | const apiMutation = useMutation(mutationFn); 18 | 19 | const mutate = ( 20 | ...payload: OptionalRestArgs< 21 | FunctionReference<"mutation", "public", Args, ReturnType> 22 | > 23 | ) => { 24 | setIsLoading(true); 25 | return apiMutation(...payload) 26 | .finally(() => setIsLoading(false)) 27 | .then((result) => result) 28 | .catch((error) => { 29 | throw error; 30 | }); 31 | }; 32 | 33 | return { 34 | mutate, 35 | isLoading, 36 | }; 37 | }; 38 | -------------------------------------------------------------------------------- /hooks/useDeleteLayers.ts: -------------------------------------------------------------------------------- 1 | import { useMutation, useSelf } from "@/liveblocks.config"; 2 | 3 | export const useDeleteLayers = () => { 4 | const selection = useSelf((me) => me.presence.selection); 5 | 6 | return useMutation( 7 | ({ storage, setMyPresence }) => { 8 | const liveLayers = storage.get("layers"); 9 | const liveLayerIds = storage.get("layerIds"); 10 | 11 | for (const id of selection) { 12 | liveLayers.delete(id); 13 | 14 | const index = liveLayerIds.indexOf(id); 15 | 16 | if (index !== -1) { 17 | liveLayerIds.delete(index); 18 | } 19 | } 20 | 21 | setMyPresence({ selection: [] }, { addToHistory: true }); 22 | }, 23 | [selection] 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /hooks/useDisableScrollBounce.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | 3 | export const useDisableScrollBounce = () => { 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 | -------------------------------------------------------------------------------- /hooks/useSelectionBounds.ts: -------------------------------------------------------------------------------- 1 | import { useSelf, useStorage } from "@/liveblocks.config"; 2 | import type { Layer, XYWH } from "@/types/canvas"; 3 | import { shallow } from "@liveblocks/react"; 4 | 5 | const boundingBox = (layers: Layer[]): XYWH | null => { 6 | const first = layers[0]; 7 | 8 | if (!first) return null; 9 | 10 | let left = first.x; 11 | let right = first.x + first.width; 12 | let top = first.y; 13 | let bottom = first.y + first.height; 14 | 15 | for (let i = 1; i < layers.length; i++) { 16 | const { x, y, width, height } = layers[i]; 17 | 18 | if (left > x) { 19 | left = x; 20 | } 21 | 22 | if (right < x + width) { 23 | right = x + width; 24 | } 25 | 26 | if (top > y) { 27 | top = y; 28 | } 29 | 30 | if (bottom < y + height) { 31 | bottom = y + height; 32 | } 33 | } 34 | 35 | return { 36 | x: left, 37 | y: top, 38 | width: right - left, 39 | height: bottom - top, 40 | }; 41 | }; 42 | 43 | export const useSelectionBounds = () => { 44 | const selection = useSelf((me) => me.presence.selection); 45 | 46 | return useStorage((root) => { 47 | const selectedLayers = selection 48 | .map((layerId) => root.layers.get(layerId)!) 49 | .filter(Boolean); 50 | 51 | return boundingBox(selectedLayers); 52 | }, shallow); 53 | }; 54 | -------------------------------------------------------------------------------- /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 { clsx, type ClassValue } 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 const connectionIdToColor = (connectionId: number) => { 21 | return COLORS[connectionId % COLORS.length]; 22 | }; 23 | 24 | export const 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 | -------------------------------------------------------------------------------- /liveblocks.config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createClient, 3 | LiveList, 4 | LiveMap, 5 | LiveObject, 6 | } from "@liveblocks/client"; 7 | import { createRoomContext } from "@liveblocks/react"; 8 | import type { Layer, Color } from "@/types/canvas"; 9 | 10 | const client = createClient({ 11 | authEndpoint: "/api/liveblocks-auth", 12 | throttle: 16, 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 | pencilColor: Color | null; 23 | }; 24 | 25 | // Optionally, Storage represents the shared document that persists in the 26 | // Room, even after all users leave. Fields under Storage typically are 27 | // LiveList, LiveMap, LiveObject instances, for which updates are 28 | // automatically persisted and synced to all connected clients. 29 | type Storage = { 30 | layers: LiveMap>; 31 | layerIds: LiveList; 32 | }; 33 | 34 | // Optionally, UserMeta represents static/readonly metadata on each user, as 35 | // provided by your own custom auth back end (if used). Useful for data that 36 | // will not change during a session, like a user's name or avatar. 37 | type UserMeta = { 38 | id?: string; 39 | info?: { 40 | name?: string; 41 | picture?: string; 42 | }; 43 | }; 44 | 45 | // Optionally, the type of custom events broadcast and listened to in this 46 | // room. Use a union for multiple events. Must be JSON-serializable. 47 | type RoomEvent = { 48 | // type: "NOTIFICATION", 49 | // ... 50 | }; 51 | 52 | // Optionally, when using Comments, ThreadMetadata represents metadata on 53 | // each thread. Can only contain booleans, strings, and numbers. 54 | export type ThreadMetadata = { 55 | // resolved: boolean; 56 | // quote: string; 57 | // time: number; 58 | }; 59 | 60 | export const { 61 | suspense: { 62 | RoomProvider, 63 | useRoom, 64 | useMyPresence, 65 | useUpdateMyPresence, 66 | useSelf, 67 | useOthers, 68 | useOthersMapped, 69 | useOthersConnectionIds, 70 | useOther, 71 | useBroadcastEvent, 72 | useEventListener, 73 | useErrorListener, 74 | useStorage, 75 | useObject, 76 | useMap, 77 | useList, 78 | useBatch, 79 | useHistory, 80 | useUndo, 81 | useRedo, 82 | useCanUndo, 83 | useCanRedo, 84 | useMutation, 85 | useStatus, 86 | useLostConnectionListener, 87 | useThreads, 88 | useUser, 89 | useCreateThread, 90 | useEditThreadMetadata, 91 | useCreateComment, 92 | useEditComment, 93 | useDeleteComment, 94 | useAddReaction, 95 | useRemoveReaction, 96 | }, 97 | } = createRoomContext( 98 | client, 99 | { 100 | async resolveUsers({ userIds }) { 101 | // Used only for Comments. Return a list of user information retrieved 102 | // from `userIds`. This info is used in comments, mentions etc. 103 | 104 | // const usersData = await __fetchUsersFromDB__(userIds); 105 | // 106 | // return usersData.map((userData) => ({ 107 | // name: userData.name, 108 | // avatar: userData.avatar.src, 109 | // })); 110 | 111 | return []; 112 | }, 113 | async resolveMentionSuggestions({ text, roomId }) { 114 | // Used only for Comments. Return a list of userIds that match `text`. 115 | // These userIds are used to create a mention list when typing in the 116 | // composer. 117 | // 118 | // For example when you type "@jo", `text` will be `"jo"`, and 119 | // you should to return an array with John and Joanna's userIds: 120 | // ["john@example.com", "joanna@example.com"] 121 | 122 | // const userIds = await __fetchAllUserIdsFromDB__(roomId); 123 | // 124 | // Return all userIds if no `text` 125 | // if (!text) { 126 | // return userIds; 127 | // } 128 | // 129 | // Otherwise, filter userIds for the search `text` and return 130 | // return userIds.filter((userId) => 131 | // userId.toLowerCase().includes(text.toLowerCase()) 132 | // ); 133 | 134 | return []; 135 | }, 136 | } 137 | ); 138 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | remotePatterns: [ 5 | { 6 | protocol: "https", 7 | hostname: "img.clerk.com", 8 | }, 9 | ], 10 | }, 11 | }; 12 | 13 | export default nextConfig; 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "miro-clone", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@clerk/nextjs": "^4.29.6", 13 | "@liveblocks/client": "^1.9.7", 14 | "@liveblocks/node": "^1.9.7", 15 | "@liveblocks/react": "^1.9.7", 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.15", 26 | "date-fns": "^3.3.1", 27 | "lucide-react": "^0.323.0", 28 | "nanoid": "^5.0.5", 29 | "next": "14.1.0", 30 | "next-themes": "^0.2.1", 31 | "perfect-freehand": "^1.2.0", 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.13.0", 40 | "zustand": "^4.5.0" 41 | }, 42 | "devDependencies": { 43 | "typescript": "^5", 44 | "@types/node": "^20", 45 | "@types/react": "^18", 46 | "@types/react-dom": "^18", 47 | "autoprefixer": "^10.0.1", 48 | "postcss": "^8", 49 | "tailwindcss": "^3.3.0", 50 | "eslint": "^8", 51 | "eslint-config-next": "14.1.0" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /provders/convexClientProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Loading from "@/components/auth/loading"; 4 | import { ClerkProvider, useAuth } from "@clerk/nextjs"; 5 | import { AuthLoading, Authenticated, ConvexReactClient } from "convex/react"; 6 | import { ConvexProviderWithClerk } from "convex/react-clerk"; 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 default function ConvexClientProvider({ 17 | children, 18 | }: ConvexClientProviderProps) { 19 | return ( 20 | 21 | 22 | {children} 23 | 24 | 25 | 26 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /provders/modalProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { RenameModal } from "@/components/modals/renameModal"; 4 | import { useEffect, useState } from "react"; 5 | 6 | export const ModalProvider = () => { 7 | const [isMounted, setIsMounted] = useState(false); 8 | 9 | useEffect(() => { 10 | setIsMounted(true); 11 | }, []); 12 | 13 | if (!isMounted) { 14 | return null; 15 | } 16 | 17 | return ( 18 | <> 19 | 20 | 21 | ); 22 | }; 23 | -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/placeholders/2.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/placeholders/3.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/placeholders/4.svg: -------------------------------------------------------------------------------- 1 | happy_music -------------------------------------------------------------------------------- /public/placeholders/5.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/placeholders/6.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/placeholders/7.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/placeholders/9.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /store/useRenameModal.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | const defaultValues = { 4 | id: "", 5 | title: "", 6 | }; 7 | 8 | interface IRenameModal { 9 | isOpen: boolean; 10 | initialValues: typeof defaultValues; 11 | onOpen: (id: string, title: string) => void; 12 | onClose: () => void; 13 | } 14 | 15 | export const useRenameModal = create((set) => ({ 16 | isOpen: false, 17 | initialValues: defaultValues, 18 | onOpen: (id, title) => { 19 | set(() => ({ isOpen: true, initialValues: { id, title } })); 20 | }, 21 | onClose: () => { 22 | set(() => ({ isOpen: false, initialValues: defaultValues })); 23 | }, 24 | })); 25 | -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | Text, 14 | Note, 15 | Rectangle, 16 | Ellipse, 17 | Path, 18 | } 19 | 20 | export type RectangleLayer = { 21 | type: LayerType.Rectangle; 22 | x: number; 23 | y: number; 24 | height: number; 25 | width: number; 26 | fill: Color; 27 | value?: string; 28 | }; 29 | 30 | export type EllipseLayer = { 31 | type: LayerType.Ellipse; 32 | x: number; 33 | y: number; 34 | height: number; 35 | width: number; 36 | fill: Color; 37 | value?: string; 38 | }; 39 | 40 | export type PathLayer = { 41 | type: LayerType.Path; 42 | x: number; 43 | y: number; 44 | height: number; 45 | width: number; 46 | fill: Color; 47 | points: number[][]; 48 | value?: string; 49 | }; 50 | 51 | export type TextLayer = { 52 | type: LayerType.Text; 53 | x: number; 54 | y: number; 55 | height: number; 56 | width: number; 57 | fill: Color; 58 | value?: string; 59 | }; 60 | 61 | export type NoteLayer = { 62 | type: LayerType.Note; 63 | x: number; 64 | y: number; 65 | height: number; 66 | width: number; 67 | fill: Color; 68 | value?: string; 69 | }; 70 | 71 | export type Point = { 72 | x: number; 73 | y: number; 74 | }; 75 | 76 | export type XYWH = { 77 | x: number; 78 | y: number; 79 | width: number; 80 | height: number; 81 | }; 82 | 83 | export enum Side { 84 | Top = 1, 85 | Bottom = 2, 86 | Left = 4, 87 | Right = 8, 88 | } 89 | 90 | export type CanvasState = 91 | | { 92 | mode: CanvasMode.None; 93 | } 94 | | { 95 | mode: CanvasMode.Pressing; 96 | origin: Point; 97 | } 98 | | { 99 | mode: CanvasMode.SelectionNet; 100 | origin: Point; 101 | current?: Point; 102 | } 103 | | { 104 | mode: CanvasMode.Translating; 105 | current: Point; 106 | } 107 | | { 108 | mode: CanvasMode.Inserting; 109 | layerType: 110 | | LayerType.Ellipse 111 | | LayerType.Rectangle 112 | | LayerType.Text 113 | | LayerType.Note; 114 | } 115 | | { 116 | mode: CanvasMode.Resizing; 117 | initialBounds: XYWH; 118 | corner: Side; 119 | } 120 | | { 121 | mode: CanvasMode.Pencil; 122 | }; 123 | 124 | export enum CanvasMode { 125 | None, 126 | Pressing, 127 | SelectionNet, 128 | Translating, 129 | Inserting, 130 | Resizing, 131 | Pencil, 132 | } 133 | 134 | export type Layer = 135 | | RectangleLayer 136 | | EllipseLayer 137 | | PathLayer 138 | | TextLayer 139 | | NoteLayer; 140 | --------------------------------------------------------------------------------