├── .eslintrc.json ├── app ├── fonts │ ├── GeistVF.woff │ └── GeistMonoVF.woff ├── (dashboard) │ ├── _components │ │ ├── boardCard │ │ │ ├── overLay.tsx │ │ │ ├── footer.tsx │ │ │ └── index.tsx │ │ ├── sidebar │ │ │ ├── index.tsx │ │ │ ├── list.tsx │ │ │ ├── item.tsx │ │ │ └── NewButton.tsx │ │ ├── EmptyFav.tsx │ │ ├── emptySearch.tsx │ │ ├── invite-button.tsx │ │ ├── EmptyOrg.tsx │ │ ├── search-input.tsx │ │ ├── navbar.tsx │ │ ├── Emptyboard.tsx │ │ ├── NewBoardBtn.tsx │ │ ├── BoardList.tsx │ │ └── OrgSidebar.tsx │ ├── layout.tsx │ └── page.tsx ├── board │ ├── [boardid] │ │ └── page.tsx │ └── _components │ │ ├── loading.tsx │ │ ├── CursorPresent.tsx │ │ ├── userAvatar.tsx │ │ ├── ToolButton.tsx │ │ ├── Cursor.tsx │ │ ├── participants.tsx │ │ ├── info.tsx │ │ ├── toolbar.tsx │ │ └── canvas.tsx ├── layout.tsx ├── api │ └── livebloack-auth │ │ └── route.ts └── globals.css ├── .hintrc ├── postcss.config.mjs ├── convex ├── auth.config.js ├── _generated │ ├── api.js │ ├── api.d.ts │ ├── dataModel.d.ts │ ├── server.js │ └── server.d.ts ├── schema.ts ├── boards.ts └── board.ts ├── components ├── ui │ ├── skeleton.tsx │ ├── input.tsx │ ├── sonner.tsx │ ├── tooltip.tsx │ ├── avatar.tsx │ ├── button.tsx │ ├── dialog.tsx │ ├── alert-dialog.tsx │ └── dropdown-menu.tsx ├── auth │ └── loading.tsx ├── room.tsx ├── hint.tsx ├── ConfirmModel.tsx ├── model │ └── renameModel.tsx └── action.tsx ├── next.config.mjs ├── middleware.ts ├── providers ├── modelProvider.tsx └── convex-client-provider.tsx ├── components.json ├── .gitignore ├── store └── useRenameModel.ts ├── tsconfig.json ├── hooks └── useMutationAip.ts ├── lib └── utils.ts ├── README.md ├── package.json ├── tailwind.config.ts ├── types └── canvas.ts └── liveblocks.config.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "next/typescript"] 3 | } 4 | -------------------------------------------------------------------------------- /app/fonts/GeistVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arbab-Mustafa/Real-Time-Miro/HEAD/app/fonts/GeistVF.woff -------------------------------------------------------------------------------- /app/fonts/GeistMonoVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arbab-Mustafa/Real-Time-Miro/HEAD/app/fonts/GeistMonoVF.woff -------------------------------------------------------------------------------- /.hintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "development" 4 | ], 5 | "hints": { 6 | "button-type": "off", 7 | "axe/name-role-value": "off" 8 | } 9 | } -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /convex/auth.config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | providers: [ 3 | { 4 | domain: "https://promoted-macaw-86.clerk.accounts.dev", 5 | applicationID: "convex", 6 | }, 7 | ], 8 | }; 9 | 10 | export default config; 11 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/boardCard/overLay.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const OverLay = () => { 4 | return ( 5 |
6 | ); 7 | }; 8 | 9 | export default OverLay; 10 | -------------------------------------------------------------------------------- /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/auth/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Loader } from "lucide-react"; 2 | 3 | import React from "react"; 4 | 5 | const Loading = () => { 6 | return ( 7 |
8 | 9 |
10 | ); 11 | }; 12 | 13 | export default Loading; 14 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | remotePatterns: [ 5 | { 6 | protocol: "https", // without colon 7 | hostname: "img.clerk.com", // ensure hostname is correct 8 | pathname: "/**", // match all paths 9 | }, 10 | ], 11 | }, 12 | }; 13 | 14 | export default nextConfig; 15 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/sidebar/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import NewButton from "./NewButton"; 3 | import List from "./list"; 4 | 5 | const Sidebar = () => { 6 | return ( 7 | 11 | ); 12 | }; 13 | 14 | export default Sidebar; 15 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { authMiddleware } from "@clerk/nextjs/server"; 2 | 3 | export default authMiddleware({}); 4 | 5 | export const config = { 6 | matcher: [ 7 | // Skip Next.js internals and all static files, unless found in search params 8 | "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)", 9 | // Always run for API routes 10 | "/(api|trpc)(.*)", 11 | ], 12 | }; 13 | -------------------------------------------------------------------------------- /providers/modelProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import RenameModel from "@/components/model/renameModel"; 4 | import { useEffect, useState } from "react"; 5 | 6 | export const ModelProvider = () => { 7 | const [isMounted, setIsMounted] = useState(false); 8 | 9 | useEffect(() => { 10 | setIsMounted(true); 11 | }, []); 12 | 13 | if (!isMounted) { 14 | return null; 15 | } 16 | return ( 17 | <> 18 | 19 | 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /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": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | } 20 | } -------------------------------------------------------------------------------- /app/(dashboard)/_components/EmptyFav.tsx: -------------------------------------------------------------------------------- 1 | import { Info } from "lucide-react"; 2 | import React from "react"; 3 | 4 | const EmptyFav = () => { 5 | return ( 6 |
7 | 8 |

9 | No Favourite Result Found 10 |

11 |
12 | ); 13 | }; 14 | 15 | export default EmptyFav; 16 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/emptySearch.tsx: -------------------------------------------------------------------------------- 1 | import { Info } from "lucide-react"; 2 | import React from "react"; 3 | 4 | const EmptySearch = () => { 5 | return ( 6 |
7 | 8 |

9 | No Result Found 10 |

11 |
12 | ); 13 | }; 14 | 15 | export default EmptySearch; 16 | -------------------------------------------------------------------------------- /app/board/[boardid]/page.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Canvas from "../_components/canvas"; 3 | import Room from "../../../components/room"; 4 | import Loading from "../_components/loading"; 5 | interface BoardIdPageProps { 6 | params: { 7 | boardid: string; 8 | }; 9 | } 10 | 11 | const BoardIdPage = ({ params }: BoardIdPageProps) => { 12 | return ( 13 | }> 14 | ; 15 | 16 | ); 17 | }; 18 | 19 | export default BoardIdPage; 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /app/board/_components/loading.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Loader } from "lucide-react"; 3 | import React from "react"; 4 | import BoardInfo from "./info"; 5 | import Participants from "./participants"; 6 | 7 | const Loading = () => { 8 | return ( 9 |
10 | 11 | 12 | 13 |
14 | ); 15 | }; 16 | 17 | export default Loading; 18 | -------------------------------------------------------------------------------- /convex/_generated/api.js: -------------------------------------------------------------------------------- 1 | /* prettier-ignore-start */ 2 | 3 | /* eslint-disable */ 4 | /** 5 | * Generated `api` utility. 6 | * 7 | * THIS CODE IS AUTOMATICALLY GENERATED. 8 | * 9 | * To regenerate, run `npx convex dev`. 10 | * @module 11 | */ 12 | 13 | import { anyApi } from "convex/server"; 14 | 15 | /** 16 | * A utility for referencing Convex functions in your app's API. 17 | * 18 | * Usage: 19 | * ```js 20 | * const myFunctionReference = api.myModule.myFunction; 21 | * ``` 22 | */ 23 | export const api = anyApi; 24 | export const internal = anyApi; 25 | 26 | /* prettier-ignore-end */ 27 | -------------------------------------------------------------------------------- /app/board/_components/CursorPresent.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { memo } from "react"; 4 | 5 | import { useOthersConnectionIds } from "@/liveblocks.config"; 6 | import Cursor from "./Cursor"; 7 | 8 | // 9 | 10 | const Cursors = () => { 11 | const id = useOthersConnectionIds(); 12 | return ( 13 | <> 14 | {id.map((connectionId) => ( 15 | 16 | ))} 17 | 18 | ); 19 | }; 20 | 21 | export const CursorPresent = memo(() => { 22 | return ( 23 | <> 24 | 25 | 26 | ); 27 | }); 28 | 29 | CursorPresent.displayName = "CursorPresent"; 30 | -------------------------------------------------------------------------------- /store/useRenameModel.ts: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | const defaultValue = { 3 | id: "", 4 | title: "", 5 | }; 6 | 7 | interface UseRenameModelProps { 8 | isOpen: boolean; 9 | initialValues: typeof defaultValue; 10 | onOpen: (id: string, title: string) => void; 11 | onClose: () => void; 12 | } 13 | 14 | export const useRenameModel = create((set) => ({ 15 | isOpen: false, 16 | onOpen: (id, title) => 17 | set({ 18 | isOpen: true, 19 | initialValues: { id, title }, 20 | }), 21 | onClose: () => set({ isOpen: false, initialValues: defaultValue }), 22 | initialValues: defaultValue, 23 | })); 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /hooks/useMutationAip.ts: -------------------------------------------------------------------------------- 1 | import { useMutation } from "convex/react"; 2 | import { useState } from "react"; 3 | 4 | const useMutationAip = (mutationFunction) => { 5 | const [pending, setPending] = useState(false); 6 | const apiMutation = useMutation(mutationFunction); 7 | 8 | const mutate = async (payload) => { 9 | setPending(true); 10 | try { 11 | const result = await apiMutation(payload); 12 | return result; 13 | } catch (err) { 14 | // Rethrow error to be caught in calling functions 15 | throw err; 16 | } finally { 17 | setPending(false); 18 | } 19 | }; 20 | 21 | return { mutate, pending }; 22 | }; 23 | 24 | export default useMutationAip; 25 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { Camera } from "@/types/canvas"; 2 | import { clsx, type ClassValue } from "clsx"; 3 | import React from "react"; 4 | import { twMerge } from "tailwind-merge"; 5 | 6 | const COLORS = ["#FFC7C7", "#FF6B6B", "#FFD3B6", "#FF922E"]; 7 | 8 | export function cn(...inputs: ClassValue[]) { 9 | return twMerge(clsx(inputs)); 10 | } 11 | export function connectionIdToColor(connectionId: number): string { 12 | return COLORS[connectionId % COLORS.length]; 13 | } 14 | 15 | export function pointerEventtoCanvasPoint( 16 | e: React.PointerEvent, 17 | camera: Camera 18 | ) { 19 | return { 20 | x: Math.round(e.clientX) - camera.x, 21 | y: Math.round(e.clientY) - camera.y, 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /app/(dashboard)/layout.tsx: -------------------------------------------------------------------------------- 1 | import Navbar from "./_components/navbar"; 2 | import OrgSidebar from "./_components/OrgSidebar"; 3 | import Sidebar from "./_components/sidebar/index"; 4 | 5 | interface DashboardLayout { 6 | children: React.ReactNode; 7 | } 8 | 9 | const DashboardLayout = ({ children }: DashboardLayout) => { 10 | return ( 11 |
12 | 13 |
14 |
15 | 16 |
17 | 18 | {children} 19 |
20 |
21 |
22 |
23 | ); 24 | }; 25 | export default DashboardLayout; 26 | -------------------------------------------------------------------------------- /app/(dashboard)/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React from "react"; 3 | import EmptyOrg from "./_components/EmptyOrg"; 4 | import { useOrganization } from "@clerk/nextjs"; 5 | import BoardList from "./_components/BoardList"; 6 | 7 | interface DashboardPageProps { 8 | searchParams: { 9 | search?: string; 10 | favorite?: string; 11 | }; 12 | } 13 | 14 | const Dashboard = ({ searchParams }: DashboardPageProps) => { 15 | const { organization } = useOrganization(); 16 | return ( 17 |
18 | {!organization ? ( 19 | 20 | ) : ( 21 | 22 | )} 23 |
24 | ); 25 | }; 26 | 27 | export default Dashboard; 28 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/invite-button.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; 3 | import { Button } from "@/components/ui/button"; 4 | import { Plus } from "lucide-react"; 5 | import { OrganizationProfile } from "@clerk/nextjs"; 6 | 7 | const OrgInviteBtn = () => { 8 | return ( 9 | 10 | 11 | 15 | 16 | 17 | 18 | 19 | 20 | ); 21 | }; 22 | 23 | export default OrgInviteBtn; 24 | -------------------------------------------------------------------------------- /app/board/_components/userAvatar.tsx: -------------------------------------------------------------------------------- 1 | import Hint from "@/components/hint"; 2 | import { 3 | Avatar, 4 | AvatarFallback, 5 | AvatarImage, 6 | } from "../../../components/ui/avatar"; 7 | 8 | interface UserAvatarProps { 9 | src?: string; 10 | fallback?: string; 11 | name?: string; 12 | borderColor?: string; 13 | } 14 | 15 | const UserAvatar = ({ src, fallback, name, borderColor }: UserAvatarProps) => { 16 | return ( 17 | 18 | 19 | 20 | 21 | {fallback} 22 | 23 | 24 | 25 | ); 26 | }; 27 | 28 | export default UserAvatar; 29 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/sidebar/list.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React from "react"; 3 | import { useOrganizationList } from "@clerk/nextjs"; 4 | import Item from "./item"; 5 | 6 | const List = () => { 7 | const { userMemberships } = useOrganizationList({ 8 | userMemberships: { 9 | infinite: true, 10 | }, 11 | }); 12 | 13 | if (!userMemberships?.data?.length) return null; 14 | return ( 15 |
    16 | {userMemberships?.data?.map((mem) => { 17 | return ( 18 | 24 | ); 25 | })} 26 |
27 | ); 28 | }; 29 | 30 | export default List; 31 | -------------------------------------------------------------------------------- /convex/schema.ts: -------------------------------------------------------------------------------- 1 | import { defineSchema, defineTable } from "convex/server"; 2 | import { v } from "convex/values"; 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 | imgUrl: v.string(), 11 | }) 12 | .index("bg_org", ["orgId"]) 13 | .searchIndex("search_title", { 14 | searchField: "title", 15 | filterFields: ["orgId"], 16 | }), 17 | 18 | userFavorites: defineTable({ 19 | orgId: v.string(), 20 | userId: v.string(), 21 | boardId: v.id("boards"), 22 | }) 23 | .index("by_board", ["boardId"]) 24 | .index("by_user_org", ["userId", "orgId"]) 25 | .index("by_user_board", ["userId", "boardId"]) 26 | .index("by_user_board_org", ["userId", "boardId", "orgId"]), 27 | }); 28 | -------------------------------------------------------------------------------- /app/board/_components/ToolButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Hint from "@/components/hint"; 4 | import { Button } from "@/components/ui/button"; 5 | import { LucideIcon } from "lucide-react"; 6 | 7 | interface ToolButtonProps { 8 | label: string; 9 | icon: LucideIcon; 10 | onClick: () => void; 11 | isActive?: boolean; 12 | isDisabled?: boolean; 13 | } 14 | 15 | const ToolBtn = ({ 16 | label, 17 | icon: Icon, 18 | onClick, 19 | isActive, 20 | isDisabled, 21 | }: ToolButtonProps) => { 22 | return ( 23 | 24 | 32 | 33 | ); 34 | }; 35 | 36 | export default ToolBtn; 37 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ); 21 | } 22 | ); 23 | Input.displayName = "Input"; 24 | 25 | export { Input }; 26 | -------------------------------------------------------------------------------- /providers/convex-client-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React from "react"; 3 | import { ClerkProvider, useAuth } from "@clerk/nextjs"; 4 | import { Authenticated, AuthLoading, ConvexReactClient } from "convex/react"; 5 | import { ConvexProviderWithClerk } from "convex/react-clerk"; 6 | import Loading from "@/components/auth/loading"; 7 | 8 | interface ConvexClientProviderProps { 9 | children: React.ReactNode; 10 | } 11 | 12 | const ConvexUrl = process.env.NEXT_PUBLIC_CONVEX_URL!; 13 | 14 | const convex = new ConvexReactClient(ConvexUrl); 15 | 16 | export const ConvexClientProvider = ({ 17 | children, 18 | }: ConvexClientProviderProps) => { 19 | return ( 20 | 21 | 22 | {children} 23 | 24 | 25 | 26 | 27 | 28 | ); 29 | }; 30 | -------------------------------------------------------------------------------- /components/room.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ReactNode } from "react"; 4 | import { LiveMap, LiveList, LiveObject } from "@liveblocks/client"; 5 | import { RoomProvider } from "../liveblocks.config"; 6 | 7 | import { Layer } from "@/types/canvas"; 8 | 9 | import { ClientSideSuspense } from "@liveblocks/react"; 10 | 11 | interface RoomProps { 12 | children: ReactNode; 13 | roomId: string; 14 | fallback: NonNullable | null; 15 | } 16 | 17 | const Room = ({ children, roomId, fallback }: RoomProps) => { 18 | return ( 19 | >(), 24 | layersId: new LiveList(), 25 | }} 26 | > 27 | 28 | {() => children} 29 | 30 | 31 | ); 32 | }; 33 | 34 | export default Room; 35 | -------------------------------------------------------------------------------- /convex/_generated/api.d.ts: -------------------------------------------------------------------------------- 1 | /* prettier-ignore-start */ 2 | 3 | /* eslint-disable */ 4 | /** 5 | * Generated `api` utility. 6 | * 7 | * THIS CODE IS AUTOMATICALLY GENERATED. 8 | * 9 | * To regenerate, run `npx convex dev`. 10 | * @module 11 | */ 12 | 13 | import type { 14 | ApiFromModules, 15 | FilterApi, 16 | FunctionReference, 17 | } from "convex/server"; 18 | import type * as board from "../board.js"; 19 | import type * as boards from "../boards.js"; 20 | 21 | /** 22 | * A utility for referencing Convex functions in your app's API. 23 | * 24 | * Usage: 25 | * ```js 26 | * const myFunctionReference = api.myModule.myFunction; 27 | * ``` 28 | */ 29 | declare const fullApi: ApiFromModules<{ 30 | board: typeof board; 31 | boards: typeof boards; 32 | }>; 33 | export declare const api: FilterApi< 34 | typeof fullApi, 35 | FunctionReference 36 | >; 37 | export declare const internal: FilterApi< 38 | typeof fullApi, 39 | FunctionReference 40 | >; 41 | 42 | /* prettier-ignore-end */ 43 | -------------------------------------------------------------------------------- /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/hint.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | Tooltip, 4 | TooltipContent, 5 | TooltipProvider, 6 | TooltipTrigger, 7 | } from "@/components/ui/tooltip"; 8 | interface HintProp { 9 | label?: string; 10 | children: React.ReactNode; 11 | side?: "top" | "bottom" | "left" | "right"; 12 | aling?: "start" | "center" | "end"; 13 | sideOffset?: number; 14 | alignOffset?: number; 15 | } 16 | 17 | const Hint = ({ label, children, side, aling, sideOffset }: HintProp) => { 18 | return ( 19 | 20 | 21 | {children} 22 | 29 |

{label}

30 |
31 |
32 |
33 | ); 34 | }; 35 | 36 | export default Hint; 37 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/EmptyOrg.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Frown } from "lucide-react"; 3 | import { CreateOrganization } from "@clerk/nextjs"; 4 | import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog"; 5 | import { Button } from "@/components/ui/button"; 6 | 7 | const EmptyOrg = () => { 8 | return ( 9 |
10 | 11 |

