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