├── .eslintrc.json
├── .gitignore
├── README.md
├── app
├── (main)
│ ├── (routes)
│ │ └── documents
│ │ │ ├── [documentId]
│ │ │ └── page.tsx
│ │ │ └── page.tsx
│ ├── _components
│ │ ├── banner.tsx
│ │ ├── document-list.tsx
│ │ ├── item.tsx
│ │ ├── menu.tsx
│ │ ├── navbar.tsx
│ │ ├── navigation.tsx
│ │ ├── publish.tsx
│ │ ├── title.tsx
│ │ ├── trash-box.tsx
│ │ └── user-item.tsx
│ └── layout.tsx
├── (marketing)
│ ├── _components
│ │ ├── footer.tsx
│ │ ├── heading.tsx
│ │ ├── heroes.tsx
│ │ ├── logo.tsx
│ │ └── navbar.tsx
│ ├── layout.tsx
│ └── page.tsx
├── (public)
│ ├── (routes)
│ │ └── preview
│ │ │ └── [documentId]
│ │ │ └── page.tsx
│ └── layout.tsx
├── api
│ └── edgestore
│ │ └── [...edgestore]
│ │ └── route.ts
├── error.tsx
├── globals.css
└── layout.tsx
├── components.json
├── components
├── cover.tsx
├── editor.tsx
├── icon-picker.tsx
├── modals
│ ├── confirm-modal.tsx
│ ├── cover-image-modal.tsx
│ └── settings-modal.tsx
├── mode-toggle.tsx
├── providers
│ ├── convex-provider.tsx
│ ├── modal-provider.tsx
│ └── theme-provider.tsx
├── search-command.tsx
├── single-image-dropzone.tsx
├── spinner.tsx
├── toolbar.tsx
└── ui
│ ├── alert-dialog.tsx
│ ├── avatar.tsx
│ ├── button.tsx
│ ├── command.tsx
│ ├── dialog.tsx
│ ├── dropdown-menu.tsx
│ ├── input.tsx
│ ├── label.tsx
│ ├── popover.tsx
│ └── skeleton.tsx
├── convex
├── README.md
├── _generated
│ ├── api.d.ts
│ ├── api.js
│ ├── dataModel.d.ts
│ ├── server.d.ts
│ └── server.js
├── auth.config.js
├── documents.ts
├── schema.ts
└── tsconfig.json
├── hooks
├── use-cover-image.tsx
├── use-origin.tsx
├── use-scroll-top.tsx
├── use-search.tsx
└── use-settings.tsx
├── lib
├── edgestore.ts
└── utils.ts
├── next.config.js
├── package-lock.json
├── package.json
├── postcss.config.js
├── public
├── documents-dark.png
├── documents.png
├── empty-dark.png
├── empty.png
├── error-dark.png
├── error.png
├── logo-dark.svg
├── logo.svg
├── next.svg
├── reading-dark.png
├── reading.png
└── vercel.svg
├── tailwind.config.ts
└── tsconfig.json
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env*.local
29 |
30 | # vercel
31 | .vercel
32 |
33 | # typescript
34 | *.tsbuildinfo
35 | next-env.d.ts
36 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # notion-clone-nextjs
2 |
3 | 
4 | 
5 | 
6 |
7 |
8 | November, 2023.
9 | A Notion Clone web application built with Next.js, React, Tailwind CSS, TypeScript, Convex, Clerk Auth, Edge Store, and Zustand.
10 |
11 | ## Features
12 | - Create, edit, and organize notes in a Notion-like interface.
13 | - Real-time updates for editing using Convex.
14 | - User authentication and management with Clerk Auth.
15 | - File upload and storage using Edge Store.
16 | - Responsive design with Tailwind CSS.
17 | - State management using Zustand.
18 |
19 | ## Getting Started
20 |
21 | These instructions will help you set up and run the project on your local machine for development and testing purposes.
22 |
23 | 1. **Clone the repository:**
24 | ```bash
25 | git clone https://github.com/evanch98/notion-clone-nextjs.git
26 | cd your-repo-name
27 | ```
28 |
29 | 2. **Install the required dependencies:**
30 | ```bash
31 | npm install
32 | ```
33 |
34 | 3. **Configure environment variables:**
35 | Create a `.env.local` file in the project root and set the necessary environment variables.
36 | ```
37 | CONVEX_DEPLOYMENT=
38 | NEXT_PUBLIC_CONVEX_URL=
39 |
40 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
41 | CLERK_SECRET_KEY=
42 |
43 | EDGE_STORE_ACCESS_KEY=
44 | EDGE_STORE_SECRET_KEY=
45 | ```
46 |
47 | 4. **Run the development server:**
48 | ```bash
49 | npm run dev
50 | ```
51 |
52 | 5. **Start building and customizing your Notion Clone!**
53 |
54 | ## Technologies Used
55 | - [Next.js](https://nextjs.org/)
56 | - [React](https://react.dev/)
57 | - [TailwindCSS](https://tailwindcss.com/)
58 | - [TypeScript](https://www.typescriptlang.org/)
59 | - [Convex](https://www.convex.dev/)
60 | - [Clerk Auth](https://clerk.com/)
61 | - [Edge Store](https://edgestore.dev/)
62 | - [Zustand](https://docs.pmnd.rs/zustand/getting-started/introduction)
63 |
64 | ## Acknowledgements
65 | - [class-variance-authority](https://www.npmjs.com/package/class-variance-authority)
66 | - [clsx](https://www.npmjs.com/package/clsx)
67 | - [cmdk](https://www.npmjs.com/package/cmdk)
68 | - [emoji-picker-react](https://www.npmjs.com/package/emoji-picker-react)
69 | - [lucide-react](https://www.npmjs.com/package/lucide-react)
70 | - [react-dropzone](https://www.npmjs.com/package/react-dropzone)
71 | - [react-textarea-autosize](https://www.npmjs.com/package/react-textarea-autosize)
72 | - [sonner](https://www.npmjs.com/package/sonner)
73 | - [tailwind-merge](https://www.npmjs.com/package/tailwind-merge)
74 | - [usehooks-ts](https://www.npmjs.com/package/usehooks-ts)
75 | - [zod](https://www.npmjs.com/package/zod)
--------------------------------------------------------------------------------
/app/(main)/(routes)/documents/[documentId]/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Cover } from "@/components/cover";
4 | import { Toolbar } from "@/components/toolbar";
5 | import { Skeleton } from "@/components/ui/skeleton";
6 | import { api } from "@/convex/_generated/api";
7 | import { Id } from "@/convex/_generated/dataModel";
8 | import { useMutation, useQuery } from "convex/react";
9 | import dynamic from "next/dynamic";
10 | import { useMemo } from "react";
11 |
12 | interface DocumentIdPageProps {
13 | params: {
14 | documentId: Id<"documents">;
15 | };
16 | }
17 |
18 | const DocumentIdPage = ({ params }: DocumentIdPageProps) => {
19 | const Editor = useMemo(
20 | () => dynamic(() => import("@/components/editor"), { ssr: false }),
21 | []
22 | );
23 |
24 | const document = useQuery(api.documents.getById, {
25 | documentId: params.documentId,
26 | });
27 |
28 | const update = useMutation(api.documents.update);
29 |
30 | const onChange = (content: string) => {
31 | update({ id: params.documentId, content: content });
32 | };
33 |
34 | if (document === undefined) {
35 | return (
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | );
48 | }
49 |
50 | if (document === null) {
51 | return Not found
;
52 | }
53 |
54 | return (
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 | );
63 | };
64 |
65 | export default DocumentIdPage;
66 |
--------------------------------------------------------------------------------
/app/(main)/(routes)/documents/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@/components/ui/button";
4 | import { api } from "@/convex/_generated/api";
5 | import { useUser } from "@clerk/clerk-react";
6 | import { useMutation } from "convex/react";
7 | import { PlusCircle } from "lucide-react";
8 | import Image from "next/image";
9 | import { useRouter } from "next/navigation";
10 | import { toast } from "sonner";
11 |
12 | const DocumentsPage = () => {
13 | const router = useRouter();
14 | const { user } = useUser();
15 | const create = useMutation(api.documents.create);
16 |
17 | const onCreate = () => {
18 | const promise = create({ title: "Untitled" }).then((documentId) =>
19 | router.push(`/documents/${documentId}`)
20 | );
21 |
22 | toast.promise(promise, {
23 | loading: "Creating a new note...",
24 | success: "New note created!",
25 | error: "Failed to create a new not.",
26 | });
27 | };
28 |
29 | return (
30 |
31 |
38 |
45 |
46 | Welcome to {user?.firstName}'s Notion
47 |
48 |
49 |
50 | Create a note
51 |
52 |
53 | );
54 | };
55 |
56 | export default DocumentsPage;
57 |
--------------------------------------------------------------------------------
/app/(main)/_components/banner.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ConfirmModal } from "@/components/modals/confirm-modal";
4 | import { Button } from "@/components/ui/button";
5 | import { api } from "@/convex/_generated/api";
6 | import { Id } from "@/convex/_generated/dataModel";
7 | import { useMutation } from "convex/react";
8 | import { useRouter } from "next/navigation";
9 | import { toast } from "sonner";
10 |
11 | interface BannerProps {
12 | documentId: Id<"documents">;
13 | }
14 |
15 | export const Banner = ({ documentId }: BannerProps) => {
16 | const router = useRouter();
17 |
18 | const remove = useMutation(api.documents.remove);
19 | const restore = useMutation(api.documents.restore);
20 |
21 | const onRemove = () => {
22 | const promise = remove({ id: documentId });
23 | toast.promise(promise, {
24 | loading: "Deleting note...",
25 | success: "Note deleted!",
26 | error: "Failed to delete note.",
27 | });
28 | router.push("/documents");
29 | };
30 |
31 | const onRestore = () => {
32 | const promise = restore({ id: documentId });
33 | toast.promise(promise, {
34 | loading: "Restoring note...",
35 | success: "Note restored!",
36 | error: "Failed to restore note.",
37 | });
38 | };
39 |
40 | return (
41 |
42 |
This page is in the trash
43 |
49 | Restore page
50 |
51 |
52 |
57 | Delete forever
58 |
59 |
60 |
61 | );
62 | };
63 |
--------------------------------------------------------------------------------
/app/(main)/_components/document-list.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { api } from "@/convex/_generated/api";
4 | import { Doc, Id } from "@/convex/_generated/dataModel";
5 | import { useQuery } from "convex/react";
6 | import { useParams, useRouter } from "next/navigation";
7 | import { useState } from "react";
8 | import { Item } from "./item";
9 | import { cn } from "@/lib/utils";
10 | import { FileIcon } from "lucide-react";
11 |
12 | interface DocumentListProps {
13 | parentDocumentId?: Id<"documents">;
14 | level?: number;
15 | data?: Doc<"documents">[];
16 | }
17 |
18 | export const DocumentList = ({
19 | parentDocumentId,
20 | level = 0,
21 | }: DocumentListProps) => {
22 | const params = useParams();
23 | const router = useRouter();
24 | const [expanded, setExpanded] = useState>({});
25 |
26 | const onExpand = (documentId: string) => {
27 | setExpanded((prevExpanded) => ({
28 | ...prevExpanded,
29 | [documentId]: !prevExpanded[documentId],
30 | }));
31 | };
32 |
33 | const documents = useQuery(api.documents.getSidebar, {
34 | parentDocument: parentDocumentId,
35 | });
36 |
37 | const onRedirect = (documentId: string) => {
38 | router.push(`/documents/${documentId}`);
39 | };
40 |
41 | // In convex, the query will only result in undefined if it is loading
42 | // otherwise, it will have a data or null
43 | if (documents === undefined) {
44 | return (
45 | <>
46 |
47 | {level === 0 && (
48 | <>
49 |
50 |
51 | >
52 | )}
53 | >
54 | );
55 | }
56 |
57 | return (
58 | <>
59 | {/* This paragraph will be rendered if it is the last element in the document tree */}
60 |
68 | No pages inside
69 |
70 | {documents.map((document) => (
71 |
72 | - onRedirect(document._id)}
75 | label={document.title}
76 | icon={FileIcon}
77 | documentIcon={document.icon}
78 | active={params.documentId === document._id}
79 | level={level}
80 | onExpand={() => onExpand(document._id)}
81 | expanded={expanded[document._id]}
82 | />
83 | {expanded[document._id] && (
84 |
85 | )}
86 |
87 | ))}
88 | >
89 | );
90 | };
91 |
--------------------------------------------------------------------------------
/app/(main)/_components/item.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | DropdownMenu,
5 | DropdownMenuContent,
6 | DropdownMenuItem,
7 | DropdownMenuSeparator,
8 | DropdownMenuTrigger,
9 | } from "@/components/ui/dropdown-menu";
10 | import { Skeleton } from "@/components/ui/skeleton";
11 | import { api } from "@/convex/_generated/api";
12 | import { Id } from "@/convex/_generated/dataModel";
13 | import { cn } from "@/lib/utils";
14 | import { useUser } from "@clerk/clerk-react";
15 | import { useMutation } from "convex/react";
16 | import {
17 | ChevronDown,
18 | ChevronRight,
19 | LucideIcon,
20 | MoreHorizontal,
21 | Plus,
22 | Trash,
23 | } from "lucide-react";
24 | import { useRouter } from "next/navigation";
25 | import { toast } from "sonner";
26 |
27 | interface ItemProps {
28 | id?: Id<"documents">;
29 | documentIcon?: string;
30 | active?: boolean;
31 | expanded?: boolean; // expanded = see the children of the document
32 | isSearched?: boolean;
33 | level?: number; // to use it to set the padding of the document
34 | onExpand?: () => void;
35 | label: string;
36 | onClick?: () => void;
37 | icon: LucideIcon;
38 | }
39 |
40 | export const Item = ({
41 | id,
42 | documentIcon,
43 | active,
44 | expanded,
45 | isSearched,
46 | level = 0,
47 | onExpand,
48 | label,
49 | onClick,
50 | icon: Icon,
51 | }: ItemProps) => {
52 | const { user } = useUser();
53 | const create = useMutation(api.documents.create);
54 | const router = useRouter();
55 | const archive = useMutation(api.documents.archive);
56 |
57 | // handle archiving a document
58 | const onArchive = (event: React.MouseEvent) => {
59 | event.stopPropagation();
60 | if (!id) return;
61 |
62 | const promise = archive({ id }).then(() => router.push("/documents"));
63 | toast.promise(promise, {
64 | loading: "Moving to trash...",
65 | success: "Note moved to trash!",
66 | error: "Failed to archive note.",
67 | });
68 | };
69 |
70 | const handleExpand = (
71 | event: React.MouseEvent
72 | ) => {
73 | event.stopPropagation();
74 | onExpand?.();
75 | };
76 |
77 | // create a new document
78 | const onCreate = (event: React.MouseEvent) => {
79 | event.stopPropagation();
80 | if (!id) return; // if there is no id, break the function
81 | const promise = create({ title: "Untitled", parentDocument: id }).then(
82 | (documentId) => {
83 | // if the document is not expanded, expand it to show the newly created child document
84 | if (!expanded) {
85 | onExpand?.();
86 | }
87 | router.push(`/documents/${documentId}`);
88 | }
89 | );
90 |
91 | toast.promise(promise, {
92 | loading: "Creating a new note...",
93 | success: "New note created!",
94 | error: "Failed to create a new note.",
95 | });
96 | };
97 |
98 | const ChevronIcon = expanded ? ChevronDown : ChevronRight; // change the icon based of the "expanded" prop
99 |
100 | return (
101 |
110 | {/* Render this part if and only if the item has an id (documents id) */}
111 | {!!id && (
112 |
117 |
118 |
119 | )}
120 | {/* Render the appropriate icon based on the presence of documentIcon */}
121 | {documentIcon ? (
122 |
{documentIcon}
123 | ) : (
124 |
125 | )}
126 |
{label}
127 | {/* Render this part if and only if the "isSearched" prop is true */}
128 | {isSearched && (
129 |
130 | Ctrl+K
131 |
132 | )}
133 | {!!id && (
134 |
135 | {/* Action button */}
136 |
137 | e.stopPropagation()}>
138 |
142 |
143 |
144 |
145 |
151 |
152 |
153 | Delete
154 |
155 |
156 |
157 | Last edited by: {user?.fullName}
158 |
159 |
160 |
161 | {/* Create button */}
162 |
169 |
170 | )}
171 |
172 | );
173 | };
174 |
175 | Item.Skeleton = function ItemSkeleton({ level }: { level?: number }) {
176 | return (
177 |
181 |
182 |
183 |
184 | );
185 | };
186 |
--------------------------------------------------------------------------------
/app/(main)/_components/menu.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@/components/ui/button";
4 | import {
5 | DropdownMenu,
6 | DropdownMenuContent,
7 | DropdownMenuItem,
8 | DropdownMenuSeparator,
9 | DropdownMenuTrigger,
10 | } from "@/components/ui/dropdown-menu";
11 | import { Skeleton } from "@/components/ui/skeleton";
12 | import { api } from "@/convex/_generated/api";
13 | import { Id } from "@/convex/_generated/dataModel";
14 | import { useUser } from "@clerk/clerk-react";
15 | import { useMutation } from "convex/react";
16 | import { MoreHorizontal, Trash } from "lucide-react";
17 | import { useRouter } from "next/navigation";
18 | import { toast } from "sonner";
19 |
20 | interface MenuProps {
21 | documentId: Id<"documents">;
22 | }
23 |
24 | export const Menu = ({ documentId }: MenuProps) => {
25 | const router = useRouter();
26 | const { user } = useUser();
27 |
28 | const archive = useMutation(api.documents.archive);
29 |
30 | const onArchive = () => {
31 | const promise = archive({ id: documentId });
32 |
33 | toast.promise(promise, {
34 | loading: "Moving to trash...",
35 | success: "Note moved to trash!",
36 | error: "Failed to archive note.",
37 | });
38 |
39 | router.push("/documents");
40 | };
41 |
42 | return (
43 |
44 |
45 |
46 |
47 |
48 |
49 |
55 |
56 |
57 | Delete
58 |
59 |
60 |
61 | Last edited by: {user?.fullName}
62 |
63 |
64 |
65 | );
66 | };
67 |
68 | Menu.Skeleton = function MenuSkeleton() {
69 | return ;
70 | };
71 |
--------------------------------------------------------------------------------
/app/(main)/_components/navbar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { api } from "@/convex/_generated/api";
4 | import { Id } from "@/convex/_generated/dataModel";
5 | import { useQuery } from "convex/react";
6 | import { MenuIcon } from "lucide-react";
7 | import { useParams } from "next/navigation";
8 | import { Title } from "./title";
9 | import { Banner } from "./banner";
10 | import { Menu } from "./menu";
11 | import { Publish } from "./publish";
12 |
13 | interface NavbarProps {
14 | isCollapsed: boolean;
15 | onResetWidth: () => void;
16 | }
17 |
18 | export const Navbar = ({ isCollapsed, onResetWidth }: NavbarProps) => {
19 | const params = useParams();
20 |
21 | const document = useQuery(api.documents.getById, {
22 | documentId: params.documentId as Id<"documents">,
23 | });
24 |
25 | if (document === undefined) {
26 | return (
27 |
28 |
29 |
30 |
31 |
32 |
33 | );
34 | }
35 |
36 | if (document === null) {
37 | return null;
38 | }
39 |
40 | return (
41 | <>
42 |
43 | {isCollapsed && (
44 |
49 | )}
50 |
57 |
58 | {document.isArchived && }
59 | >
60 | );
61 | };
62 |
--------------------------------------------------------------------------------
/app/(main)/_components/navigation.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { cn } from "@/lib/utils";
4 | import {
5 | ChevronsLeft,
6 | MenuIcon,
7 | Plus,
8 | PlusCircle,
9 | Search,
10 | Settings,
11 | Trash,
12 | } from "lucide-react";
13 | import { useParams, usePathname, useRouter } from "next/navigation";
14 | import { ElementRef, useEffect, useRef, useState } from "react";
15 | import { useMediaQuery } from "usehooks-ts";
16 | import { UserItem } from "./user-item";
17 | import { useMutation } from "convex/react";
18 | import { api } from "@/convex/_generated/api";
19 | import { Item } from "./item";
20 | import { toast } from "sonner";
21 | import { DocumentList } from "./document-list";
22 | import {
23 | Popover,
24 | PopoverContent,
25 | PopoverTrigger,
26 | } from "@/components/ui/popover";
27 | import { TrashBox } from "./trash-box";
28 | import { useSearch } from "@/hooks/use-search";
29 | import { useSettings } from "@/hooks/use-settings";
30 | import { Navbar } from "./navbar";
31 |
32 | export const Navigation = () => {
33 | const router = useRouter();
34 | const search = useSearch();
35 | const settings = useSettings();
36 | const params = useParams();
37 | const pathname = usePathname();
38 | const isMobile = useMediaQuery("(max-width: 768px)"); // mobile screen size break point
39 | const create = useMutation(api.documents.create);
40 |
41 | const isResizingRef = useRef(false);
42 | const sidebarRef = useRef>(null);
43 | const navbarRef = useRef>(null);
44 | const [isResetting, setIsResetting] = useState(false);
45 | const [isCollapsed, setIsCollapsed] = useState(isMobile); // collapse the sidebar by default if it is a mobile
46 |
47 | useEffect(() => {
48 | if (isMobile) {
49 | collapse();
50 | } else {
51 | resetWidth();
52 | }
53 | }, [isMobile]);
54 |
55 | useEffect(() => {
56 | if (isMobile) {
57 | collapse();
58 | }
59 | }, [pathname, isMobile]);
60 |
61 | const handleMouseDown = (
62 | event: React.MouseEvent
63 | ) => {
64 | event.preventDefault();
65 | event.stopPropagation();
66 |
67 | isResizingRef.current = true;
68 | document.addEventListener("mousemove", handleMouseMove);
69 | document.addEventListener("mouseup", handleMouseUp);
70 | };
71 |
72 | // to handle resizing the sidebar
73 | const handleMouseMove = (event: MouseEvent) => {
74 | if (!isResizingRef.current) return; // if isResizingRef is false, break the function
75 | let newWidth = event.clientX; // get the width
76 |
77 | if (newWidth < 240) newWidth = 240; // minimum width limit
78 | if (newWidth > 480) newWidth = 480; // maximum width limit
79 |
80 | // if sidebarRef and navbarRef are active
81 | if (sidebarRef.current && navbarRef.current) {
82 | sidebarRef.current.style.width = `${newWidth}px`; // set the sidebar width
83 | navbarRef.current.style.setProperty("left", `${newWidth}px`); // reposition the navbar
84 | navbarRef.current.style.setProperty(
85 | "width",
86 | `calc(100% - ${newWidth}px)`
87 | ); // recalculate the navbar width
88 | }
89 | };
90 |
91 | const handleMouseUp = () => {
92 | isResizingRef.current = false;
93 | document.removeEventListener("mousemove", handleMouseMove);
94 | document.removeEventListener("mouseup", handleMouseUp);
95 | };
96 |
97 | // reset the sidebar width to its original width
98 | const resetWidth = () => {
99 | if (sidebarRef.current && navbarRef.current) {
100 | setIsCollapsed(false);
101 | setIsResetting(true);
102 |
103 | sidebarRef.current.style.width = isMobile ? "100%" : "240px";
104 | navbarRef.current.style.setProperty(
105 | "width",
106 | isMobile ? "0" : "calc(100% - 240px)"
107 | );
108 | navbarRef.current.style.setProperty("left", isMobile ? "100%" : "240px");
109 | setTimeout(() => setIsResetting(false), 300);
110 | }
111 | };
112 |
113 | // handle the collapsing of the sidebar
114 | const collapse = () => {
115 | if (sidebarRef.current && navbarRef.current) {
116 | setIsCollapsed(true);
117 | setIsResetting(true);
118 |
119 | sidebarRef.current.style.width = "0";
120 | navbarRef.current.style.setProperty("width", "100%");
121 | navbarRef.current.style.setProperty("left", "0");
122 | setTimeout(() => setIsResetting(false), 300);
123 | }
124 | };
125 |
126 | // handle creating a new document
127 | const handleCreate = () => {
128 | const promise = create({ title: "Untitled" }).then((documentId) =>
129 | router.push(`/documents/${documentId}`)
130 | );
131 | toast.promise(promise, {
132 | loading: "Creating a new note...",
133 | success: "New note created!",
134 | error: "Failed to create a new note.",
135 | });
136 | };
137 |
138 | return (
139 | <>
140 |
192 | {/* Navbar */}
193 |
201 | {!!params.documentId ? (
202 |
203 | ) : (
204 |
205 | {/* If the sidebar is collapsed, show the menu icon to let the user reopen the sidebar */}
206 | {isCollapsed && (
207 |
212 | )}
213 |
214 | )}
215 |
216 | >
217 | );
218 | };
219 |
--------------------------------------------------------------------------------
/app/(main)/_components/publish.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@/components/ui/button";
4 | import {
5 | Popover,
6 | PopoverContent,
7 | PopoverTrigger,
8 | } from "@/components/ui/popover";
9 | import { api } from "@/convex/_generated/api";
10 | import { Doc } from "@/convex/_generated/dataModel";
11 | import { useOrigin } from "@/hooks/use-origin";
12 | import { useMutation } from "convex/react";
13 | import { CheckIcon, CopyIcon, Globe } from "lucide-react";
14 | import { useState } from "react";
15 | import { toast } from "sonner";
16 |
17 | interface PublishProps {
18 | initialData: Doc<"documents">;
19 | }
20 |
21 | export const Publish = ({ initialData }: PublishProps) => {
22 | const origin = useOrigin();
23 | const update = useMutation(api.documents.update);
24 |
25 | const [copied, setCopied] = useState(false);
26 | const [isSubmitting, setIsSubmitting] = useState(false);
27 |
28 | const url = `${origin}/preview/${initialData._id}`;
29 |
30 | const onPublish = () => {
31 | setIsSubmitting(true);
32 |
33 | const promise = update({ id: initialData._id, isPublished: true }).finally(
34 | () => setIsSubmitting(false)
35 | );
36 |
37 | toast.promise(promise, {
38 | loading: "Publishing...",
39 | success: "Note published!",
40 | error: "Failed to publish note.",
41 | });
42 | };
43 |
44 | const onUnpublish = () => {
45 | setIsSubmitting(true);
46 |
47 | const promise = update({ id: initialData._id, isPublished: false }).finally(
48 | () => setIsSubmitting(false)
49 | );
50 |
51 | toast.promise(promise, {
52 | loading: "Unpublishing...",
53 | success: "Note unpublished!",
54 | error: "Failed to unpublish note.",
55 | });
56 | };
57 |
58 | const onCopy = () => {
59 | navigator.clipboard.writeText(url);
60 | setCopied(true);
61 |
62 | setTimeout(() => {
63 | setCopied(false);
64 | }, 1000);
65 | };
66 |
67 | return (
68 |
69 |
70 |
71 | Publish
72 | {initialData.isPublished && (
73 |
74 | )}
75 |
76 |
77 |
78 | {initialData.isPublished ? (
79 |
80 |
81 |
82 |
83 | This note is live on web.
84 |
85 |
86 |
87 |
92 |
97 | {copied ? (
98 |
99 | ) : (
100 |
101 | )}
102 |
103 |
104 |
110 | Unpublish
111 |
112 |
113 | ) : (
114 |
115 |
116 |
Publish this note
117 |
118 | Share your work with others.
119 |
120 |
126 | Publish
127 |
128 |
129 | )}
130 |
131 |
132 | );
133 | };
134 |
--------------------------------------------------------------------------------
/app/(main)/_components/title.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@/components/ui/button";
4 | import { Input } from "@/components/ui/input";
5 | import { Skeleton } from "@/components/ui/skeleton";
6 | import { api } from "@/convex/_generated/api";
7 | import { Doc } from "@/convex/_generated/dataModel";
8 | import { useMutation } from "convex/react";
9 | import { useRef, useState } from "react";
10 |
11 | interface TitleProps {
12 | initialData: Doc<"documents">;
13 | }
14 |
15 | export const Title = ({ initialData }: TitleProps) => {
16 | const inputRef = useRef(null);
17 | const update = useMutation(api.documents.update);
18 |
19 | const [title, setTitle] = useState(initialData.title || "Untitled");
20 | const [isEditing, setIsEditing] = useState(false);
21 |
22 | const enableInput = () => {
23 | setTitle(initialData.title);
24 | setIsEditing(true);
25 | setTimeout(() => {
26 | inputRef.current?.focus();
27 | inputRef.current?.setSelectionRange(0, inputRef.current.value.length);
28 | }, 0);
29 | };
30 |
31 | const disableInput = () => {
32 | setIsEditing(false);
33 | };
34 |
35 | const onChange = (event: React.ChangeEvent) => {
36 | setTitle(event.target.value);
37 | update({ id: initialData._id, title: event.target.value || "Untitled" });
38 | };
39 |
40 | const onKeyDown = (event: React.KeyboardEvent) => {
41 | if (event.key === "Enter") {
42 | disableInput();
43 | }
44 | };
45 |
46 | return (
47 |
48 | {!!initialData.icon &&
{initialData.icon}
}
49 | {isEditing ? (
50 |
59 | ) : (
60 |
66 | {initialData.title}
67 |
68 | )}
69 |
70 | );
71 | };
72 |
73 | Title.Skeleton = function TitleSkeleton() {
74 | return ;
75 | };
76 |
--------------------------------------------------------------------------------
/app/(main)/_components/trash-box.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ConfirmModal } from "@/components/modals/confirm-modal";
4 | import { Spinner } from "@/components/spinner";
5 | import { Input } from "@/components/ui/input";
6 | import { api } from "@/convex/_generated/api";
7 | import { Id } from "@/convex/_generated/dataModel";
8 | import { useMutation, useQuery } from "convex/react";
9 | import { Search, Trash, Undo } from "lucide-react";
10 | import { useParams, useRouter } from "next/navigation";
11 | import { useState } from "react";
12 | import { toast } from "sonner";
13 |
14 | export const TrashBox = () => {
15 | const router = useRouter();
16 | const params = useParams();
17 | const documents = useQuery(api.documents.getTrash);
18 | const restore = useMutation(api.documents.restore);
19 | const remove = useMutation(api.documents.remove);
20 |
21 | const [search, setSearch] = useState("");
22 |
23 | const filteredDocuments = documents?.filter((document) => {
24 | return document.title.toLowerCase().includes(search.toLowerCase());
25 | });
26 |
27 | const onClick = (documentId: string) => {
28 | router.push(`/documents/${documentId}`);
29 | };
30 |
31 | const onRestore = (
32 | event: React.MouseEvent,
33 | documentId: Id<"documents">
34 | ) => {
35 | event.stopPropagation();
36 | const promise = restore({ id: documentId });
37 |
38 | toast.promise(promise, {
39 | loading: "Restoring note...",
40 | success: "Note restored!",
41 | error: "Failed to restore note.",
42 | });
43 | };
44 |
45 | const onRemove = (documentId: Id<"documents">) => {
46 | const promise = remove({ id: documentId });
47 |
48 | toast.promise(promise, {
49 | loading: "Deleting note...",
50 | success: "Note deleted!",
51 | error: "Failed to delete note.",
52 | });
53 |
54 | if (params.documentId === documentId) {
55 | router.push("/documents");
56 | }
57 | };
58 |
59 | if (documents === undefined) {
60 | return (
61 |
62 |
63 |
64 | );
65 | }
66 |
67 | return (
68 |
69 |
70 |
71 | setSearch(e.target.value)}
74 | className="h-7 px-2 focus-visible:ring-transparent bg-secondary"
75 | placeholder="Filter by page title..."
76 | />
77 |
78 | {/* Will be rendered only if it is the last element */}
79 |
80 |
81 | No documents found.
82 |
83 | {filteredDocuments?.map((document) => (
84 |
onClick(document._id)}
88 | className="text-sm rounded-sm w-full hover:bg-primary/5 flex items-center text-primary justify-between"
89 | >
90 |
{document.title}
91 |
92 |
onRestore(e, document._id)}
94 | role="button"
95 | className="rounded-sm p-2 hover:bg-neutral-200 dark:hover:bg-neutral-600"
96 | >
97 |
98 |
99 |
onRemove(document._id)}>
100 |
104 |
105 |
106 |
107 |
108 |
109 | ))}
110 |
111 |
112 | );
113 | };
114 |
--------------------------------------------------------------------------------
/app/(main)/_components/user-item.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Avatar, AvatarImage } from "@/components/ui/avatar";
4 | import {
5 | DropdownMenu,
6 | DropdownMenuContent,
7 | DropdownMenuItem,
8 | DropdownMenuLabel,
9 | DropdownMenuSeparator,
10 | DropdownMenuTrigger,
11 | } from "@/components/ui/dropdown-menu";
12 | import { SignOutButton, useUser } from "@clerk/clerk-react";
13 | import { ChevronsLeftRight } from "lucide-react";
14 |
15 | export const UserItem = () => {
16 | const { user } = useUser();
17 | return (
18 |
19 |
20 |
24 |
25 |
26 |
27 |
28 |
29 | {user?.fullName}'s Notion
30 |
31 |
32 |
33 |
34 |
35 |
41 |
42 |
43 | {user?.emailAddresses[0].emailAddress}
44 |
45 |
46 |
51 |
52 |
53 | {user?.fullName}'s Notion
54 |
55 |
56 |
57 |
58 |
59 |
63 | Log Out
64 |
65 |
66 |
67 | );
68 | };
69 |
--------------------------------------------------------------------------------
/app/(main)/layout.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Spinner } from "@/components/spinner";
4 | import { useConvexAuth } from "convex/react";
5 | import { redirect } from "next/navigation";
6 | import { Navigation } from "./_components/navigation";
7 | import { SearchCommand } from "@/components/search-command";
8 |
9 | const MainLayout = ({ children }: { children: React.ReactNode }) => {
10 | const { isAuthenticated, isLoading } = useConvexAuth();
11 |
12 | // if it is a loading state, show the Spinner component
13 | if (isLoading) {
14 | return (
15 |
16 |
17 |
18 | );
19 | }
20 |
21 | // to protect the route from being accessed without logging in.
22 | // redirect the user to the landing page
23 | if (!isAuthenticated) {
24 | return redirect("/");
25 | }
26 |
27 | return (
28 |
29 |
30 |
31 |
32 | {children}
33 |
34 |
35 | );
36 | };
37 |
38 | export default MainLayout;
39 |
--------------------------------------------------------------------------------
/app/(marketing)/_components/footer.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { Logo } from "./logo";
3 |
4 | export const Footer = () => {
5 | return (
6 |
7 |
8 |
9 |
10 | Privacy Policy
11 |
12 |
13 | Terms & Conditions
14 |
15 |
16 |
17 | );
18 | };
19 |
--------------------------------------------------------------------------------
/app/(marketing)/_components/heading.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Spinner } from "@/components/spinner";
4 | import { Button } from "@/components/ui/button";
5 | import { SignInButton } from "@clerk/clerk-react";
6 | import { useConvexAuth } from "convex/react";
7 | import { ArrowRight } from "lucide-react";
8 | import Link from "next/link";
9 |
10 | export const Heading = () => {
11 | const { isAuthenticated, isLoading } = useConvexAuth();
12 |
13 | return (
14 |
15 |
16 | Your Ideas, Documents, & Plans. Unified. Welcome to{" "}
17 | Notion
18 |
19 |
20 | Notion is the connected workspace where
21 | better, faster work happens.
22 |
23 | {isLoading && (
24 |
25 |
26 |
27 | )}
28 | {isAuthenticated && !isLoading && (
29 |
30 |
31 | Enter Notion
32 |
33 |
34 | )}
35 | {!isAuthenticated && !isLoading && (
36 |
37 |
38 | Get Notion free
39 |
40 |
41 | )}
42 |
43 | );
44 | };
45 |
--------------------------------------------------------------------------------
/app/(marketing)/_components/heroes.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 |
3 | export const Heroes = () => {
4 | return (
5 |
6 |
7 |
8 |
14 |
20 |
21 |
22 |
28 |
34 |
35 |
36 |
37 | );
38 | };
39 |
--------------------------------------------------------------------------------
/app/(marketing)/_components/logo.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 | import { Poppins } from "next/font/google";
3 | import { cn } from "@/lib/utils";
4 |
5 | const font = Poppins({
6 | subsets: ["latin"],
7 | weight: ["400", "600"],
8 | });
9 |
10 | export const Logo = () => {
11 | return (
12 |
13 |
20 |
27 |
Notion
28 |
29 | );
30 | };
31 |
--------------------------------------------------------------------------------
/app/(marketing)/_components/navbar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useScrollTop } from "@/hooks/use-scroll-top";
4 | import { cn } from "@/lib/utils";
5 | import { Logo } from "./logo";
6 | import { ModeToggle } from "@/components/mode-toggle";
7 | import { useConvexAuth } from "convex/react";
8 | import { SignInButton, UserButton } from "@clerk/clerk-react";
9 | import { Button } from "@/components/ui/button";
10 | import { Spinner } from "@/components/spinner";
11 | import Link from "next/link";
12 |
13 | export const Navbar = () => {
14 | const scrolled = useScrollTop();
15 | const { isAuthenticated, isLoading } = useConvexAuth();
16 |
17 | return (
18 |
24 |
25 |
26 | {isLoading && }
27 | {!isAuthenticated && !isLoading && (
28 | <>
29 |
30 |
31 | Log in
32 |
33 |
34 |
35 | Get Notion free
36 |
37 | >
38 | )}
39 | {isAuthenticated && !isLoading && (
40 | <>
41 |
42 | Enter Notion
43 |
44 |
45 | >
46 | )}
47 |
48 |
49 |
50 | );
51 | };
52 |
--------------------------------------------------------------------------------
/app/(marketing)/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Navbar } from "./_components/navbar";
2 |
3 | const MarketingLayout = ({ children }: { children: React.ReactNode }) => {
4 | return (
5 |
6 |
7 | {children}
8 |
9 | );
10 | };
11 |
12 | export default MarketingLayout;
13 |
--------------------------------------------------------------------------------
/app/(marketing)/page.tsx:
--------------------------------------------------------------------------------
1 | import { Footer } from "./_components/footer";
2 | import { Heading } from "./_components/heading";
3 | import { Heroes } from "./_components/heroes";
4 |
5 | const MarketingPage = () => {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | );
15 | };
16 |
17 | export default MarketingPage;
18 |
--------------------------------------------------------------------------------
/app/(public)/(routes)/preview/[documentId]/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Cover } from "@/components/cover";
4 | import { Toolbar } from "@/components/toolbar";
5 | import { Skeleton } from "@/components/ui/skeleton";
6 | import { api } from "@/convex/_generated/api";
7 | import { Id } from "@/convex/_generated/dataModel";
8 | import { useMutation, useQuery } from "convex/react";
9 | import dynamic from "next/dynamic";
10 | import { useMemo } from "react";
11 |
12 | interface DocumentIdPageProps {
13 | params: {
14 | documentId: Id<"documents">;
15 | };
16 | }
17 |
18 | const DocumentIdPage = ({ params }: DocumentIdPageProps) => {
19 | const Editor = useMemo(
20 | () => dynamic(() => import("@/components/editor"), { ssr: false }),
21 | []
22 | );
23 |
24 | const document = useQuery(api.documents.getById, {
25 | documentId: params.documentId,
26 | });
27 |
28 | const update = useMutation(api.documents.update);
29 |
30 | const onChange = (content: string) => {
31 | update({ id: params.documentId, content: content });
32 | };
33 |
34 | if (document === undefined) {
35 | return (
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 | );
48 | }
49 |
50 | if (document === null) {
51 | return Not found
;
52 | }
53 |
54 | return (
55 |
56 |
57 |
58 |
59 |
64 |
65 |
66 | );
67 | };
68 |
69 | export default DocumentIdPage;
70 |
--------------------------------------------------------------------------------
/app/(public)/layout.tsx:
--------------------------------------------------------------------------------
1 | const PublicLayout = ({ children }: { children: React.ReactNode }) => {
2 | return {children}
;
3 | };
4 |
5 | export default PublicLayout;
6 |
--------------------------------------------------------------------------------
/app/api/edgestore/[...edgestore]/route.ts:
--------------------------------------------------------------------------------
1 | import { initEdgeStore } from "@edgestore/server";
2 | import { createEdgeStoreNextHandler } from "@edgestore/server/adapters/next/app";
3 | const es = initEdgeStore.create();
4 | /**
5 | * This is the main router for the Edge Store buckets.
6 | */
7 | const edgeStoreRouter = es.router({
8 | publicFiles: es.fileBucket().beforeDelete(() => {
9 | return true; // allow delete
10 | }),
11 | });
12 | const handler = createEdgeStoreNextHandler({
13 | router: edgeStoreRouter,
14 | });
15 | export { handler as GET, handler as POST };
16 | /**
17 | * This type is used to create the type-safe client for the frontend.
18 | */
19 | export type EdgeStoreRouter = typeof edgeStoreRouter;
20 |
--------------------------------------------------------------------------------
/app/error.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@/components/ui/button";
4 | import Image from "next/image";
5 | import Link from "next/link";
6 |
7 | const Error = () => {
8 | return (
9 |
10 |
17 |
24 |
Something went wrong!
25 |
26 | Go back
27 |
28 |
29 | );
30 | };
31 |
32 | export default Error;
33 |
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | html,
6 | body,
7 | :root {
8 | height: 100%;
9 | }
10 |
11 | @layer base {
12 | :root {
13 | --background: 0 0% 100%;
14 | --foreground: 0 0% 3.9%;
15 |
16 | --card: 0 0% 100%;
17 | --card-foreground: 0 0% 3.9%;
18 |
19 | --popover: 0 0% 100%;
20 | --popover-foreground: 0 0% 3.9%;
21 |
22 | --primary: 0 0% 9%;
23 | --primary-foreground: 0 0% 98%;
24 |
25 | --secondary: 0 0% 96.1%;
26 | --secondary-foreground: 0 0% 9%;
27 |
28 | --muted: 0 0% 96.1%;
29 | --muted-foreground: 0 0% 45.1%;
30 |
31 | --accent: 0 0% 96.1%;
32 | --accent-foreground: 0 0% 9%;
33 |
34 | --destructive: 0 84.2% 60.2%;
35 | --destructive-foreground: 0 0% 98%;
36 |
37 | --border: 0 0% 89.8%;
38 | --input: 0 0% 89.8%;
39 | --ring: 0 0% 3.9%;
40 |
41 | --radius: 0.5rem;
42 | }
43 |
44 | .dark {
45 | --background: 0 0% 3.9%;
46 | --foreground: 0 0% 98%;
47 |
48 | --card: 0 0% 3.9%;
49 | --card-foreground: 0 0% 98%;
50 |
51 | --popover: 0 0% 3.9%;
52 | --popover-foreground: 0 0% 98%;
53 |
54 | --primary: 0 0% 98%;
55 | --primary-foreground: 0 0% 9%;
56 |
57 | --secondary: 0 0% 14.9%;
58 | --secondary-foreground: 0 0% 98%;
59 |
60 | --muted: 0 0% 14.9%;
61 | --muted-foreground: 0 0% 63.9%;
62 |
63 | --accent: 0 0% 14.9%;
64 | --accent-foreground: 0 0% 98%;
65 |
66 | --destructive: 0 62.8% 30.6%;
67 | --destructive-foreground: 0 0% 98%;
68 |
69 | --border: 0 0% 14.9%;
70 | --input: 0 0% 14.9%;
71 | --ring: 0 0% 83.1%;
72 | }
73 | }
74 |
75 | @layer base {
76 | * {
77 | @apply border-border;
78 | }
79 | body {
80 | @apply bg-background text-foreground;
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Inter } from "next/font/google";
3 | import "./globals.css";
4 | import { ThemeProvider } from "@/components/providers/theme-provider";
5 | import { ConvexClientProvider } from "@/components/providers/convex-provider";
6 | import { Toaster } from "sonner";
7 | import { ModalProvider } from "@/components/providers/modal-provider";
8 | import { EdgeStoreProvider } from "@/lib/edgestore";
9 |
10 | const inter = Inter({ subsets: ["latin"] });
11 |
12 | export const metadata: Metadata = {
13 | title: "Notion Clone",
14 | description: "The connected workspace where better, faster work happens.",
15 | icons: {
16 | icon: [
17 | {
18 | media: "(prefers-color-scheme: light)",
19 | url: "/logo.svg",
20 | href: "/logo.svg",
21 | },
22 | {
23 | media: "(prefers-color-scheme: dark)",
24 | url: "/logo-dark.svg",
25 | href: "/logo-dark.svg",
26 | },
27 | ],
28 | },
29 | };
30 |
31 | export default function RootLayout({
32 | children,
33 | }: {
34 | children: React.ReactNode;
35 | }) {
36 | return (
37 |
38 |
39 |
40 |
41 |
48 |
49 |
50 | {children}
51 |
52 |
53 |
54 |
55 |
56 | );
57 | }
58 |
--------------------------------------------------------------------------------
/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 | },
12 | "aliases": {
13 | "components": "@/components",
14 | "utils": "@/lib/utils"
15 | }
16 | }
--------------------------------------------------------------------------------
/components/cover.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@/components/ui/button";
4 | import { Skeleton } from "@/components/ui/skeleton";
5 | import { api } from "@/convex/_generated/api";
6 | import { Id } from "@/convex/_generated/dataModel";
7 | import { useCoverImage } from "@/hooks/use-cover-image";
8 | import { useEdgeStore } from "@/lib/edgestore";
9 | import { cn } from "@/lib/utils";
10 | import { useMutation } from "convex/react";
11 | import { ImageIcon, X } from "lucide-react";
12 | import Image from "next/image";
13 | import { useParams } from "next/navigation";
14 |
15 | interface CoverProps {
16 | url?: string;
17 | preview?: boolean;
18 | }
19 |
20 | export const Cover = ({ url, preview }: CoverProps) => {
21 | const params = useParams();
22 | const { edgestore } = useEdgeStore();
23 | const coverImage = useCoverImage();
24 | const removeCoverImage = useMutation(api.documents.removeCoverImage);
25 |
26 | const onRemove = async () => {
27 | if (url) {
28 | await edgestore.publicFiles.delete({ url: url });
29 | }
30 | removeCoverImage({ id: params.documentId as Id<"documents"> });
31 | };
32 |
33 | return (
34 |
41 | {!!url &&
}
42 | {url && !preview && (
43 |
44 | coverImage.onReplace(url)}
46 | className="text-muted-foreground text-xs"
47 | variant="outline"
48 | size="sm"
49 | >
50 |
51 | Change cover
52 |
53 |
59 |
60 | Remove
61 |
62 |
63 | )}
64 |
65 | );
66 | };
67 |
68 | Cover.Skeleton = function CoverSkeleton() {
69 | return ;
70 | };
71 |
--------------------------------------------------------------------------------
/components/editor.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { BlockNoteEditor, PartialBlock } from "@blocknote/core";
4 | import { BlockNoteView, useBlockNote } from "@blocknote/react";
5 | import "@blocknote/core/style.css";
6 | import { useTheme } from "next-themes";
7 | import { useEdgeStore } from "@/lib/edgestore";
8 |
9 | interface EditorProps {
10 | onChange: (value: string) => void;
11 | initialContent?: string;
12 | editable?: boolean;
13 | }
14 |
15 | const Editor = ({ onChange, initialContent, editable }: EditorProps) => {
16 | const { resolvedTheme } = useTheme();
17 | const { edgestore } = useEdgeStore();
18 |
19 | const handleUpload = async (file: File) => {
20 | const response = await edgestore.publicFiles.upload({ file });
21 | return response.url;
22 | };
23 |
24 | const editor: BlockNoteEditor = useBlockNote({
25 | editable,
26 | initialContent: initialContent
27 | ? (JSON.parse(initialContent) as PartialBlock[])
28 | : undefined,
29 | onEditorContentChange: (editor) => {
30 | onChange(JSON.stringify(editor.topLevelBlocks, null, 2));
31 | },
32 | uploadFile: handleUpload,
33 | });
34 |
35 | return (
36 |
37 |
41 |
42 | );
43 | };
44 |
45 | export default Editor;
46 |
--------------------------------------------------------------------------------
/components/icon-picker.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | Popover,
5 | PopoverContent,
6 | PopoverTrigger,
7 | } from "@/components/ui/popover";
8 | import EmojiPicker, { Theme } from "emoji-picker-react";
9 | import { useTheme } from "next-themes";
10 |
11 | interface IconPickerProps {
12 | onChange: (icon: string) => void;
13 | children: React.ReactNode;
14 | asChild?: boolean;
15 | }
16 |
17 | export const IconPicker = ({
18 | onChange,
19 | children,
20 | asChild,
21 | }: IconPickerProps) => {
22 | const { resolvedTheme } = useTheme();
23 | const currentTheme = (resolvedTheme || "light") as keyof typeof themeMap;
24 |
25 | const themeMap = {
26 | dark: Theme.DARK,
27 | light: Theme.LIGHT,
28 | };
29 |
30 | const theme = themeMap[currentTheme];
31 |
32 | return (
33 |
34 | {children}
35 |
36 | onChange(data.emoji)}
40 | />
41 |
42 |
43 | );
44 | };
45 |
--------------------------------------------------------------------------------
/components/modals/confirm-modal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | AlertDialog,
5 | AlertDialogAction,
6 | AlertDialogCancel,
7 | AlertDialogContent,
8 | AlertDialogDescription,
9 | AlertDialogFooter,
10 | AlertDialogHeader,
11 | AlertDialogTitle,
12 | AlertDialogTrigger,
13 | } from "@/components/ui/alert-dialog";
14 |
15 | interface ConfirmModalProps {
16 | children: React.ReactNode;
17 | onConfirm: () => void;
18 | }
19 |
20 | export const ConfirmModal = ({ children, onConfirm }: ConfirmModalProps) => {
21 | const handleConfirm = (
22 | event: React.MouseEvent
23 | ) => {
24 | event.stopPropagation();
25 | onConfirm();
26 | };
27 |
28 | return (
29 |
30 | e.stopPropagation()} asChild>
31 | {children}
32 |
33 |
34 |
35 | Are you absolutely sure?
36 |
37 | This action cannot be undone.
38 |
39 |
40 |
41 | e.stopPropagation()}>
42 | Cancel
43 |
44 | Confirm
45 |
46 |
47 |
48 | );
49 | };
50 |
--------------------------------------------------------------------------------
/components/modals/cover-image-modal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { SingleImageDropzone } from "@/components/single-image-dropzone";
4 | import { Dialog, DialogContent, DialogHeader } from "@/components/ui/dialog";
5 | import { api } from "@/convex/_generated/api";
6 | import { Id } from "@/convex/_generated/dataModel";
7 | import { useCoverImage } from "@/hooks/use-cover-image";
8 | import { useEdgeStore } from "@/lib/edgestore";
9 | import { useMutation } from "convex/react";
10 | import { useParams } from "next/navigation";
11 | import { useState } from "react";
12 |
13 | export const CoverImageModal = () => {
14 | const params = useParams();
15 | const update = useMutation(api.documents.update);
16 | const [file, setFile] = useState();
17 | const [isSubmitting, setIsSubmitting] = useState(false);
18 | const coverImage = useCoverImage();
19 | const { edgestore } = useEdgeStore();
20 |
21 | const onChange = async (file?: File) => {
22 | if (file) {
23 | setIsSubmitting(true);
24 | setFile(file);
25 |
26 | const res = await edgestore.publicFiles.upload({
27 | file,
28 | options: { replaceTargetUrl: coverImage.url },
29 | });
30 |
31 | await update({
32 | id: params.documentId as Id<"documents">,
33 | coverImage: res.url,
34 | });
35 |
36 | onClose();
37 | }
38 | };
39 |
40 | const onClose = () => {
41 | setFile(undefined);
42 | setIsSubmitting(false);
43 | coverImage.onClose();
44 | };
45 |
46 | return (
47 |
48 |
49 |
50 | Cover Image
51 |
52 |
58 |
59 |
60 | );
61 | };
62 |
--------------------------------------------------------------------------------
/components/modals/settings-modal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Dialog, DialogContent, DialogHeader } from "@/components/ui/dialog";
4 | import { Label } from "@/components/ui/label";
5 | import { ModeToggle } from "@/components/mode-toggle";
6 | import { useSettings } from "@/hooks/use-settings";
7 |
8 | export const SettingsModal = () => {
9 | const settings = useSettings();
10 |
11 | return (
12 |
13 |
14 |
15 | My settings
16 |
17 |
18 |
19 | Appearance
20 |
21 | Customize how Notion looks on your device
22 |
23 |
24 |
25 |
26 |
27 |
28 | );
29 | };
30 |
--------------------------------------------------------------------------------
/components/mode-toggle.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { Moon, Sun } from "lucide-react";
5 | import { useTheme } from "next-themes";
6 |
7 | import { Button } from "@/components/ui/button";
8 | import {
9 | DropdownMenu,
10 | DropdownMenuContent,
11 | DropdownMenuItem,
12 | DropdownMenuTrigger,
13 | } from "@/components/ui/dropdown-menu";
14 |
15 | export function ModeToggle() {
16 | const { setTheme } = useTheme();
17 |
18 | return (
19 |
20 |
21 |
22 |
23 |
24 | Toggle theme
25 |
26 |
27 |
28 | setTheme("light")}>
29 | Light
30 |
31 | setTheme("dark")}>
32 | Dark
33 |
34 | setTheme("system")}>
35 | System
36 |
37 |
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/components/providers/convex-provider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ReactNode } from "react";
4 | import { ConvexReactClient } from "convex/react";
5 | import { ConvexProviderWithClerk } from "convex/react-clerk";
6 | import { ClerkProvider, useAuth } from "@clerk/clerk-react";
7 |
8 | const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
9 |
10 | export const ConvexClientProvider = ({ children }: { children: ReactNode }) => {
11 | return (
12 |
15 |
16 | {children}
17 |
18 |
19 | );
20 | };
21 |
--------------------------------------------------------------------------------
/components/providers/modal-provider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { CoverImageModal } from "@/components/modals/cover-image-modal";
4 | import { SettingsModal } from "@/components/modals/settings-modal";
5 | import { useEffect, useState } from "react";
6 |
7 | export const ModalProvider = () => {
8 | const [isMounted, setIsMounted] = useState(false);
9 |
10 | useEffect(() => {
11 | setIsMounted(true);
12 | }, []);
13 |
14 | if (!isMounted) {
15 | return null;
16 | }
17 |
18 | return (
19 | <>
20 |
21 |
22 | >
23 | );
24 | };
25 |
--------------------------------------------------------------------------------
/components/providers/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { ThemeProvider as NextThemesProvider } from "next-themes";
5 | import { type ThemeProviderProps } from "next-themes/dist/types";
6 |
7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
8 | return {children} ;
9 | }
10 |
--------------------------------------------------------------------------------
/components/search-command.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | CommandDialog,
5 | CommandEmpty,
6 | CommandGroup,
7 | CommandInput,
8 | CommandItem,
9 | CommandList,
10 | } from "@/components/ui/command";
11 | import { api } from "@/convex/_generated/api";
12 | import { useSearch } from "@/hooks/use-search";
13 | import { useUser } from "@clerk/clerk-react";
14 | import { useQuery } from "convex/react";
15 | import { File } from "lucide-react";
16 | import { useRouter } from "next/navigation";
17 | import { useEffect, useState } from "react";
18 |
19 | export const SearchCommand = () => {
20 | const { user } = useUser();
21 | const router = useRouter();
22 | const documents = useQuery(api.documents.getSearch);
23 | const [isMounted, setIsMounted] = useState(false);
24 |
25 | const toggle = useSearch((store) => store.toggle);
26 | const isOpen = useSearch((store) => store.isOpen);
27 | const onClose = useSearch((store) => store.onClose);
28 |
29 | useEffect(() => {
30 | setIsMounted(true);
31 | }, []);
32 |
33 | useEffect(() => {
34 | const down = (e: KeyboardEvent) => {
35 | if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
36 | e.preventDefault();
37 | toggle();
38 | }
39 | };
40 |
41 | document.addEventListener("keydown", down);
42 |
43 | return () => document.removeEventListener("keydown", down);
44 | }, [toggle]);
45 |
46 | const onSelect = (id: string) => {
47 | router.push(`/documents/${id}`);
48 | onClose();
49 | };
50 |
51 | if (!isMounted) return null;
52 |
53 | return (
54 |
55 |
56 |
57 | No results found.
58 |
59 | {documents?.map((document) => (
60 |
66 | {document.icon ? (
67 | {document.icon}
68 | ) : (
69 |
70 | )}
71 | {document.title}
72 |
73 | ))}
74 |
75 |
76 |
77 | );
78 | };
79 |
--------------------------------------------------------------------------------
/components/single-image-dropzone.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { UploadCloudIcon, X } from "lucide-react";
4 | import * as React from "react";
5 | import { useDropzone, type DropzoneOptions } from "react-dropzone";
6 | import { twMerge } from "tailwind-merge";
7 | import { Spinner } from "./spinner";
8 |
9 | const variants = {
10 | base: "relative rounded-md flex justify-center items-center flex-col cursor-pointer min-h-[150px] min-w-[200px] border border-dashed border-gray-400 dark:border-gray-300 transition-colors duration-200 ease-in-out",
11 | image:
12 | "border-0 p-0 min-h-0 min-w-0 relative shadow-md bg-slate-200 dark:bg-slate-900 rounded-md",
13 | active: "border-2",
14 | disabled:
15 | "bg-gray-200 border-gray-300 cursor-default pointer-events-none bg-opacity-30 dark:bg-gray-700",
16 | accept: "border border-blue-500 bg-blue-500 bg-opacity-10",
17 | reject: "border border-red-700 bg-red-700 bg-opacity-10",
18 | };
19 |
20 | type InputProps = {
21 | width?: number;
22 | height?: number;
23 | className?: string;
24 | value?: File | string;
25 | onChange?: (file?: File) => void | Promise;
26 | disabled?: boolean;
27 | dropzoneOptions?: Omit;
28 | };
29 |
30 | const ERROR_MESSAGES = {
31 | fileTooLarge(maxSize: number) {
32 | return `The file is too large. Max size is ${formatFileSize(maxSize)}.`;
33 | },
34 | fileInvalidType() {
35 | return "Invalid file type.";
36 | },
37 | tooManyFiles(maxFiles: number) {
38 | return `You can only add ${maxFiles} file(s).`;
39 | },
40 | fileNotSupported() {
41 | return "The file is not supported.";
42 | },
43 | };
44 |
45 | const SingleImageDropzone = React.forwardRef(
46 | (
47 | { dropzoneOptions, width, height, value, className, disabled, onChange },
48 | ref
49 | ) => {
50 | const imageUrl = React.useMemo(() => {
51 | if (typeof value === "string") {
52 | // in case a url is passed in, use it to display the image
53 | return value;
54 | } else if (value) {
55 | // in case a file is passed in, create a base64 url to display the image
56 | return URL.createObjectURL(value);
57 | }
58 | return null;
59 | }, [value]);
60 |
61 | // dropzone configuration
62 | const {
63 | getRootProps,
64 | getInputProps,
65 | acceptedFiles,
66 | fileRejections,
67 | isFocused,
68 | isDragAccept,
69 | isDragReject,
70 | } = useDropzone({
71 | accept: { "image/*": [] },
72 | multiple: false,
73 | disabled,
74 | onDrop: (acceptedFiles) => {
75 | const file = acceptedFiles[0];
76 | if (file) {
77 | void onChange?.(file);
78 | }
79 | },
80 | ...dropzoneOptions,
81 | });
82 |
83 | // styling
84 | const dropZoneClassName = React.useMemo(
85 | () =>
86 | twMerge(
87 | variants.base,
88 | isFocused && variants.active,
89 | disabled && variants.disabled,
90 | imageUrl && variants.image,
91 | (isDragReject ?? fileRejections[0]) && variants.reject,
92 | isDragAccept && variants.accept,
93 | className
94 | ).trim(),
95 | [
96 | isFocused,
97 | imageUrl,
98 | fileRejections,
99 | isDragAccept,
100 | isDragReject,
101 | disabled,
102 | className,
103 | ]
104 | );
105 |
106 | // error validation messages
107 | const errorMessage = React.useMemo(() => {
108 | if (fileRejections[0]) {
109 | const { errors } = fileRejections[0];
110 | if (errors[0]?.code === "file-too-large") {
111 | return ERROR_MESSAGES.fileTooLarge(dropzoneOptions?.maxSize ?? 0);
112 | } else if (errors[0]?.code === "file-invalid-type") {
113 | return ERROR_MESSAGES.fileInvalidType();
114 | } else if (errors[0]?.code === "too-many-files") {
115 | return ERROR_MESSAGES.tooManyFiles(dropzoneOptions?.maxFiles ?? 0);
116 | } else {
117 | return ERROR_MESSAGES.fileNotSupported();
118 | }
119 | }
120 | return undefined;
121 | }, [fileRejections, dropzoneOptions]);
122 |
123 | return (
124 |
125 | {disabled && (
126 |
127 |
128 |
129 | )}
130 |
139 | {/* Main File Input */}
140 |
141 |
142 | {imageUrl ? (
143 | // Image Preview
144 |
149 | ) : (
150 | // Upload Icon
151 |
152 |
153 |
154 | Click or drag file to this area to upload
155 |
156 |
157 | )}
158 |
159 | {/* Remove Image Icon */}
160 | {imageUrl && !disabled && (
161 |
{
164 | e.stopPropagation();
165 | void onChange?.(undefined);
166 | }}
167 | >
168 |
169 |
174 |
175 |
176 | )}
177 |
178 |
179 | {/* Error Text */}
180 |
{errorMessage}
181 |
182 | );
183 | }
184 | );
185 | SingleImageDropzone.displayName = "SingleImageDropzone";
186 |
187 | const Button = React.forwardRef<
188 | HTMLButtonElement,
189 | React.ButtonHTMLAttributes
190 | >(({ className, ...props }, ref) => {
191 | return (
192 |
205 | );
206 | });
207 | Button.displayName = "Button";
208 |
209 | function formatFileSize(bytes?: number) {
210 | if (!bytes) {
211 | return "0 Bytes";
212 | }
213 | bytes = Number(bytes);
214 | if (bytes === 0) {
215 | return "0 Bytes";
216 | }
217 | const k = 1024;
218 | const dm = 2;
219 | const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
220 | const i = Math.floor(Math.log(bytes) / Math.log(k));
221 | return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
222 | }
223 |
224 | export { SingleImageDropzone };
225 |
--------------------------------------------------------------------------------
/components/spinner.tsx:
--------------------------------------------------------------------------------
1 | import { Loader } from "lucide-react";
2 | import { cva, type VariantProps } from "class-variance-authority";
3 | import { cn } from "@/lib/utils";
4 |
5 | const spinnerVariants = cva("text-muted-foreground animate-spin", {
6 | variants: {
7 | size: {
8 | default: "h-4 w-4",
9 | sm: "h-2 w-2",
10 | lg: "h-6 w-6",
11 | icon: "h-10 w-10",
12 | },
13 | defaultVariants: {
14 | size: "default",
15 | },
16 | },
17 | });
18 |
19 | interface SpinnerProps extends VariantProps {}
20 |
21 | export const Spinner = ({ size }: SpinnerProps) => {
22 | return ;
23 | };
24 |
--------------------------------------------------------------------------------
/components/toolbar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Doc } from "@/convex/_generated/dataModel";
4 | import { IconPicker } from "@/components/icon-picker";
5 | import { Button } from "@/components/ui/button";
6 | import { useMutation } from "convex/react";
7 | import { ImageIcon, Smile, X } from "lucide-react";
8 | import { ElementRef, useRef, useState } from "react";
9 | import { api } from "@/convex/_generated/api";
10 | import TextareaAutosize from "react-textarea-autosize";
11 | import { useCoverImage } from "@/hooks/use-cover-image";
12 |
13 | interface ToolbarProps {
14 | initialData: Doc<"documents">;
15 | preview?: boolean;
16 | }
17 |
18 | export const Toolbar = ({ initialData, preview }: ToolbarProps) => {
19 | const inputRef = useRef>(null);
20 | const [isEditing, setIsEditing] = useState(false);
21 | const [value, setValue] = useState(initialData.title);
22 |
23 | const update = useMutation(api.documents.update);
24 | const removeIcon = useMutation(api.documents.removeIcon);
25 |
26 | const coverImage = useCoverImage();
27 |
28 | const enableInput = () => {
29 | if (preview) return;
30 | setIsEditing(true);
31 | setTimeout(() => {
32 | setValue(initialData.title);
33 | inputRef.current?.focus();
34 | }, 0);
35 | };
36 |
37 | const disableInput = () => setIsEditing(false);
38 |
39 | const onInput = (value: string) => {
40 | setValue(value);
41 | update({
42 | id: initialData._id,
43 | title: value || "Untitled",
44 | });
45 | };
46 |
47 | const onKeyDown = (event: React.KeyboardEvent) => {
48 | if (event.key === "Enter") {
49 | event.preventDefault();
50 | disableInput();
51 | }
52 | };
53 |
54 | const onIconSelect = (icon: string) => {
55 | update({
56 | id: initialData._id,
57 | icon,
58 | });
59 | };
60 |
61 | const onRemoveIcon = () => {
62 | removeIcon({ id: initialData._id });
63 | };
64 |
65 | return (
66 |
67 | {/* Render this when the user is viewing their document */}
68 | {!!initialData.icon && !preview && (
69 |
70 |
71 |
72 | {initialData.icon}
73 |
74 |
75 |
81 |
82 |
83 |
84 | )}
85 | {/* Render this when the guest is viewing others' document */}
86 | {!!initialData.icon && preview && (
87 |
{initialData.icon}
88 | )}
89 |
90 | {!initialData.icon && !preview && (
91 |
92 |
97 |
98 | Add icon
99 |
100 |
101 | )}
102 | {!initialData.coverImage && !preview && (
103 |
109 |
110 | Add cover
111 |
112 | )}
113 |
114 | {isEditing && !preview ? (
115 |
onInput(e.target.value)}
121 | className="text-5xl bg-transparent font-bold break-words outline-none text-[#3F3F3F] dark:text-[#CFCFCF] resize-none"
122 | />
123 | ) : (
124 |
128 | {initialData.title}
129 |
130 | )}
131 |
132 | );
133 | };
134 |
--------------------------------------------------------------------------------
/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, children, ...props }, ref) => (
19 |
27 | ));
28 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
29 |
30 | const AlertDialogContent = React.forwardRef<
31 | React.ElementRef,
32 | React.ComponentPropsWithoutRef
33 | >(({ className, ...props }, ref) => (
34 |
35 |
36 |
44 |
45 | ));
46 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
47 |
48 | const AlertDialogHeader = ({
49 | className,
50 | ...props
51 | }: React.HTMLAttributes) => (
52 |
59 | );
60 | AlertDialogHeader.displayName = "AlertDialogHeader";
61 |
62 | const AlertDialogFooter = ({
63 | className,
64 | ...props
65 | }: React.HTMLAttributes) => (
66 |
73 | );
74 | AlertDialogFooter.displayName = "AlertDialogFooter";
75 |
76 | const AlertDialogTitle = React.forwardRef<
77 | React.ElementRef,
78 | React.ComponentPropsWithoutRef
79 | >(({ className, ...props }, ref) => (
80 |
85 | ));
86 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
87 |
88 | const AlertDialogDescription = React.forwardRef<
89 | React.ElementRef,
90 | React.ComponentPropsWithoutRef
91 | >(({ className, ...props }, ref) => (
92 |
97 | ));
98 | AlertDialogDescription.displayName =
99 | AlertDialogPrimitive.Description.displayName;
100 |
101 | const AlertDialogAction = React.forwardRef<
102 | React.ElementRef,
103 | React.ComponentPropsWithoutRef
104 | >(({ className, ...props }, ref) => (
105 |
110 | ));
111 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
112 |
113 | const AlertDialogCancel = React.forwardRef<
114 | React.ElementRef,
115 | React.ComponentPropsWithoutRef
116 | >(({ className, ...props }, ref) => (
117 |
126 | ));
127 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
128 |
129 | export {
130 | AlertDialog,
131 | AlertDialogPortal,
132 | AlertDialogOverlay,
133 | AlertDialogTrigger,
134 | AlertDialogContent,
135 | AlertDialogHeader,
136 | AlertDialogFooter,
137 | AlertDialogTitle,
138 | AlertDialogDescription,
139 | AlertDialogAction,
140 | AlertDialogCancel,
141 | };
142 |
--------------------------------------------------------------------------------
/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AvatarPrimitive from "@radix-ui/react-avatar"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Avatar = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 | ))
21 | Avatar.displayName = AvatarPrimitive.Root.displayName
22 |
23 | const AvatarImage = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => (
27 |
32 | ))
33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName
34 |
35 | const AvatarFallback = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 | ))
48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
49 |
50 | export { Avatar, AvatarImage, AvatarFallback }
51 |
--------------------------------------------------------------------------------
/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-sm 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 | },
22 | size: {
23 | default: "h-10 px-4 py-2",
24 | sm: "h-9 rounded-md px-3",
25 | lg: "h-11 rounded-md px-8",
26 | icon: "h-10 w-10",
27 | },
28 | },
29 | defaultVariants: {
30 | variant: "default",
31 | size: "default",
32 | },
33 | }
34 | )
35 |
36 | export interface ButtonProps
37 | extends React.ButtonHTMLAttributes,
38 | VariantProps {
39 | asChild?: boolean
40 | }
41 |
42 | const Button = React.forwardRef(
43 | ({ className, variant, size, asChild = false, ...props }, ref) => {
44 | const Comp = asChild ? Slot : "button"
45 | return (
46 |
51 | )
52 | }
53 | )
54 | Button.displayName = "Button"
55 |
56 | export { Button, buttonVariants }
57 |
--------------------------------------------------------------------------------
/components/ui/command.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { DialogProps } from "@radix-ui/react-dialog"
5 | import { Command as CommandPrimitive } from "cmdk"
6 | import { Search } from "lucide-react"
7 |
8 | import { cn } from "@/lib/utils"
9 | import { Dialog, DialogContent } from "@/components/ui/dialog"
10 |
11 | const Command = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef
14 | >(({ className, ...props }, ref) => (
15 |
23 | ))
24 | Command.displayName = CommandPrimitive.displayName
25 |
26 | interface CommandDialogProps extends DialogProps {}
27 |
28 | const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
29 | return (
30 |
31 |
32 |
33 | {children}
34 |
35 |
36 |
37 | )
38 | }
39 |
40 | const CommandInput = React.forwardRef<
41 | React.ElementRef,
42 | React.ComponentPropsWithoutRef
43 | >(({ className, ...props }, ref) => (
44 |
45 |
46 |
54 |
55 | ))
56 |
57 | CommandInput.displayName = CommandPrimitive.Input.displayName
58 |
59 | const CommandList = React.forwardRef<
60 | React.ElementRef,
61 | React.ComponentPropsWithoutRef
62 | >(({ className, ...props }, ref) => (
63 |
68 | ))
69 |
70 | CommandList.displayName = CommandPrimitive.List.displayName
71 |
72 | const CommandEmpty = React.forwardRef<
73 | React.ElementRef,
74 | React.ComponentPropsWithoutRef
75 | >((props, ref) => (
76 |
81 | ))
82 |
83 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName
84 |
85 | const CommandGroup = React.forwardRef<
86 | React.ElementRef,
87 | React.ComponentPropsWithoutRef
88 | >(({ className, ...props }, ref) => (
89 |
97 | ))
98 |
99 | CommandGroup.displayName = CommandPrimitive.Group.displayName
100 |
101 | const CommandSeparator = React.forwardRef<
102 | React.ElementRef,
103 | React.ComponentPropsWithoutRef
104 | >(({ className, ...props }, ref) => (
105 |
110 | ))
111 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName
112 |
113 | const CommandItem = React.forwardRef<
114 | React.ElementRef,
115 | React.ComponentPropsWithoutRef
116 | >(({ className, ...props }, ref) => (
117 |
125 | ))
126 |
127 | CommandItem.displayName = CommandPrimitive.Item.displayName
128 |
129 | const CommandShortcut = ({
130 | className,
131 | ...props
132 | }: React.HTMLAttributes) => {
133 | return (
134 |
141 | )
142 | }
143 | CommandShortcut.displayName = "CommandShortcut"
144 |
145 | export {
146 | Command,
147 | CommandDialog,
148 | CommandInput,
149 | CommandList,
150 | CommandEmpty,
151 | CommandGroup,
152 | CommandItem,
153 | CommandShortcut,
154 | CommandSeparator,
155 | }
156 |
--------------------------------------------------------------------------------
/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DialogPrimitive from "@radix-ui/react-dialog"
5 | import { X } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Dialog = DialogPrimitive.Root
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger
12 |
13 | const DialogPortal = DialogPrimitive.Portal
14 |
15 | const DialogClose = DialogPrimitive.Close
16 |
17 | const DialogOverlay = React.forwardRef<
18 | React.ElementRef,
19 | React.ComponentPropsWithoutRef
20 | >(({ className, ...props }, ref) => (
21 |
29 | ))
30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
31 |
32 | const DialogContent = React.forwardRef<
33 | React.ElementRef,
34 | React.ComponentPropsWithoutRef
35 | >(({ className, children, ...props }, ref) => (
36 |
37 |
38 |
46 | {children}
47 |
48 |
49 | Close
50 |
51 |
52 |
53 | ))
54 | DialogContent.displayName = DialogPrimitive.Content.displayName
55 |
56 | const DialogHeader = ({
57 | className,
58 | ...props
59 | }: React.HTMLAttributes) => (
60 |
67 | )
68 | DialogHeader.displayName = "DialogHeader"
69 |
70 | const DialogFooter = ({
71 | className,
72 | ...props
73 | }: React.HTMLAttributes) => (
74 |
81 | )
82 | DialogFooter.displayName = "DialogFooter"
83 |
84 | const DialogTitle = React.forwardRef<
85 | React.ElementRef,
86 | React.ComponentPropsWithoutRef
87 | >(({ className, ...props }, ref) => (
88 |
96 | ))
97 | DialogTitle.displayName = DialogPrimitive.Title.displayName
98 |
99 | const DialogDescription = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | DialogDescription.displayName = DialogPrimitive.Description.displayName
110 |
111 | export {
112 | Dialog,
113 | DialogPortal,
114 | DialogOverlay,
115 | DialogClose,
116 | DialogTrigger,
117 | DialogContent,
118 | DialogHeader,
119 | DialogFooter,
120 | DialogTitle,
121 | DialogDescription,
122 | }
123 |
--------------------------------------------------------------------------------
/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
5 | import { Check, ChevronRight, Circle } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const DropdownMenu = DropdownMenuPrimitive.Root
10 |
11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
12 |
13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group
14 |
15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal
16 |
17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub
18 |
19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
20 |
21 | const DropdownMenuSubTrigger = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef & {
24 | inset?: boolean
25 | }
26 | >(({ className, inset, children, ...props }, ref) => (
27 |
36 | {children}
37 |
38 |
39 | ))
40 | DropdownMenuSubTrigger.displayName =
41 | DropdownMenuPrimitive.SubTrigger.displayName
42 |
43 | const DropdownMenuSubContent = React.forwardRef<
44 | React.ElementRef,
45 | React.ComponentPropsWithoutRef
46 | >(({ className, ...props }, ref) => (
47 |
55 | ))
56 | DropdownMenuSubContent.displayName =
57 | DropdownMenuPrimitive.SubContent.displayName
58 |
59 | const DropdownMenuContent = React.forwardRef<
60 | React.ElementRef,
61 | React.ComponentPropsWithoutRef
62 | >(({ className, sideOffset = 4, ...props }, ref) => (
63 |
64 |
73 |
74 | ))
75 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
76 |
77 | const DropdownMenuItem = React.forwardRef<
78 | React.ElementRef,
79 | React.ComponentPropsWithoutRef & {
80 | inset?: boolean
81 | }
82 | >(({ className, inset, ...props }, ref) => (
83 |
92 | ))
93 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
94 |
95 | const DropdownMenuCheckboxItem = React.forwardRef<
96 | React.ElementRef,
97 | React.ComponentPropsWithoutRef
98 | >(({ className, children, checked, ...props }, ref) => (
99 |
108 |
109 |
110 |
111 |
112 |
113 | {children}
114 |
115 | ))
116 | DropdownMenuCheckboxItem.displayName =
117 | DropdownMenuPrimitive.CheckboxItem.displayName
118 |
119 | const DropdownMenuRadioItem = React.forwardRef<
120 | React.ElementRef,
121 | React.ComponentPropsWithoutRef
122 | >(({ className, children, ...props }, ref) => (
123 |
131 |
132 |
133 |
134 |
135 |
136 | {children}
137 |
138 | ))
139 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
140 |
141 | const DropdownMenuLabel = React.forwardRef<
142 | React.ElementRef,
143 | React.ComponentPropsWithoutRef & {
144 | inset?: boolean
145 | }
146 | >(({ className, inset, ...props }, ref) => (
147 |
156 | ))
157 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
158 |
159 | const DropdownMenuSeparator = React.forwardRef<
160 | React.ElementRef,
161 | React.ComponentPropsWithoutRef
162 | >(({ className, ...props }, ref) => (
163 |
168 | ))
169 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
170 |
171 | const DropdownMenuShortcut = ({
172 | className,
173 | ...props
174 | }: React.HTMLAttributes) => {
175 | return (
176 |
180 | )
181 | }
182 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
183 |
184 | export {
185 | DropdownMenu,
186 | DropdownMenuTrigger,
187 | DropdownMenuContent,
188 | DropdownMenuItem,
189 | DropdownMenuCheckboxItem,
190 | DropdownMenuRadioItem,
191 | DropdownMenuLabel,
192 | DropdownMenuSeparator,
193 | DropdownMenuShortcut,
194 | DropdownMenuGroup,
195 | DropdownMenuPortal,
196 | DropdownMenuSub,
197 | DropdownMenuSubContent,
198 | DropdownMenuSubTrigger,
199 | DropdownMenuRadioGroup,
200 | }
201 |
--------------------------------------------------------------------------------
/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | )
21 | }
22 | )
23 | Input.displayName = "Input"
24 |
25 | export { Input }
26 |
--------------------------------------------------------------------------------
/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11 | )
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ))
24 | Label.displayName = LabelPrimitive.Root.displayName
25 |
26 | export { Label }
27 |
--------------------------------------------------------------------------------
/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as PopoverPrimitive from "@radix-ui/react-popover"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Popover = PopoverPrimitive.Root
9 |
10 | const PopoverTrigger = PopoverPrimitive.Trigger
11 |
12 | const PopoverContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
16 |
17 |
27 |
28 | ))
29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName
30 |
31 | export { Popover, PopoverTrigger, PopoverContent }
32 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/convex/README.md:
--------------------------------------------------------------------------------
1 | # Welcome to your Convex functions directory!
2 |
3 | Write your Convex functions here. See
4 | https://docs.convex.dev/using/writing-convex-functions for more.
5 |
6 | A query function that takes two arguments looks like:
7 |
8 | ```ts
9 | // functions.js
10 | import { query } from "./_generated/server";
11 | import { v } from "convex/values";
12 |
13 | export const myQueryFunction = query({
14 | // Validators for arguments.
15 | args: {
16 | first: v.number(),
17 | second: v.string(),
18 | },
19 |
20 | // Function implementation.
21 | handler: async (ctx, args) => {
22 | // Read the database as many times as you need here.
23 | // See https://docs.convex.dev/database/reading-data.
24 | const documents = await ctx.db.query("tablename").collect();
25 |
26 | // Arguments passed from the client are properties of the args object.
27 | console.log(args.first, args.second);
28 |
29 | // Write arbitrary JavaScript here: filter, aggregate, build derived data,
30 | // remove non-public properties, or create new objects.
31 | return documents;
32 | },
33 | });
34 | ```
35 |
36 | Using this query function in a React component looks like:
37 |
38 | ```ts
39 | const data = useQuery(api.functions.myQueryFunction, {
40 | first: 10,
41 | second: "hello",
42 | });
43 | ```
44 |
45 | A mutation function looks like:
46 |
47 | ```ts
48 | // functions.js
49 | import { mutation } from "./_generated/server";
50 | import { v } from "convex/values";
51 |
52 | export const myMutationFunction = mutation({
53 | // Validators for arguments.
54 | args: {
55 | first: v.string(),
56 | second: v.string(),
57 | },
58 |
59 | // Function implementation.
60 | handler: async (ctx, args) => {
61 | // Insert or modify documents in the database here.
62 | // Mutations can also read from the database like queries.
63 | // See https://docs.convex.dev/database/writing-data.
64 | const message = { body: args.first, author: args.second };
65 | const id = await ctx.db.insert("messages", message);
66 |
67 | // Optionally, return a value from your mutation.
68 | return await ctx.db.get(id);
69 | },
70 | });
71 | ```
72 |
73 | Using this mutation function in a React component looks like:
74 |
75 | ```ts
76 | const mutation = useMutation(api.functions.myMutationFunction);
77 | function handleButtonPress() {
78 | // fire and forget, the most common way to use mutations
79 | mutation({ first: "Hello!", second: "me" });
80 | // OR
81 | // use the result once the mutation has completed
82 | mutation({ first: "Hello!", second: "me" }).then((result) =>
83 | console.log(result)
84 | );
85 | }
86 | ```
87 |
88 | Use the Convex CLI to push your functions to a deployment. See everything
89 | the Convex CLI can do by running `npx convex -h` in your project root
90 | directory. To learn more, launch the docs with `npx convex docs`.
91 |
--------------------------------------------------------------------------------
/convex/_generated/api.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated `api` utility.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * Generated by convex@1.4.1.
8 | * To regenerate, run `npx convex dev`.
9 | * @module
10 | */
11 |
12 | import type {
13 | ApiFromModules,
14 | FilterApi,
15 | FunctionReference,
16 | } from "convex/server";
17 | import type * as documents from "../documents";
18 |
19 | /**
20 | * A utility for referencing Convex functions in your app's API.
21 | *
22 | * Usage:
23 | * ```js
24 | * const myFunctionReference = api.myModule.myFunction;
25 | * ```
26 | */
27 | declare const fullApi: ApiFromModules<{
28 | documents: typeof documents;
29 | }>;
30 | export declare const api: FilterApi<
31 | typeof fullApi,
32 | FunctionReference
33 | >;
34 | export declare const internal: FilterApi<
35 | typeof fullApi,
36 | FunctionReference
37 | >;
38 |
--------------------------------------------------------------------------------
/convex/_generated/api.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated `api` utility.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * Generated by convex@1.4.1.
8 | * To regenerate, run `npx convex dev`.
9 | * @module
10 | */
11 |
12 | import { anyApi } from "convex/server";
13 |
14 | /**
15 | * A utility for referencing Convex functions in your app's API.
16 | *
17 | * Usage:
18 | * ```js
19 | * const myFunctionReference = api.myModule.myFunction;
20 | * ```
21 | */
22 | export const api = anyApi;
23 | export const internal = anyApi;
24 |
--------------------------------------------------------------------------------
/convex/_generated/dataModel.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated data model types.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * Generated by convex@1.4.1.
8 | * To regenerate, run `npx convex dev`.
9 | * @module
10 | */
11 |
12 | import type { DataModelFromSchemaDefinition } from "convex/server";
13 | import type { DocumentByName, TableNamesInDataModel } from "convex/server";
14 | import type { GenericId } from "convex/values";
15 | import schema from "../schema";
16 |
17 | /**
18 | * The names of all of your Convex tables.
19 | */
20 | export type TableNames = TableNamesInDataModel;
21 |
22 | /**
23 | * The type of a document stored in Convex.
24 | *
25 | * @typeParam TableName - A string literal type of the table name (like "users").
26 | */
27 | export type Doc = DocumentByName<
28 | DataModel,
29 | TableName
30 | >;
31 |
32 | /**
33 | * An identifier for a document in Convex.
34 | *
35 | * Convex documents are uniquely identified by their `Id`, which is accessible
36 | * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
37 | *
38 | * Documents can be loaded using `db.get(id)` in query and mutation functions.
39 | *
40 | * IDs are just strings at runtime, but this type can be used to distinguish them from other
41 | * strings when type checking.
42 | *
43 | * @typeParam TableName - A string literal type of the table name (like "users").
44 | */
45 | export type Id = GenericId;
46 |
47 | /**
48 | * A type describing your Convex data model.
49 | *
50 | * This type includes information about what tables you have, the type of
51 | * documents stored in those tables, and the indexes defined on them.
52 | *
53 | * This type is used to parameterize methods like `queryGeneric` and
54 | * `mutationGeneric` to make them type-safe.
55 | */
56 | export type DataModel = DataModelFromSchemaDefinition;
57 |
--------------------------------------------------------------------------------
/convex/_generated/server.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated utilities for implementing server-side Convex query and mutation functions.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * Generated by convex@1.4.1.
8 | * To regenerate, run `npx convex dev`.
9 | * @module
10 | */
11 |
12 | import {
13 | ActionBuilder,
14 | HttpActionBuilder,
15 | MutationBuilder,
16 | QueryBuilder,
17 | GenericActionCtx,
18 | GenericMutationCtx,
19 | GenericQueryCtx,
20 | GenericDatabaseReader,
21 | GenericDatabaseWriter,
22 | } from "convex/server";
23 | import type { DataModel } from "./dataModel.js";
24 |
25 | /**
26 | * Define a query in this Convex app's public API.
27 | *
28 | * This function will be allowed to read your Convex database and will be accessible from the client.
29 | *
30 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
31 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
32 | */
33 | export declare const query: QueryBuilder;
34 |
35 | /**
36 | * Define a query that is only accessible from other Convex functions (but not from the client).
37 | *
38 | * This function will be allowed to read from your Convex database. It will not be accessible from the client.
39 | *
40 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
41 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
42 | */
43 | export declare const internalQuery: QueryBuilder;
44 |
45 | /**
46 | * Define a mutation in this Convex app's public API.
47 | *
48 | * This function will be allowed to modify your Convex database and will be accessible from the client.
49 | *
50 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
51 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
52 | */
53 | export declare const mutation: MutationBuilder;
54 |
55 | /**
56 | * Define a mutation that is only accessible from other Convex functions (but not from the client).
57 | *
58 | * This function will be allowed to modify your Convex database. It will not be accessible from the client.
59 | *
60 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
61 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
62 | */
63 | export declare const internalMutation: MutationBuilder;
64 |
65 | /**
66 | * Define an action in this Convex app's public API.
67 | *
68 | * An action is a function which can execute any JavaScript code, including non-deterministic
69 | * code and code with side-effects, like calling third-party services.
70 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
71 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
72 | *
73 | * @param func - The action. It receives an {@link ActionCtx} as its first argument.
74 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible.
75 | */
76 | export declare const action: ActionBuilder;
77 |
78 | /**
79 | * Define an action that is only accessible from other Convex functions (but not from the client).
80 | *
81 | * @param func - The function. It receives an {@link ActionCtx} as its first argument.
82 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible.
83 | */
84 | export declare const internalAction: ActionBuilder;
85 |
86 | /**
87 | * Define an HTTP action.
88 | *
89 | * This function will be used to respond to HTTP requests received by a Convex
90 | * deployment if the requests matches the path and method where this action
91 | * is routed. Be sure to route your action in `convex/http.js`.
92 | *
93 | * @param func - The function. It receives an {@link ActionCtx} as its first argument.
94 | * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
95 | */
96 | export declare const httpAction: HttpActionBuilder;
97 |
98 | /**
99 | * A set of services for use within Convex query functions.
100 | *
101 | * The query context is passed as the first argument to any Convex query
102 | * function run on the server.
103 | *
104 | * This differs from the {@link MutationCtx} because all of the services are
105 | * read-only.
106 | */
107 | export type QueryCtx = GenericQueryCtx;
108 |
109 | /**
110 | * A set of services for use within Convex mutation functions.
111 | *
112 | * The mutation context is passed as the first argument to any Convex mutation
113 | * function run on the server.
114 | */
115 | export type MutationCtx = GenericMutationCtx;
116 |
117 | /**
118 | * A set of services for use within Convex action functions.
119 | *
120 | * The action context is passed as the first argument to any Convex action
121 | * function run on the server.
122 | */
123 | export type ActionCtx = GenericActionCtx;
124 |
125 | /**
126 | * An interface to read from the database within Convex query functions.
127 | *
128 | * The two entry points are {@link DatabaseReader.get}, which fetches a single
129 | * document by its {@link Id}, or {@link DatabaseReader.query}, which starts
130 | * building a query.
131 | */
132 | export type DatabaseReader = GenericDatabaseReader;
133 |
134 | /**
135 | * An interface to read from and write to the database within Convex mutation
136 | * functions.
137 | *
138 | * Convex guarantees that all writes within a single mutation are
139 | * executed atomically, so you never have to worry about partial writes leaving
140 | * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
141 | * for the guarantees Convex provides your functions.
142 | */
143 | export type DatabaseWriter = GenericDatabaseWriter;
144 |
--------------------------------------------------------------------------------
/convex/_generated/server.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated utilities for implementing server-side Convex query and mutation functions.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * Generated by convex@1.4.1.
8 | * To regenerate, run `npx convex dev`.
9 | * @module
10 | */
11 |
12 | import {
13 | actionGeneric,
14 | httpActionGeneric,
15 | queryGeneric,
16 | mutationGeneric,
17 | internalActionGeneric,
18 | internalMutationGeneric,
19 | internalQueryGeneric,
20 | } from "convex/server";
21 |
22 | /**
23 | * Define a query in this Convex app's public API.
24 | *
25 | * This function will be allowed to read your Convex database and will be accessible from the client.
26 | *
27 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
28 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
29 | */
30 | export const query = queryGeneric;
31 |
32 | /**
33 | * Define a query that is only accessible from other Convex functions (but not from the client).
34 | *
35 | * This function will be allowed to read from your Convex database. It will not be accessible from the client.
36 | *
37 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
38 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
39 | */
40 | export const internalQuery = internalQueryGeneric;
41 |
42 | /**
43 | * Define a mutation in this Convex app's public API.
44 | *
45 | * This function will be allowed to modify your Convex database and will be accessible from the client.
46 | *
47 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
48 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
49 | */
50 | export const mutation = mutationGeneric;
51 |
52 | /**
53 | * Define a mutation that is only accessible from other Convex functions (but not from the client).
54 | *
55 | * This function will be allowed to modify your Convex database. It will not be accessible from the client.
56 | *
57 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
58 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
59 | */
60 | export const internalMutation = internalMutationGeneric;
61 |
62 | /**
63 | * Define an action in this Convex app's public API.
64 | *
65 | * An action is a function which can execute any JavaScript code, including non-deterministic
66 | * code and code with side-effects, like calling third-party services.
67 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
68 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
69 | *
70 | * @param func - The action. It receives an {@link ActionCtx} as its first argument.
71 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible.
72 | */
73 | export const action = actionGeneric;
74 |
75 | /**
76 | * Define an action that is only accessible from other Convex functions (but not from the client).
77 | *
78 | * @param func - The function. It receives an {@link ActionCtx} as its first argument.
79 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible.
80 | */
81 | export const internalAction = internalActionGeneric;
82 |
83 | /**
84 | * Define a Convex HTTP action.
85 | *
86 | * @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object
87 | * as its second.
88 | * @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`.
89 | */
90 | export const httpAction = httpActionGeneric;
91 |
--------------------------------------------------------------------------------
/convex/auth.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | providers: [
3 | {
4 | domain: "https://dominant-werewolf-64.clerk.accounts.dev",
5 | applicationID: "convex",
6 | },
7 | ],
8 | };
9 |
--------------------------------------------------------------------------------
/convex/documents.ts:
--------------------------------------------------------------------------------
1 | import { v } from "convex/values";
2 | import { mutation, query } from "./_generated/server";
3 | import { Doc, Id } from "./_generated/dataModel";
4 |
5 | // to set the isArchived to true
6 | export const archive = mutation({
7 | args: { id: v.id("documents") },
8 | handler: async (ctx, args) => {
9 | const identity = await ctx.auth.getUserIdentity();
10 |
11 | if (!identity) {
12 | throw new Error("Not authenticated");
13 | }
14 |
15 | const userId = identity.subject;
16 |
17 | const existingDocument = await ctx.db.get(args.id);
18 |
19 | if (!existingDocument) {
20 | throw new Error("Not found");
21 | }
22 |
23 | if (existingDocument.userId !== userId) {
24 | throw new Error("Unauthorized");
25 | }
26 |
27 | // a recursive function to set the isArchived of the children to true
28 | const recursiveArchive = async (documentId: Id<"documents">) => {
29 | // get the children
30 | const children = await ctx.db
31 | .query("documents")
32 | .withIndex("by_user_parent", (q) =>
33 | q.eq("userId", userId).eq("parentDocument", documentId)
34 | )
35 | .collect();
36 |
37 | // loop through children to set isArchived to true
38 | for (const child of children) {
39 | await ctx.db.patch(child._id, { isArchived: true });
40 | await recursiveArchive(child._id); // to loop through the children of the children
41 | }
42 | };
43 |
44 | // set the isArchived to true
45 | const document = await ctx.db.patch(args.id, {
46 | isArchived: true,
47 | });
48 |
49 | recursiveArchive(args.id);
50 |
51 | return document;
52 | },
53 | });
54 |
55 | // get the documents for the sidebar
56 | export const getSidebar = query({
57 | args: {
58 | parentDocument: v.optional(v.id("documents")),
59 | },
60 | handler: async (ctx, args) => {
61 | const identity = await ctx.auth.getUserIdentity();
62 |
63 | if (!identity) {
64 | throw new Error("Not authenticated");
65 | }
66 |
67 | const userId = identity.subject;
68 |
69 | const documents = await ctx.db
70 | .query("documents")
71 | .withIndex("by_user_parent", (q) =>
72 | q.eq("userId", userId).eq("parentDocument", args.parentDocument)
73 | )
74 | .filter((q) => q.eq(q.field("isArchived"), false))
75 | .order("desc")
76 | .collect();
77 |
78 | return documents;
79 | },
80 | });
81 |
82 | // create a document
83 | export const create = mutation({
84 | args: {
85 | title: v.string(),
86 | parentDocument: v.optional(v.id("documents")),
87 | },
88 | handler: async (ctx, args) => {
89 | const identity = await ctx.auth.getUserIdentity();
90 |
91 | if (!identity) {
92 | throw new Error("Not authenticated");
93 | }
94 |
95 | const userId = identity.subject;
96 |
97 | const document = await ctx.db.insert("documents", {
98 | title: args.title,
99 | parentDocument: args.parentDocument,
100 | userId,
101 | isArchived: false,
102 | isPublished: false,
103 | });
104 |
105 | return document;
106 | },
107 | });
108 |
109 | // to get the archived documents
110 | export const getTrash = query({
111 | handler: async (ctx) => {
112 | const identity = await ctx.auth.getUserIdentity();
113 |
114 | if (!identity) {
115 | throw new Error("Not authenticated");
116 | }
117 |
118 | const userId = identity.subject;
119 |
120 | const documents = await ctx.db
121 | .query("documents")
122 | .withIndex("by_user", (q) => q.eq("userId", userId))
123 | .filter((q) => q.eq(q.field("isArchived"), true))
124 | .order("desc")
125 | .collect();
126 |
127 | return documents;
128 | },
129 | });
130 |
131 | // to restore the archived documents
132 | // the logic is to detach the child from the parent if the parent is archived and
133 | // restore all the children
134 | export const restore = mutation({
135 | args: { id: v.id("documents") },
136 | handler: async (ctx, args) => {
137 | const identity = await ctx.auth.getUserIdentity();
138 |
139 | if (!identity) {
140 | throw new Error("Not authenticated");
141 | }
142 |
143 | const userId = identity.subject;
144 |
145 | const existingDocument = await ctx.db.get(args.id);
146 |
147 | if (!existingDocument) {
148 | throw new Error("Not found");
149 | }
150 |
151 | if (existingDocument.userId !== userId) {
152 | throw new Error("Unauthorized");
153 | }
154 |
155 | // to restore the children of the document being restored
156 | const recursiveRestore = async (documentId: Id<"documents">) => {
157 | const children = await ctx.db
158 | .query("documents")
159 | .withIndex("by_user_parent", (q) =>
160 | q.eq("userId", userId).eq("parentDocument", documentId)
161 | )
162 | .collect();
163 |
164 | for (const child of children) {
165 | await ctx.db.patch(child._id, {
166 | isArchived: false,
167 | });
168 |
169 | await recursiveRestore(child._id);
170 | }
171 | };
172 |
173 | const options: Partial> = {
174 | isArchived: false,
175 | };
176 |
177 | // if the parent is archived, detach the document from the parent
178 | if (existingDocument.parentDocument) {
179 | const parent = await ctx.db.get(existingDocument.parentDocument);
180 | if (parent?.isArchived) {
181 | options.parentDocument = undefined;
182 | }
183 | }
184 |
185 | const document = await ctx.db.patch(args.id, options);
186 |
187 | recursiveRestore(args.id);
188 |
189 | return document;
190 | },
191 | });
192 |
193 | // ro actually remove the document from the database
194 | export const remove = mutation({
195 | args: { id: v.id("documents") },
196 | handler: async (ctx, args) => {
197 | const identity = await ctx.auth.getUserIdentity();
198 |
199 | if (!identity) {
200 | throw new Error("Not authenticated");
201 | }
202 |
203 | const userId = identity.subject;
204 |
205 | const existingDocument = await ctx.db.get(args.id);
206 |
207 | if (!existingDocument) {
208 | throw new Error("Not found");
209 | }
210 |
211 | if (existingDocument.userId !== userId) {
212 | throw new Error("Unauthorized");
213 | }
214 |
215 | const document = await ctx.db.delete(args.id);
216 |
217 | return document;
218 | },
219 | });
220 |
221 | export const getSearch = query({
222 | handler: async (ctx) => {
223 | const identity = await ctx.auth.getUserIdentity();
224 |
225 | if (!identity) {
226 | throw new Error("Not authenticated");
227 | }
228 |
229 | const userId = identity.subject;
230 |
231 | const documents = await ctx.db
232 | .query("documents")
233 | .withIndex("by_user", (q) => q.eq("userId", userId))
234 | .filter((q) => q.eq(q.field("isArchived"), false))
235 | .order("desc")
236 | .collect();
237 |
238 | return documents;
239 | },
240 | });
241 |
242 | export const getById = query({
243 | args: { documentId: v.id("documents") },
244 | handler: async (ctx, args) => {
245 | const identity = await ctx.auth.getUserIdentity();
246 |
247 | const document = await ctx.db.get(args.documentId);
248 |
249 | if (!document) {
250 | throw new Error("Not found");
251 | }
252 |
253 | // Anyone can see it if the document is published and not archived
254 | if (document.isPublished && !document.isArchived) {
255 | return document;
256 | }
257 |
258 | if (!identity) {
259 | throw new Error("Not authenticated");
260 | }
261 |
262 | const userId = identity.subject;
263 |
264 | if (document.userId !== userId) {
265 | throw new Error("Unauthorized");
266 | }
267 |
268 | return document;
269 | },
270 | });
271 |
272 | export const update = mutation({
273 | args: {
274 | id: v.id("documents"),
275 | title: v.optional(v.string()),
276 | content: v.optional(v.string()),
277 | coverImage: v.optional(v.string()),
278 | icon: v.optional(v.string()),
279 | isPublished: v.optional(v.boolean()),
280 | },
281 | handler: async (ctx, args) => {
282 | const identity = await ctx.auth.getUserIdentity();
283 |
284 | if (!identity) {
285 | throw new Error("Unauthenticated");
286 | }
287 |
288 | const userId = identity.subject;
289 |
290 | const { id, ...rest } = args;
291 |
292 | const existingDocument = await ctx.db.get(args.id);
293 |
294 | if (!existingDocument) {
295 | throw new Error("Not found");
296 | }
297 |
298 | if (existingDocument.userId !== userId) {
299 | throw new Error("Unauthorized");
300 | }
301 |
302 | const document = await ctx.db.patch(args.id, { ...rest });
303 |
304 | return document;
305 | },
306 | });
307 |
308 | export const removeIcon = mutation({
309 | args: { id: v.id("documents") },
310 | handler: async (ctx, args) => {
311 | const identity = await ctx.auth.getUserIdentity();
312 |
313 | if (!identity) {
314 | throw new Error("Unauthenticated");
315 | }
316 |
317 | const userId = identity.subject;
318 |
319 | const existingDocument = await ctx.db.get(args.id);
320 |
321 | if (!existingDocument) {
322 | throw new Error("Not found");
323 | }
324 |
325 | if (existingDocument.userId !== userId) {
326 | throw new Error("Unauthorized");
327 | }
328 |
329 | const document = await ctx.db.patch(args.id, { icon: undefined });
330 |
331 | return document;
332 | },
333 | });
334 |
335 | export const removeCoverImage = mutation({
336 | args: { id: v.id("documents") },
337 | handler: async (ctx, args) => {
338 | const identity = await ctx.auth.getUserIdentity();
339 |
340 | if (!identity) {
341 | throw new Error("Unauthenticated");
342 | }
343 |
344 | const userId = identity.subject;
345 |
346 | const existingDocument = await ctx.db.get(args.id);
347 |
348 | if (!existingDocument) {
349 | throw new Error("Not found");
350 | }
351 |
352 | if (existingDocument.userId !== userId) {
353 | throw new Error("Unauthorized");
354 | }
355 |
356 | const document = await ctx.db.patch(args.id, { coverImage: undefined });
357 |
358 | return document;
359 | },
360 | });
361 |
--------------------------------------------------------------------------------
/convex/schema.ts:
--------------------------------------------------------------------------------
1 | import { defineSchema, defineTable } from "convex/server";
2 | import { v } from "convex/values";
3 |
4 | export default defineSchema({
5 | documents: defineTable({
6 | title: v.string(),
7 | userId: v.string(),
8 | isArchived: v.boolean(),
9 | parentDocument: v.optional(v.id("documents")),
10 | content: v.optional(v.string()),
11 | coverImage: v.optional(v.string()),
12 | icon: v.optional(v.string()),
13 | isPublished: v.boolean(),
14 | })
15 | .index("by_user", ["userId"])
16 | .index("by_user_parent", ["userId", "parentDocument"]),
17 | });
18 |
--------------------------------------------------------------------------------
/convex/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | /* This TypeScript project config describes the environment that
3 | * Convex functions run in and is used to typecheck them.
4 | * You can modify it, but some settings required to use Convex.
5 | */
6 | "compilerOptions": {
7 | /* These settings are not required by Convex and can be modified. */
8 | "allowJs": true,
9 | "strict": true,
10 |
11 | /* These compiler options are required by Convex */
12 | "target": "ESNext",
13 | "lib": ["ES2021", "dom"],
14 | "forceConsistentCasingInFileNames": true,
15 | "allowSyntheticDefaultImports": true,
16 | "module": "ESNext",
17 | "moduleResolution": "Node",
18 | "isolatedModules": true,
19 | "skipLibCheck": true,
20 | "noEmit": true
21 | },
22 | "include": ["./**/*"],
23 | "exclude": ["./_generated"]
24 | }
25 |
--------------------------------------------------------------------------------
/hooks/use-cover-image.tsx:
--------------------------------------------------------------------------------
1 | import { create } from "zustand";
2 |
3 | type CoverImageStore = {
4 | url?: string;
5 | isOpen: boolean;
6 | onOpen: () => void;
7 | onClose: () => void;
8 | onReplace: (url: string) => void;
9 | };
10 |
11 | export const useCoverImage = create((set) => ({
12 | url: undefined,
13 | isOpen: false,
14 | onOpen: () => set({ isOpen: true, url: undefined }),
15 | onClose: () => set({ isOpen: false, url: undefined }),
16 | onReplace: (url: string) => set({ isOpen: true, url }),
17 | }));
18 |
--------------------------------------------------------------------------------
/hooks/use-origin.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 |
3 | export const useOrigin = () => {
4 | const [isMounted, setIsMounted] = useState(false);
5 | const origin =
6 | typeof window !== "undefined" && window.location.origin
7 | ? window.location.origin
8 | : "";
9 |
10 | useEffect(() => {
11 | setIsMounted(true);
12 | }, []);
13 |
14 | if (!isMounted) return "";
15 |
16 | return origin;
17 | };
18 |
--------------------------------------------------------------------------------
/hooks/use-scroll-top.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 |
3 | export const useScrollTop = (threshold = 10) => {
4 | const [scrolled, setScrolled] = useState(false);
5 |
6 | useEffect(() => {
7 | const handleScroll = () => {
8 | if (window.scrollY > threshold) {
9 | setScrolled(true);
10 | } else {
11 | setScrolled(false);
12 | }
13 | };
14 |
15 | window.addEventListener("scroll", handleScroll);
16 | return () => window.removeEventListener("scroll", handleScroll);
17 | }, [threshold]);
18 |
19 | return scrolled;
20 | };
21 |
--------------------------------------------------------------------------------
/hooks/use-search.tsx:
--------------------------------------------------------------------------------
1 | import { create } from "zustand";
2 |
3 | type SearchStore = {
4 | isOpen: boolean;
5 | onOpen: () => void;
6 | onClose: () => void;
7 | toggle: () => void;
8 | };
9 |
10 | export const useSearch = create((set, get) => ({
11 | isOpen: false,
12 | onOpen: () => set({ isOpen: true }),
13 | onClose: () => set({ isOpen: false }),
14 | toggle: () => set({ isOpen: !get().isOpen }),
15 | }));
16 |
--------------------------------------------------------------------------------
/hooks/use-settings.tsx:
--------------------------------------------------------------------------------
1 | import { create } from "zustand";
2 |
3 | type SettingsStore = {
4 | isOpen: boolean;
5 | onOpen: () => void;
6 | onClose: () => void;
7 | };
8 |
9 | export const useSettings = create((set) => ({
10 | isOpen: false,
11 | onOpen: () => set({ isOpen: true }),
12 | onClose: () => set({ isOpen: false }),
13 | }));
14 |
--------------------------------------------------------------------------------
/lib/edgestore.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { type EdgeStoreRouter } from "../app/api/edgestore/[...edgestore]/route";
4 | import { createEdgeStoreProvider } from "@edgestore/react";
5 | const { EdgeStoreProvider, useEdgeStore } =
6 | createEdgeStoreProvider();
7 | export { EdgeStoreProvider, useEdgeStore };
8 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = { images: { domains: ["files.edgestore.dev"] } };
3 |
4 | module.exports = nextConfig;
5 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "notion-clone",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@blocknote/core": "^0.9.6",
13 | "@blocknote/react": "^0.9.6",
14 | "@clerk/clerk-react": "^4.26.6",
15 | "@edgestore/react": "^0.1.4",
16 | "@edgestore/server": "^0.1.4",
17 | "@radix-ui/react-alert-dialog": "^1.0.5",
18 | "@radix-ui/react-avatar": "^1.0.4",
19 | "@radix-ui/react-dialog": "^1.0.5",
20 | "@radix-ui/react-dropdown-menu": "^2.0.6",
21 | "@radix-ui/react-label": "^2.0.2",
22 | "@radix-ui/react-popover": "^1.0.7",
23 | "@radix-ui/react-slot": "^1.0.2",
24 | "class-variance-authority": "^0.7.0",
25 | "clsx": "^2.0.0",
26 | "cmdk": "^0.2.0",
27 | "convex": "^1.4.1",
28 | "emoji-picker-react": "^4.5.7",
29 | "lucide-react": "^0.288.0",
30 | "next": "13.5.6",
31 | "next-themes": "^0.2.1",
32 | "react": "^18",
33 | "react-dom": "^18",
34 | "react-dropzone": "^14.2.3",
35 | "react-textarea-autosize": "^8.5.3",
36 | "sonner": "^1.0.3",
37 | "tailwind-merge": "^1.14.0",
38 | "tailwindcss-animate": "^1.0.7",
39 | "usehooks-ts": "^2.9.1",
40 | "zod": "^3.22.4",
41 | "zustand": "^4.4.4"
42 | },
43 | "devDependencies": {
44 | "@types/node": "^20",
45 | "@types/react": "^18",
46 | "@types/react-dom": "^18",
47 | "autoprefixer": "^10",
48 | "eslint": "^8",
49 | "eslint-config-next": "13.5.6",
50 | "postcss": "^8",
51 | "tailwindcss": "^3",
52 | "typescript": "^5"
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/public/documents-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/evanch98/notion-clone-nextjs/e11db8b0798ca9493329cdf859ccaec1aefa985a/public/documents-dark.png
--------------------------------------------------------------------------------
/public/documents.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/evanch98/notion-clone-nextjs/e11db8b0798ca9493329cdf859ccaec1aefa985a/public/documents.png
--------------------------------------------------------------------------------
/public/empty-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/evanch98/notion-clone-nextjs/e11db8b0798ca9493329cdf859ccaec1aefa985a/public/empty-dark.png
--------------------------------------------------------------------------------
/public/empty.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/evanch98/notion-clone-nextjs/e11db8b0798ca9493329cdf859ccaec1aefa985a/public/empty.png
--------------------------------------------------------------------------------
/public/error-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/evanch98/notion-clone-nextjs/e11db8b0798ca9493329cdf859ccaec1aefa985a/public/error-dark.png
--------------------------------------------------------------------------------
/public/error.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/evanch98/notion-clone-nextjs/e11db8b0798ca9493329cdf859ccaec1aefa985a/public/error.png
--------------------------------------------------------------------------------
/public/logo-dark.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
Notion icon
8 |
9 |
10 |
--------------------------------------------------------------------------------
/public/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
7 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/reading-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/evanch98/notion-clone-nextjs/e11db8b0798ca9493329cdf859ccaec1aefa985a/public/reading-dark.png
--------------------------------------------------------------------------------
/public/reading.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/evanch98/notion-clone-nextjs/e11db8b0798ca9493329cdf859ccaec1aefa985a/public/reading.png
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | darkMode: ["class"],
4 | content: [
5 | './pages/**/*.{ts,tsx}',
6 | './components/**/*.{ts,tsx}',
7 | './app/**/*.{ts,tsx}',
8 | './src/**/*.{ts,tsx}',
9 | ],
10 | theme: {
11 | container: {
12 | center: true,
13 | padding: "2rem",
14 | screens: {
15 | "2xl": "1400px",
16 | },
17 | },
18 | extend: {
19 | colors: {
20 | border: "hsl(var(--border))",
21 | input: "hsl(var(--input))",
22 | ring: "hsl(var(--ring))",
23 | background: "hsl(var(--background))",
24 | foreground: "hsl(var(--foreground))",
25 | primary: {
26 | DEFAULT: "hsl(var(--primary))",
27 | foreground: "hsl(var(--primary-foreground))",
28 | },
29 | secondary: {
30 | DEFAULT: "hsl(var(--secondary))",
31 | foreground: "hsl(var(--secondary-foreground))",
32 | },
33 | destructive: {
34 | DEFAULT: "hsl(var(--destructive))",
35 | foreground: "hsl(var(--destructive-foreground))",
36 | },
37 | muted: {
38 | DEFAULT: "hsl(var(--muted))",
39 | foreground: "hsl(var(--muted-foreground))",
40 | },
41 | accent: {
42 | DEFAULT: "hsl(var(--accent))",
43 | foreground: "hsl(var(--accent-foreground))",
44 | },
45 | popover: {
46 | DEFAULT: "hsl(var(--popover))",
47 | foreground: "hsl(var(--popover-foreground))",
48 | },
49 | card: {
50 | DEFAULT: "hsl(var(--card))",
51 | foreground: "hsl(var(--card-foreground))",
52 | },
53 | },
54 | borderRadius: {
55 | lg: "var(--radius)",
56 | md: "calc(var(--radius) - 2px)",
57 | sm: "calc(var(--radius) - 4px)",
58 | },
59 | keyframes: {
60 | "accordion-down": {
61 | from: { height: 0 },
62 | to: { height: "var(--radix-accordion-content-height)" },
63 | },
64 | "accordion-up": {
65 | from: { height: "var(--radix-accordion-content-height)" },
66 | to: { height: 0 },
67 | },
68 | },
69 | animation: {
70 | "accordion-down": "accordion-down 0.2s ease-out",
71 | "accordion-up": "accordion-up 0.2s ease-out",
72 | },
73 | },
74 | },
75 | plugins: [require("tailwindcss-animate")],
76 | }
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------