├── .eslintrc.json
├── .gitignore
├── .prettierrc
├── README.md
├── app
├── (landing)
│ ├── _components
│ │ ├── Footer.tsx
│ │ ├── Heading.tsx
│ │ ├── Heroes.tsx
│ │ ├── Logo.tsx
│ │ └── Navbar.tsx
│ ├── layout.tsx
│ └── page.tsx
├── (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
├── (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
│ ├── ConfirmModal.tsx
│ ├── CoverImageModal.tsx
│ └── SettingsModal.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
├── useCoverImage.tsx
├── useOrigin.tsx
├── useScrollTop.tsx
├── useSearch.tsx
└── useSettings.tsx
├── lib
├── edgestore.ts
└── utils.ts
├── next.config.mjs
├── package-lock.json
├── package.json
├── postcss.config.js
├── public
├── empty-dark.svg
├── empty.svg
├── error-dark.svg
├── error.svg
├── idea-dark.svg
├── idea.svg
├── logo-dark.svg
├── logo.svg
├── team-dark.svg
└── team.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 | .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 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": ["prettier-plugin-tailwindcss"]
3 | }
4 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Zotion
2 |
3 | This project is a simplified clone of the popular productivity application, Notion. It's designed to replicate some of the core features of Notion, providing a platform where users can create, edit, and organize their notes in a flexible and intuitive interface.
4 |
5 | It uses Convex as the backend, which is a real-time database that allows for instant data updates. The application also uses Edgestore, a distributed key-value store, to manage the images and files uploaded by the users.The user authentication is handled by Clerk, a secure and scalable user authentication API.
6 |
7 | ## Live
8 |
9 | Zotion - [https://zotion-app.vercel.app/](https://zotion-app.vercel.app/)
10 |
11 | ## Features
12 |
13 | **Productivity and Organization**s
14 |
15 | - 📝 Notion-style editor for seamless note-taking
16 | - 📂 Infinite children documents for hierarchical organization
17 | - ➡️🔀⬅️ Expandable and fully collapsible sidebar for easy navigation
18 | - 🎨 Customizable icons for each document, updating in real-time
19 | - 🗑️ Trash can with soft delete and file recovery options
20 |
21 | **User Experience**
22 |
23 | - 🌓 Light and Dark mode to suit preferences
24 | - 📱 Full mobile responsiveness for productivity on the go
25 | - 🛬 Landing page for a welcoming user entry point
26 | - 🖼️ Cover image for each document to add a personal touch
27 |
28 | **Data Management**
29 |
30 | - 🔄 Real-time database for instant data updates
31 | - 📤📥 File upload, deletion, and replacement options
32 |
33 | **Security and Sharing**
34 |
35 | - 🔐 Authentication to secure notes
36 | - 🌍 Option to publish your note to the web for sharing
37 |
38 | ## Technologies
39 |
40 | 
41 | 
42 | 
43 | 
44 | 
45 | 
46 | 
47 | 
48 |
49 | ## Installation
50 |
51 | 1. Clone the repository
52 | 2. Install the dependencies
53 |
54 | ```
55 | npm install
56 | ```
57 |
58 | 3. Set up the environment variables
59 |
60 | ```
61 | # Deployment used by `npx convex dev`
62 | CONVEX_DEPLOYMENT=
63 | NEXT_PUBLIC_CONVEX_URL=
64 |
65 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
66 | CLERK_SECRET_KEY=
67 |
68 | EDGE_STORE_ACCESS_KEY=
69 | EDGE_STORE_SECRET_KEY=
70 | ```
71 |
72 | 4. Run Convex
73 |
74 | ```
75 | npx convex dev
76 | ```
77 |
78 | 5. Run the development server
79 |
80 | ```
81 | npm run dev
82 | ```
83 |
84 | ## Acknowledgements
85 |
86 | [CodewithAntonio](https://www.youtube.com/@codewithantonio)
87 |
--------------------------------------------------------------------------------
/app/(landing)/_components/Footer.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import { Logo } from "./Logo";
3 |
4 | export const Footer = () => {
5 | return (
6 |
17 | );
18 | };
19 |
--------------------------------------------------------------------------------
/app/(landing)/_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🚀. Welcome to{" "}
17 | Zotion
18 |
19 |
20 | Zotion is the connected workspace where
better, faster work
21 | happens.
22 |
23 | {isLoading && (
24 |
25 |
26 |
27 | )}
28 | {isAuthenticated && !isLoading && (
29 |
35 | )}
36 | {!isAuthenticated && !isLoading && (
37 |
38 |
42 |
43 | )}
44 |
45 | );
46 | };
47 |
--------------------------------------------------------------------------------
/app/(landing)/_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/(landing)/_components/Logo.tsx:
--------------------------------------------------------------------------------
1 | import { Poppins } from "next/font/google";
2 | import { cn } from "@/lib/utils";
3 | import Image from "next/image";
4 |
5 | const font = Poppins({
6 | subsets: ["latin"],
7 | weight: ["400", "600"],
8 | });
9 |
10 | export const Logo = () => {
11 | return (
12 |
13 |
20 |
27 |
Zotion
28 |
29 | );
30 | };
31 |
--------------------------------------------------------------------------------
/app/(landing)/_components/Navbar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useScrollTop } from "@/hooks/useScrollTop";
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 { isAuthenticated, isLoading } = useConvexAuth();
15 | const scrolled = useScrollTop();
16 |
17 | return (
18 |
53 | );
54 | };
55 |
--------------------------------------------------------------------------------
/app/(landing)/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Navbar } from "./_components/Navbar";
2 |
3 | const LandingLayout = ({ children }: { children: React.ReactNode }) => {
4 | return (
5 |
6 |
7 | {children}
8 |
9 | );
10 | };
11 | export default LandingLayout;
12 |
--------------------------------------------------------------------------------
/app/(landing)/page.tsx:
--------------------------------------------------------------------------------
1 | import { Footer } from "./_components/Footer";
2 | import { Heading } from "./_components/Heading";
3 | import { Heroes } from "./_components/Heroes";
4 |
5 | export default function LandingPage() {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/app/(main)/(routes)/documents/[documentId]/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import dynamic from "next/dynamic";
4 | import { useMemo } from "react";
5 |
6 | import { Cover } from "@/components/cover";
7 | import { Toolbar } from "@/components/toolbar";
8 | import { Skeleton } from "@/components/ui/skeleton";
9 |
10 | import { api } from "@/convex/_generated/api";
11 | import { Id } from "@/convex/_generated/dataModel";
12 | import { useMutation, useQuery } from "convex/react";
13 |
14 | interface DocumentIdPageProps {
15 | params: {
16 | documentId: Id<"documents">;
17 | };
18 | }
19 |
20 | const DocumentIdPage = ({ params }: DocumentIdPageProps) => {
21 | const Editor = useMemo(
22 | () => dynamic(() => import("@/components/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 | return Not found
;
57 | }
58 |
59 | return (
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 | );
68 | };
69 | export default DocumentIdPage;
70 |
--------------------------------------------------------------------------------
/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 | import { useMutation } from "convex/react";
8 | import { api } from "@/convex/_generated/api";
9 | import { toast } from "sonner";
10 | import { useRouter } from "next/navigation";
11 |
12 | const DocumentsPage = () => {
13 | const { user } = useUser();
14 | const router = useRouter();
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 note.",
26 | });
27 | };
28 |
29 | return (
30 |
31 |
39 |
47 |
48 | Welcome to {user?.firstName}'s Zotion
49 |
50 |
54 |
55 | );
56 | };
57 | export default DocumentsPage;
58 |
--------------------------------------------------------------------------------
/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 { 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 | const remove = useMutation(api.documents.remove);
18 | const restore = useMutation(api.documents.restore);
19 |
20 | const onRemove = () => {
21 | const promise = remove({ id: documentId });
22 |
23 | toast.promise(promise, {
24 | loading: "Deleting note...",
25 | success: "Note Deleted!",
26 | error: "Failed to delete note.",
27 | });
28 |
29 | router.push("/documents");
30 | };
31 |
32 | const onRestore = () => {
33 | const promise = restore({ id: documentId });
34 |
35 | toast.promise(promise, {
36 | loading: "Restoring note...",
37 | success: "Note restored!",
38 | error: "Failed to restore note.",
39 | });
40 | };
41 |
42 | return (
43 |
44 |
45 | This page is in the Trash.
46 |
47 |
55 |
56 |
63 |
64 |
65 | );
66 | };
67 |
--------------------------------------------------------------------------------
/app/(main)/_components/DocumentList.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState } from "react";
4 | import { useQuery } from "convex/react";
5 | import { useParams, useRouter } from "next/navigation";
6 |
7 | import { cn } from "@/lib/utils";
8 | import { api } from "@/convex/_generated/api";
9 | import { Doc, Id } from "@/convex/_generated/dataModel";
10 |
11 | import { Item } from "./Item";
12 |
13 | import { FileIcon } from "lucide-react";
14 |
15 | interface DocumentListProps {
16 | parentDocumentId?: Id<"documents">;
17 | level?: number;
18 | data?: Doc<"documents">[];
19 | }
20 |
21 | export const DocumentList = ({
22 | parentDocumentId,
23 | level = 0,
24 | }: DocumentListProps) => {
25 | const params = useParams();
26 | const router = useRouter();
27 | const [expanded, setExpanded] = useState>({});
28 |
29 | const onExpand = (documentId: string) => {
30 | setExpanded((prevExpanded) => ({
31 | ...prevExpanded,
32 | [documentId]: !prevExpanded[documentId],
33 | }));
34 | };
35 |
36 | const documents = useQuery(api.documents.getSidebar, {
37 | parentDocument: parentDocumentId,
38 | });
39 |
40 | const onRedirect = (documentId: string) => {
41 | router.push(`/documents/${documentId}`);
42 | };
43 |
44 | if (documents === undefined) {
45 | return (
46 | <>
47 |
48 | {level === 0 && (
49 | <>
50 |
51 |
52 | >
53 | )}
54 | >
55 | );
56 | }
57 |
58 | return (
59 | <>
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 { Skeleton } from "@/components/ui/skeleton";
4 | import { api } from "@/convex/_generated/api";
5 | import { Id } from "@/convex/_generated/dataModel";
6 | import { cn } from "@/lib/utils";
7 | import { useRouter } from "next/navigation";
8 | import { useMutation } from "convex/react";
9 | import { toast } from "sonner";
10 | import {
11 | DropdownMenu,
12 | DropdownMenuSeparator,
13 | DropdownMenuTrigger,
14 | DropdownMenuContent,
15 | DropdownMenuItem,
16 | } from "@/components/ui/dropdown-menu";
17 | import {
18 | ChevronDown,
19 | ChevronRight,
20 | LucideIcon,
21 | MoreHorizontal,
22 | Plus,
23 | Trash,
24 | } from "lucide-react";
25 | import { useUser } from "@clerk/clerk-react";
26 |
27 | interface ItemProps {
28 | id?: Id<"documents">;
29 | documentIcon?: string;
30 | active?: boolean;
31 | expanded?: boolean;
32 | isSearch?: boolean;
33 | level?: number;
34 | onExpand?: () => void;
35 | label: string;
36 | onClick?: () => void;
37 | icon: LucideIcon;
38 | }
39 |
40 | export const Item = ({
41 | id,
42 | label,
43 | onClick,
44 | icon: Icon,
45 | active,
46 | documentIcon,
47 | isSearch,
48 | level = 0,
49 | onExpand,
50 | expanded,
51 | }: ItemProps) => {
52 | const { user } = useUser();
53 | const router = useRouter();
54 | const create = useMutation(api.documents.create);
55 | const archive = useMutation(api.documents.archive);
56 |
57 | const onArchive = (event: React.MouseEvent) => {
58 | event.stopPropagation();
59 | if (!id) return;
60 | const promise = archive({ id }).then(() => router.push("/documents"));
61 |
62 | toast.promise(promise, {
63 | loading: "Moving to trash...",
64 | success: "Note moved to trash!",
65 | error: "Failed to archive note.",
66 | });
67 | };
68 |
69 | const handleExpand = (
70 | event: React.MouseEvent,
71 | ) => {
72 | event.stopPropagation();
73 | onExpand?.();
74 | };
75 |
76 | const onCreate = (event: React.MouseEvent) => {
77 | event.stopPropagation();
78 | if (!id) return;
79 |
80 | const promise = create({ title: "Untitled", parentDocument: id }).then(
81 | (documentId) => {
82 | if (!expanded) {
83 | onExpand?.();
84 | }
85 | router.push(`/documents/${documentId}`);
86 | },
87 | );
88 |
89 | toast.promise(promise, {
90 | loading: "Creating new note",
91 | success: "New note created.",
92 | error: "Failed to create note.",
93 | });
94 | };
95 |
96 | const ChevronIcon = expanded ? ChevronDown : ChevronRight;
97 |
98 | return (
99 |
108 | {!!id && (
109 |
114 |
115 |
116 | )}
117 | {documentIcon ? (
118 |
{documentIcon}
119 | ) : (
120 |
121 | )}
122 |
123 |
{label}
124 | {isSearch && (
125 |
126 | CTRLK
127 |
128 | )}
129 | {!!id && (
130 |
131 |
132 | e.stopPropagation()} asChild>
133 |
137 |
138 |
139 |
140 |
146 |
147 |
148 | Delete
149 |
150 |
151 |
152 | Last edited by: {user?.fullName}
153 |
154 |
155 |
156 |
163 |
164 | )}
165 |
166 | );
167 | };
168 |
169 | Item.Skeleton = function ItemSkeleton({ level }: { level?: number }) {
170 | return (
171 |
175 |
176 |
177 |
178 | );
179 | };
180 |
--------------------------------------------------------------------------------
/app/(main)/_components/Menu.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useRouter } from "next/navigation";
4 | import { Id } from "@/convex/_generated/dataModel";
5 | import { useUser } from "@clerk/clerk-react";
6 | import { useMutation } from "convex/react";
7 | import { api } from "@/convex/_generated/api";
8 | import { toast } from "sonner";
9 | import {
10 | DropdownMenu,
11 | DropdownMenuContent,
12 | DropdownMenuItem,
13 | DropdownMenuSeparator,
14 | DropdownMenuTrigger,
15 | } from "@/components/ui/dropdown-menu";
16 | import { MoreHorizontal, Trash } from "lucide-react";
17 | import { Button } from "@/components/ui/button";
18 | import { Skeleton } from "@/components/ui/skeleton";
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 | const document = useQuery(api.documents.getById, {
21 | documentId: params.documentId as Id<"documents">,
22 | });
23 |
24 | if (document === undefined) {
25 | return (
26 |
32 | );
33 | }
34 |
35 | if (document === null) {
36 | return null;
37 | }
38 |
39 | return (
40 | <>
41 |
58 | {document.isArchived && }
59 | >
60 | );
61 | };
62 |
--------------------------------------------------------------------------------
/app/(main)/_components/Navigation.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React, { ElementRef, useEffect, useRef, useState } from "react";
4 | import { useMediaQuery } from "usehooks-ts";
5 | import { useMutation } from "convex/react";
6 | import { useParams, usePathname, useRouter } from "next/navigation";
7 |
8 | import { cn } from "@/lib/utils";
9 | import { api } from "@/convex/_generated/api";
10 | import { DocumentList } from "./DocumentList";
11 | import { Item } from "./Item";
12 | import { UserItem } from "./UserItem";
13 |
14 | import { toast } from "sonner";
15 | import {
16 | ChevronsLeft,
17 | MenuIcon,
18 | Plus,
19 | PlusCircle,
20 | Search,
21 | Settings,
22 | Trash,
23 | } from "lucide-react";
24 | import {
25 | Popover,
26 | PopoverContent,
27 | PopoverTrigger,
28 | } from "@/components/ui/popover";
29 | import { TrashBox } from "./TrashBox";
30 | import { useSearch } from "@/hooks/useSearch";
31 | import { useSettings } from "@/hooks/useSettings";
32 | import { Navbar } from "./Navbar";
33 |
34 | const Navigation = () => {
35 | const search = useSearch();
36 | const settings = useSettings();
37 | const router = useRouter();
38 | const pathname = usePathname();
39 | const params = useParams();
40 | const isMobile = useMediaQuery("(max-width: 768px)");
41 | const create = useMutation(api.documents.create);
42 |
43 | const isResizingRef = useRef(false);
44 | const sidebarRef = useRef>(null);
45 | const navbarRef = useRef>(null);
46 | const [isResetting, setIsResetting] = useState(false);
47 | const [isCollapsed, setIsCollapsed] = useState(isMobile);
48 |
49 | useEffect(() => {
50 | if (isMobile) {
51 | collapse();
52 | } else {
53 | resetWidth();
54 | }
55 | }, [isMobile]);
56 |
57 | useEffect(() => {
58 | if (isMobile) {
59 | collapse();
60 | }
61 | }, [pathname, isMobile]);
62 |
63 | const handleMouseDown = (
64 | event: React.MouseEvent,
65 | ) => {
66 | event.preventDefault();
67 | event.stopPropagation();
68 |
69 | isResizingRef.current = true;
70 | document.addEventListener("mousemove", handleMouseMove);
71 | document.addEventListener("mouseup", handleMouseUp);
72 | };
73 |
74 | const handleMouseMove = (e: MouseEvent) => {
75 | if (!isResizingRef.current) return;
76 | let newWidth = e.clientX;
77 |
78 | if (newWidth < 240) newWidth = 240;
79 | if (newWidth > 480) newWidth = 480;
80 |
81 | if (sidebarRef.current && navbarRef.current) {
82 | sidebarRef.current.style.width = `${newWidth}px`;
83 | navbarRef.current.style.setProperty("left", `${newWidth}px`);
84 | navbarRef.current.style.setProperty(
85 | "width",
86 | `calc(100% - ${newWidth}px)`,
87 | );
88 | }
89 | };
90 |
91 | const handleMouseUp = () => {
92 | isResizingRef.current = false;
93 | document.removeEventListener("mousemove", handleMouseMove);
94 | document.removeEventListener("mouseup", handleMouseUp);
95 | };
96 |
97 | const resetWidth = () => {
98 | if (sidebarRef.current && navbarRef.current) {
99 | setIsCollapsed(false);
100 | setIsResetting(true);
101 |
102 | sidebarRef.current.style.width = isMobile ? "100%" : "240px";
103 | navbarRef.current.style.removeProperty("width");
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 | const collapse = () => {
114 | if (sidebarRef.current && navbarRef.current) {
115 | setIsCollapsed(true);
116 | setIsResetting(true);
117 |
118 | sidebarRef.current.style.width = "0";
119 | navbarRef.current.style.setProperty("width", "100%");
120 | navbarRef.current.style.setProperty("left", "0");
121 | setTimeout(() => setIsResetting(false), 300);
122 | }
123 | };
124 |
125 | const handleCreate = () => {
126 | const promise = create({ title: "Untitled" }).then((documentId) =>
127 | router.push(`/documents/${documentId}`),
128 | );
129 |
130 | toast.promise(promise, {
131 | loading: "Creating a new note....",
132 | success: "New note created.",
133 | error: "Failed to create a note.",
134 | });
135 | };
136 |
137 | return (
138 | <>
139 |
184 |
192 | {!!params.documentId ? (
193 |
194 | ) : (
195 |
209 | )}
210 |
211 | >
212 | );
213 | };
214 | export default Navigation;
215 |
--------------------------------------------------------------------------------
/app/(main)/_components/Publish.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Doc } from "@/convex/_generated/dataModel";
4 |
5 | import {
6 | Popover,
7 | PopoverTrigger,
8 | PopoverContent,
9 | } from "@/components/ui/popover";
10 | import { useOrigin } from "@/hooks/useOrigin";
11 | import { useMutation } from "convex/react";
12 | import { api } from "@/convex/_generated/api";
13 | import { useState } from "react";
14 | import { toast } from "sonner";
15 | import { Button } from "@/components/ui/button";
16 | import { Check, Copy, Divide, Globe } from "lucide-react";
17 |
18 | interface PublishProps {
19 | initialData: Doc<"documents">;
20 | }
21 |
22 | export const Publish = ({ initialData }: PublishProps) => {
23 | const origin = useOrigin();
24 | const update = useMutation(api.documents.update);
25 |
26 | const [copied, setCopied] = useState(false);
27 | const [isSubmitting, setIsSubmitting] = useState(false);
28 |
29 | const url = `${origin}/preview/${initialData._id}`;
30 |
31 | const onPublish = () => {
32 | setIsSubmitting(true);
33 |
34 | const promise = update({
35 | id: initialData._id,
36 | isPublished: true,
37 | }).finally(() => setIsSubmitting(false));
38 |
39 | toast.promise(promise, {
40 | loading: "Publishing...",
41 | success: "Note published!",
42 | error: "Failed to publish note.",
43 | });
44 | };
45 |
46 | const onUnpublish = () => {
47 | setIsSubmitting(true);
48 |
49 | const promise = update({
50 | id: initialData._id,
51 | isPublished: false,
52 | }).finally(() => setIsSubmitting(false));
53 |
54 | toast.promise(promise, {
55 | loading: "Unpublishing...",
56 | success: "Note unpublished",
57 | error: "Failed to unpublish note.",
58 | });
59 | };
60 |
61 | const onCopy = () => {
62 | navigator.clipboard.writeText(url);
63 | setCopied(true);
64 |
65 | setTimeout(() => {
66 | setCopied(false);
67 | }, 1000);
68 | };
69 |
70 | return (
71 |
72 |
73 |
79 |
80 |
81 | {initialData.isPublished ? (
82 |
83 |
84 |
85 |
86 | This note is live on the web.
87 |
88 |
89 |
90 |
95 |
106 |
107 |
115 |
116 | ) : (
117 |
118 |
119 |
Published this note
120 |
121 | Share your work with others
122 |
123 |
131 |
132 | )}
133 |
134 |
135 | );
136 | };
137 |
--------------------------------------------------------------------------------
/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 { ChangeEvent, 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 disabledInput = () => {
32 | setIsEditing(false);
33 | };
34 |
35 | const onChange = (event: ChangeEvent) => {
36 | setTitle(event.target.value);
37 | update({
38 | id: initialData._id,
39 | title: event.target.value || "Untitled",
40 | });
41 | };
42 |
43 | const onKeyDown = (event: React.KeyboardEvent) => {
44 | if (event.key === "Enter") {
45 | disabledInput();
46 | }
47 | };
48 |
49 | return (
50 |
51 | {!!initialData.icon &&
{initialData.icon}
}
52 | {isEditing ? (
53 |
62 | ) : (
63 |
71 | )}
72 |
73 | );
74 | };
75 |
76 | Title.Skeleton = function TitleSkeleton() {
77 | return ;
78 | };
79 |
--------------------------------------------------------------------------------
/app/(main)/_components/TrashBox.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ConfirmModal } from "@/components/modals/ConfirmModal";
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 |
66 |
67 |
68 | );
69 | }
70 |
71 | return (
72 |
73 |
74 |
75 | setSearch(e.target.value)}
78 | className="h-7 bg-secondary px-2 focus-visible:ring-transparent"
79 | placeholder="Filter by page title..."
80 | aria-label="Filter by page title"
81 | />
82 |
83 |
84 | {filteredDocuments?.length === 0 && (
85 |
86 | No documents found.
87 |
88 | )}
89 | {filteredDocuments?.map((document) => (
90 |
115 | ))}
116 |
117 |
118 | );
119 | };
120 |
--------------------------------------------------------------------------------
/app/(main)/_components/UserItem.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 | DropdownMenuShortcut,
11 | DropdownMenuTrigger,
12 | } from "@/components/ui/dropdown-menu";
13 | import { SignOutButton, useUser } from "@clerk/clerk-react";
14 | import { ChevronsLeftRight } from "lucide-react";
15 |
16 | export const UserItem = () => {
17 | const { user } = useUser();
18 |
19 | return (
20 |
21 |
22 |
26 |
27 |
28 |
29 |
30 |
31 | {user?.fullName}'s Zotion
32 |
33 |
34 |
35 |
36 |
37 |
43 |
44 |
45 | {user?.emailAddresses[0].emailAddress}
46 |
47 |
48 |
53 |
54 |
55 | {user?.fullName}'s Zotion
56 |
57 |
58 |
59 |
60 |
61 |
62 | Log Out
63 |
64 |
65 |
66 | );
67 | };
68 |
--------------------------------------------------------------------------------
/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 (isLoading) {
13 | return (
14 |
15 |
16 |
17 | );
18 | }
19 |
20 | if (!isAuthenticated) {
21 | return redirect("/");
22 | }
23 |
24 | return (
25 |
26 |
27 |
28 |
29 | {children}
30 |
31 |
32 | );
33 | };
34 | export default MainLayout;
35 |
--------------------------------------------------------------------------------
/app/(public)/(routes)/preview/[documentId]/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import dynamic from "next/dynamic";
4 | import { useMemo } from "react";
5 |
6 | import { Cover } from "@/components/cover";
7 | import { Toolbar } from "@/components/toolbar";
8 | import { Skeleton } from "@/components/ui/skeleton";
9 |
10 | import { api } from "@/convex/_generated/api";
11 | import { Id } from "@/convex/_generated/dataModel";
12 | import { useMutation, useQuery } from "convex/react";
13 |
14 | interface DocumentIdPageProps {
15 | params: {
16 | documentId: Id<"documents">;
17 | };
18 | }
19 |
20 | const DocumentIdPage = ({ params }: DocumentIdPageProps) => {
21 | const Editor = useMemo(
22 | () => dynamic(() => import("@/components/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 | return Not found
;
57 | }
58 |
59 | return (
60 |
61 |
62 |
63 |
64 |
69 |
70 |
71 | );
72 | };
73 | export default DocumentIdPage;
74 |
--------------------------------------------------------------------------------
/app/(public)/(routes)/preview/layout.tsx:
--------------------------------------------------------------------------------
1 | const PublicLayout = ({ children }: { children: React.ReactNode }) => {
2 | return {children}
;
3 | };
4 | export default PublicLayout;
5 |
--------------------------------------------------------------------------------
/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;
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 Image from "next/image";
4 | import Link from "next/link";
5 |
6 | import { Button } from "@/components/ui/button";
7 |
8 | const Error = () => {
9 | return (
10 |
11 |
18 |
25 |
Something went wrong!
26 |
27 | Go back
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 | @layer base {
11 | :root {
12 | --background: 0 0% 100%;
13 | --foreground: 0 0% 3.9%;
14 |
15 | --card: 0 0% 100%;
16 | --card-foreground: 0 0% 3.9%;
17 |
18 | --popover: 0 0% 100%;
19 | --popover-foreground: 0 0% 3.9%;
20 |
21 | --primary: 0 0% 9%;
22 | --primary-foreground: 0 0% 98%;
23 |
24 | --secondary: 0 0% 96.1%;
25 | --secondary-foreground: 0 0% 9%;
26 |
27 | --muted: 0 0% 96.1%;
28 | --muted-foreground: 0 0% 45.1%;
29 |
30 | --accent: 0 0% 96.1%;
31 | --accent-foreground: 0 0% 9%;
32 |
33 | --destructive: 0 84.2% 60.2%;
34 | --destructive-foreground: 0 0% 98%;
35 |
36 | --border: 0 0% 89.8%;
37 | --input: 0 0% 89.8%;
38 | --ring: 0 0% 3.9%;
39 |
40 | --radius: 0.5rem;
41 | }
42 |
43 | .dark {
44 | --background: 0 0% 3.9%;
45 | --foreground: 0 0% 98%;
46 |
47 | --card: 0 0% 3.9%;
48 | --card-foreground: 0 0% 98%;
49 |
50 | --popover: 0 0% 3.9%;
51 | --popover-foreground: 0 0% 98%;
52 |
53 | --primary: 0 0% 98%;
54 | --primary-foreground: 0 0% 9%;
55 |
56 | --secondary: 0 0% 14.9%;
57 | --secondary-foreground: 0 0% 98%;
58 |
59 | --muted: 0 0% 14.9%;
60 | --muted-foreground: 0 0% 63.9%;
61 |
62 | --accent: 0 0% 14.9%;
63 | --accent-foreground: 0 0% 98%;
64 |
65 | --destructive: 0 62.8% 30.6%;
66 | --destructive-foreground: 0 0% 98%;
67 |
68 | --border: 0 0% 14.9%;
69 | --input: 0 0% 14.9%;
70 | --ring: 0 0% 83.1%;
71 | }
72 | }
73 |
74 | .mantine-Menu-dropdown {
75 | max-height: 36rem !important;
76 | }
77 |
78 | @layer base {
79 | * {
80 | @apply border-border;
81 | }
82 | body {
83 | @apply bg-background text-foreground;
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Inter } from "next/font/google";
3 | import { Toaster } from "sonner";
4 | import "./globals.css";
5 |
6 | import { ThemeProvider } from "@/components/providers/theme-provider";
7 | import { ConvexClientProvider } from "@/components/providers/convex-provider";
8 | import { ModalProvider } from "@/components/providers/modal-provider";
9 | import { EdgeStoreProvider } from "@/lib/edgestore";
10 |
11 | const inter = Inter({ subsets: ["latin"] });
12 |
13 | export const metadata: Metadata = {
14 | title: "Zotion",
15 | description:
16 | "The seamless platform where creative and productive work happens.",
17 | icons: {
18 | icon: [
19 | {
20 | media: "(prefers-color-scheme: light)",
21 | url: "/logo.svg",
22 | href: "/logo.svg",
23 | },
24 | {
25 | media: "(prefers-color-scheme: dark)",
26 | url: "/logo-dark.svg",
27 | href: "/logo-dark.svg",
28 | },
29 | ],
30 | },
31 | };
32 |
33 | export default function RootLayout({
34 | children,
35 | }: Readonly<{
36 | children: React.ReactNode;
37 | }>) {
38 | return (
39 |
40 |
41 |
42 |
43 |
50 |
51 |
52 | {children}
53 |
54 |
55 |
56 |
57 |
58 | );
59 | }
60 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "app/globals.css",
9 | "baseColor": "neutral",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/components/cover.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { cn } from "@/lib/utils";
4 | import Image from "next/image";
5 | import { Button } from "./ui/button";
6 | import { ImageIcon, X } from "lucide-react";
7 | import { useCoverImage } from "@/hooks/useCoverImage";
8 | import { useMutation } from "convex/react";
9 | import { api } from "@/convex/_generated/api";
10 | import { useParams } from "next/navigation";
11 | import { Id } from "@/convex/_generated/dataModel";
12 | import { useEdgeStore } from "@/lib/edgestore";
13 | import { Skeleton } from "./ui/skeleton";
14 |
15 | interface CoverImageProps {
16 | url?: string;
17 | preview?: boolean;
18 | }
19 |
20 | export const Cover = ({ url, preview }: CoverImageProps) => {
21 | const { edgestore } = useEdgeStore();
22 |
23 | const params = useParams();
24 | const coverImage = useCoverImage();
25 | const removeCoverImage = useMutation(api.documents.removeCoverImage);
26 |
27 | const onRemove = async () => {
28 | if (url) {
29 | await edgestore.publicFiles.delete({
30 | url: url,
31 | });
32 | }
33 | removeCoverImage({
34 | id: params.documentId as Id<"documents">,
35 | });
36 | };
37 |
38 | return (
39 |
46 | {!!url && (
47 |
48 | )}
49 | {url && !preview && (
50 |
51 | coverImage.onReplace(url)}
53 | className="text-xs text-muted-foreground"
54 | variant="outline"
55 | size="sm"
56 | >
57 |
58 | Change cover
59 |
60 |
66 |
67 | Remove
68 |
69 |
70 | )}
71 |
72 | );
73 | };
74 |
75 | Cover.Skeleton = function CoverSkeleton() {
76 | return ;
77 | };
78 |
--------------------------------------------------------------------------------
/components/editor.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { BlockNoteEditor, PartialBlock } from "@blocknote/core";
4 | import { useCreateBlockNote } from "@blocknote/react";
5 | import { BlockNoteView } from "@blocknote/mantine";
6 | import { useTheme } from "next-themes";
7 | import { useEdgeStore } from "@/lib/edgestore";
8 | import "@blocknote/core/style.css";
9 | import "@blocknote/mantine/style.css";
10 |
11 | interface EditorProps {
12 | onChange: (value: string) => void;
13 | initialContent?: string;
14 | editable?: boolean;
15 | }
16 |
17 | const Editor = ({ onChange, initialContent, editable }: EditorProps) => {
18 | const { resolvedTheme } = useTheme();
19 |
20 | const { edgestore } = useEdgeStore();
21 |
22 | const handleUpload = async (file: File) => {
23 | const res = await edgestore.publicFiles.upload({
24 | file,
25 | });
26 |
27 | return res.url;
28 | };
29 |
30 | const editor: BlockNoteEditor = useCreateBlockNote({
31 | initialContent: initialContent
32 | ? (JSON.parse(initialContent) as PartialBlock[])
33 | : undefined,
34 | uploadFile: handleUpload,
35 | });
36 |
37 | const handleEditorChange = () => {
38 | onChange(JSON.stringify(editor.document, null, 2));
39 | };
40 |
41 | return (
42 |
43 |
49 |
50 | );
51 | };
52 |
53 | export default Editor;
54 |
--------------------------------------------------------------------------------
/components/icon-picker.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import EmojiPicker, { Theme } from "emoji-picker-react";
4 | import { useTheme } from "next-themes";
5 |
6 | import {
7 | Popover,
8 | PopoverContent,
9 | PopoverTrigger,
10 | } from "@/components/ui/popover";
11 |
12 | interface IconPickerPorps {
13 | onChange: (icon: string) => void;
14 | children: React.ReactNode;
15 | asChild?: boolean;
16 | }
17 |
18 | export const IconPicker = ({
19 | onChange,
20 | children,
21 | asChild,
22 | }: IconPickerPorps) => {
23 | const { resolvedTheme } = useTheme();
24 | const currentTheme = (resolvedTheme || "light") as keyof typeof themeMap;
25 |
26 | const themeMap = {
27 | dark: Theme.DARK,
28 | light: Theme.LIGHT,
29 | };
30 |
31 | const theme = themeMap[currentTheme];
32 |
33 | return (
34 |
35 | {children}
36 |
37 | onChange(data.emoji)}
41 | />
42 |
43 |
44 | );
45 | };
46 |
--------------------------------------------------------------------------------
/components/modals/ConfirmModal.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 ConfirmmModalProps {
16 | children: React.ReactNode;
17 | onConfirm: () => void;
18 | }
19 | export const ConfirmModal = ({ children, onConfirm }: ConfirmmModalProps) => {
20 | const handleConfirm = (
21 | e: React.MouseEvent,
22 | ) => {
23 | e.stopPropagation();
24 | onConfirm();
25 | };
26 |
27 | return (
28 |
29 | e.stopPropagation()} asChild>
30 | {children}
31 |
32 |
33 |
34 | Are you absolutely sure?
35 |
36 | This action cannot be undone.
37 |
38 |
39 |
40 | e.stopPropagation()}>
41 | Cancel
42 |
43 |
47 | Confirm
48 |
49 |
50 |
51 |
52 | );
53 | };
54 |
--------------------------------------------------------------------------------
/components/modals/CoverImageModal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | Dialog,
5 | DialogContent,
6 | DialogHeader,
7 | DialogTitle,
8 | } from "@/components/ui/dialog";
9 | import { useCoverImage } from "@/hooks/useCoverImage";
10 | import { SingleImageDropzone } from "@/components/single-image-dropzone";
11 | import { useState } from "react";
12 | import { useEdgeStore } from "@/lib/edgestore";
13 | import { useMutation } from "convex/react";
14 | import { api } from "@/convex/_generated/api";
15 | import { useParams } from "next/navigation";
16 | import { Id } from "@/convex/_generated/dataModel";
17 |
18 | export const CoverImageModal = () => {
19 | const params = useParams();
20 |
21 | const [file, setFile] = useState();
22 | const [isSubmitting, setIsSubmitting] = useState(false);
23 |
24 | const update = useMutation(api.documents.update);
25 | const coverImage = useCoverImage();
26 | const { edgestore } = useEdgeStore();
27 |
28 | const onClose = () => {
29 | setFile(undefined);
30 | setIsSubmitting(false);
31 | coverImage.onClose();
32 | };
33 |
34 | const onChange = async (file?: File) => {
35 | if (file) {
36 | setIsSubmitting(true);
37 | setFile(file);
38 |
39 | const res = await edgestore.publicFiles.upload({
40 | file,
41 | options: {
42 | replaceTargetUrl: coverImage.url,
43 | },
44 | });
45 |
46 | await update({
47 | id: params.documentId as Id<"documents">,
48 | coverImage: res.url,
49 | });
50 |
51 | onClose();
52 | }
53 | };
54 |
55 | return (
56 |
72 | );
73 | };
74 |
--------------------------------------------------------------------------------
/components/modals/SettingsModal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Dialog, DialogContent, DialogHeader } from "@/components/ui/dialog";
4 | import { Label } from "@/components/ui/label";
5 | import { useSettings } from "@/hooks/useSettings";
6 | import { ModeToggle } from "../mode-toggle";
7 |
8 | export const SettingsModal = () => {
9 | const settings = useSettings();
10 | return (
11 |
27 | );
28 | };
29 |
--------------------------------------------------------------------------------
/components/mode-toggle.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { MoonIcon, SunIcon } 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 { useEffect, useState } from "react";
4 |
5 | import { SettingsModal } from "@/components/modals/SettingsModal";
6 | import { CoverImageModal } from "@/components/modals/CoverImageModal";
7 |
8 | export const ModalProvider = () => {
9 | const [isMounted, setIsMounted] = useState(false);
10 |
11 | useEffect(() => {
12 | setIsMounted(true);
13 | }, []);
14 |
15 | if (!isMounted) {
16 | return null;
17 | }
18 |
19 | return (
20 | <>
21 |
22 |
23 | >
24 | );
25 | };
26 |
--------------------------------------------------------------------------------
/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 { useEffect, useState } from "react";
4 | import { File } from "lucide-react";
5 | import { useQuery } from "convex/react";
6 | import { useRouter } from "next/navigation";
7 | import { useUser } from "@clerk/clerk-react";
8 |
9 | import {
10 | CommandDialog,
11 | CommandEmpty,
12 | CommandGroup,
13 | CommandInput,
14 | CommandItem,
15 | CommandList,
16 | } from "@/components/ui/command";
17 | import { useSearch } from "@/hooks/useSearch";
18 | import { api } from "@/convex/_generated/api";
19 |
20 | export const SearchCommand = () => {
21 | const { user } = useUser();
22 | const router = useRouter();
23 | const documents = useQuery(api.documents.getSearch);
24 | const [isMounted, setIsMounted] = useState(false);
25 |
26 | const toggle = useSearch((store) => store.toggle);
27 | const isOpen = useSearch((store) => store.isOpen);
28 | const onClose = useSearch((store) => store.onClose);
29 |
30 | useEffect(() => {
31 | setIsMounted(true);
32 | }, []);
33 |
34 | useEffect(() => {
35 | const down = (e: KeyboardEvent) => {
36 | if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
37 | e.preventDefault();
38 | toggle();
39 | }
40 | };
41 |
42 | document.addEventListener("keydown", down);
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) {
52 | return null;
53 | }
54 |
55 | return (
56 |
57 |
58 |
59 | No results found.
60 |
61 | {documents?.map((document) => (
62 |
68 | {document.icon ? (
69 | {document.icon}
70 | ) : (
71 |
72 | )}
73 | {document.title}
74 |
75 | ))}
76 |
77 |
78 |
79 | );
80 | };
81 |
--------------------------------------------------------------------------------
/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 |
8 | import { Spinner } from "./spinner";
9 |
10 | const variants = {
11 | 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",
12 | image:
13 | "border-0 p-0 min-h-0 min-w-0 relative shadow-md bg-slate-200 dark:bg-slate-900 rounded-md",
14 | active: "border-2",
15 | disabled:
16 | "bg-gray-200 border-gray-300 cursor-default pointer-events-none bg-opacity-30 dark:bg-gray-700",
17 | accept: "border border-blue-500 bg-blue-500 bg-opacity-10",
18 | reject: "border border-red-700 bg-red-700 bg-opacity-10",
19 | };
20 |
21 | type InputProps = {
22 | width?: number;
23 | height?: number;
24 | className?: string;
25 | value?: File | string;
26 | onChange?: (file?: File) => void | Promise;
27 | disabled?: boolean;
28 | dropzoneOptions?: Omit;
29 | };
30 |
31 | const ERROR_MESSAGES = {
32 | fileTooLarge(maxSize: number) {
33 | return `The file is too large. Max size is ${formatFileSize(maxSize)}.`;
34 | },
35 | fileInvalidType() {
36 | return "Invalid file type.";
37 | },
38 | tooManyFiles(maxFiles: number) {
39 | return `You can only add ${maxFiles} file(s).`;
40 | },
41 | fileNotSupported() {
42 | return "The file is not supported.";
43 | },
44 | };
45 |
46 | const SingleImageDropzone = React.forwardRef(
47 | (
48 | { dropzoneOptions, width, height, value, className, disabled, onChange },
49 | ref,
50 | ) => {
51 | const imageUrl = React.useMemo(() => {
52 | if (typeof value === "string") {
53 | // in case a url is passed in, use it to display the image
54 | return value;
55 | } else if (value) {
56 | // in case a file is passed in, create a base64 url to display the image
57 | return URL.createObjectURL(value);
58 | }
59 | return null;
60 | }, [value]);
61 |
62 | // dropzone configuration
63 | const {
64 | getRootProps,
65 | getInputProps,
66 | acceptedFiles,
67 | fileRejections,
68 | isFocused,
69 | isDragAccept,
70 | isDragReject,
71 | } = useDropzone({
72 | accept: { "image/*": [] },
73 | multiple: false,
74 | disabled,
75 | onDrop: (acceptedFiles) => {
76 | const file = acceptedFiles[0];
77 | if (file) {
78 | void onChange?.(file);
79 | }
80 | },
81 | ...dropzoneOptions,
82 | });
83 |
84 | // styling
85 | const dropZoneClassName = React.useMemo(
86 | () =>
87 | twMerge(
88 | variants.base,
89 | isFocused && variants.active,
90 | disabled && variants.disabled,
91 | imageUrl && variants.image,
92 | (isDragReject ?? fileRejections[0]) && variants.reject,
93 | isDragAccept && variants.accept,
94 | className,
95 | ).trim(),
96 | [
97 | isFocused,
98 | imageUrl,
99 | fileRejections,
100 | isDragAccept,
101 | isDragReject,
102 | disabled,
103 | className,
104 | ],
105 | );
106 |
107 | // error validation messages
108 | const errorMessage = React.useMemo(() => {
109 | if (fileRejections[0]) {
110 | const { errors } = fileRejections[0];
111 | if (errors[0]?.code === "file-too-large") {
112 | return ERROR_MESSAGES.fileTooLarge(dropzoneOptions?.maxSize ?? 0);
113 | } else if (errors[0]?.code === "file-invalid-type") {
114 | return ERROR_MESSAGES.fileInvalidType();
115 | } else if (errors[0]?.code === "too-many-files") {
116 | return ERROR_MESSAGES.tooManyFiles(dropzoneOptions?.maxFiles ?? 0);
117 | } else {
118 | return ERROR_MESSAGES.fileNotSupported();
119 | }
120 | }
121 | return undefined;
122 | }, [fileRejections, dropzoneOptions]);
123 |
124 | return (
125 |
126 | {disabled && (
127 |
128 |
129 |
130 | )}
131 |
140 | {/* Main File Input */}
141 |
142 |
143 | {imageUrl ? (
144 | // Image Preview
145 |
![{acceptedFiles[0]?.name}]({imageUrl})
150 | ) : (
151 | // Upload Icon
152 |
153 |
154 |
155 | Click or drag to this area to upload
156 |
157 |
158 | )}
159 |
160 | {/* Remove Image Icon */}
161 | {imageUrl && !disabled && (
162 |
{
165 | e.stopPropagation();
166 | void onChange?.(undefined);
167 | }}
168 | >
169 |
170 |
175 |
176 |
177 | )}
178 |
179 |
180 | {/* Error Text */}
181 |
{errorMessage}
182 |
183 | );
184 | },
185 | );
186 | SingleImageDropzone.displayName = "SingleImageDropzone";
187 |
188 | const Button = React.forwardRef<
189 | HTMLButtonElement,
190 | React.ButtonHTMLAttributes
191 | >(({ className, ...props }, ref) => {
192 | return (
193 |
206 | );
207 | });
208 | Button.displayName = "Button";
209 |
210 | function formatFileSize(bytes?: number) {
211 | if (!bytes) {
212 | return "0 Bytes";
213 | }
214 | bytes = Number(bytes);
215 | if (bytes === 0) {
216 | return "0 Bytes";
217 | }
218 | const k = 1024;
219 | const dm = 2;
220 | const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
221 | const i = Math.floor(Math.log(bytes) / Math.log(k));
222 | return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
223 | }
224 |
225 | export { SingleImageDropzone };
226 |
--------------------------------------------------------------------------------
/components/spinner.tsx:
--------------------------------------------------------------------------------
1 | import { Loader } from "lucide-react";
2 |
3 | import { cva, type VariantProps } from "class-variance-authority";
4 | import { cn } from "@/lib/utils";
5 |
6 | const spinnerVariants = cva("text-muted-foreground animate-spin", {
7 | variants: {
8 | size: {
9 | default: "h-4 w-4",
10 | sm: "h-2 w-2",
11 | md: "h-6 w-6",
12 | icon: "h-10 w-10",
13 | },
14 | defaultVariants: {
15 | size: "default",
16 | },
17 | },
18 | });
19 |
20 | interface SpinnerProps extends VariantProps {}
21 |
22 | export const Spinner = ({ size }: SpinnerProps) => {
23 | return ;
24 | };
25 |
--------------------------------------------------------------------------------
/components/toolbar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ElementRef, useRef, useState } from "react";
4 |
5 | import { useMutation } from "convex/react";
6 | import { api } from "@/convex/_generated/api";
7 | import { Doc } from "@/convex/_generated/dataModel";
8 |
9 | import { useCoverImage } from "@/hooks/useCoverImage";
10 |
11 | import { Button } from "./ui/button";
12 | import TextareaAutosize from "react-textarea-autosize";
13 | import { IconPicker } from "./icon-picker";
14 | import { ImageIcon, Smile, X } from "lucide-react";
15 |
16 | interface ToolbarProps {
17 | initialData: Doc<"documents">;
18 | preview?: boolean;
19 | }
20 |
21 | export const Toolbar = ({ initialData, preview }: ToolbarProps) => {
22 | const inputRef = useRef>(null);
23 |
24 | const [isEditing, setIsEditing] = useState(false);
25 | const [value, setValue] = useState(initialData.title);
26 |
27 | const update = useMutation(api.documents.update);
28 | const removeIcon = useMutation(api.documents.removeIcon);
29 | const coverImage = useCoverImage();
30 |
31 | const enableInput = () => {
32 | if (preview) return;
33 |
34 | setIsEditing(true);
35 | setTimeout(() => {
36 | setValue(initialData.title);
37 | inputRef.current?.focus();
38 | }, 0);
39 | };
40 |
41 | const disableInput = () => setIsEditing(false);
42 |
43 | const onInput = (value: string) => {
44 | setValue(value);
45 | update({
46 | id: initialData._id,
47 | title: value || "Untitled",
48 | });
49 | };
50 |
51 | const onKeyDown = (event: React.KeyboardEvent) => {
52 | if (event.key === "Enter") {
53 | event.preventDefault();
54 | disableInput();
55 | }
56 | };
57 |
58 | const onIconSelect = (icon: string) => {
59 | update({
60 | id: initialData._id,
61 | icon,
62 | });
63 | };
64 |
65 | const onRemoveIcon = () => {
66 | removeIcon({
67 | id: initialData._id,
68 | });
69 | };
70 |
71 | return (
72 |
73 | {!!initialData.icon && !preview && (
74 |
75 |
76 |
77 | {initialData.icon}
78 |
79 |
80 |
86 |
87 |
88 |
89 | )}
90 | {!!initialData.icon && preview && (
91 |
{initialData.icon}
92 | )}
93 |
94 | {!initialData.icon && !preview && (
95 |
96 |
101 |
102 | Add icon
103 |
104 |
105 | )}
106 | {!initialData.coverImage && !preview && (
107 |
113 |
114 | Add Cover
115 |
116 | )}
117 |
118 | {isEditing && !preview ? (
119 |
onInput(e.target.value)}
126 | className="resize-none break-words bg-transparent text-5xl font-bold text-[#3F3F3F] outline-none dark:text-[#CFCFCF]"
127 | />
128 | ) : (
129 |
133 | {initialData.title}
134 |
135 | )}
136 |
137 | );
138 | };
139 |
--------------------------------------------------------------------------------
/components/ui/alert-dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
5 |
6 | import { cn } from "@/lib/utils";
7 | import { buttonVariants } from "@/components/ui/button";
8 |
9 | const AlertDialog = AlertDialogPrimitive.Root;
10 |
11 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
12 |
13 | const AlertDialogPortal = AlertDialogPrimitive.Portal;
14 |
15 | const AlertDialogOverlay = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, ...props }, ref) => (
19 |
27 | ));
28 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
29 |
30 | const AlertDialogContent = React.forwardRef<
31 | React.ElementRef,
32 | React.ComponentPropsWithoutRef
33 | >(({ className, ...props }, ref) => (
34 |
35 |
36 |
44 |
45 | ));
46 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
47 |
48 | const AlertDialogHeader = ({
49 | className,
50 | ...props
51 | }: React.HTMLAttributes) => (
52 |
59 | );
60 | AlertDialogHeader.displayName = "AlertDialogHeader";
61 |
62 | const AlertDialogFooter = ({
63 | className,
64 | ...props
65 | }: React.HTMLAttributes) => (
66 |
73 | );
74 | AlertDialogFooter.displayName = "AlertDialogFooter";
75 |
76 | const AlertDialogTitle = React.forwardRef<
77 | React.ElementRef,
78 | React.ComponentPropsWithoutRef
79 | >(({ className, ...props }, ref) => (
80 |
85 | ));
86 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
87 |
88 | const AlertDialogDescription = React.forwardRef<
89 | React.ElementRef,
90 | React.ComponentPropsWithoutRef
91 | >(({ className, ...props }, ref) => (
92 |
97 | ));
98 | AlertDialogDescription.displayName =
99 | AlertDialogPrimitive.Description.displayName;
100 |
101 | const AlertDialogAction = React.forwardRef<
102 | React.ElementRef,
103 | React.ComponentPropsWithoutRef
104 | >(({ className, ...props }, ref) => (
105 |
110 | ));
111 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
112 |
113 | const AlertDialogCancel = React.forwardRef<
114 | React.ElementRef,
115 | React.ComponentPropsWithoutRef
116 | >(({ className, ...props }, ref) => (
117 |
126 | ));
127 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
128 |
129 | export {
130 | AlertDialog,
131 | AlertDialogPortal,
132 | AlertDialogOverlay,
133 | AlertDialogTrigger,
134 | AlertDialogContent,
135 | AlertDialogHeader,
136 | AlertDialogFooter,
137 | AlertDialogTitle,
138 | AlertDialogDescription,
139 | AlertDialogAction,
140 | AlertDialogCancel,
141 | };
142 |
--------------------------------------------------------------------------------
/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 { type 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 |
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.8.0.
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.js";
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.8.0.
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.8.0.
8 | * To regenerate, run `npx convex dev`.
9 | * @module
10 | */
11 |
12 | import type {
13 | DataModelFromSchemaDefinition,
14 | DocumentByName,
15 | TableNamesInDataModel,
16 | SystemTableNames,
17 | } from "convex/server";
18 | import type { GenericId } from "convex/values";
19 | import schema from "../schema.js";
20 |
21 | /**
22 | * The names of all of your Convex tables.
23 | */
24 | export type TableNames = TableNamesInDataModel;
25 |
26 | /**
27 | * The type of a document stored in Convex.
28 | *
29 | * @typeParam TableName - A string literal type of the table name (like "users").
30 | */
31 | export type Doc = DocumentByName<
32 | DataModel,
33 | TableName
34 | >;
35 |
36 | /**
37 | * An identifier for a document in Convex.
38 | *
39 | * Convex documents are uniquely identified by their `Id`, which is accessible
40 | * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
41 | *
42 | * Documents can be loaded using `db.get(id)` in query and mutation functions.
43 | *
44 | * IDs are just strings at runtime, but this type can be used to distinguish them from other
45 | * strings when type checking.
46 | *
47 | * @typeParam TableName - A string literal type of the table name (like "users").
48 | */
49 | export type Id =
50 | GenericId;
51 |
52 | /**
53 | * A type describing your Convex data model.
54 | *
55 | * This type includes information about what tables you have, the type of
56 | * documents stored in those tables, and the indexes defined on them.
57 | *
58 | * This type is used to parameterize methods like `queryGeneric` and
59 | * `mutationGeneric` to make them type-safe.
60 | */
61 | export type DataModel = DataModelFromSchemaDefinition;
62 |
--------------------------------------------------------------------------------
/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.8.0.
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.8.0.
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://sweet-stinkbug-98.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 | export const archive = mutation({
6 | args: { id: v.id("documents") },
7 | handler: async (ctx, args) => {
8 | const identity = await ctx.auth.getUserIdentity();
9 |
10 | if (!identity) {
11 | throw new Error("Not authenticated");
12 | }
13 |
14 | const userId = identity.subject;
15 |
16 | const exisingDocument = await ctx.db.get(args.id);
17 |
18 | if (!exisingDocument) {
19 | throw new Error("Document not found");
20 | }
21 |
22 | if (exisingDocument.userId !== userId) {
23 | throw new Error("Not authorized");
24 | }
25 |
26 | const recursiveArchive = async (documentId: Id<"documents">) => {
27 | const children = await ctx.db
28 | .query("documents")
29 | .withIndex("by_user_parent", (q) =>
30 | q.eq("userId", userId).eq("parentDocument", documentId),
31 | )
32 | .collect();
33 |
34 | for (const child of children) {
35 | await ctx.db.patch(child._id, {
36 | isArchived: true,
37 | });
38 |
39 | await recursiveArchive(child._id);
40 | }
41 | };
42 |
43 | const document = await ctx.db.patch(args.id, {
44 | isArchived: true,
45 | });
46 |
47 | recursiveArchive(args.id);
48 |
49 | return document;
50 | },
51 | });
52 |
53 | export const getSidebar = query({
54 | args: {
55 | parentDocument: v.optional(v.id("documents")),
56 | },
57 | handler: async (ctx, args) => {
58 | const identity = await ctx.auth.getUserIdentity();
59 |
60 | if (!identity) {
61 | throw new Error("Not authenticated");
62 | }
63 |
64 | const userId = identity.subject;
65 |
66 | const documents = await ctx.db
67 | .query("documents")
68 | .withIndex("by_user_parent", (q) =>
69 | q.eq("userId", userId).eq("parentDocument", args.parentDocument),
70 | )
71 | .filter((q) => q.eq(q.field("isArchived"), false))
72 | .order("desc")
73 | .collect();
74 |
75 | return documents;
76 | },
77 | });
78 |
79 | export const create = mutation({
80 | args: {
81 | title: v.string(),
82 | parentDocument: v.optional(v.id("documents")),
83 | },
84 | handler: async (ctx, args) => {
85 | const identity = await ctx.auth.getUserIdentity();
86 |
87 | if (!identity) {
88 | throw new Error("Not authenticated");
89 | }
90 |
91 | const userId = identity.subject;
92 |
93 | const document = await ctx.db.insert("documents", {
94 | title: args.title,
95 | parentDocument: args.parentDocument,
96 | userId,
97 | isArchived: false,
98 | isPublished: false,
99 | });
100 |
101 | return document;
102 | },
103 | });
104 |
105 | export const getTrash = query({
106 | handler: async (ctx) => {
107 | const identity = await ctx.auth.getUserIdentity();
108 |
109 | if (!identity) {
110 | throw new Error("Not authenticated");
111 | }
112 |
113 | const userId = identity.subject;
114 |
115 | const documents = await ctx.db
116 | .query("documents")
117 | .withIndex("by_user", (q) => q.eq("userId", userId))
118 | .filter((q) => q.eq(q.field("isArchived"), true))
119 | .order("desc")
120 | .collect();
121 |
122 | return documents;
123 | },
124 | });
125 |
126 | export const restore = mutation({
127 | args: { id: v.id("documents") },
128 | handler: async (ctx, args) => {
129 | const identity = await ctx.auth.getUserIdentity();
130 |
131 | if (!identity) {
132 | throw new Error("Not authenticated");
133 | }
134 |
135 | const userId = identity.subject;
136 |
137 | const exisingDocument = await ctx.db.get(args.id);
138 |
139 | if (!exisingDocument) {
140 | throw new Error("Document not found");
141 | }
142 |
143 | if (exisingDocument.userId !== userId) {
144 | throw new Error("Not authorized");
145 | }
146 |
147 | const recursiveRestore = async (documentId: Id<"documents">) => {
148 | const children = await ctx.db
149 | .query("documents")
150 | .withIndex("by_user_parent", (q) =>
151 | q.eq("userId", userId).eq("parentDocument", documentId),
152 | )
153 | .collect();
154 |
155 | for (const child of children) {
156 | await ctx.db.patch(child._id, {
157 | isArchived: false,
158 | });
159 |
160 | await recursiveRestore(child._id);
161 | }
162 | };
163 |
164 | const options: Partial> = {
165 | isArchived: false,
166 | };
167 |
168 | if (exisingDocument.parentDocument) {
169 | const parent = await ctx.db.get(exisingDocument.parentDocument);
170 |
171 | if (parent?.isArchived) {
172 | options.parentDocument = undefined;
173 | }
174 | }
175 |
176 | const document = await ctx.db.patch(args.id, options);
177 |
178 | recursiveRestore(args.id);
179 |
180 | return document;
181 | },
182 | });
183 |
184 | export const remove = mutation({
185 | args: { id: v.id("documents") },
186 | handler: async (ctx, args) => {
187 | const identity = await ctx.auth.getUserIdentity();
188 |
189 | if (!identity) {
190 | throw new Error("Not authenticated");
191 | }
192 |
193 | const userId = identity.subject;
194 |
195 | const exisingDocument = await ctx.db.get(args.id);
196 |
197 | if (!exisingDocument) {
198 | throw new Error("Document not found");
199 | }
200 |
201 | if (exisingDocument.userId !== userId) {
202 | throw new Error("Not authorized");
203 | }
204 |
205 | const document = await ctx.db.delete(args.id);
206 |
207 | return document;
208 | },
209 | });
210 |
211 | export const getSearch = query({
212 | handler: async (ctx) => {
213 | const identity = await ctx.auth.getUserIdentity();
214 |
215 | if (!identity) {
216 | throw new Error("Not authenticated");
217 | }
218 |
219 | const userId = identity.subject;
220 |
221 | const documents = await ctx.db
222 | .query("documents")
223 | .withIndex("by_user", (q) => q.eq("userId", userId))
224 | .filter((q) => q.eq(q.field("isArchived"), false))
225 | .order("desc")
226 | .collect();
227 |
228 | return documents;
229 | },
230 | });
231 |
232 | export const getById = query({
233 | args: { documentId: v.id("documents") },
234 | handler: async (ctx, args) => {
235 | const identity = await ctx.auth.getUserIdentity();
236 |
237 | const document = await ctx.db.get(args.documentId);
238 |
239 | if (!document) {
240 | throw new Error("Document not found");
241 | }
242 |
243 | if (document.isPublished && !document.isArchived) {
244 | return document;
245 | }
246 |
247 | if (!identity) {
248 | throw new Error("Not authenticated");
249 | }
250 |
251 | const userId = identity.subject;
252 |
253 | if (document.userId !== userId) {
254 | throw new Error("Not authorized");
255 | }
256 |
257 | return document;
258 | },
259 | });
260 |
261 | export const update = mutation({
262 | args: {
263 | id: v.id("documents"),
264 | title: v.optional(v.string()),
265 | content: v.optional(v.string()),
266 | coverImage: v.optional(v.string()),
267 | icon: v.optional(v.string()),
268 | isPublished: v.optional(v.boolean()),
269 | },
270 | handler: async (ctx, args) => {
271 | const identity = await ctx.auth.getUserIdentity();
272 |
273 | if (!identity) {
274 | throw new Error("Not authenticated");
275 | }
276 |
277 | const userId = identity.subject;
278 |
279 | const { id, ...rest } = args;
280 |
281 | const existingDocument = await ctx.db.get(args.id);
282 |
283 | if (!existingDocument) {
284 | throw new Error("Document not found");
285 | }
286 |
287 | if (existingDocument.userId !== userId) {
288 | throw new Error("Unauthorized");
289 | }
290 |
291 | const document = await ctx.db.patch(args.id, {
292 | ...rest,
293 | });
294 |
295 | return document;
296 | },
297 | });
298 |
299 | export const removeIcon = mutation({
300 | args: { id: v.id("documents") },
301 | handler: async (ctx, args) => {
302 | const identity = await ctx.auth.getUserIdentity();
303 |
304 | if (!identity) {
305 | throw new Error("Not authenticated");
306 | }
307 |
308 | const userId = identity.subject;
309 |
310 | const existingDocument = await ctx.db.get(args.id);
311 |
312 | if (!existingDocument) {
313 | throw new Error("Document not found");
314 | }
315 |
316 | if (existingDocument.userId !== userId) {
317 | throw new Error("Unauthorized");
318 | }
319 |
320 | const document = await ctx.db.patch(args.id, {
321 | icon: undefined,
322 | });
323 |
324 | return document;
325 | },
326 | });
327 |
328 | export const removeCoverImage = mutation({
329 | args: { id: v.id("documents") },
330 | handler: async (ctx, args) => {
331 | const identity = await ctx.auth.getUserIdentity();
332 |
333 | if (!identity) {
334 | throw new Error("Not authenticated");
335 | }
336 |
337 | const userId = identity.subject;
338 |
339 | const existingDocument = await ctx.db.get(args.id);
340 |
341 | if (!existingDocument) {
342 | throw new Error("Document not found");
343 | }
344 |
345 | if (existingDocument.userId !== userId) {
346 | throw new Error("Unauthorized");
347 | }
348 |
349 | const document = await ctx.db.patch(args.id, {
350 | coverImage: undefined,
351 | });
352 |
353 | return document;
354 | },
355 | });
356 |
--------------------------------------------------------------------------------
/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/useCoverImage.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/useOrigin.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 |
3 | export const useOrigin = () => {
4 | const [mounted, setMounted] = useState(false);
5 |
6 | const origin =
7 | typeof window !== "undefined" && window.location.origin
8 | ? window.location.origin
9 | : "";
10 |
11 | useEffect(() => {
12 | setMounted(true);
13 | }, []);
14 |
15 | if (!mounted) {
16 | return "";
17 | }
18 |
19 | return origin;
20 | };
21 |
--------------------------------------------------------------------------------
/hooks/useScrollTop.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 |
3 | export const useScrollTop = (treshold = 10) => {
4 | const [scrolled, setScrolled] = useState(false);
5 |
6 | useEffect(() => {
7 | const handleScroll = () => {
8 | if (window.scrollY > treshold) {
9 | setScrolled(true);
10 | } else {
11 | setScrolled(false);
12 | }
13 | };
14 |
15 | window.addEventListener("scroll", handleScroll);
16 |
17 | return () => window.removeEventListener("scroll", handleScroll);
18 | }, [treshold]);
19 |
20 | return scrolled;
21 | };
22 |
--------------------------------------------------------------------------------
/hooks/useSearch.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/useSettings.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 | import { type EdgeStoreRouter } from "../app/api/edgestore/[...edgestore]/route";
3 | import { createEdgeStoreProvider } from "@edgestore/react";
4 | const { EdgeStoreProvider, useEdgeStore } =
5 | createEdgeStoreProvider();
6 | export { EdgeStoreProvider, useEdgeStore };
7 |
--------------------------------------------------------------------------------
/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.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | images: {
4 | remotePatterns: [
5 | {
6 | protocol: "https",
7 | hostname: "files.edgestore.dev",
8 | },
9 | ],
10 | },
11 | };
12 |
13 | export default nextConfig;
14 |
--------------------------------------------------------------------------------
/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.14.5",
13 | "@blocknote/mantine": "^0.14.6",
14 | "@blocknote/react": "^0.14.6",
15 | "@clerk/clerk-react": "^4.30.3",
16 | "@edgestore/react": "^0.3.3",
17 | "@edgestore/server": "^0.3.3",
18 | "@radix-ui/react-alert-dialog": "^1.0.5",
19 | "@radix-ui/react-avatar": "^1.0.4",
20 | "@radix-ui/react-dialog": "^1.0.5",
21 | "@radix-ui/react-dropdown-menu": "^2.0.6",
22 | "@radix-ui/react-label": "^2.0.2",
23 | "@radix-ui/react-popover": "^1.0.7",
24 | "@radix-ui/react-slot": "^1.0.2",
25 | "class-variance-authority": "^0.7.0",
26 | "clsx": "^2.1.0",
27 | "cmdk": "^0.2.1",
28 | "convex": "^1.20.0",
29 | "emoji-picker-react": "^4.7.10",
30 | "lucide-react": "^0.315.0",
31 | "next": "^14.2.28",
32 | "next-themes": "^0.2.1",
33 | "react": "^18",
34 | "react-dom": "^18",
35 | "react-dropzone": "^14.2.3",
36 | "react-textarea-autosize": "^8.5.3",
37 | "sharp": "^0.33.2",
38 | "sonner": "^1.3.1",
39 | "tailwind-merge": "^2.2.1",
40 | "tailwindcss-animate": "^1.0.7",
41 | "usehooks-ts": "^2.10.0",
42 | "zod": "^3.24.2",
43 | "zustand": "^4.5.0"
44 | },
45 | "devDependencies": {
46 | "@types/node": "^20",
47 | "@types/react": "^18",
48 | "@types/react-dom": "^18",
49 | "autoprefixer": "^10.0.1",
50 | "eslint": "^8",
51 | "eslint-config-next": "14.1.0",
52 | "postcss": "^8",
53 | "prettier": "^3.2.4",
54 | "prettier-plugin-tailwindcss": "^0.5.11",
55 | "tailwindcss": "^3.3.0",
56 | "typescript": "^5"
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/public/idea.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/logo-dark.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/public/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | const config = {
4 | darkMode: ["class"],
5 | content: [
6 | "./pages/**/*.{ts,tsx}",
7 | "./components/**/*.{ts,tsx}",
8 | "./app/**/*.{ts,tsx}",
9 | "./src/**/*.{ts,tsx}",
10 | ],
11 | prefix: "",
12 | theme: {
13 | container: {
14 | center: true,
15 | padding: "2rem",
16 | screens: {
17 | "2xl": "1400px",
18 | },
19 | },
20 | extend: {
21 | colors: {
22 | border: "hsl(var(--border))",
23 | input: "hsl(var(--input))",
24 | ring: "hsl(var(--ring))",
25 | background: "hsl(var(--background))",
26 | foreground: "hsl(var(--foreground))",
27 | primary: {
28 | DEFAULT: "hsl(var(--primary))",
29 | foreground: "hsl(var(--primary-foreground))",
30 | },
31 | secondary: {
32 | DEFAULT: "hsl(var(--secondary))",
33 | foreground: "hsl(var(--secondary-foreground))",
34 | },
35 | destructive: {
36 | DEFAULT: "hsl(var(--destructive))",
37 | foreground: "hsl(var(--destructive-foreground))",
38 | },
39 | muted: {
40 | DEFAULT: "hsl(var(--muted))",
41 | foreground: "hsl(var(--muted-foreground))",
42 | },
43 | accent: {
44 | DEFAULT: "hsl(var(--accent))",
45 | foreground: "hsl(var(--accent-foreground))",
46 | },
47 | popover: {
48 | DEFAULT: "hsl(var(--popover))",
49 | foreground: "hsl(var(--popover-foreground))",
50 | },
51 | card: {
52 | DEFAULT: "hsl(var(--card))",
53 | foreground: "hsl(var(--card-foreground))",
54 | },
55 | },
56 | borderRadius: {
57 | lg: "var(--radius)",
58 | md: "calc(var(--radius) - 2px)",
59 | sm: "calc(var(--radius) - 4px)",
60 | },
61 | keyframes: {
62 | "accordion-down": {
63 | from: { height: "0" },
64 | to: { height: "var(--radix-accordion-content-height)" },
65 | },
66 | "accordion-up": {
67 | from: { height: "var(--radix-accordion-content-height)" },
68 | to: { height: "0" },
69 | },
70 | },
71 | animation: {
72 | "accordion-down": "accordion-down 0.2s ease-out",
73 | "accordion-up": "accordion-up 0.2s ease-out",
74 | },
75 | },
76 | },
77 | plugins: [require("tailwindcss-animate")],
78 | } satisfies Config;
79 |
80 | export default config;
81 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "preserve",
14 | "incremental": true,
15 | "plugins": [
16 | {
17 | "name": "next",
18 | },
19 | ],
20 | "paths": {
21 | "@/*": ["./*"],
22 | },
23 | },
24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
25 | "exclude": ["node_modules"],
26 | }
27 |
--------------------------------------------------------------------------------