├── .eslintrc.json ├── .gitignore ├── README.md ├── app ├── (main) │ ├── (routes) │ │ └── documents │ │ │ ├── [documentId] │ │ │ └── page.tsx │ │ │ └── page.tsx │ ├── _components │ │ ├── Banner.tsx │ │ ├── DocumentList.tsx │ │ ├── Item.tsx │ │ ├── Menu.tsx │ │ ├── Navbar.tsx │ │ ├── Navigation.tsx │ │ ├── Publish.tsx │ │ ├── Title.tsx │ │ ├── TrashBox.tsx │ │ └── UserItem.tsx │ └── layout.tsx ├── (marketing) │ ├── _components │ │ ├── Footer.tsx │ │ ├── Heading.tsx │ │ ├── Hero.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 │ ├── Editor.tsx │ └── styles.css ├── IconPicker.tsx ├── ModeToggle.tsx ├── SearchCommand.tsx ├── SingleImageDropzone.tsx ├── Spinner.tsx ├── Toolbar.tsx ├── modals │ ├── ConfirmModal.tsx │ ├── CoverImageModal.tsx │ └── SettingsModal.tsx ├── providers │ ├── ConvexProvider.tsx │ ├── ModalProvider.tsx │ └── ThemeProvider.tsx └── ui │ ├── alert-dialog.tsx │ ├── avatar.tsx │ ├── button.tsx │ ├── command.tsx │ ├── dialog.tsx │ ├── dropdown-menu.tsx │ ├── input.tsx │ ├── label.tsx │ ├── popover.tsx │ ├── skeleton.tsx │ └── textarea.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 ├── useCoverImage.tsx ├── useOrigin.tsx ├── useSearch.tsx └── useSettings.tsx ├── lib ├── edgestore.ts └── utils.ts ├── next.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public └── assets │ ├── 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 ├── 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 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Jotion 2 | 3 |

A full-stack Productivity and Note-Taking Web Application built using Next.js.