Welcome to MIRO

12 | 13 | Create an Org to get started 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 |
26 | ); 27 | }; 28 | 29 | export default EmptyOrg; 30 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import localFont from "next/font/local"; 3 | import "./globals.css"; 4 | import { ConvexClientProvider } from "@/providers/convex-client-provider"; 5 | import { Toaster } from "@/components/ui/sonner"; 6 | import { ModelProvider } from "@/providers/modelProvider"; 7 | 8 | const geistSans = localFont({ 9 | src: "./fonts/GeistVF.woff", 10 | variable: "--font-geist-sans", 11 | weight: "100 900", 12 | }); 13 | const geistMono = localFont({ 14 | src: "./fonts/GeistMonoVF.woff", 15 | variable: "--font-geist-mono", 16 | weight: "100 900", 17 | }); 18 | 19 | export const metadata: Metadata = { 20 | title: "Real-Time Miro", 21 | description: " A real-time collaborative whiteboard app", 22 | }; 23 | 24 | export default function RootLayout({ 25 | children, 26 | }: Readonly<{ 27 | children: React.ReactNode; 28 | }>) { 29 | return ( 30 | 31 | 34 | 35 | 36 | 37 | {children} 38 | 39 | 40 | 41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/sidebar/item.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { cn } from "@/lib/utils"; 3 | import { useOrganization, useOrganizationList } from "@clerk/nextjs"; 4 | import Image from "next/image"; 5 | import Hint from "@/components/hint"; 6 | 7 | interface ItemProp { 8 | id: string; 9 | name: string; 10 | imageUrl: string; 11 | } 12 | 13 | const Item = ({ id, name, imageUrl }: ItemProp) => { 14 | const { organization } = useOrganization(); 15 | const { setActive } = useOrganizationList(); 16 | 17 | const isActive = organization?.id === id; 18 | 19 | const handleClick = () => { 20 | if (!setActive) return; 21 | 22 | setActive({ organization: id }); 23 | }; 24 | return ( 25 |
26 | 27 | {name} 37 | 38 |
39 | ); 40 | }; 41 | 42 | export default Item; 43 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/sidebar/NewButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React from "react"; 3 | import { 4 | Dialog, 5 | DialogContent, 6 | DialogDescription, 7 | DialogHeader, 8 | DialogTitle, 9 | DialogTrigger, 10 | } from "@/components/ui/dialog"; 11 | import { Plus } from "lucide-react"; 12 | import { CreateOrganization } from "@clerk/clerk-react"; 13 | import Hint from "@/components/hint"; 14 | 15 | const NewButton = () => { 16 | return ( 17 | 18 | 19 |
20 | 26 | 29 | 30 |
31 |
32 | 33 | 34 | 35 |
36 | ); 37 | }; 38 | 39 | export default NewButton; 40 | -------------------------------------------------------------------------------- /components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const TooltipProvider = TooltipPrimitive.Provider 9 | 10 | const Tooltip = TooltipPrimitive.Root 11 | 12 | const TooltipTrigger = TooltipPrimitive.Trigger 13 | 14 | const TooltipContent = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef 17 | >(({ className, sideOffset = 4, ...props }, ref) => ( 18 | 27 | )) 28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName 29 | 30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 31 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/search-input.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useEffect, ChangeEvent, useState } from "react"; 4 | import qs from "query-string"; 5 | import { Search } from "lucide-react"; 6 | 7 | import { useRouter } from "next/navigation"; 8 | import { Input } from "@/components/ui/input"; 9 | import { useDebounce } from "use-debounce"; 10 | 11 | const SearchInput = () => { 12 | const router = useRouter(); 13 | const [value, setValue] = useState(""); 14 | const [debounceValue] = useDebounce(value, 500); 15 | 16 | const handleChange = (e: ChangeEvent) => { 17 | setValue(e.target.value); 18 | }; 19 | 20 | useEffect(() => { 21 | const url = qs.stringifyUrl( 22 | { 23 | url: "/", 24 | query: { 25 | search: debounceValue, 26 | }, 27 | }, 28 | { skipEmptyString: true, skipNull: true } 29 | ); 30 | router.push(url); 31 | }, [debounceValue, router]); 32 | 33 | return ( 34 |
35 | 36 | 37 | 43 |
44 | ); 45 | }; 46 | 47 | export default SearchInput; 48 | -------------------------------------------------------------------------------- /components/ConfirmModel.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { 3 | AlertDialog, 4 | AlertDialogAction, 5 | AlertDialogCancel, 6 | AlertDialogContent, 7 | AlertDialogDescription, 8 | AlertDialogFooter, 9 | AlertDialogHeader, 10 | AlertDialogTitle, 11 | AlertDialogTrigger, 12 | } from "@/components/ui/alert-dialog"; 13 | 14 | interface ConfirmModelProps { 15 | children?: React.ReactNode; 16 | onConfirm: () => void; 17 | disabled?: boolean; 18 | heder?: string; 19 | description?: string; 20 | } 21 | 22 | const ConfirmModel = ({ 23 | children, 24 | onConfirm, 25 | disabled, 26 | heder, 27 | description, 28 | }: ConfirmModelProps) => { 29 | const handleConfirm = () => { 30 | onConfirm(); 31 | }; 32 | 33 | return ( 34 | 35 | {children} 36 | 37 | 38 | {heder} 39 | {description} 40 | 41 | 42 | Cancel 43 | 44 | Continue 45 | 46 | 47 | 48 | 49 | ); 50 | }; 51 | 52 | export default ConfirmModel; 53 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/navbar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { 3 | OrganizationSwitcher, 4 | UserButton, 5 | useOrganization, 6 | } from "@clerk/nextjs"; 7 | 8 | import SearchInput from "./search-input"; 9 | import OrgInviteBtn from "./invite-button"; 10 | 11 | const Navbar = () => { 12 | const { organization } = useOrganization(); 13 | return ( 14 |
15 |
16 | 17 |
18 |
19 | 41 |
42 | {organization && } 43 | 44 | 45 |
46 | ); 47 | }; 48 | 49 | export default Navbar; 50 | -------------------------------------------------------------------------------- /app/board/_components/Cursor.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React from "react"; 3 | import { memo } from "react"; 4 | 5 | import { connectionIdToColor } from "@/lib/utils"; 6 | import { MousePointer2 } from "lucide-react"; 7 | import { useOther } from "@/liveblocks.config"; 8 | 9 | interface CursorProps { 10 | connectionId: number; 11 | } 12 | 13 | const Cursor = memo(({ connectionId }: CursorProps) => { 14 | const info = useOther(connectionId, (user) => user?.info); 15 | const cursor = useOther(connectionId, (user) => user.presence.cursor); 16 | const name = info?.name || "TeamMate"; 17 | 18 | if (!cursor) return null; 19 | 20 | const { x, y } = cursor; 21 | 22 | return ( 23 | 29 | 36 |
40 | {name} 41 |
42 |
43 | ); 44 | }); 45 | 46 | export default Cursor; 47 | 48 | Cursor.displayName = "Cursor"; 49 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/boardCard/footer.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | import { Star } from "lucide-react"; 3 | import React from "react"; 4 | 5 | interface FooterProps { 6 | isFavorite: boolean; 7 | title: string; 8 | authorLabel: string; 9 | createdAtLabel: string; 10 | onClick: () => void; 11 | disabled: boolean; 12 | } 13 | 14 | const Footer = ({ 15 | isFavorite, 16 | title, 17 | authorLabel, 18 | createdAtLabel, 19 | onClick, 20 | disabled, 21 | }: FooterProps) => { 22 | const handleClick = ( 23 | event: React.MouseEvent 24 | ) => { 25 | event.stopPropagation(); 26 | event.preventDefault(); 27 | onClick(); 28 | }; 29 | 30 | return ( 31 |
32 |

{title}

33 |

34 | {authorLabel} , {createdAtLabel} 35 |

36 | 48 |
49 | ); 50 | }; 51 | 52 | export default Footer; 53 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). 2 | ## Getting Started 3 | 4 | First, run the development server: 5 | 6 | ```bash 7 | npm run dev 8 | # or. 9 | yarn dev.. 10 | # or 11 | pnpm dev 12 | # or 13 | bun dev 14 | ``` 15 | 16 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 17 | 18 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 19 | 20 | This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. 21 | 22 | ## Learn More 23 | 24 | To learn more about Next.js, take a look at the following resources: 25 | 26 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 27 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 28 | 29 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! 30 | 31 | ## Deploy on Vercel 32 | 33 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 34 | 35 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.. 36 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/Emptyboard.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useRouter } from "next/navigation"; 3 | import React from "react"; 4 | import { Button } from "@/components/ui/button"; 5 | import { api } from "@/convex/_generated/api"; 6 | 7 | import { ClipboardCheck } from "lucide-react"; 8 | import { useOrganization } from "@clerk/nextjs"; 9 | import useMutationAip from "@/hooks/useMutationAip"; 10 | import { toast } from "sonner"; 11 | 12 | const Emptyboard = () => { 13 | const { organization } = useOrganization(); 14 | const { mutate, pending } = useMutationAip(api.board.create); 15 | const Router = useRouter(); 16 | 17 | const handleCLick = () => { 18 | if (!organization) return; 19 | mutate({ 20 | orgId: organization.id, 21 | title: "New Board", 22 | }) 23 | .then((id) => { 24 | toast.success("Board created successfully"); 25 | Router.push(`/board/${id}`); 26 | }) 27 | .catch(() => { 28 | toast.error("Failed to create board"); 29 | }); 30 | }; 31 | 32 | return ( 33 |
34 | 35 | 36 |

