├── .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 |
12 | {" "}
13 | Invite Member
14 |
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 |
30 |
31 |
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 | Create Organization
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 |
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 |
27 |
28 |
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 |
44 |
47 |
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 |
41 | Create board
42 |
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 |
41 | {/*
*/}
42 |
43 | New Board
44 |
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 |
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 | {/* */}
27 |
28 | MIRO
29 |
30 |
31 |
32 |
33 |
54 |
55 |
56 |
62 |
63 |
64 | Team boards
65 |
66 |
67 |
73 |
79 |
80 | Fav boards
81 |
82 |
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 |
46 | {/* */}
47 |
53 | MIRO
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 | onOpen(data?._id, data.title)}
66 | >
67 | {data.title}
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
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 |
82 |
83 | Delete
84 |
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 |
67 |
68 |
69 |
70 |
71 |
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 |
--------------------------------------------------------------------------------