├── .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 | ![GitHub repo size](https://img.shields.io/github/repo-size/evanch98/notion-clone-nextjs) 4 | ![GitHub stars](https://img.shields.io/github/stars/evanch98/notion-clone-nextjs?style=social) 5 | ![GitHub forks](https://img.shields.io/github/forks/evanch98/notion-clone-nextjs?style=social) 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 | Empty 38 | Empty 45 |

46 | Welcome to {user?.firstName}'s Notion 47 |

48 | 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 | 51 | 52 | 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 |
167 | 168 |
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 | 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 | 33 | ); 34 | } 35 | 36 | if (document === null) { 37 | return null; 38 | } 39 | 40 | return ( 41 | <> 42 |