37 | Create Your First Board 38 |

39 |
40 | 43 |
44 |
45 | ); 46 | }; 47 | 48 | export default Emptyboard; 49 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/NewBoardBtn.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | "use client"; 3 | import { api } from "@/convex/_generated/api"; 4 | import { useRouter } from "next/navigation"; 5 | import useMutationAip from "@/hooks/useMutationAip"; 6 | import { cn } from "@/lib/utils"; 7 | import { Plus } from "lucide-react"; 8 | import React from "react"; 9 | import { toast } from "sonner"; 10 | 11 | interface NewBoardBtnProps { 12 | orgId: string; 13 | disabled: boolean; 14 | } 15 | 16 | const NewBoardBtn = ({ orgId, disabled }: NewBoardBtnProps) => { 17 | const { mutate, pending } = useMutationAip(api.board.create); 18 | const Router = useRouter(); 19 | 20 | const handleClick = () => { 21 | mutate({ orgId, title: "New Board" }) 22 | .then((res) => { 23 | toast.success("Board created successfully"); 24 | Router.push(`/board/${res}`); 25 | }) 26 | .catch((err) => { 27 | toast.error("Failed to create board"); 28 | }); 29 | }; 30 | 31 | return ( 32 | 45 | ); 46 | }; 47 | 48 | export default NewBoardBtn; 49 | -------------------------------------------------------------------------------- /components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | Avatar.displayName = AvatarPrimitive.Root.displayName 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )) 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )) 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 49 | 50 | export { Avatar, AvatarImage, AvatarFallback } 51 | -------------------------------------------------------------------------------- /app/api/livebloack-auth/route.ts: -------------------------------------------------------------------------------- 1 | import { api } from "@/convex/_generated/api"; 2 | import { currentUser, auth } from "@clerk/nextjs/server"; 3 | import { Liveblocks } from "@liveblocks/node"; 4 | import { ConvexHttpClient } from "convex/browser"; 5 | 6 | const convex = new ConvexHttpClient(process.env.NEXT_PUBLIC_CONVEX_URL!); 7 | 8 | const liveblocks = new Liveblocks({ 9 | secret: 10 | "sk_dev_RWjg0gtAEYluDiERj1shKZtaizK_EObR8slXoeMb2L7PUrjdvujo4P5Qq-X5AIys", 11 | }); 12 | 13 | export async function POST(request: Request) { 14 | const authrization = await auth(); 15 | const user = await currentUser(); 16 | 17 | // console.log("Auth Info ", { authrization, user }); 18 | 19 | if (!user || !authrization) { 20 | return new Response("Unauthorized", { status: 403 }); 21 | } 22 | 23 | const { room } = await request.json(); 24 | const board = await convex.query(api.board.get, { id: room }); 25 | 26 | // console.log("Auth Info 2 ", { 27 | // board, 28 | // room, 29 | // boardOrgId: board?.orgId, 30 | // userOrgId: authrization.orgId, 31 | // }); 32 | 33 | if (board?.orgId !== authrization.orgId) { 34 | return new Response("Unauthorized", { status: 403 }); 35 | } 36 | const userInfo = { 37 | name: user.firstName!, 38 | picture: user.imageUrl!, 39 | }; 40 | 41 | console.log("Auth Info 3 ", { userInfo }); 42 | 43 | const session = liveblocks.prepareSession(user.id, { userInfo }); 44 | 45 | if (room) { 46 | session.allow(room, session.FULL_ACCESS); 47 | } 48 | 49 | const { status, body } = await session.authorize(); 50 | 51 | // console.log("Auth Info 4 ", { status, body }); 52 | 53 | return new Response(body, { status }); 54 | } 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "realtime", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@clerk/clerk-react": "^5.11.0", 13 | "@clerk/nextjs": "^5.7.1", 14 | "@liveblocks/client": "^2.12.0", 15 | "@liveblocks/node": "^2.12.0", 16 | "@liveblocks/react": "^2.12.0", 17 | "@radix-ui/react-alert-dialog": "^1.1.2", 18 | "@radix-ui/react-avatar": "^1.1.1", 19 | "@radix-ui/react-dialog": "^1.1.2", 20 | "@radix-ui/react-dropdown-menu": "^2.1.2", 21 | "@radix-ui/react-slot": "^1.1.0", 22 | "@radix-ui/react-tooltip": "^1.1.3", 23 | "class-variance-authority": "^0.7.0", 24 | "clsx": "^2.1.1", 25 | "convex": "^1.16.3", 26 | "convex-helpers": "^0.1.64", 27 | "create-liveblocks-app": "^2.20240816.0", 28 | "date-fns": "^4.1.0", 29 | "install": "^0.13.0", 30 | "lucide-react": "^0.447.0", 31 | "nanoid": "^5.0.8", 32 | "next": "14.2.14", 33 | "next-themes": "^0.3.0", 34 | "npm": "^10.9.0", 35 | "query-string": "^9.1.1", 36 | "react": "^18.3.1", 37 | "react-dom": "^18", 38 | "react-icons": "^5.3.0", 39 | "sonner": "^1.5.0", 40 | "swr": "^2.2.5", 41 | "tailwind-merge": "^2.5.3", 42 | "tailwindcss-animate": "^1.0.7", 43 | "use-debounce": "^10.0.3", 44 | "usehooks-ts": "^3.1.0", 45 | "zustand": "^5.0.0" 46 | }, 47 | "devDependencies": { 48 | "@types/node": "^20", 49 | "@types/react": "^18", 50 | "@types/react-dom": "^18", 51 | "eslint": "^8", 52 | "eslint-config-next": "14.2.14", 53 | "postcss": "^8", 54 | "tailwindcss": "^3.4.1", 55 | "typescript": "^5" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /app/board/_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_PARTICIPANTS = 2; 8 | 9 | const Participants = () => { 10 | const currentUser = useSelf(); 11 | const users = useOthers(); 12 | 13 | const hasMoreParticipants = users.length > MAX_PARTICIPANTS; 14 | return ( 15 |
16 |
17 | {users.slice(0, MAX_PARTICIPANTS).map(({ connectionId, info }) => { 18 | return ( 19 | 26 | ); 27 | })} 28 | 29 | {currentUser && ( 30 | 36 | )} 37 | 38 | {hasMoreParticipants && ( 39 | 43 | )} 44 |
45 |
46 | ); 47 | }; 48 | 49 | export default Participants; 50 | 51 | Participants.Skeleton = function ParticipantsSkeleton() { 52 | return ( 53 |
54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | darkMode: ["class"], 5 | content: [ 6 | "./pages/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./components/**/*.{js,ts,jsx,tsx,mdx}", 8 | "./app/**/*.{js,ts,jsx,tsx,mdx}", 9 | ], 10 | theme: { 11 | extend: { 12 | colors: { 13 | background: 'hsl(var(--background))', 14 | foreground: 'hsl(var(--foreground))', 15 | card: { 16 | DEFAULT: 'hsl(var(--card))', 17 | foreground: 'hsl(var(--card-foreground))' 18 | }, 19 | popover: { 20 | DEFAULT: 'hsl(var(--popover))', 21 | foreground: 'hsl(var(--popover-foreground))' 22 | }, 23 | primary: { 24 | DEFAULT: 'hsl(var(--primary))', 25 | foreground: 'hsl(var(--primary-foreground))' 26 | }, 27 | secondary: { 28 | DEFAULT: 'hsl(var(--secondary))', 29 | foreground: 'hsl(var(--secondary-foreground))' 30 | }, 31 | muted: { 32 | DEFAULT: 'hsl(var(--muted))', 33 | foreground: 'hsl(var(--muted-foreground))' 34 | }, 35 | accent: { 36 | DEFAULT: 'hsl(var(--accent))', 37 | foreground: 'hsl(var(--accent-foreground))' 38 | }, 39 | destructive: { 40 | DEFAULT: 'hsl(var(--destructive))', 41 | foreground: 'hsl(var(--destructive-foreground))' 42 | }, 43 | border: 'hsl(var(--border))', 44 | input: 'hsl(var(--input))', 45 | ring: 'hsl(var(--ring))', 46 | chart: { 47 | '1': 'hsl(var(--chart-1))', 48 | '2': 'hsl(var(--chart-2))', 49 | '3': 'hsl(var(--chart-3))', 50 | '4': 'hsl(var(--chart-4))', 51 | '5': 'hsl(var(--chart-5))' 52 | } 53 | }, 54 | borderRadius: { 55 | lg: 'var(--radius)', 56 | md: 'calc(var(--radius) - 2px)', 57 | sm: 'calc(var(--radius) - 4px)' 58 | } 59 | } 60 | }, 61 | plugins: [require("tailwindcss-animate")], 62 | }; 63 | export default config; 64 | -------------------------------------------------------------------------------- /convex/_generated/dataModel.d.ts: -------------------------------------------------------------------------------- 1 | /* prettier-ignore-start */ 2 | 3 | /* eslint-disable */ 4 | /** 5 | * Generated data model types. 6 | * 7 | * THIS CODE IS AUTOMATICALLY GENERATED. 8 | * 9 | * To regenerate, run `npx convex dev`. 10 | * @module 11 | */ 12 | 13 | import type { 14 | DataModelFromSchemaDefinition, 15 | DocumentByName, 16 | TableNamesInDataModel, 17 | SystemTableNames, 18 | } from "convex/server"; 19 | import type { GenericId } from "convex/values"; 20 | import schema from "../schema.js"; 21 | 22 | /** 23 | * The names of all of your Convex tables. 24 | */ 25 | export type TableNames = TableNamesInDataModel; 26 | 27 | /** 28 | * The type of a document stored in Convex. 29 | * 30 | * @typeParam TableName - A string literal type of the table name (like "users"). 31 | */ 32 | export type Doc = DocumentByName< 33 | DataModel, 34 | TableName 35 | >; 36 | 37 | /** 38 | * An identifier for a document in Convex. 39 | * 40 | * Convex documents are uniquely identified by their `Id`, which is accessible 41 | * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). 42 | * 43 | * Documents can be loaded using `db.get(id)` in query and mutation functions. 44 | * 45 | * IDs are just strings at runtime, but this type can be used to distinguish them from other 46 | * strings when type checking. 47 | * 48 | * @typeParam TableName - A string literal type of the table name (like "users"). 49 | */ 50 | export type Id = 51 | GenericId; 52 | 53 | /** 54 | * A type describing your Convex data model. 55 | * 56 | * This type includes information about what tables you have, the type of 57 | * documents stored in those tables, and the indexes defined on them. 58 | * 59 | * This type is used to parameterize methods like `queryGeneric` and 60 | * `mutationGeneric` to make them type-safe. 61 | */ 62 | export type DataModel = DataModelFromSchemaDefinition; 63 | 64 | /* prettier-ignore-end */ 65 | -------------------------------------------------------------------------------- /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 | favorite: 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.favorite) { 19 | const favoriteBoards = await ctx.db 20 | .query("userFavorites") 21 | .withIndex("by_user_org", (q) => 22 | q.eq("userId", identity.subject).eq("orgId", args.orgId) 23 | ) 24 | .order("desc") 25 | .collect(); 26 | 27 | const ids = favoriteBoards.map((favorite) => favorite.boardId); 28 | const boards = await getAllOrThrow(ctx.db, ids); 29 | 30 | return boards.map((board) => ({ ...board, isFavorite: true })); 31 | } 32 | 33 | const title = args.search ? args.search : ""; 34 | let boards = []; 35 | 36 | if (title) { 37 | boards = await ctx.db 38 | .query("boards") 39 | .withSearchIndex("search_title", (q) => 40 | q.search("title", title).eq("orgId", args.orgId) 41 | ) 42 | .collect(); 43 | } else { 44 | boards = await ctx.db 45 | .query("boards") 46 | .withIndex("bg_org", (q) => q.eq("orgId", args.orgId)) 47 | .order("desc") 48 | .collect(); 49 | } 50 | 51 | const boardsWithFavorites = boards.map((board) => { 52 | return ctx.db 53 | .query("userFavorites") 54 | .withIndex("by_user_board", (q) => 55 | q.eq("userId", identity.subject).eq("boardId", board._id) 56 | ) 57 | .unique() 58 | .then((favorite) => { 59 | return { ...board, isFavorite: !!favorite }; 60 | }); 61 | }); 62 | 63 | const boardWithFavoriteBoolean = Promise.all(boardsWithFavorites); 64 | 65 | return boardWithFavoriteBoolean; 66 | }, 67 | }); 68 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | font-family: Arial, Helvetica, sans-serif; 7 | } 8 | 9 | html, 10 | body, 11 | :root { 12 | height: 100%; 13 | } 14 | 15 | @layer utilities { 16 | .text-balance { 17 | text-wrap: balance; 18 | } 19 | } 20 | 21 | @layer base { 22 | :root { 23 | --background: 0 0% 100%; 24 | --foreground: 0 0% 3.9%; 25 | --card: 0 0% 100%; 26 | --card-foreground: 0 0% 3.9%; 27 | --popover: 0 0% 100%; 28 | --popover-foreground: 0 0% 3.9%; 29 | --primary: 0 0% 9%; 30 | --primary-foreground: 0 0% 98%; 31 | --secondary: 0 0% 96.1%; 32 | --secondary-foreground: 0 0% 9%; 33 | --muted: 0 0% 96.1%; 34 | --muted-foreground: 0 0% 45.1%; 35 | --accent: 0 0% 96.1%; 36 | --accent-foreground: 0 0% 9%; 37 | --destructive: 0 84.2% 60.2%; 38 | --destructive-foreground: 0 0% 98%; 39 | --border: 0 0% 89.8%; 40 | --input: 0 0% 89.8%; 41 | --ring: 0 0% 3.9%; 42 | --chart-1: 12 76% 61%; 43 | --chart-2: 173 58% 39%; 44 | --chart-3: 197 37% 24%; 45 | --chart-4: 43 74% 66%; 46 | --chart-5: 27 87% 67%; 47 | --radius: 0.5rem; 48 | } 49 | .dark { 50 | --background: 0 0% 3.9%; 51 | --foreground: 0 0% 98%; 52 | --card: 0 0% 3.9%; 53 | --card-foreground: 0 0% 98%; 54 | --popover: 0 0% 3.9%; 55 | --popover-foreground: 0 0% 98%; 56 | --primary: 0 0% 98%; 57 | --primary-foreground: 0 0% 9%; 58 | --secondary: 0 0% 14.9%; 59 | --secondary-foreground: 0 0% 98%; 60 | --muted: 0 0% 14.9%; 61 | --muted-foreground: 0 0% 63.9%; 62 | --accent: 0 0% 14.9%; 63 | --accent-foreground: 0 0% 98%; 64 | --destructive: 0 62.8% 30.6%; 65 | --destructive-foreground: 0 0% 98%; 66 | --border: 0 0% 14.9%; 67 | --input: 0 0% 14.9%; 68 | --ring: 0 0% 83.1%; 69 | --chart-1: 220 70% 50%; 70 | --chart-2: 160 60% 45%; 71 | --chart-3: 30 80% 55%; 72 | --chart-4: 280 65% 60%; 73 | --chart-5: 340 75% 55%; 74 | } 75 | } 76 | 77 | @layer base { 78 | * { 79 | @apply border-border; 80 | } 81 | body { 82 | @apply bg-background text-foreground; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Slot } from "@radix-ui/react-slot"; 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | board: "hover:bg-blue-500/20 hover:text-blue-800", 22 | boardActive: "bg-blue-500/20 text-blue-800", 23 | }, 24 | size: { 25 | default: "h-10 px-4 py-2", 26 | sm: "h-9 rounded-md px-3", 27 | lg: "h-11 rounded-md px-8", 28 | icon: "h-10 w-10", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "default", 33 | size: "default", 34 | }, 35 | } 36 | ); 37 | 38 | export interface ButtonProps 39 | extends React.ButtonHTMLAttributes, 40 | VariantProps { 41 | asChild?: boolean; 42 | } 43 | 44 | const Button = React.forwardRef( 45 | ({ className, variant, size, asChild = false, ...props }, ref) => { 46 | const Comp = asChild ? Slot : "button"; 47 | return ( 48 | 53 | ); 54 | } 55 | ); 56 | Button.displayName = "Button"; 57 | 58 | export { Button, buttonVariants }; 59 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/BoardList.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-vars */ 2 | import React from "react"; 3 | import EmptySearch from "./emptySearch"; 4 | import EmptyFav from "./EmptyFav"; 5 | import Emptyboard from "./Emptyboard"; 6 | import { api } from "@/convex/_generated/api"; 7 | import { useQuery } from "convex/react"; 8 | import BoardCard from "./boardCard"; 9 | import NewBoardBtn from "./NewBoardBtn"; 10 | 11 | interface BoardListProps { 12 | orgId: string; 13 | query: { 14 | search?: string; 15 | favorite?: string; 16 | }; 17 | } 18 | 19 | const BoardList = ({ orgId, query }: BoardListProps) => { 20 | const data = useQuery(api.boards.get, { orgId, ...query }); 21 | 22 | if (data === undefined) { 23 | return ( 24 |
25 |

26 | {query.favorite ? "Favorite Boards" : "Team Boards"} 27 |

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

55 | {query.favorite ? "Favorite Boards" : "Team Boards"} 56 |

57 |
58 | 59 | {data.map((board) => ( 60 | 71 | ))} 72 |
73 |
74 | ); 75 | }; 76 | 77 | export default BoardList; 78 | -------------------------------------------------------------------------------- /components/model/renameModel.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { FormEventHandler, useEffect, useState } from "react"; 3 | import { 4 | Dialog, 5 | DialogContent, 6 | DialogDescription, 7 | DialogFooter, 8 | DialogHeader, 9 | DialogTitle, 10 | } from "@/components/ui/dialog"; 11 | import { useRenameModel } from "@/store/useRenameModel"; 12 | import { Input } from "../ui/input"; 13 | import { DialogClose } from "@radix-ui/react-dialog"; 14 | import { Button } from "../ui/button"; 15 | import useMutationAip from "@/hooks/useMutationAip"; 16 | import { api } from "@/convex/_generated/api"; 17 | import { toast } from "sonner"; 18 | 19 | const RenameModel = () => { 20 | const { mutate, pending } = useMutationAip(api.board.update); 21 | const { isOpen, onClose, initialValues } = useRenameModel(); 22 | const [title, setTitle] = useState(initialValues.title); 23 | 24 | useEffect(() => { 25 | setTitle(initialValues.title); 26 | }, [initialValues.title]); 27 | 28 | const onSubmit: FormEventHandler = (e) => { 29 | e.preventDefault(); 30 | 31 | mutate({ id: initialValues.id, title }) 32 | .then(() => { 33 | toast.success("Board title updated !"); 34 | onClose(); 35 | }) 36 | .catch(() => { 37 | toast.error("Failed to update board title !"); 38 | }); 39 | }; 40 | 41 | return ( 42 | 43 | 44 | 45 | Edit Board Title 46 | Enter New Title 47 | 48 | 49 |
50 | setTitle(e.target.value)} 56 | placeholder="Board Title" 57 | /> 58 | 59 | 60 | 61 | 64 | 65 | 68 | 69 |
70 |
71 |
72 |
73 | ); 74 | }; 75 | 76 | export default RenameModel; 77 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/OrgSidebar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React from "react"; 3 | import { Poppins } from "next/font/google"; 4 | import Link from "next/link"; 5 | // import Image from "next/image"; 6 | import { cn } from "@/lib/utils"; 7 | import { OrganizationSwitcher } from "@clerk/nextjs"; 8 | import { Button } from "@/components/ui/button"; 9 | import { LayoutDashboard, Star } from "lucide-react"; 10 | import { useSearchParams } from "next/navigation"; 11 | 12 | const font = Poppins({ 13 | subsets: ["latin"], 14 | 15 | weight: ["600"], 16 | }); 17 | 18 | const OrgSidebar = () => { 19 | const searchParams = useSearchParams(); 20 | const favorite = searchParams.get("favorites"); 21 | 22 | return ( 23 |
24 | 25 |
26 | {/* logo */} 27 | 28 | MIRO 29 | 30 |
31 | 32 | 33 | 54 | 55 |
56 | 67 | 83 |
84 |
85 | ); 86 | }; 87 | 88 | export default OrgSidebar; 89 | -------------------------------------------------------------------------------- /types/canvas.ts: -------------------------------------------------------------------------------- 1 | export type Color = { 2 | r: number; 3 | g: number; 4 | b: number; 5 | }; 6 | 7 | export type Camera = { 8 | x: number; 9 | y: number; 10 | }; 11 | 12 | export enum LayerType { 13 | Rectangle, 14 | Path, 15 | Ellipse, 16 | Text, 17 | Note, 18 | } 19 | 20 | export type Point = { 21 | x: number; 22 | y: number; 23 | }; 24 | 25 | export type XYWH = { 26 | x: number; 27 | y: number; 28 | width: number; 29 | height: number; 30 | }; 31 | 32 | export enum Side { 33 | Top = 1, 34 | Right = 2, 35 | Bottom = 4, 36 | Left = 8, 37 | } 38 | 39 | export type RectangleLayer = { 40 | type: LayerType.Rectangle; 41 | x: number; 42 | y: number; 43 | width: number; 44 | height: number; 45 | fill: Color; 46 | value?: string; 47 | }; 48 | 49 | export type EllipseLayer = { 50 | type: LayerType.Ellipse; 51 | x: number; 52 | y: number; 53 | width: number; 54 | height: number; 55 | fill: Color; 56 | value?: string; 57 | }; 58 | 59 | export type PathLayer = { 60 | type: LayerType.Path; 61 | x: number; 62 | y: number; 63 | width: number; 64 | height: number; 65 | fill: Color; 66 | points: number[][]; 67 | value?: string; 68 | }; 69 | 70 | export type TextleLayer = { 71 | type: LayerType.Text; 72 | x: number; 73 | y: number; 74 | width: number; 75 | height: number; 76 | fill: Color; 77 | value?: string; 78 | }; 79 | 80 | export type NoteLayer = { 81 | type: LayerType.Note; 82 | x: number; 83 | y: number; 84 | width: number; 85 | height: number; 86 | fill: Color; 87 | value?: string; 88 | }; 89 | 90 | export enum CanvasMode { 91 | None, 92 | Pressing, 93 | Translating, 94 | SelectingNet, 95 | Inserting, 96 | Pencil, 97 | Resizing, 98 | } 99 | 100 | export type CanvasState = 101 | | { 102 | Mode: CanvasMode.None; 103 | } 104 | | { 105 | Mode: CanvasMode.SelectingNet; 106 | Origin: Point; 107 | Current?: Point; 108 | } 109 | | { 110 | Mode: CanvasMode.Inserting; 111 | LayerType: 112 | | LayerType.Ellipse 113 | | LayerType.Path 114 | | LayerType.Rectangle 115 | | LayerType.Text 116 | | LayerType.Note; 117 | } 118 | | { 119 | Mode: CanvasMode.Pressing; 120 | Origin: Point; 121 | } 122 | | { 123 | Mode: CanvasMode.Translating; 124 | Origin: Point; 125 | } 126 | | { 127 | Mode: CanvasMode.Pencil; 128 | } 129 | | { 130 | Mode: CanvasMode.Resizing; 131 | InitialBounce: XYWH; 132 | Cornor: Side; 133 | }; 134 | 135 | export type Layer = 136 | | RectangleLayer 137 | | EllipseLayer 138 | | PathLayer 139 | | TextleLayer 140 | | NoteLayer; 141 | -------------------------------------------------------------------------------- /app/board/_components/info.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from "convex/react"; 2 | import { api } from "../../../convex/_generated/api"; 3 | import { Id } from "../../../convex/_generated/dataModel"; 4 | // import Image from "next/image"; 5 | import { Poppins } from "next/font/google"; 6 | import Action from "@/components/action"; 7 | 8 | import { cn } from "../../../lib/utils"; 9 | import { Button } from "@/components/ui/button"; 10 | import Link from "next/link"; 11 | import Hint from "@/components/hint"; 12 | 13 | import { useRenameModel } from "@/store/useRenameModel"; 14 | import { Separator } from "@radix-ui/react-dropdown-menu"; 15 | import { Menu } from "lucide-react"; 16 | 17 | // 18 | interface BoardInfoProps { 19 | boardId: string; 20 | } 21 | // 22 | 23 | const font = Poppins({ 24 | subsets: ["latin"], 25 | 26 | weight: ["600"], 27 | }); 28 | 29 | // 30 | 31 | const TabSeparator = () => { 32 | return
|
; 33 | }; 34 | 35 | const BoardInfo = ({ boardId }: BoardInfoProps) => { 36 | const { onOpen } = useRenameModel(); 37 | const data = useQuery(api.board.get, { id: boardId as Id<"boards"> }); 38 | 39 | if (!data) return

Null

; 40 | 41 | return ( 42 |
43 | 44 | 45 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 69 | 70 | 71 | 72 | 73 |
74 | 75 | 78 | 79 |
80 |
81 |
82 | ); 83 | }; 84 | 85 | export default BoardInfo; 86 | 87 | BoardInfo.Skeleton = function InfoSkeleton() { 88 | return ( 89 |
90 | ); 91 | }; 92 | -------------------------------------------------------------------------------- /components/action.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { DropdownMenuContentProps } from "@radix-ui/react-dropdown-menu"; 3 | import React from "react"; 4 | 5 | import { 6 | DropdownMenu, 7 | DropdownMenuContent, 8 | DropdownMenuItem, 9 | DropdownMenuTrigger, 10 | } from "@/components/ui/dropdown-menu"; 11 | import { Link2, Pencil, Trash2 } from "lucide-react"; 12 | import { toast } from "sonner"; 13 | 14 | import { api } from "@/convex/_generated/api"; 15 | import useMutationAip from "@/hooks/useMutationAip"; 16 | import ConfirmModel from "./ConfirmModel"; 17 | import { Button } from "./ui/button"; 18 | import { useRenameModel } from "@/store/useRenameModel"; 19 | 20 | interface ActionProps { 21 | children: React.ReactNode; 22 | side?: DropdownMenuContentProps["side"]; 23 | sideOffset?: DropdownMenuContentProps["sideOffset"]; 24 | id: string; 25 | title: string; 26 | } 27 | 28 | const Action = ({ children, side, sideOffset, id, title }: ActionProps) => { 29 | const { onOpen } = useRenameModel(); 30 | const { mutate, pending } = useMutationAip(api.board.remove); 31 | 32 | const copyLink = () => { 33 | navigator.clipboard 34 | .writeText(`${window.location.origin}/board/${id}`) 35 | .then(() => toast.success("Link copied to clipboard")) 36 | .catch(() => toast.error("Failed to copy link")); 37 | }; 38 | 39 | const handleDelete = () => { 40 | mutate({ id }) 41 | .then(() => { 42 | toast.success("Board deleted !"); 43 | }) 44 | .catch(() => { 45 | toast.error("Failed to delete !"); 46 | }); 47 | }; 48 | 49 | return ( 50 | 51 | {children} 52 | e.stopPropagation()} 57 | > 58 | 59 | 60 | Copy Link 61 | 62 | {/* */} 63 | onOpen(id, title)} 66 | > 67 | 68 | Rename Title 69 | 70 | {/* */} 71 | 77 | 85 | 86 | 87 | 88 | ); 89 | }; 90 | 91 | export default Action; 92 | -------------------------------------------------------------------------------- /app/(dashboard)/_components/boardCard/index.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import Link from "next/link"; 3 | import { toast } from "sonner"; 4 | import React from "react"; 5 | import OverLay from "./overLay"; 6 | 7 | import { api } from "@/convex/_generated/api"; 8 | import useMutationAip from "@/hooks/useMutationAip"; 9 | import { formatDistanceToNow } from "date-fns"; 10 | import { useAuth } from "@clerk/nextjs"; 11 | import Footer from "./footer"; 12 | import { Skeleton } from "@/components/ui/skeleton"; 13 | import Action from "@/components/action"; 14 | import { MoreHorizontal } from "lucide-react"; 15 | 16 | interface BoardCardProps { 17 | id: string; 18 | title: string; 19 | imgUrl: string; 20 | authorId: string; 21 | authorName: string; 22 | createdAt: number; 23 | orgId: string; 24 | isFavorite: boolean; 25 | } 26 | 27 | const BoardCard = ({ 28 | id, 29 | title, 30 | imgUrl, 31 | authorId, 32 | authorName, 33 | createdAt, 34 | orgId, 35 | isFavorite, 36 | }: BoardCardProps) => { 37 | const { userId } = useAuth(); 38 | const authorlabel = userId === authorId ? "You" : authorName; 39 | const createdAtLabel = formatDistanceToNow(createdAt, { addSuffix: true }); 40 | 41 | const { mutate: onFavorite, pending: pendingFavorite } = useMutationAip( 42 | api.board.favorite 43 | ); 44 | const { mutate: onUnfavorite, pending: pendingUnfavorite } = useMutationAip( 45 | api.board.unFavorite 46 | ); 47 | 48 | // 49 | 50 | const toggleFavorite = () => { 51 | if (isFavorite) { 52 | onUnfavorite({ id }).catch(() => { 53 | toast.error("Failed to unfavorite board"); 54 | }); 55 | } else { 56 | onFavorite({ id, orgId }).catch(() => { 57 | toast.error("Failed to favorite board"); 58 | }); 59 | } 60 | }; 61 | 62 | return ( 63 | 64 |
65 |
66 | {title} 67 | 68 | 69 | 72 | 73 |
74 |
82 |
83 | 84 | ); 85 | }; 86 | 87 | export default BoardCard; 88 | 89 | BoardCard.Skeleton = function BoardCardSklten() { 90 | return ( 91 |
92 | 93 |
94 | ); 95 | }; 96 | -------------------------------------------------------------------------------- /convex/_generated/server.js: -------------------------------------------------------------------------------- 1 | /* prettier-ignore-start */ 2 | 3 | /* eslint-disable */ 4 | /** 5 | * Generated utilities for implementing server-side Convex query and mutation functions. 6 | * 7 | * THIS CODE IS AUTOMATICALLY GENERATED. 8 | * 9 | * To regenerate, run `npx convex dev`. 10 | * @module 11 | */ 12 | 13 | import { 14 | actionGeneric, 15 | httpActionGeneric, 16 | queryGeneric, 17 | mutationGeneric, 18 | internalActionGeneric, 19 | internalMutationGeneric, 20 | internalQueryGeneric, 21 | } from "convex/server"; 22 | 23 | /** 24 | * Define a query in this Convex app's public API. 25 | * 26 | * This function will be allowed to read your Convex database and will be accessible from the client. 27 | * 28 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 29 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 30 | */ 31 | export const query = queryGeneric; 32 | 33 | /** 34 | * Define a query that is only accessible from other Convex functions (but not from the client). 35 | * 36 | * This function will be allowed to read from your Convex database. It will not be accessible from the client. 37 | * 38 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 39 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 40 | */ 41 | export const internalQuery = internalQueryGeneric; 42 | 43 | /** 44 | * Define a mutation in this Convex app's public API. 45 | * 46 | * This function will be allowed to modify your Convex database and will be accessible from the client. 47 | * 48 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 49 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 50 | */ 51 | export const mutation = mutationGeneric; 52 | 53 | /** 54 | * Define a mutation that is only accessible from other Convex functions (but not from the client). 55 | * 56 | * This function will be allowed to modify your Convex database. It will not be accessible from the client. 57 | * 58 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 59 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 60 | */ 61 | export const internalMutation = internalMutationGeneric; 62 | 63 | /** 64 | * Define an action in this Convex app's public API. 65 | * 66 | * An action is a function which can execute any JavaScript code, including non-deterministic 67 | * code and code with side-effects, like calling third-party services. 68 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. 69 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. 70 | * 71 | * @param func - The action. It receives an {@link ActionCtx} as its first argument. 72 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible. 73 | */ 74 | export const action = actionGeneric; 75 | 76 | /** 77 | * Define an action that is only accessible from other Convex functions (but not from the client). 78 | * 79 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 80 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible. 81 | */ 82 | export const internalAction = internalActionGeneric; 83 | 84 | /** 85 | * Define a Convex HTTP action. 86 | * 87 | * @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object 88 | * as its second. 89 | * @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`. 90 | */ 91 | export const httpAction = httpActionGeneric; 92 | 93 | /* prettier-ignore-end */ 94 | -------------------------------------------------------------------------------- /app/board/_components/toolbar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ToolBtn from "./ToolButton"; 3 | import { 4 | Circle, 5 | MousePointer2, 6 | Pencil, 7 | Redo2, 8 | Square, 9 | StickyNote, 10 | Type, 11 | Undo2, 12 | } from "lucide-react"; 13 | import { CanvasMode, CanvasState, LayerType } from "@/types/canvas"; 14 | 15 | interface ToolbarProps { 16 | canvasState: CanvasState; 17 | setCanvasState: (state: CanvasState) => void; 18 | undo: () => void; 19 | redo: () => void; 20 | canUndo: boolean; 21 | canRedo: boolean; 22 | } 23 | 24 | const Toolbar = ({ 25 | canvasState, 26 | setCanvasState, 27 | undo, 28 | redo, 29 | canRedo, 30 | canUndo, 31 | }: ToolbarProps) => { 32 | return ( 33 |
34 |
35 | setCanvasState({ Mode: CanvasMode.None })} 39 | isActive={ 40 | canvasState.Mode === CanvasMode.None || 41 | canvasState.Mode === CanvasMode.Pressing || 42 | canvasState.Mode === CanvasMode.Translating || 43 | canvasState.Mode === CanvasMode.SelectingNet || 44 | canvasState.Mode === CanvasMode.Resizing 45 | } 46 | /> 47 | 51 | setCanvasState({ 52 | Mode: CanvasMode.Inserting, 53 | LayerType: LayerType.Text, 54 | }) 55 | } 56 | isActive={ 57 | canvasState.Mode === CanvasMode.Inserting && 58 | canvasState.LayerType === LayerType.Text 59 | } 60 | /> 61 | 65 | setCanvasState({ 66 | Mode: CanvasMode.Inserting, 67 | LayerType: LayerType.Note, 68 | }) 69 | } 70 | isActive={ 71 | canvasState.Mode === CanvasMode.Inserting && 72 | canvasState.LayerType === LayerType.Note 73 | } 74 | /> 75 | 79 | setCanvasState({ 80 | Mode: CanvasMode.Inserting, 81 | LayerType: LayerType.Rectangle, 82 | }) 83 | } 84 | isActive={ 85 | canvasState.Mode === CanvasMode.Inserting && 86 | canvasState.LayerType === LayerType.Rectangle 87 | } 88 | /> 89 | 93 | setCanvasState({ 94 | Mode: CanvasMode.Inserting, 95 | LayerType: LayerType.Ellipse, 96 | }) 97 | } 98 | isActive={ 99 | canvasState.Mode === CanvasMode.Inserting && 100 | canvasState.LayerType === LayerType.Ellipse 101 | } 102 | /> 103 | 107 | setCanvasState({ 108 | Mode: CanvasMode.Pencil, 109 | }) 110 | } 111 | isActive={canvasState.Mode === CanvasMode.Pencil} 112 | /> 113 |
114 |
115 | 116 | 117 |
118 |
119 | ); 120 | }; 121 | 122 | export default Toolbar; 123 | -------------------------------------------------------------------------------- /liveblocks.config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createClient, 3 | LiveList, 4 | LiveMap, 5 | LiveObject, 6 | } from "@liveblocks/client"; 7 | import { createRoomContext } from "@liveblocks/react"; 8 | 9 | import { Layer, Color } from "@/types/canvas"; 10 | 11 | const client = createClient({ authEndpoint: "/api/livebloack-auth" }); 12 | 13 | // Presence represents the properties that exist on every user in the Room 14 | // and that will automatically be kept in sync. Accessible through the 15 | // `user.presence` property. Must be JSON-serializable. 16 | type Presence = { 17 | throttle: 16; 18 | cursor: { x: number; y: number } | null; 19 | selection: string[]; 20 | }; 21 | 22 | // Optionally, Storage represents the shared document that persists in the 23 | // Room, even after all users leave. Fields under Storage typically are 24 | // LiveList, LiveMap, LiveObject instances, for which updates are 25 | // automatically persisted and synced to all connected clients. 26 | type Storage = { 27 | layers: LiveMap>; 28 | layersId: LiveList; 29 | }; 30 | 31 | // Optionally, UserMeta represents static/readonly metadata on each user, as 32 | // provided by your own custom auth back end (if used). Useful for data that 33 | // will not change during a session, like a user's name or avatar. 34 | type UserMeta = { 35 | id?: string; 36 | info?: { name?: string; picture?: string }; 37 | }; 38 | 39 | // Optionally, the type of custom events broadcast and listened to in this 40 | // room. Use a union for multiple events. Must be JSON-serializable. 41 | type RoomEvent = { 42 | // type: "NOTIFICATION", 43 | // ... 44 | }; 45 | 46 | // Optionally, when using Comments, ThreadMetadata represents metadata on 47 | // each thread. Can only contain booleans, strings, and numbers. 48 | export type ThreadMetadata = { 49 | // resolved: boolean; 50 | // quote: string; 51 | // time: number; 52 | }; 53 | 54 | export const { 55 | suspense: { 56 | RoomProvider, 57 | useRoom, 58 | useMyPresence, 59 | useUpdateMyPresence, 60 | useSelf, 61 | useOthers, 62 | useOthersMapped, 63 | useOthersConnectionIds, 64 | useOther, 65 | useBroadcastEvent, 66 | useEventListener, 67 | useErrorListener, 68 | useStorage, 69 | useObject, 70 | useMap, 71 | useList, 72 | useBatch, 73 | useHistory, 74 | useUndo, 75 | useRedo, 76 | useCanUndo, 77 | useCanRedo, 78 | useMutation, 79 | useStatus, 80 | useLostConnectionListener, 81 | useThreads, 82 | useUser, 83 | useCreateThread, 84 | useEditThreadMetadata, 85 | useCreateComment, 86 | useEditComment, 87 | useDeleteComment, 88 | useAddReaction, 89 | useRemoveReaction, 90 | }, 91 | } = createRoomContext( 92 | client, 93 | { 94 | async resolveUsers({ userIds }) { 95 | // Used only for Comments. Return a list of user information retrieved 96 | // from `userIds`. This info is used in comments, mentions etc. 97 | 98 | // const usersData = await __fetchUsersFromDB__(userIds); 99 | // 100 | // return usersData.map((userData) => ({ 101 | // name: userData.name, 102 | // avatar: userData.avatar.src, 103 | // })); 104 | 105 | return []; 106 | }, 107 | async resolveMentionSuggestions({ text, roomId }) { 108 | // Used only for Comments. Return a list of userIds that match `text`. 109 | // These userIds are used to create a mention list when typing in the 110 | // composer. 111 | // 112 | // For example when you type "@jo", `text` will be `"jo"`, and 113 | // you should to return an array with John and Joanna's userIds: 114 | // ["john@example.com", "joanna@example.com"] 115 | 116 | // const userIds = await __fetchAllUserIdsFromDB__(roomId); 117 | // 118 | // Return all userIds if no `text` 119 | // if (!text) { 120 | // return userIds; 121 | // } 122 | // 123 | // Otherwise, filter userIds for the search `text` and return 124 | // return userIds.filter((userId) => 125 | // userId.toLowerCase().includes(text.toLowerCase()) 126 | // ); 127 | 128 | return []; 129 | }, 130 | } 131 | ); 132 | -------------------------------------------------------------------------------- /app/board/_components/canvas.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { useCallback, useState } from "react"; 3 | import BoardInfo from "./info"; 4 | import Participants from "./participants"; 5 | import Toolbar from "./toolbar"; 6 | import { 7 | Camera, 8 | CanvasMode, 9 | CanvasState, 10 | Color, 11 | LayerType, 12 | Point, 13 | } from "@/types/canvas"; 14 | import { 15 | useHistory, 16 | useCanRedo, 17 | useCanUndo, 18 | useMutation, 19 | useStorage, 20 | } from "@/liveblocks.config"; 21 | import { CursorPresent } from "./CursorPresent"; 22 | import { pointerEventtoCanvasPoint } from "@/lib/utils"; 23 | import { nanoid } from "nanoid"; 24 | import { LiveObject } from "@liveblocks/client"; 25 | 26 | const MAX_LAYER = 300; 27 | 28 | interface CanvasProps { 29 | boardId: string; 30 | } 31 | 32 | const Canvas = ({ boardId }: CanvasProps) => { 33 | const layerIds = useStorage((root) => root.layersId); 34 | 35 | const [canvasState, setCanvasState] = useState({ 36 | Mode: CanvasMode.None, 37 | }); 38 | 39 | const [camera, setCamera] = useState({ x: 0, y: 0 }); 40 | const [lastUsedColor, setLastUsedColor] = useState({ 41 | r: 0, 42 | g: 0, 43 | b: 0, 44 | }); 45 | 46 | const onWheel = useCallback((e: React.WheelEvent) => { 47 | setCamera((camera) => ({ 48 | x: camera.x - e.deltaX, 49 | y: camera.y - e.deltaY, 50 | })); 51 | }, []); 52 | 53 | const history = useHistory(); 54 | const canUndo = useCanUndo(); 55 | const canRedo = useCanRedo(); 56 | 57 | const insertLayer = useMutation( 58 | ( 59 | { storage, setMyPresence }, 60 | layerType: 61 | | LayerType.Ellipse 62 | | LayerType.Rectangle 63 | | LayerType.Text 64 | | LayerType.Note, 65 | Position: Point 66 | ) => { 67 | const liveLayers = storage.get("layers"); 68 | if (liveLayers.size >= MAX_LAYER) { 69 | return; 70 | } 71 | const LivelayerIds = storage.get("layersId"); 72 | const layerId = nanoid(); 73 | 74 | const layer = new LiveObject({ 75 | type: layerType, 76 | x: Position.x, 77 | y: Position.y, 78 | 79 | width: 100, 80 | height: 100, 81 | fill: lastUsedColor, 82 | }); 83 | LivelayerIds.push(layerId); 84 | liveLayers.set(layerId, layer); 85 | setMyPresence({ selection: [layerId] }, { addToHistory: true }); 86 | setCanvasState({ Mode: CanvasMode.None }); 87 | }, 88 | [lastUsedColor] 89 | ); 90 | const onPointerMove = useMutation( 91 | ({ setMyPresence }, e: React.PointerEvent) => { 92 | e.preventDefault(); 93 | 94 | const current = pointerEventtoCanvasPoint(e, camera); 95 | 96 | setMyPresence({ cursor: current }); 97 | }, 98 | [boardId] 99 | ); 100 | const onPointerLeave = useMutation(({ setMyPresence }) => { 101 | setMyPresence({ cursor: null }); 102 | }, []); 103 | 104 | const onPointerUp = useMutation( 105 | ({}, e) => { 106 | const point = pointerEventtoCanvasPoint(e, camera); 107 | if (canvasState.Mode === CanvasMode.Inserting) { 108 | insertLayer(canvasState.layerType, point); 109 | } else { 110 | setCanvasState({ Mode: CanvasMode.None }); 111 | } 112 | 113 | history.resume(); 114 | }, 115 | [camera, canvasState, history, insertLayer] 116 | ); 117 | 118 | return ( 119 |
120 | 121 | 122 | 130 | 136 | 141 | 142 | 143 | 144 |
145 | ); 146 | }; 147 | export default Canvas; 148 | -------------------------------------------------------------------------------- /convex/board.ts: -------------------------------------------------------------------------------- 1 | import { v } from "convex/values"; 2 | import { mutation, query } from "./_generated/server"; 3 | 4 | const images = [ 5 | "/public/1.jpg", 6 | "/public/2.jpg", 7 | "/public/3.jpg", 8 | "/public/4.jpg", 9 | ]; 10 | 11 | export const create = mutation({ 12 | args: { 13 | orgId: v.string(), 14 | title: v.string(), 15 | }, 16 | handler: async (ctx, args) => { 17 | const identity = await ctx.auth.getUserIdentity(); 18 | if (!identity) { 19 | throw new Error("Not authenticated"); 20 | } 21 | 22 | const randomImg = images[Math.floor(Math.random() * images.length)]; 23 | 24 | const board = await ctx.db.insert("boards", { 25 | title: args.title, 26 | orgId: args.orgId, 27 | authorId: identity.subject, 28 | authorName: identity.name!, 29 | imgUrl: randomImg, 30 | }); 31 | 32 | return board; 33 | }, 34 | }); 35 | 36 | export const remove = mutation({ 37 | args: { 38 | id: v.id("boards"), 39 | }, 40 | handler: async (ctx, args) => { 41 | const identity = await ctx.auth.getUserIdentity(); 42 | 43 | if (!identity) { 44 | throw new Error("Not authenticated"); 45 | } 46 | 47 | const userId = identity.subject; 48 | const existingFav = await ctx.db 49 | .query("userFavorites") 50 | .withIndex("by_user_board", (q) => 51 | q.eq("userId", userId).eq("boardId", args.id) 52 | ) 53 | .unique(); 54 | 55 | if (existingFav) { 56 | await ctx.db.delete(existingFav._id); 57 | } 58 | await ctx.db.delete(args.id); 59 | }, 60 | }); 61 | 62 | export const update = mutation({ 63 | args: { id: v.id("boards"), title: v.string() }, 64 | handler: async (ctx, args) => { 65 | const identity = await ctx.auth.getUserIdentity(); 66 | if (!identity) { 67 | throw new Error("Not authenticated"); 68 | } 69 | const title = args.title.trim(); 70 | 71 | if (!title) { 72 | throw new Error("Title is required"); 73 | } 74 | 75 | if (title.length > 40) { 76 | throw new Error("Title is too long"); 77 | } 78 | 79 | const board = await ctx.db.patch(args.id, { 80 | title: args.title, 81 | }); 82 | 83 | return board; 84 | }, 85 | }); 86 | 87 | export const favorite = mutation({ 88 | args: { 89 | id: v.id("boards"), 90 | 91 | orgId: v.string(), 92 | }, 93 | handler: async (ctx, args) => { 94 | const identity = await ctx.auth.getUserIdentity(); 95 | 96 | if (!identity) { 97 | throw new Error("Unauthorized"); 98 | } 99 | const board = await ctx.db.get(args.id); 100 | 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) { 115 | throw new Error("Already favorited"); 116 | } 117 | 118 | await ctx.db.insert("userFavorites", { 119 | userId, 120 | boardId: board._id, 121 | orgId: args.orgId, 122 | }); 123 | 124 | return board; 125 | }, 126 | }); 127 | 128 | export const unFavorite = mutation({ 129 | args: { 130 | id: v.id("boards"), 131 | }, 132 | handler: async (ctx, args) => { 133 | const identity = await ctx.auth.getUserIdentity(); 134 | 135 | if (!identity) { 136 | throw new Error("Unauthorized"); 137 | } 138 | const board = await ctx.db.get(args.id); 139 | 140 | if (!board) { 141 | throw new Error("Board not found"); 142 | } 143 | 144 | const userId = identity.subject; 145 | 146 | const existingFavorite = await ctx.db 147 | .query("userFavorites") 148 | .withIndex("by_user_board", (q) => 149 | q.eq("userId", userId).eq("boardId", board._id) 150 | ) 151 | .unique(); 152 | 153 | if (!existingFavorite) { 154 | throw new Error(" Not favorited"); 155 | } 156 | 157 | await ctx.db.delete(existingFavorite._id); 158 | 159 | return board; 160 | }, 161 | }); 162 | 163 | export const get = query({ 164 | args: { id: v.id("boards") }, 165 | handler: async (ctx, args) => { 166 | return ctx.db.get(args.id); 167 | }, 168 | }); 169 | -------------------------------------------------------------------------------- /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/alert-dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" 5 | 6 | import { cn } from "@/lib/utils" 7 | import { buttonVariants } from "@/components/ui/button" 8 | 9 | const AlertDialog = AlertDialogPrimitive.Root 10 | 11 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger 12 | 13 | const AlertDialogPortal = AlertDialogPrimitive.Portal 14 | 15 | const AlertDialogOverlay = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, ...props }, ref) => ( 19 | 27 | )) 28 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName 29 | 30 | const AlertDialogContent = React.forwardRef< 31 | React.ElementRef, 32 | React.ComponentPropsWithoutRef 33 | >(({ className, ...props }, ref) => ( 34 | 35 | 36 | 44 | 45 | )) 46 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName 47 | 48 | const AlertDialogHeader = ({ 49 | className, 50 | ...props 51 | }: React.HTMLAttributes) => ( 52 |
59 | ) 60 | AlertDialogHeader.displayName = "AlertDialogHeader" 61 | 62 | const AlertDialogFooter = ({ 63 | className, 64 | ...props 65 | }: React.HTMLAttributes) => ( 66 |
73 | ) 74 | AlertDialogFooter.displayName = "AlertDialogFooter" 75 | 76 | const AlertDialogTitle = React.forwardRef< 77 | React.ElementRef, 78 | React.ComponentPropsWithoutRef 79 | >(({ className, ...props }, ref) => ( 80 | 85 | )) 86 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName 87 | 88 | const AlertDialogDescription = React.forwardRef< 89 | React.ElementRef, 90 | React.ComponentPropsWithoutRef 91 | >(({ className, ...props }, ref) => ( 92 | 97 | )) 98 | AlertDialogDescription.displayName = 99 | AlertDialogPrimitive.Description.displayName 100 | 101 | const AlertDialogAction = React.forwardRef< 102 | React.ElementRef, 103 | React.ComponentPropsWithoutRef 104 | >(({ className, ...props }, ref) => ( 105 | 110 | )) 111 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName 112 | 113 | const AlertDialogCancel = React.forwardRef< 114 | React.ElementRef, 115 | React.ComponentPropsWithoutRef 116 | >(({ className, ...props }, ref) => ( 117 | 126 | )) 127 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName 128 | 129 | export { 130 | AlertDialog, 131 | AlertDialogPortal, 132 | AlertDialogOverlay, 133 | AlertDialogTrigger, 134 | AlertDialogContent, 135 | AlertDialogHeader, 136 | AlertDialogFooter, 137 | AlertDialogTitle, 138 | AlertDialogDescription, 139 | AlertDialogAction, 140 | AlertDialogCancel, 141 | } 142 | -------------------------------------------------------------------------------- /convex/_generated/server.d.ts: -------------------------------------------------------------------------------- 1 | /* prettier-ignore-start */ 2 | 3 | /* eslint-disable */ 4 | /** 5 | * Generated utilities for implementing server-side Convex query and mutation functions. 6 | * 7 | * THIS CODE IS AUTOMATICALLY GENERATED. 8 | * 9 | * To regenerate, run `npx convex dev`. 10 | * @module 11 | */ 12 | 13 | import { 14 | ActionBuilder, 15 | HttpActionBuilder, 16 | MutationBuilder, 17 | QueryBuilder, 18 | GenericActionCtx, 19 | GenericMutationCtx, 20 | GenericQueryCtx, 21 | GenericDatabaseReader, 22 | GenericDatabaseWriter, 23 | } from "convex/server"; 24 | import type { DataModel } from "./dataModel.js"; 25 | 26 | /** 27 | * Define a query in this Convex app's public API. 28 | * 29 | * This function will be allowed to read your Convex database and will be accessible from the client. 30 | * 31 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 32 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 33 | */ 34 | export declare const query: QueryBuilder; 35 | 36 | /** 37 | * Define a query that is only accessible from other Convex functions (but not from the client). 38 | * 39 | * This function will be allowed to read from your Convex database. It will not be accessible from the client. 40 | * 41 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. 42 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. 43 | */ 44 | export declare const internalQuery: QueryBuilder; 45 | 46 | /** 47 | * Define a mutation in this Convex app's public API. 48 | * 49 | * This function will be allowed to modify your Convex database and will be accessible from the client. 50 | * 51 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 52 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 53 | */ 54 | export declare const mutation: MutationBuilder; 55 | 56 | /** 57 | * Define a mutation that is only accessible from other Convex functions (but not from the client). 58 | * 59 | * This function will be allowed to modify your Convex database. It will not be accessible from the client. 60 | * 61 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. 62 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. 63 | */ 64 | export declare const internalMutation: MutationBuilder; 65 | 66 | /** 67 | * Define an action in this Convex app's public API. 68 | * 69 | * An action is a function which can execute any JavaScript code, including non-deterministic 70 | * code and code with side-effects, like calling third-party services. 71 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. 72 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. 73 | * 74 | * @param func - The action. It receives an {@link ActionCtx} as its first argument. 75 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible. 76 | */ 77 | export declare const action: ActionBuilder; 78 | 79 | /** 80 | * Define an action that is only accessible from other Convex functions (but not from the client). 81 | * 82 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 83 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible. 84 | */ 85 | export declare const internalAction: ActionBuilder; 86 | 87 | /** 88 | * Define an HTTP action. 89 | * 90 | * This function will be used to respond to HTTP requests received by a Convex 91 | * deployment if the requests matches the path and method where this action 92 | * is routed. Be sure to route your action in `convex/http.js`. 93 | * 94 | * @param func - The function. It receives an {@link ActionCtx} as its first argument. 95 | * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. 96 | */ 97 | export declare const httpAction: HttpActionBuilder; 98 | 99 | /** 100 | * A set of services for use within Convex query functions. 101 | * 102 | * The query context is passed as the first argument to any Convex query 103 | * function run on the server. 104 | * 105 | * This differs from the {@link MutationCtx} because all of the services are 106 | * read-only. 107 | */ 108 | export type QueryCtx = GenericQueryCtx; 109 | 110 | /** 111 | * A set of services for use within Convex mutation functions. 112 | * 113 | * The mutation context is passed as the first argument to any Convex mutation 114 | * function run on the server. 115 | */ 116 | export type MutationCtx = GenericMutationCtx; 117 | 118 | /** 119 | * A set of services for use within Convex action functions. 120 | * 121 | * The action context is passed as the first argument to any Convex action 122 | * function run on the server. 123 | */ 124 | export type ActionCtx = GenericActionCtx; 125 | 126 | /** 127 | * An interface to read from the database within Convex query functions. 128 | * 129 | * The two entry points are {@link DatabaseReader.get}, which fetches a single 130 | * document by its {@link Id}, or {@link DatabaseReader.query}, which starts 131 | * building a query. 132 | */ 133 | export type DatabaseReader = GenericDatabaseReader; 134 | 135 | /** 136 | * An interface to read from and write to the database within Convex mutation 137 | * functions. 138 | * 139 | * Convex guarantees that all writes within a single mutation are 140 | * executed atomically, so you never have to worry about partial writes leaving 141 | * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) 142 | * for the guarantees Convex provides your functions. 143 | */ 144 | export type DatabaseWriter = GenericDatabaseWriter; 145 | 146 | /* prettier-ignore-end */ 147 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------