4 | 5 | 6 | ## Preview of live site 7 | ![Screenshot (140)](https://github.com/sougata-github/Jotion/assets/102734212/b83a0559-33c2-40c3-8d83-50a29cdf9b7f) 8 | ![Screenshot (141)](https://github.com/sougata-github/Jotion/assets/102734212/03aa9922-7e1f-447b-a204-72c86d6f880b) 9 | 10 | 11 | ## Key Features 12 | 13 | - Real-time Database 14 | - Notion-style Editor 15 | - Light and Dark Mode 16 | - Infinite Children Documents 17 | - Trash can & soft delete 18 | - Authentication 19 | - Real time updates 20 | - File upload 21 | - File deletion 22 | - Expandable sidebar 23 | - Fully responsive 24 | - Publish your note to the web 25 | - Cover image of each document 26 | - Recover deleted files 27 | 28 | ## Tech Stack 29 | 30 | - Next.js 14 31 | - Tailwind CSS 32 | - TypeScript 33 | - Clerk 34 | - Convex 35 | - Shadcn UI 36 | - Edgestore 37 | - BlockNote Editor 38 | - Next themes 39 | - Sonner 40 | - Zustand 41 | - Zod 42 | - Vercel 43 | -------------------------------------------------------------------------------- /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 | 7 | import { api } from "@/convex/_generated/api"; 8 | import { Id } from "@/convex/_generated/dataModel"; 9 | import { useMutation, useQuery } from "convex/react"; 10 | 11 | import dynamic from "next/dynamic"; 12 | import { useMemo } from "react"; 13 | 14 | interface Props { 15 | params: { 16 | documentId: Id<"documents">; 17 | }; 18 | } 19 | 20 | const Page = ({ params }: Props) => { 21 | const Editor = useMemo( 22 | () => dynamic(() => import("@/components/Editor/Editor"), { ssr: false }), 23 | [] 24 | ); 25 | 26 | const document = useQuery(api.documents.getById, { 27 | documentId: params.documentId, 28 | }); 29 | 30 | const update = useMutation(api.documents.update); 31 | 32 | const onChange = (content: string) => { 33 | update({ 34 | id: params.documentId, 35 | content, 36 | }); 37 | }; 38 | 39 | if (document === undefined) { 40 | return ( 41 |
42 | 43 |
44 |
45 | 46 | 47 | 48 | 49 |
50 |
51 |
52 | ); 53 | } 54 | 55 | if (document === null) { 56 |
Not Found
; 57 | } 58 | 59 | return ( 60 |
61 | 62 |
63 | 64 | 65 |
66 |
67 | ); 68 | }; 69 | 70 | export default Page; 71 | -------------------------------------------------------------------------------- /app/(main)/(routes)/documents/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | import { useUser } from "@clerk/clerk-react"; 5 | import { Button } from "@/components/ui/button"; 6 | import { PlusCircle } from "lucide-react"; 7 | 8 | import { useMutation } from "convex/react"; 9 | import { api } from "@/convex/_generated/api"; 10 | import { toast } from "sonner"; 11 | import { useRouter } from "next/navigation"; 12 | 13 | const Page = () => { 14 | const { user } = useUser(); 15 | const router = useRouter(); 16 | 17 | const create = useMutation(api.documents.create); 18 | 19 | const onCreate = () => { 20 | const promise = create({ title: "Untitled" }).then((documentId) => 21 | router.push(`/documents/${documentId}`) 22 | ); 23 | toast.promise(promise, { 24 | loading: "Creating a new note...", 25 | success: "New Note created.", 26 | error: "Failed to create a new note!", 27 | }); 28 | }; 29 | 30 | return ( 31 |
32 | Empty 39 | Empty 46 |

Welcome to Jotion

47 | 51 |
52 | ); 53 | }; 54 | 55 | export default Page; 56 | -------------------------------------------------------------------------------- /app/(main)/_components/Banner.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import ConfirmModal from "@/components/modals/ConfirmModal"; 4 | import { Button } from "@/components/ui/button"; 5 | import { api } from "@/convex/_generated/api"; 6 | import { Id } from "@/convex/_generated/dataModel"; 7 | import { useEdgeStore } from "@/lib/edgestore"; 8 | 9 | import { useMutation } from "convex/react"; 10 | import { useRouter } from "next/navigation"; 11 | 12 | import { toast } from "sonner"; 13 | 14 | interface BannerProps { 15 | documentId: Id<"documents">; 16 | url?: string; 17 | } 18 | 19 | const Banner = ({ documentId, url }: BannerProps) => { 20 | const router = useRouter(); 21 | 22 | const remove = useMutation(api.documents.remove); 23 | const restore = useMutation(api.documents.restore); 24 | 25 | const { edgestore } = useEdgeStore(); 26 | 27 | const onRemove = async () => { 28 | //removes the file from the edge store bucket as well. 29 | if (url) { 30 | await edgestore.publicFiles.delete({ 31 | url: url, 32 | }); 33 | } 34 | 35 | const promise = remove({ id: documentId }); 36 | 37 | toast.promise(promise, { 38 | loading: "Deleting Note...", 39 | success: "Note deleted.", 40 | error: "Failed to remove Note!", 41 | }); 42 | 43 | router.push("/documents"); 44 | }; 45 | 46 | const onRestore = () => { 47 | const promise = restore({ id: documentId }); 48 | 49 | toast.promise(promise, { 50 | loading: "Restoring Note...", 51 | success: "Note Restored.", 52 | error: "Failed to restore Note!", 53 | }); 54 | }; 55 | 56 | return ( 57 |
58 |

ⓘ This page is in trash.

59 | 67 | 68 | 71 | 72 |
73 | ); 74 | }; 75 | 76 | export default Banner; 77 | -------------------------------------------------------------------------------- /app/(main)/_components/DocumentList.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { api } from "@/convex/_generated/api"; 4 | import { Doc, Id } from "@/convex/_generated/dataModel"; 5 | 6 | import { useQuery } from "convex/react"; 7 | import { useParams, useRouter } from "next/navigation"; 8 | import { useState } from "react"; 9 | 10 | import Item from "./Item"; 11 | 12 | import { cn } from "@/lib/utils"; 13 | import { FileIcon } from "lucide-react"; 14 | 15 | interface DocumentListProps { 16 | parentDocumentId?: Id<"documents">; 17 | level?: number; 18 | data?: Doc<"documents">[]; 19 | } 20 | 21 | const DocumentList = ({ parentDocumentId, level = 0 }: DocumentListProps) => { 22 | const params = useParams(); 23 | const router = useRouter(); 24 | 25 | const [expanded, setExpanded] = useState>({}); 26 | 27 | const onExpand = (documentId: string) => { 28 | setExpanded((prevExpanded) => ({ 29 | ...prevExpanded, 30 | [documentId]: !prevExpanded[documentId], 31 | })); 32 | }; 33 | 34 | const documents = useQuery(api.documents.getSidebar, { 35 | parentDocument: parentDocumentId, 36 | }); 37 | 38 | const onRedirect = (documentId: string) => { 39 | router.push(`/documents/${documentId}`); 40 | }; 41 | 42 | //loading state 43 | if (documents === undefined) { 44 | return ( 45 | <> 46 | 47 | {level === 0 && ( 48 | <> 49 | 50 | 51 | 52 | )} 53 | 54 | ); 55 | } 56 | 57 | return ( 58 | <> 59 |

69 | {level === 0 ? "No pages" : "No pages inside"} 70 |

71 | {documents.map((document) => ( 72 |
73 | onRedirect(document._id)} 76 | label={document.title} 77 | icon={FileIcon} 78 | documentIcon={document.icon} 79 | active={params.documentId === document._id} 80 | onExpand={() => onExpand(document._id)} 81 | expanded={expanded[document._id]} 82 | level={level} 83 | /> 84 | {expanded[document._id] && ( 85 | //to display all the child documents under a parent document 86 | //all the documents with that parent document id 87 | 88 | )} 89 |
90 | ))} 91 | 92 | ); 93 | }; 94 | 95 | export default DocumentList; 96 | -------------------------------------------------------------------------------- /app/(main)/_components/Item.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { api } from "@/convex/_generated/api"; 4 | import { Id } from "@/convex/_generated/dataModel"; 5 | 6 | import { useMutation } from "convex/react"; 7 | import { useRouter } from "next/navigation"; 8 | import { useUser } from "@clerk/clerk-react"; 9 | 10 | import { toast } from "sonner"; 11 | import { 12 | DropdownMenu, 13 | DropdownMenuContent, 14 | DropdownMenuItem, 15 | DropdownMenuSeparator, 16 | DropdownMenuTrigger, 17 | } from "@/components/ui/dropdown-menu"; 18 | import { 19 | ChevronDown, 20 | ChevronRight, 21 | LucideIcon, 22 | MoreHorizontal, 23 | Plus, 24 | Trash, 25 | } from "lucide-react"; 26 | import { cn } from "@/lib/utils"; 27 | import { Skeleton } from "@/components/ui/skeleton"; 28 | 29 | interface ItemProps { 30 | id?: Id<"documents">; 31 | documentIcon?: string; 32 | active?: boolean; 33 | expanded?: boolean; 34 | isSearch?: boolean; 35 | level?: number; 36 | onExpand?: () => void; 37 | label: string; 38 | onClick?: () => void; 39 | icon: LucideIcon; 40 | } 41 | 42 | const Item = ({ 43 | id, 44 | documentIcon, 45 | active, 46 | expanded, 47 | level = 0, 48 | onExpand, 49 | label, 50 | onClick, 51 | icon: Icon, 52 | }: ItemProps) => { 53 | const ChevronIcon = expanded ? ChevronDown : ChevronRight; 54 | 55 | const { user } = useUser(); 56 | 57 | const router = useRouter(); 58 | 59 | const create = useMutation(api.documents.create); 60 | const archive = useMutation(api.documents.archive); 61 | 62 | const onArchive = (event: React.MouseEvent) => { 63 | event.stopPropagation(); 64 | if (!id) return; 65 | const promise = archive({ id }); 66 | 67 | toast.promise(promise, { 68 | loading: "Moving to trash...", 69 | success: "Note moved to trash.", 70 | error: "Failed to archive note!", 71 | }); 72 | }; 73 | 74 | const handleExpand = ( 75 | event: React.MouseEvent 76 | ) => { 77 | event.stopPropagation(); 78 | onExpand?.(); 79 | }; 80 | 81 | const onCreate = (event: React.MouseEvent) => { 82 | event.stopPropagation(); 83 | if (!id) return; 84 | const promise = create({ title: "Untitled", parentDocument: id }).then( 85 | (documentId) => { 86 | if (!expanded) { 87 | onExpand?.(); 88 | } 89 | router.push(`/documents/${documentId}`); 90 | } 91 | ); 92 | toast.promise(promise, { 93 | loading: "Creating a new note...", 94 | success: "New Note created.", 95 | error: "Failed to create a new note!", 96 | }); 97 | }; 98 | 99 | return ( 100 |
109 | {!!id && ( 110 |
115 | 116 |
117 | )} 118 | {documentIcon ? ( 119 |
{documentIcon}
120 | ) : ( 121 | 122 | )} 123 | {label} 124 | {!!id && ( 125 |
126 | 127 | e.stopPropagation()}> 128 |
132 | 133 |
134 |
135 | 141 | 142 | 143 | Move to trash 144 | 145 | 146 |
147 | Last edited by: {user?.fullName} 148 |
149 |
150 |
151 |
156 | 157 |
158 |
159 | )} 160 |
161 | ); 162 | }; 163 | 164 | Item.Skeleton = function ItemSkeleton({ level }: { level?: number }) { 165 | return ( 166 |
172 | 173 | 174 |
175 | ); 176 | }; 177 | 178 | export default Item; 179 | -------------------------------------------------------------------------------- /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 | 13 | import { api } from "@/convex/_generated/api"; 14 | import { Id } from "@/convex/_generated/dataModel"; 15 | 16 | import { useUser } from "@clerk/clerk-react"; 17 | import { useMutation } from "convex/react"; 18 | import { useRouter } from "next/navigation"; 19 | 20 | import { MoreHorizontal, Trash } from "lucide-react"; 21 | import { toast } from "sonner"; 22 | 23 | interface MenuProps { 24 | documentId: Id<"documents">; 25 | } 26 | 27 | const Menu = ({ documentId }: MenuProps) => { 28 | const router = useRouter(); 29 | const { user } = useUser(); 30 | 31 | const archive = useMutation(api.documents.archive); 32 | 33 | const onArchive = () => { 34 | const promise = archive({ id: documentId }); 35 | 36 | toast.promise(promise, { 37 | loading: "Moving to trash...", 38 | success: "Note moved to trash.", 39 | error: "Failed to archive note!", 40 | }); 41 | 42 | router.push("/documents"); 43 | }; 44 | 45 | return ( 46 | 47 | 48 | 51 | 52 | 58 | 59 | 60 | Move to trash 61 | 62 | 63 |
64 | Last edited by: {user?.fullName}. 65 |
66 |
67 |
68 | ); 69 | }; 70 | 71 | Menu.Skeleton = function MenuSkeleton() { 72 | return ; 73 | }; 74 | 75 | export default Menu; 76 | -------------------------------------------------------------------------------- /app/(main)/_components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { api } from "@/convex/_generated/api"; 4 | import { Id } from "@/convex/_generated/dataModel"; 5 | 6 | import { useQuery } from "convex/react"; 7 | import { useParams } from "next/navigation"; 8 | 9 | import { MenuIcon } from "lucide-react"; 10 | 11 | import Title from "./Title"; 12 | import Banner from "./Banner"; 13 | import Menu from "./Menu"; 14 | import Publish from "./Publish"; 15 | 16 | interface NavbarProps { 17 | isCollapsed: boolean; 18 | onResetWidth: () => void; 19 | } 20 | 21 | const Navbar = ({ isCollapsed, onResetWidth }: NavbarProps) => { 22 | const params = useParams(); 23 | 24 | const document = useQuery(api.documents.getById, { 25 | documentId: params.documentId as Id<"documents">, 26 | }); 27 | 28 | if (document === undefined) { 29 | return ( 30 | 36 | ); 37 | } 38 | 39 | if (document === null) { 40 | return null; 41 | } 42 | 43 | return ( 44 | <> 45 |