├── .eslintrc.json
├── .gitignore
├── README.md
├── app
├── (main)
│ ├── (routes)
│ │ └── documents
│ │ │ ├── [documentId]
│ │ │ └── page.tsx
│ │ │ └── page.tsx
│ ├── _components
│ │ ├── Banner.tsx
│ │ ├── DocumentList.tsx
│ │ ├── Item.tsx
│ │ ├── Menu.tsx
│ │ ├── Navbar.tsx
│ │ ├── Navigation.tsx
│ │ ├── Publish.tsx
│ │ ├── Title.tsx
│ │ ├── TrashBox.tsx
│ │ └── UserItem.tsx
│ └── layout.tsx
├── (marketing)
│ ├── _components
│ │ ├── FAQs.tsx
│ │ ├── Features.tsx
│ │ ├── Footer.tsx
│ │ ├── Heading.tsx
│ │ ├── Heroes.tsx
│ │ ├── Logo.tsx
│ │ ├── Navbar.tsx
│ │ └── WorkTogether.tsx
│ ├── layout.tsx
│ └── page.tsx
├── (public)
│ ├── (routes)
│ │ └── preview
│ │ │ └── [documentId]
│ │ │ └── page.tsx
│ └── layout.tsx
├── api
│ └── edgestore
│ │ └── [...edgestore]
│ │ └── route.ts
├── error.tsx
├── globals.css
└── layout.tsx
├── components.json
├── components
├── cover.tsx
├── editor.tsx
├── icon-picker.tsx
├── modals
│ ├── confirm-modal.tsx
│ ├── cover-image-modal.tsx
│ └── settings-modal.tsx
├── mode-toggle.tsx
├── providers
│ ├── convex-provider.tsx
│ ├── modal-provider.tsx
│ └── theme-provider.tsx
├── search-command.tsx
├── single-image-dropzone.tsx
├── spinner.tsx
├── toolbar.tsx
└── ui
│ ├── alert-dialog.tsx
│ ├── avatar.tsx
│ ├── button.tsx
│ ├── command.tsx
│ ├── dialog.tsx
│ ├── dropdown-menu.tsx
│ ├── input.tsx
│ ├── label.tsx
│ ├── popover.tsx
│ └── skeleton.tsx
├── convex
├── README.md
├── _generated
│ ├── api.d.ts
│ ├── api.js
│ ├── dataModel.d.ts
│ ├── server.d.ts
│ └── server.js
├── auth.config.js
├── documents.ts
├── schema.ts
└── tsconfig.json
├── hooks
├── use-cover-image.tsx
├── use-origin.tsx
├── use-scroll-top.tsx
├── use-search.tsx
└── use-settings.tsx
├── image-2.png
├── image-3.png
├── image-4.png
├── image.png
├── lib
├── edgestore.ts
└── utils.ts
├── next.config.js
├── package-lock.json
├── package.json
├── postcss.config.js
├── prettier.config.js
├── public
├── empty-dark.png
├── empty.png
├── error-dark.png
├── error.png
├── logo-dark.jpg
├── logo.jpg
├── next.svg
├── notes-pana-dark.png
├── notes-pana.png
└── vercel.svg
├── tailwind.config.ts
└── tsconfig.json
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env*.local
29 |
30 | # vercel
31 | .vercel
32 |
33 | # typescript
34 | *.tsbuildinfo
35 | next-env.d.ts
36 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Quickflow: A Better & Open-Source Alternative to Notion
2 |
3 | 
4 |
5 | Quickflow is an open-source alternative to Notion that aims to provide a superior user experience.
6 |
7 | ## Technologies Used
8 |
9 | - [Next.js 13](https://nextjs.org/blog/next-13): A powerful React framework for building modern web applications.
10 | - [Shadcn UI](https://ui.shadcn.com/): Beautifully designed components that you can copy and paste into your apps.
11 | - [Clerk](https://clerk.com/): A complete suite of embeddable UIs, flexible APIs, and admin dashboards to authenticate and manage your users.
12 | - [Convex](https://www.convex.dev/): A Backend Application Platform that keeps you focused on building your product. Convex Functions, Database, File Storage, Scheduling, and Search fit together cohesively.
13 | - [TailwindCSS](https://tailwindcss.com/): A utility-first CSS framework for streamlined web application styling.
14 | - [EdgeStore](https://edgestore.dev/): A service for storing and managing data.
15 |
16 | ## Key Features
17 |
18 | - Real-time database 🔗
19 | - Notion-style editor 📝
20 | - Light and Dark mode 🌓
21 | - Infinite children documents 🌲
22 | - Trash can & soft delete 🗑️
23 | - Authentication 🔐
24 | - File upload, deletion, and replacement
25 | - Icons for each document (changes in real-time) 🌠
26 | - Expandable and fully collapsable sidebar ➡️🔀⬅️
27 | - Full mobile responsiveness 📱
28 | - Publish your note to the web 🌐
29 | - Landing page 🛬
30 | - Cover image for each document 🖼️
31 | - Recover deleted files 🔄📄
32 |
33 | ## Setting Up the Project Locally
34 |
35 | ### Prerequisites
36 |
37 | Ensure that you have Node.js version 18.x.x or higher installed.
38 |
39 | ### Cloning the Repository
40 |
41 | ```shell
42 | git clone https://github.com/Nick-h4ck3r/quickflow.git
43 | cd quickflow
44 | ```
45 |
46 | ### Installing Dependencies
47 |
48 | ```shell
49 | npm i
50 | ```
51 |
52 | ### Setting Up Convex
53 |
54 | 1. Create an account on [convex.dev](https://www.convex.dev/)
55 | 2. Run the following command:
56 |
57 | ```shell
58 | npx convex dev
59 | ```
60 |
61 | 3. Follow the prompts to log in and select `a new project` in terminal.
62 |
63 | 
64 |
65 | 4. A `.env.local` file will be created with `CONVEX_DEPLOYMENT` and `NEXT_PUBLIC_CONVEX_URL` variables.
66 |
67 | Keep this terminal running, as the Convex dev server is running locally. If it crashes or closes, run `npx convex dev` again.
68 |
69 | ### Setting Up Clerk for Authentication
70 |
71 | 1. Create an account on [clerk.com](https://clerk.com/)
72 | 2. Create a new application in the dashboard.
73 | 3. Enable social sign-ins (Google and GitHub recommended) and disable email option.
74 | 4. Copy the `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` and `CLERK_SECRET_KEY` to your `.env.local` file.
75 | 5. Now, click on JWT Templates (in left sidebar) option in convex project dashboard.
76 |
77 | 
78 |
79 | 6. Click on `Create New Template` and select `convex`.
80 |
81 | 7. Copy the issuer URL and update the `domain` in `convex/auth.config.js`:
82 |
83 | ```javascript
84 | export default {
85 | providers: [
86 | {
87 | domain: "https://your-issuer-url.clerk.accounts.dev",
88 | applicationID: "convex",
89 | },
90 | ],
91 | };
92 | ```
93 |
94 | ### Setting Up EdgeStore for Data Storage
95 |
96 | 1. Create an account on [edgestore.dev](https://edgestore.dev/)
97 | 2. Create a new project in the dashboard.
98 | 3. Copy the `EDGE_STORE_ACCESS_KEY` and `EDGE_STORE_SECRET_KEY` to your `.env.local` file.
99 |
100 | ### Finalizing Environment Setup
101 |
102 | Ensure your `.env.local` file looks like this:
103 |
104 | ```
105 | CONVEX_DEPLOYMENT=
106 | NEXT_PUBLIC_CONVEX_URL=
107 |
108 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=
109 | CLERK_SECRET_KEY=
110 |
111 | EDGE_STORE_ACCESS_KEY=
112 | EDGE_STORE_SECRET_KEY=
113 | ```
114 |
115 | ### Starting the Development Server
116 |
117 | ```shell
118 | npm run dev
119 | ```
120 |
121 | ## Deployment
122 |
123 | To deploy the app on Vercel:
124 |
125 | 1. Import the project to deploy on vecrel.
126 | 2. Replace the build command with: `npm run build && npx convex deploy`
127 | 3. To get production env for convex, go to convex project, change the environment to `production`.
128 | 4. Click on `settings`, and copy the `Deployment URL` to `NEXT_PUBLIC_CONVEX_URL`.
129 | 5.After that, click on `Generate Production Deploy Key`, and copy it too.
130 | 6. Add the following environment variables in vercel deployment.
131 | - `CONVEX_DEPLOY_KEY`: convex-generate-production-deploy-key-here
132 | - `CONVEX_DEPLOYMENT`: convex-generate-production-deploy-key-here
133 | - `NEXT_PUBLIC_CONVEX_URL` : convex-deployment-url-here
134 | - `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY`: Same as in `.env.local`
135 | - `CLERK_SECRET_KEY`: Same as in `.env.local`
136 | - `EDGE_STORE_ACCESS_KEY`: Same as in `.env.local`
137 | - `EDGE_STORE_SECRET_KEY`: Same as in `.env.local`
138 |
139 | 
140 |
141 | ## Contributing
142 |
143 | Contributions are welcome! Please feel free to submit a Pull Request.
144 |
145 | ## Credits
146 |
147 | Special thanks to Antonio for his invaluable tutorial, which served as the foundation for building this app. You can find the tutorial [here](https://youtu.be/0OaDyjB9Ib8?si=D38xIsi46hG7M2sC).
148 |
149 | ## License
150 |
151 | [MIT License](LICENSE)
--------------------------------------------------------------------------------
/app/(main)/(routes)/documents/[documentId]/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import dynamic from "next/dynamic";
4 | import { useMemo } from "react";
5 | import { Cover } from "@/components/cover";
6 | import { Toolbar } from "@/components/toolbar";
7 | import { Skeleton } from "@/components/ui/skeleton";
8 | import { api } from "@/convex/_generated/api";
9 | import { Id } from "@/convex/_generated/dataModel";
10 | import { useMutation, useQuery } from "convex/react";
11 |
12 | interface DocumentIdPageProps {
13 | params: {
14 | documentId: Id<"documents">;
15 | };
16 | }
17 |
18 | const DocumentIdPage = ({ params }: DocumentIdPageProps) => {
19 | const Editor = useMemo(
20 | () => dynamic(() => import("@/components/editor"), { ssr: false }),
21 | [],
22 | );
23 | const document = useQuery(api.documents.getById, {
24 | documentId: params.documentId,
25 | });
26 |
27 | const update = useMutation(api.documents.update);
28 |
29 | const onChange = (content: string) => {
30 | update({
31 | id: params.documentId,
32 | content,
33 | });
34 | };
35 |
36 | if (document === undefined) {
37 | return (
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 | );
50 | }
51 |
52 | if (document === null) {
53 | return Not found
;
54 | }
55 |
56 | return (
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 | );
65 | };
66 |
67 | export default DocumentIdPage;
68 |
--------------------------------------------------------------------------------
/app/(main)/(routes)/documents/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | // import hooks
4 | import { useMutation } from "convex/react";
5 | import { useUser } from "@clerk/clerk-react";
6 |
7 | // import components
8 | import { Button } from "@/components/ui/button";
9 | import { PlusCircle } from "lucide-react";
10 | import Image from "next/image";
11 | import { toast } from "sonner";
12 |
13 | import { api } from "@/convex/_generated/api";
14 | import { useRouter } from "next/navigation";
15 |
16 | function Page() {
17 | const { user } = useUser();
18 | const router = useRouter();
19 |
20 | const create = useMutation(api.documents.create);
21 |
22 | const onCreate = () => {
23 | const promise = create({ title: "Untitled" }).then((docId) => {
24 | router.push(`/documents/${docId}`);
25 | });
26 |
27 | toast.promise(promise, {
28 | loading: "Creating a new note...",
29 | success: "New note created!",
30 | error: "Failed to create a note.",
31 | });
32 | };
33 |
34 | return (
35 |
36 |
43 |
50 |
51 | Welcome to {user?.firstName}'s Quickflow
52 |
53 |
54 | Create a note
55 |
56 |
57 | );
58 | }
59 |
60 | export default Page;
61 |
--------------------------------------------------------------------------------
/app/(main)/_components/Banner.tsx:
--------------------------------------------------------------------------------
1 | import { useMutation } from "convex/react";
2 | import { useParams, useRouter } from "next/navigation";
3 |
4 | import { toast } from "sonner";
5 |
6 | import { api } from "@/convex/_generated/api";
7 | import { Id } from "@/convex/_generated/dataModel";
8 | import { Button } from "@/components/ui/button";
9 | import { ConfirmModal } from "@/components/modals/confirm-modal";
10 |
11 | interface BannerProps {
12 | documentId: Id<"documents">;
13 | }
14 |
15 | export const Banner = ({ documentId }: BannerProps) => {
16 | const router = useRouter();
17 |
18 | const remove = useMutation(api.documents.remove);
19 | const restore = useMutation(api.documents.restore);
20 |
21 | const onRemove = () => {
22 | const promise = remove({ id: documentId });
23 |
24 | toast.promise(promise, {
25 | loading: "Deleting note...",
26 | success: "Note deleted!",
27 | error: " Failed to delete note.",
28 | });
29 |
30 | router.push("/documents");
31 | };
32 |
33 | const onRestore = () => {
34 | const promise = restore({ id: documentId });
35 |
36 | toast.promise(promise, {
37 | loading: "Restoring note...",
38 | success: "Note restored!",
39 | error: " Failed to restore note.",
40 | });
41 | };
42 |
43 | return (
44 |
45 |
This page has been archived.
46 |
52 | Restore page
53 |
54 |
55 |
60 | Delete forever
61 |
62 |
63 |
64 | );
65 | };
66 |
--------------------------------------------------------------------------------
/app/(main)/_components/DocumentList.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useParams, useRouter } from "next/navigation";
4 | import { useState } from "react";
5 |
6 | import { Doc, Id } from "@/convex/_generated/dataModel";
7 | import { useQuery } from "convex/react";
8 | import { api } from "@/convex/_generated/api";
9 | import { Item } from "./Item";
10 | import { cn } from "@/lib/utils";
11 | import { FileIcon } from "lucide-react";
12 |
13 | interface DocumentListProps {
14 | parentDocumentId?: Id<"documents">;
15 | level?: number;
16 | data?: Doc<"documents">[];
17 | }
18 |
19 | export const DocumentList = ({
20 | parentDocumentId,
21 | level = 0,
22 | }: DocumentListProps) => {
23 | const params = useParams();
24 | const router = useRouter();
25 | const [expanded, setExpanded] = useState>({});
26 |
27 | const onExpand = (documentId: string) => {
28 | setExpanded((prevExpanded) => ({
29 | ...prevExpanded,
30 | [documentId]: !prevExpanded[documentId],
31 | }));
32 | };
33 |
34 | const documents = useQuery(api.documents.getSidebar, {
35 | parentDocument: parentDocumentId,
36 | });
37 |
38 | const onRedirect = (documentId: string) => {
39 | router.push(`/documents/${documentId}`);
40 | };
41 |
42 | if (documents === undefined) {
43 | return (
44 | <>
45 |
46 | {level === 0 && (
47 | <>
48 |
49 |
50 | >
51 | )}
52 | >
53 | );
54 | }
55 |
56 | return (
57 | <>
58 |
66 | No pages inside
67 |
68 |
69 | {documents.map((document) => (
70 |
71 | - 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 { useUser } from "@clerk/clerk-react";
4 | import { Skeleton } from "@/components/ui/skeleton";
5 | import { api } from "@/convex/_generated/api";
6 | import { Id } from "@/convex/_generated/dataModel";
7 | import { cn } from "@/lib/utils";
8 | import {
9 | DropdownMenu,
10 | DropdownMenuTrigger,
11 | DropdownMenuContent,
12 | DropdownMenuItem,
13 | DropdownMenuSeparator,
14 | } from "@/components/ui/dropdown-menu";
15 | import { useMutation, useQuery } from "convex/react";
16 | import { GenericId } from "convex/values";
17 | import {
18 | ChevronDown,
19 | ChevronRight,
20 | LucideIcon,
21 | MoreHorizontal,
22 | Plus,
23 | Trash,
24 | Copy,
25 | } from "lucide-react";
26 | import { useRouter } from "next/navigation";
27 | import { toast } from "sonner";
28 |
29 | interface ItemProps {
30 | // optional props
31 | id?: Id<"documents">;
32 | documentIcon?: string;
33 | active?: boolean;
34 | expanded?: boolean;
35 | isSearch?: boolean;
36 | level?: number;
37 | onExpand?: () => void;
38 |
39 | // required props
40 | label: string;
41 | onClick?: () => void;
42 | icon: LucideIcon;
43 | }
44 |
45 | export const Item = ({
46 | id,
47 | label,
48 | onClick,
49 | icon: Icon,
50 | active,
51 | documentIcon,
52 | level = 0,
53 | isSearch,
54 | onExpand,
55 | expanded,
56 | }: ItemProps) => {
57 | const { user } = useUser();
58 | const router = useRouter();
59 | const create = useMutation(api.documents.create);
60 | const archive = useMutation(api.documents.archive);
61 | const copy = useMutation(api.documents.copy);
62 |
63 | const document = useQuery(api.documents.getById, id ? {
64 | documentId: id as Id<"documents">,
65 | } : "skip");
66 |
67 | const handleExpand = (e: React.MouseEvent) => {
68 | e.stopPropagation();
69 | onExpand?.();
70 | };
71 |
72 | const onCreate = (e: React.MouseEvent) => {
73 | e.stopPropagation();
74 | if (!id) return;
75 |
76 | const promise = create({ title: "Untitled", parentDocument: id }).then(
77 | (documentId) => {
78 | if (!expanded) {
79 | onExpand?.();
80 | }
81 | router.push(`/documents/${documentId}`);
82 | },
83 | );
84 | toast.promise(promise, {
85 | loading: "Creating a new note...",
86 | success: "New note created!",
87 | error: "Failed to create a note.",
88 | });
89 | };
90 |
91 | const onArchive = (e: React.MouseEvent) => {
92 | e.stopPropagation();
93 |
94 | if (!id) return;
95 | const promise = archive({ id });
96 |
97 | toast.promise(promise, {
98 | loading: "Moving to trash...",
99 | success: "Note moved to trash!",
100 | error: "Failed to archive a note.",
101 | });
102 |
103 | router.push("/documents");
104 | };
105 |
106 | const onCopy = (e: React.MouseEvent) => {
107 | e.stopPropagation();
108 | if (!id || !document) return;
109 |
110 | const promise = copy({
111 | title: `Copy of ${document?.title}`,
112 | content: document?.content,
113 | coverImage: document?.coverImage,
114 | icon: document?.icon,
115 | parentDocument: document?.parentDocument,
116 | }).then((documentId) => {
117 | if (!expanded) {
118 | onExpand?.();
119 | }
120 | router.push(`/documents/${documentId}`);
121 | });
122 | toast.promise(promise, {
123 | loading: "Copying a new note...",
124 | success: "Note copied!",
125 | error: "Failed to copy the note.",
126 | });
127 | };
128 |
129 | const ChevronIcon = expanded ? ChevronDown : ChevronRight;
130 |
131 | return (
132 |
141 | {!!id && (
142 |
147 |
148 |
149 | )}
150 |
151 | {documentIcon ? (
152 |
{documentIcon}
153 | ) : (
154 |
155 | )}
156 |
157 |
{label}
158 |
159 | {isSearch && (
160 |
161 | ctrl + q
162 |
163 | )}
164 |
165 | {!!id && (
166 |
167 |
168 | e.stopPropagation()} asChild>
169 |
173 |
174 |
175 |
176 |
182 |
183 |
184 | Delete
185 |
186 |
190 |
191 | Make a copy
192 |
193 |
194 |
195 | Last edited by: {user?.fullName}
196 |
197 |
198 |
199 |
206 |
207 | )}
208 |
209 | );
210 | };
211 |
212 | Item.Skeleton = function ItemSkeleton({ level }: { level?: number }) {
213 | return (
214 |
220 |
221 |
222 |
223 | );
224 | };
225 |
--------------------------------------------------------------------------------
/app/(main)/_components/Menu.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Id } from "@/convex/_generated/dataModel";
4 | import { api } from "@/convex/_generated/api";
5 |
6 | import {
7 | DropdownMenu,
8 | DropdownMenuContent,
9 | DropdownMenuTrigger,
10 | DropdownMenuItem,
11 | DropdownMenuSeparator,
12 | } from "@/components/ui/dropdown-menu";
13 | import { MenuIcon, MoreHorizontalIcon, TrashIcon } from "lucide-react";
14 | import { Button } from "@/components/ui/button";
15 |
16 | import { toast } from "sonner";
17 |
18 | import { useMutation } from "convex/react";
19 | import { useRouter } from "next/navigation";
20 | import { useUser } from "@clerk/clerk-react";
21 | import { Skeleton } from "@/components/ui/skeleton";
22 |
23 | interface MenuProps {
24 | documentId: Id<"documents">;
25 | }
26 |
27 | export const Menu = ({ documentId }: MenuProps) => {
28 | const router = useRouter();
29 | const { user } = useUser();
30 | const archive = useMutation(api.documents.archive);
31 |
32 | const onArchive = () => {
33 | const promise = archive({ id: documentId });
34 |
35 | toast.promise(promise, {
36 | loading: "Moving to trash...",
37 | success: "Note moved to trash!",
38 | error: "Failed to archive a note.",
39 | });
40 |
41 | router.push("/documents");
42 | };
43 |
44 | return (
45 |
46 |
47 |
48 |
49 |
50 |
51 |
57 |
58 |
59 | Delete
60 |
61 |
62 |
63 | Last edited by: {user?.fullName}
64 |
65 |
66 |
67 | );
68 | };
69 |
70 | Menu.Skeleton = function MenuSkeleton() {
71 | return ;
72 | };
73 |
--------------------------------------------------------------------------------
/app/(main)/_components/Navbar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useMutation, useQuery } from "convex/react";
4 | import { useParams, useRouter } from "next/navigation";
5 |
6 | import { api } from "@/convex/_generated/api";
7 | import { Id } from "@/convex/_generated/dataModel";
8 |
9 | import { Title } from "./Title";
10 | import { Banner } from "./Banner";
11 | import { Menu } from "./Menu";
12 |
13 | import { Button } from "@/components/ui/button";
14 | import { Copy, MenuIcon } from "lucide-react";
15 | import { Publish } from "./Publish";
16 | import { toast } from "sonner";
17 |
18 | interface NavbarProps {
19 | isCollapsed: boolean;
20 | onResetWidth: () => void;
21 | }
22 |
23 | function Navbar({ isCollapsed, onResetWidth }: NavbarProps) {
24 | const params = useParams();
25 | const router = useRouter();
26 | const copy = useMutation(api.documents.copy);
27 | const document = useQuery(api.documents.getById, {
28 | documentId: params.documentId as Id<"documents">,
29 | });
30 |
31 | if (document === undefined) {
32 | return (
33 |
39 | );
40 | }
41 |
42 | if (document === null) {
43 | return null;
44 | }
45 |
46 | const onCopy = (e: React.MouseEvent) => {
47 | e.stopPropagation();
48 |
49 | const promise = copy({
50 | title: `Copy of ${document?.title}`,
51 | content: document?.content,
52 | coverImage: document?.coverImage,
53 | icon: document?.icon,
54 | parentDocument: document?.parentDocument,
55 | }).then((documentId) => {
56 | router.push(`/documents/${documentId}`);
57 | });
58 | toast.promise(promise, {
59 | loading: "Copying a new note...",
60 | success: "Note copied!",
61 | error: "Failed to copy the note.",
62 | });
63 | };
64 | return (
65 | <>
66 |
67 | {isCollapsed && (
68 |
73 | )}
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 | {document.isArchived && }
87 | >
88 | );
89 | }
90 |
91 | export default Navbar;
92 |
--------------------------------------------------------------------------------
/app/(main)/_components/Navigation.tsx:
--------------------------------------------------------------------------------
1 | import { ElementRef, useEffect, useRef, useState } from "react";
2 |
3 | import {
4 | ChevronsLeft,
5 | MenuIcon,
6 | Plus,
7 | PlusCircle,
8 | Search,
9 | Settings,
10 | Trash,
11 | } from "lucide-react";
12 |
13 | import { cn } from "@/lib/utils";
14 | import { toast } from "sonner";
15 | import { api } from "@/convex/_generated/api";
16 |
17 | // import components
18 | import { Item } from "./Item";
19 | import { DocumentList } from "./DocumentList";
20 | import UserItem from "./UserItem";
21 | import { TrashBox } from "./TrashBox";
22 | import {
23 | Popover,
24 | PopoverTrigger,
25 | PopoverContent,
26 | } from "@/components/ui/popover";
27 |
28 | // import hooks
29 | import { useSearch } from "@/hooks/use-search";
30 | import { useSettings } from "@/hooks/use-settings";
31 | import { useMediaQuery } from "usehooks-ts";
32 | import { useQuery, useMutation } from "convex/react";
33 | import { usePathname, useParams, useRouter } from "next/navigation";
34 | import Navbar from "./Navbar";
35 |
36 | function Navigation() {
37 | const pathname = usePathname();
38 | const params = useParams();
39 | const router = useRouter();
40 |
41 | const isMobile = useMediaQuery("(max-width:768px)");
42 |
43 | const search = useSearch();
44 | const settings = useSettings();
45 |
46 | const create = useMutation(api.documents.create);
47 |
48 | const isResizingRef = useRef(false);
49 | const sidebarRef = useRef>(null);
50 | const navbarRef = useRef>(null);
51 | const [isResetting, setIsResetting] = useState(false);
52 | const [isCollapsed, setIsCollapsed] = useState(isMobile);
53 |
54 | useEffect(() => {
55 | if (isMobile) {
56 | collapse();
57 | } else {
58 | resetWidth();
59 | }
60 | }, [isMobile]);
61 |
62 | useEffect(() => {
63 | if (isMobile) {
64 | collapse();
65 | }
66 | }, [pathname, isMobile]);
67 |
68 | const handleMouseDown = (e: React.MouseEvent) => {
69 | e.preventDefault();
70 | e.stopPropagation();
71 |
72 | isResizingRef.current = true;
73 | document.addEventListener("mousemove", handleMouseMove);
74 | document.addEventListener("mouseup", handleMouseUp);
75 | };
76 |
77 | const handleMouseMove = (e: MouseEvent) => {
78 | if (!isResizingRef.current) return;
79 |
80 | let newWidth = e.clientX;
81 |
82 | if (newWidth < 240) newWidth = 240;
83 | if (newWidth > 400) newWidth = 480;
84 |
85 | if (sidebarRef.current && navbarRef.current) {
86 | sidebarRef.current.style.width = `${newWidth}px`;
87 | navbarRef.current.style.setProperty("left", `${newWidth}px`);
88 | navbarRef.current.style.setProperty(
89 | "width",
90 | `calc(100% - ${newWidth}px)`,
91 | );
92 | }
93 | };
94 |
95 | const handleMouseUp = () => {
96 | isResizingRef.current = false;
97 | document.removeEventListener("mousemove", handleMouseMove);
98 | document.removeEventListener("mouseup", handleMouseUp);
99 | };
100 |
101 | const resetWidth = () => {
102 | if (sidebarRef.current && navbarRef.current) {
103 | setIsCollapsed(false);
104 | setIsResetting(true);
105 |
106 | sidebarRef.current.style.width = isMobile ? "100%" : "240px";
107 | navbarRef.current.style.setProperty(
108 | "width",
109 | isMobile ? "0" : "calc(100% - 240px)",
110 | );
111 | navbarRef.current.style.setProperty("left", isMobile ? "100%" : "240px");
112 | setTimeout(() => setIsResetting(false), 300);
113 | }
114 | };
115 |
116 | const collapse = () => {
117 | if (sidebarRef.current && navbarRef.current) {
118 | setIsCollapsed(true);
119 | setIsResetting(true);
120 |
121 | sidebarRef.current.style.width = "0";
122 | navbarRef.current.style.setProperty("width", "100%");
123 | navbarRef.current.style.setProperty("left", "0");
124 | setTimeout(() => setIsResetting(false), 300);
125 | }
126 | };
127 |
128 | const handleCreate = () => {
129 | const promise = create({
130 | title: "Untitled",
131 | }).then((documentId) => {
132 | router.push(`/documents/${documentId}`);
133 | });
134 |
135 | toast.promise(promise, {
136 | loading: "Creating a new note...",
137 | success: "New note created!",
138 | error: "Failed to create a note.",
139 | });
140 | };
141 |
142 | return (
143 | <>
144 |
189 |
190 |
198 | {!!params.documentId ? (
199 |
200 | ) : (
201 |
202 | {isCollapsed && (
203 |
208 | )}
209 |
210 | )}
211 |
212 | >
213 | );
214 | }
215 |
216 | export default Navigation;
217 |
--------------------------------------------------------------------------------
/app/(main)/_components/Publish.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@/components/ui/button";
4 | import {
5 | Popover,
6 | PopoverContent,
7 | PopoverTrigger,
8 | } from "@/components/ui/popover";
9 | import { api } from "@/convex/_generated/api";
10 | import { Doc } from "@/convex/_generated/dataModel";
11 | import { useOrigin } from "@/hooks/use-origin";
12 | import { useMutation } from "convex/react";
13 | import { CheckIcon, CopyIcon, GlobeIcon } from "lucide-react";
14 | import { useState } from "react";
15 | import { toast } from "sonner";
16 |
17 | interface PublishProps {
18 | initialData: Doc<"documents">;
19 | }
20 |
21 | export const Publish = ({ initialData }: PublishProps) => {
22 | const origin = useOrigin();
23 | const update = useMutation(api.documents.update);
24 |
25 | const [isSubmitting, setIsSubmitting] = useState(false);
26 | const [copied, setCopied] = useState(false);
27 |
28 | const url = `${origin}/preview/${initialData._id}`;
29 |
30 | const onPublish = () => {
31 | setIsSubmitting(true);
32 |
33 | const promise = update({
34 | id: initialData._id,
35 | isPublished: true,
36 | }).finally(() => setIsSubmitting(false));
37 |
38 | toast.promise(promise, {
39 | loading: "Publishing...",
40 | success: "Note published!",
41 | error: "Failed to publish note.",
42 | });
43 | };
44 |
45 | const onUnPublish = () => {
46 | setIsSubmitting(true);
47 |
48 | const promise = update({
49 | id: initialData._id,
50 | isPublished: false,
51 | }).finally(() => setIsSubmitting(false));
52 |
53 | toast.promise(promise, {
54 | loading: "Unpublishing...",
55 | success: "Note unpublished!",
56 | error: "Failed to unpublish note.",
57 | });
58 | };
59 |
60 | const onCopy = () => {
61 | navigator.clipboard.writeText(url);
62 | setCopied(true);
63 |
64 | setTimeout(() => {
65 | setCopied(false);
66 | }, 1000);
67 | };
68 |
69 | return (
70 |
71 |
72 |
73 | Publish
74 | {initialData.isPublished && (
75 |
76 | )}
77 |
78 |
79 |
80 | {initialData.isPublished ? (
81 |
82 |
83 |
84 |
85 | This note is live on web.
86 |
87 |
88 |
89 |
94 |
99 | {copied ? (
100 |
101 | ) : (
102 |
103 | )}
104 |
105 |
106 |
112 | Unpublish
113 |
114 |
115 | ) : (
116 |
117 |
118 |
Publish this note
119 |
120 | Share your work with others.
121 |
122 |
128 | Publish
129 |
130 |
131 | )}
132 |
133 |
134 | );
135 | };
136 |
--------------------------------------------------------------------------------
/app/(main)/_components/Title.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import React, { KeyboardEvent, useRef, useState } from "react";
4 | import { useMutation } from "convex/react";
5 |
6 | import { api } from "@/convex/_generated/api";
7 | import { Doc } from "@/convex/_generated/dataModel";
8 |
9 | import { Input } from "@/components/ui/input";
10 | import { Button } from "@/components/ui/button";
11 | import { Skeleton } from "@/components/ui/skeleton";
12 |
13 | interface TitleProps {
14 | initialData: Doc<"documents">;
15 | }
16 |
17 | export const Title = ({ initialData }: TitleProps) => {
18 | const update = useMutation(api.documents.update);
19 | const inputRef = useRef(null);
20 |
21 | const [title, setTitle] = useState(initialData.title || "Untitled");
22 | const [isEditing, setIsEditing] = useState(false);
23 |
24 | const enableInput = () => {
25 | setTitle(initialData.title);
26 | setIsEditing(true);
27 |
28 | setTimeout(() => {
29 | inputRef.current?.focus();
30 | inputRef.current?.setSelectionRange(0, inputRef.current.value.length);
31 | }, 0);
32 | };
33 |
34 | const disableInput = () => {
35 | setIsEditing(false);
36 | };
37 |
38 | const onChange = (e: React.ChangeEvent) => {
39 | setTitle(e.target.value);
40 | update({
41 | id: initialData._id,
42 | title: e.target.value || "Untitled",
43 | });
44 | };
45 |
46 | const onKeyDown = (e: KeyboardEvent) => {
47 | if (e.key === "Enter") {
48 | disableInput();
49 | }
50 | };
51 |
52 | return (
53 |
54 | {!!initialData.icon &&
{initialData.icon}
}
55 |
56 | {isEditing ? (
57 |
66 | ) : (
67 |
73 | {initialData.title}
74 |
75 | )}
76 |
77 | );
78 | };
79 |
80 | Title.Skeleton = function TitleSkeleton() {
81 | return ;
82 | };
83 |
--------------------------------------------------------------------------------
/app/(main)/_components/TrashBox.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState } from "react";
4 | import { useParams, useRouter } from "next/navigation";
5 | import { useQuery, useMutation } from "convex/react";
6 | import { Search, Trash, Undo } from "lucide-react";
7 | import { toast } from "sonner";
8 |
9 | import { api } from "@/convex/_generated/api";
10 | import { Id } from "@/convex/_generated/dataModel";
11 | import { Spinner } from "@/components/spinner";
12 | import { Input } from "@/components/ui/input";
13 | import { ConfirmModal } from "@/components/modals/confirm-modal";
14 |
15 | export const TrashBox = () => {
16 | const router = useRouter();
17 | const params = useParams();
18 | const documents = useQuery(api.documents.getTrash);
19 | const restore = useMutation(api.documents.restore);
20 | const remove = useMutation(api.documents.remove);
21 |
22 | const [search, setSearch] = useState("");
23 |
24 | const filteredDocuments = documents?.filter((document) => {
25 | return document.title.toLowerCase().includes(search.toLowerCase());
26 | });
27 |
28 | const onClick = (documentId: string) => {
29 | router.push(`/documents/${documentId}`);
30 | };
31 |
32 | const onRestore = (
33 | event: React.MouseEvent,
34 | documentId: Id<"documents">,
35 | ) => {
36 | event.stopPropagation();
37 | const promise = restore({ id: documentId });
38 |
39 | toast.promise(promise, {
40 | loading: "Restoring note...",
41 | success: "Note restored!",
42 | error:" Failed to restore note."
43 | });
44 | };
45 |
46 | const onRemove = (
47 | documentId: Id<"documents">,
48 | ) => {
49 | const promise = remove({ id: documentId });
50 |
51 | toast.promise(promise, {
52 | loading: "Deleting note...",
53 | success: "Note deleted!",
54 | error:" Failed to delete note."
55 | });
56 |
57 | if (params.documentId === documentId) {
58 | router.push("/documents");
59 | }
60 | };
61 |
62 | if (documents === undefined) {
63 | return (
64 |
65 |
66 |
67 | );
68 | }
69 |
70 | return (
71 |
72 |
73 |
74 | setSearch(e.target.value)}
77 | className="h-7 px-2 focus-visible:ring-transparent bg-secondary"
78 | placeholder="Filter by page title..."
79 | />
80 |
81 |
82 |
83 | No documents found.
84 |
85 | {filteredDocuments?.map((document) => (
86 |
onClick(document._id)}
90 | className="text-sm rounded-sm w-full hover:bg-primary/5 flex items-center text-primary justify-between"
91 | >
92 |
93 | {document.title}
94 |
95 |
96 |
onRestore(e, document._id)}
98 | role="button"
99 | className="rounded-sm p-2 hover:bg-neutral-200 dark:hover:bg-neutral-600"
100 | >
101 |
102 |
103 |
onRemove(document._id)}>
104 |
108 |
109 |
110 |
111 |
112 |
113 | ))}
114 |
115 |
116 | );
117 | };
--------------------------------------------------------------------------------
/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 | DropdownMenuSeparator,
9 | DropdownMenuTrigger,
10 | } from "@/components/ui/dropdown-menu";
11 |
12 | import { SignOutButton, useUser } from "@clerk/clerk-react";
13 | import { ChevronsLeftRight } from "lucide-react";
14 |
15 | function UserItem() {
16 |
17 | const { user } = useUser();
18 |
19 | return (
20 |
21 |
22 |
26 |
27 |
28 |
29 |
30 |
31 | {user?.fullName}' Quickflow
32 |
33 |
34 |
35 |
36 |
37 |
43 |
44 |
45 | {user?.emailAddresses[0].emailAddress}
46 |
47 |
48 |
53 |
54 |
55 | {user?.fullName}'s Quickflow
56 |
57 |
58 |
59 |
60 |
61 |
65 | Sign out
66 |
67 |
68 |
69 | );
70 | }
71 |
72 | export default UserItem;
73 |
--------------------------------------------------------------------------------
/app/(main)/layout.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useConvexAuth } from "convex/react";
4 | import { redirect } from "next/navigation";
5 |
6 | import { Spinner } from "@/components/spinner";
7 | import { SearchCommand } from "@/components/search-command";
8 |
9 | import Navigation from "./_components/Navigation";
10 |
11 | const MainLayout = ({ children }: { children: React.ReactNode }) => {
12 | const { isAuthenticated, isLoading } = useConvexAuth();
13 |
14 | if (isLoading) {
15 | return (
16 |
17 |
18 |
19 | );
20 | }
21 |
22 | if (!isAuthenticated) {
23 | return redirect("/");
24 | }
25 |
26 | return (
27 |
28 |
29 |
30 |
31 | {children}
32 |
33 |
34 | );
35 | };
36 |
37 | export default MainLayout;
38 |
--------------------------------------------------------------------------------
/app/(marketing)/_components/FAQs.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | type Props = {};
4 |
5 | const FAQs = (props: Props) => {
6 | const questionAnswers = [
7 | {
8 | question: "Is Quickflow an open-source application?",
9 | answer:
10 | "Yes, Quickflow is an open-source note-taking app. You can explore, contribute, and customize the code on our GitHub repository. Visit our GitHub to get involved and stay updated with the latest developments.",
11 | },
12 | {
13 | question: "What makes Quickflow different from other note-taking apps?",
14 | answer:
15 | "Quickflow stands out with its real-time collaboration, intuitive Notion-style editor, and versatile features like infinite document hierarchy and file management. It's designed for seamless, efficient, and personalized note-taking.",
16 | },
17 | {
18 | question: "Can I use Quickflow on my mobile device?",
19 | answer:
20 | "Absolutely! Quickflow is fully responsive, ensuring a smooth experience on your mobile device. Work on the go without compromising productivity.",
21 | },
22 | {
23 | question: "How secure is Quickflow for storing sensitive information?",
24 | answer:
25 | "Quickflow prioritizes security with robust authentication features, ensuring your data is safe. Feel confident in using Quickflow for both personal and professional note-taking needs.",
26 | },
27 | ];
28 |
29 | return (
30 |
31 |
32 |
33 |
34 | FAQ
35 |
36 |
37 | Frequent questions & answers
38 |
39 |
40 |
41 |
42 | {questionAnswers.map((item, index) => (
43 |
44 |
45 | {item.question}
46 |
47 | {item.answer}
48 |
49 | ))}
50 |
51 |
52 |
53 | );
54 | };
55 |
56 | export default FAQs;
57 |
--------------------------------------------------------------------------------
/app/(marketing)/_components/Features.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | const Features = () => {
4 | const features = [
5 | {
6 | name: "Real-time Database",
7 | description:
8 | "Experience seamless collaboration with real-time updates. Your data, always in sync, ensuring you're on the same page with your team.",
9 | },
10 | {
11 | name: "Notion-Style Editor",
12 | description:
13 | "Enjoy a familiar editing experience, but with the power of Quickflow. Streamlined, efficient, and designed for ultimate productivity.",
14 | },
15 | {
16 | name: "Light and Dark Mode",
17 | description:
18 | "Personalize your workspace with a choice of light or dark mode. Quickflow adapts to your preferences, making work easy on your eyes.",
19 | },
20 | {
21 | name: "Infinite Documents",
22 | description:
23 | "Organize your thoughts limitlessly. Quickflow allows you to create an endless hierarchy of documents, ensuring your ideas flow freely.",
24 | },
25 | {
26 | name: "Recover & Soft Delete",
27 | description:
28 | "Mistakes happen. Quickflow lets you recover deleted content, offering a safety net for your valuable information.",
29 | },
30 | {
31 | name: "Privacy Focused",
32 | description:
33 | "Keep your data secure. Quickflow prioritizes your privacy with robust authentication features.",
34 | },
35 | {
36 | name: "Seamless Files Management",
37 | description:
38 | "Effortlessly manage your files with Quickflow. Upload, delete, and replace documents seamlessly within the platform.",
39 | },
40 | {
41 | name: "Full Mobile Responsiveness",
42 | description:
43 | "Work on the go. Quickflow is fully responsive, adapting to your device, so you can be productive anytime, anywhere.",
44 | },
45 | {
46 | name: "Publish Your Note to the Web",
47 | description:
48 | "Share your ideas effortlessly. Quickflow allows you to publish your notes to the web, reaching a broader audience.",
49 | },
50 | ];
51 |
52 | return (
53 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 | Why choose Quickflow over 100's of other note taking apps?
66 |
67 |
68 | the unique qualities that make Quickflow stand out among the sea of note-taking apps.
69 |
70 |
71 |
72 |
86 |
87 |
88 |
89 |
90 |
91 |
120 |
121 |
122 | );
123 | };
124 |
125 | export default Features;
126 |
--------------------------------------------------------------------------------
/app/(marketing)/_components/Footer.tsx:
--------------------------------------------------------------------------------
1 | import { Button } from "@/components/ui/button";
2 | import Logo from "./Logo";
3 |
4 | function Footer() {
5 | return (
6 |
7 |
8 |
9 | Privacy Policy
10 | Terms and Conditions
11 |
12 |
13 | );
14 | }
15 |
16 | export default Footer;
17 |
--------------------------------------------------------------------------------
/app/(marketing)/_components/Heading.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Spinner } from "@/components/spinner";
4 | import { Button } from "@/components/ui/button";
5 | import { SignInButton } from "@clerk/clerk-react";
6 | import { useConvexAuth } from "convex/react";
7 | import { ArrowRight } from "lucide-react";
8 | import Link from "next/link";
9 |
10 | const Heading = () => {
11 | const { isAuthenticated, isLoading } = useConvexAuth();
12 |
13 | return (
14 |
15 |
16 | Quickflow: A Better & Open-Source Notion
17 | Alternative
18 |
19 |
20 |
21 | Quickflow is the connected workspace where
22 | better, faster work happens.
23 |
24 | {isLoading && (
25 |
26 |
27 |
28 | )}
29 | {isAuthenticated && !isLoading && (
30 |
31 |
32 | Enter Quickflow
33 |
34 |
35 |
36 | )}
37 | {!isAuthenticated && !isLoading && (
38 |
39 |
40 | Get Quickflow free
41 |
42 |
43 |
44 | )}
45 |
46 | );
47 | };
48 |
49 | export default Heading;
50 |
--------------------------------------------------------------------------------
/app/(marketing)/_components/Heroes.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 | import React from "react";
3 |
4 | type Props = {};
5 |
6 | function Heroes({}: Props) {
7 | return (
8 |
9 |
10 |
11 |
17 |
23 |
24 |
25 |
26 |
27 | );
28 | }
29 |
30 | export default Heroes;
31 |
--------------------------------------------------------------------------------
/app/(marketing)/_components/Logo.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { cn } from "@/lib/utils";
4 | import { Poppins } from "next/font/google";
5 | import Image from "next/image";
6 | import { redirect } from "next/navigation";
7 |
8 | const font = Poppins({
9 | subsets: ["latin"],
10 | weight: ["400", "600"],
11 | });
12 |
13 | function Logo() {
14 | return (
15 | redirect("/")}
17 | className="hidden items-center gap-x-2 md:flex"
18 | >
19 |
26 |
33 |
Quickflow
34 |
35 | );
36 | }
37 |
38 | export default Logo;
39 |
--------------------------------------------------------------------------------
/app/(marketing)/_components/Navbar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | // imports from auth packages
4 | import { useConvexAuth } from "convex/react";
5 | import { SignInButton, UserButton } from "@clerk/clerk-react";
6 |
7 | // imports from components
8 | import { Button } from "@/components/ui/button";
9 | import { Spinner } from "@/components/spinner";
10 | import { ModeToggle } from "@/components/mode-toggle";
11 |
12 | // others
13 | import { useScrollTop } from "@/hooks/use-scroll-top";
14 | import { cn } from "@/lib/utils";
15 |
16 | import Logo from "./Logo";
17 | import Link from "next/link";
18 |
19 | const Navbar = () => {
20 | const { isAuthenticated, isLoading } = useConvexAuth();
21 | const scrolled = useScrollTop();
22 | return (
23 |
29 |
30 |
31 | {isLoading && }
32 | {!isAuthenticated && !isLoading && (
33 | <>
34 |
35 |
36 | Log in
37 |
38 |
39 |
40 | Create Account
41 |
42 | >
43 | )}
44 | {isAuthenticated && !isLoading && (
45 | <>
46 |
47 | Enter Quickflow
48 |
49 |
50 | >
51 | )}
52 |
53 |
54 |
55 | );
56 | };
57 |
58 | export default Navbar;
59 |
--------------------------------------------------------------------------------
/app/(marketing)/_components/WorkTogether.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 |
3 | const WorkTogether = () => {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 | {" "}
11 | Let's works on something togheter.{" "}
12 |
13 |
14 | Ready to take your projects to the next level? Let's collaborate
15 | and turn your vision into reality. Whether you need custom
16 | features, integrations, or consultations, I'm here to help.
17 |
18 |
19 |
23 | Explore my portfolio
24 |
30 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | );
43 | };
44 |
45 | export default WorkTogether;
46 |
--------------------------------------------------------------------------------
/app/(marketing)/layout.tsx:
--------------------------------------------------------------------------------
1 | import Navbar from "./_components/Navbar";
2 |
3 | const MarketingLayout = ({ children }: { children: React.ReactNode }) => {
4 | return (
5 |
6 |
7 |
8 |
9 | {children}
10 |
11 |
12 | );
13 | };
14 |
15 | export default MarketingLayout;
16 |
--------------------------------------------------------------------------------
/app/(marketing)/page.tsx:
--------------------------------------------------------------------------------
1 | import FAQs from "./_components/FAQs";
2 | import Features from "./_components/Features";
3 | import Footer from "./_components/Footer";
4 | import Heading from "./_components/Heading";
5 | import Heroes from "./_components/Heroes";
6 | import WorkTogether from "./_components/WorkTogether";
7 |
8 | export default function MarketingPage() {
9 | return (
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | );
23 | }
24 |
--------------------------------------------------------------------------------
/app/(public)/(routes)/preview/[documentId]/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import dynamic from "next/dynamic";
4 | import { useMemo } from "react";
5 | import { Cover } from "@/components/cover";
6 | import { Toolbar } from "@/components/toolbar";
7 | import { Skeleton } from "@/components/ui/skeleton";
8 | import { api } from "@/convex/_generated/api";
9 | import { Id } from "@/convex/_generated/dataModel";
10 | import { useMutation, useQuery } from "convex/react";
11 |
12 | interface DocumentIdPageProps {
13 | params: {
14 | documentId: Id<"documents">;
15 | };
16 | }
17 |
18 | const DocumentIdPage = ({ params }: DocumentIdPageProps) => {
19 | const Editor = useMemo(
20 | () => dynamic(() => import("@/components/editor"), { ssr: false }),
21 | [],
22 | );
23 | const document = useQuery(api.documents.getById, {
24 | documentId: params.documentId,
25 | });
26 |
27 | const update = useMutation(api.documents.update);
28 |
29 | const onChange = (content: string) => {
30 | update({
31 | id: params.documentId,
32 | content,
33 | });
34 | };
35 |
36 | if (document === undefined) {
37 | return (
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 | );
50 | }
51 |
52 | if (document === null) {
53 | return Not found
;
54 | }
55 |
56 | return (
57 |
58 |
59 |
60 |
61 |
66 |
67 |
68 | );
69 | };
70 |
71 | export default DocumentIdPage;
72 |
--------------------------------------------------------------------------------
/app/(public)/layout.tsx:
--------------------------------------------------------------------------------
1 | const PublicLayout = ({
2 | children
3 | }: {
4 | children: React.ReactNode;
5 | }) => {
6 | return (
7 |
8 | {children}
9 |
10 | );
11 | }
12 |
13 | export default PublicLayout;
--------------------------------------------------------------------------------
/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 |
26 | Something went wrong!
27 |
28 |
29 |
30 | Go back
31 |
32 |
33 |
34 | );
35 | }
36 |
37 | export default Error;
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | html,
6 | body,
7 | :root {
8 | height: 100%;
9 | }
10 |
11 | @layer base {
12 | :root {
13 | --background: 0 0% 100%;
14 | --foreground: 0 0% 3.9%;
15 |
16 | --card: 0 0% 100%;
17 | --card-foreground: 0 0% 3.9%;
18 |
19 | --popover: 0 0% 100%;
20 | --popover-foreground: 0 0% 3.9%;
21 |
22 | --primary: 0 0% 9%;
23 | --primary-foreground: 0 0% 98%;
24 |
25 | --secondary: 0 0% 96.1%;
26 | --secondary-foreground: 0 0% 9%;
27 |
28 | --muted: 0 0% 96.1%;
29 | --muted-foreground: 0 0% 45.1%;
30 |
31 | --accent: 0 0% 96.1%;
32 | --accent-foreground: 0 0% 9%;
33 |
34 | --destructive: 0 84.2% 60.2%;
35 | --destructive-foreground: 0 0% 98%;
36 |
37 | --border: 0 0% 89.8%;
38 | --input: 0 0% 89.8%;
39 | --ring: 0 0% 3.9%;
40 |
41 | --radius: 0.5rem;
42 | }
43 |
44 | .dark {
45 | --background: 0 0% 3.9%;
46 | --foreground: 0 0% 98%;
47 |
48 | --card: 0 0% 3.9%;
49 | --card-foreground: 0 0% 98%;
50 |
51 | --popover: 0 0% 3.9%;
52 | --popover-foreground: 0 0% 98%;
53 |
54 | --primary: 0 0% 98%;
55 | --primary-foreground: 0 0% 9%;
56 |
57 | --secondary: 0 0% 14.9%;
58 | --secondary-foreground: 0 0% 98%;
59 |
60 | --muted: 0 0% 14.9%;
61 | --muted-foreground: 0 0% 63.9%;
62 |
63 | --accent: 0 0% 14.9%;
64 | --accent-foreground: 0 0% 98%;
65 |
66 | --destructive: 0 62.8% 30.6%;
67 | --destructive-foreground: 0 0% 98%;
68 |
69 | --border: 0 0% 14.9%;
70 | --input: 0 0% 14.9%;
71 | --ring: 0 0% 83.1%;
72 | }
73 | }
74 |
75 | @layer base {
76 | * {
77 | @apply border-border scrollbar-none;
78 | }
79 | body {
80 | @apply bg-background text-foreground;
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Outfit } from "next/font/google";
2 | import type { Metadata } from "next";
3 |
4 | import { Toaster } from "sonner";
5 |
6 | import "./globals.css";
7 |
8 | import { ThemeProvider } from "@/components/providers/theme-provider";
9 | import { ConvexClientProvider } from "@/components/providers/convex-provider";
10 | import { ModalProvider } from "@/components/providers/modal-provider";
11 | import { EdgeStoreProvider } from "../lib/edgestore";
12 |
13 | const outfit = Outfit({
14 | weight: ["100", "200", "300", "400", "500", "600", "700", "800", "900"],
15 | subsets: ["latin"],
16 | });
17 |
18 | export const metadata: Metadata = {
19 | title: "Quickflow",
20 | description: "A Better & Open-Source Notion Alternative",
21 | icons: {
22 | icon: [
23 | {
24 | media: "(prefers-color-scheme: light)",
25 | url: "/logo.jpg",
26 | href: "/logo.jpg",
27 | },
28 | {
29 | media: "(prefers-color-scheme: dark)",
30 | url: "/logo-dark.jpg",
31 | href: "/logo-dark.jpg",
32 | },
33 | ],
34 | },
35 | };
36 |
37 | export default function RootLayout({
38 | children,
39 | }: {
40 | children: React.ReactNode;
41 | }) {
42 | return (
43 |
44 |
45 |
46 |
47 |
54 |
55 |
56 | {children}
57 |
58 |
59 |
60 |
61 |
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "app/globals.css",
9 | "baseColor": "neutral",
10 | "cssVariables": true
11 | },
12 | "aliases": {
13 | "components": "@/components",
14 | "utils": "@/lib/utils"
15 | }
16 | }
--------------------------------------------------------------------------------
/components/cover.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Image from "next/image";
4 | import { ImageIcon, XIcon } from "lucide-react";
5 | import { useMutation } from "convex/react";
6 | import { useParams } from "next/navigation";
7 |
8 | import { useCoverImage } from "@/hooks/use-cover-image";
9 |
10 | import { Button } from "@/components/ui/button";
11 | import { Skeleton } from "@/components/ui/skeleton";
12 |
13 | import { api } from "@/convex/_generated/api";
14 | import { Id } from "@/convex/_generated/dataModel";
15 |
16 | import { cn } from "@/lib/utils";
17 | import { useEdgeStore } from "@/lib/edgestore";
18 |
19 |
20 | interface CoverImageProps {
21 | url?: string;
22 | preview?: boolean;
23 | }
24 |
25 | export const Cover = ({ url, preview }: CoverImageProps) => {
26 | const coverImage = useCoverImage();
27 | const removeImage = useMutation(api.documents.removeCoverImage);
28 | const params = useParams();
29 | const { edgestore } = useEdgeStore();
30 |
31 | const onRemoveCoverImage = async () => {
32 | if (url) {
33 | await edgestore.publicFiles.delete({
34 | url: url,
35 | });
36 | }
37 | removeImage({ id: params.documentId as Id<"documents"> });
38 | };
39 |
40 | return (
41 |
48 | {!!url &&
}
49 |
50 | {url && !preview && (
51 |
52 | coverImage.onReplace(url)}
54 | className="text-xs text-muted-foreground"
55 | variant="outline"
56 | size="sm"
57 | >
58 |
59 | Change cover
60 |
61 |
67 |
68 |
69 | Remove
70 |
71 |
72 |
73 | )}
74 |
75 | );
76 | };
77 |
78 | Cover.Skeleton = function CoverSkeleton() {
79 | return ;
80 | };
81 |
--------------------------------------------------------------------------------
/components/editor.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { BlockNoteEditor, PartialBlock } from "@blocknote/core";
4 | import { BlockNoteView, useBlockNote } from "@blocknote/react";
5 | import "@blocknote/core/style.css";
6 | import { useTheme } from "next-themes";
7 | import { useEdgeStore } from "@/lib/edgestore";
8 |
9 | interface EditorProps {
10 | onChange: (value: string) => void;
11 | initialContent?: string;
12 | editable?: boolean;
13 | }
14 |
15 | const Editor = ({ onChange, initialContent, editable }: EditorProps) => {
16 | const { resolvedTheme } = useTheme();
17 | const { edgestore } = useEdgeStore();
18 |
19 | const handleUpload = async (file: File) => {
20 | const res = await edgestore.publicFiles.upload({
21 | file,
22 | });
23 |
24 | return res.url;
25 | };
26 |
27 | const editor: BlockNoteEditor = useBlockNote({
28 | editable,
29 | initialContent: initialContent
30 | ? (JSON.parse(initialContent) as PartialBlock[])
31 | : undefined,
32 | onEditorContentChange: (editor) => {
33 | onChange(JSON.stringify(editor.topLevelBlocks, null, 2));
34 | },
35 | uploadFile: handleUpload,
36 | });
37 |
38 | return (
39 |
40 |
44 |
45 | );
46 | };
47 |
48 | export default Editor;
49 |
--------------------------------------------------------------------------------
/components/icon-picker.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import EmojiPicker, { Theme, EmojiStyle } 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 IconPickerProps {
13 | onChange: (icon: string) => void;
14 | children: React.ReactNode;
15 | asChild?: boolean;
16 | }
17 |
18 | export const IconPicker = ({
19 | children,
20 | onChange,
21 | asChild,
22 | }: IconPickerProps) => {
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 | return (
33 |
34 | {children}
35 |
36 | onChange(data.emoji)}
40 | emojiStyle={EmojiStyle.FACEBOOK}
41 | />
42 |
43 |
44 | );
45 | };
46 |
--------------------------------------------------------------------------------
/components/modals/confirm-modal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | AlertDialog,
5 | AlertDialogAction,
6 | AlertDialogCancel,
7 | AlertDialogContent,
8 | AlertDialogDescription,
9 | AlertDialogFooter,
10 | AlertDialogHeader,
11 | AlertDialogTitle,
12 | AlertDialogTrigger,
13 | } from "@/components/ui/alert-dialog";
14 |
15 | interface ConfirmModalProps {
16 | children: React.ReactNode;
17 | onConfirm: () => void;
18 | }
19 |
20 | export const ConfirmModal = ({ children, onConfirm }: ConfirmModalProps) => {
21 | const handleConfirm = (
22 | e: React.MouseEvent,
23 | ) => {
24 | e.stopPropagation();
25 | onConfirm();
26 | };
27 |
28 | return (
29 |
30 | e.stopPropagation()} asChild>
31 | {children}
32 |
33 |
34 |
35 | Are you absolutely sure?
36 |
37 | This action cannot be undone.
38 |
39 |
40 |
41 | e.stopPropagation()}>
42 | Cancel
43 |
44 | Confirm
45 |
46 |
47 |
48 | );
49 | };
50 |
--------------------------------------------------------------------------------
/components/modals/cover-image-modal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState } from "react";
4 | import { useParams } from "next/navigation";
5 | import { useMutation } from "convex/react";
6 |
7 | import { SingleImageDropzone } from "@/components/single-image-dropzone";
8 | import { Dialog, DialogHeader, DialogContent } from "@/components/ui/dialog";
9 |
10 | import { api } from "@/convex/_generated/api";
11 |
12 | import { useEdgeStore } from "@/lib/edgestore";
13 | import { useCoverImage } from "@/hooks/use-cover-image";
14 | import { Id } from "@/convex/_generated/dataModel";
15 |
16 | export const CoverImageModal = () => {
17 | const params = useParams();
18 | const update = useMutation(api.documents.update);
19 |
20 | const [file, setFile] = useState();
21 | const [isSubmitting, setIsSubmitting] = useState(false);
22 |
23 | const coverImage = useCoverImage();
24 | const { edgestore } = useEdgeStore();
25 |
26 | const onChange = async (file?: File) => {
27 | if (file) {
28 | setFile(file);
29 | setIsSubmitting(true);
30 |
31 | let res;
32 |
33 | if (coverImage.url) {
34 | res = await edgestore.publicFiles.upload({
35 | file: file,
36 | options: {
37 | replaceTargetUrl: coverImage.url,
38 | },
39 | });
40 | } else {
41 | res = await edgestore.publicFiles.upload({ file });
42 | }
43 |
44 | await update({
45 | id: params.documentId as Id<"documents">,
46 | coverImage: res.url,
47 | });
48 |
49 | onClose();
50 | }
51 | };
52 |
53 | const onClose = () => {
54 | setFile(undefined);
55 | setIsSubmitting(false);
56 | coverImage.onClose();
57 | };
58 |
59 | return (
60 |
61 |
62 |
63 | Cover Image
64 |
65 |
71 |
72 |
73 | );
74 | };
75 |
--------------------------------------------------------------------------------
/components/modals/settings-modal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useSettings } from "@/hooks/use-settings";
4 | import { Dialog, DialogContent, DialogHeader } from "@/components/ui/dialog";
5 | import { Label } from "@/components/ui/label";
6 | import { ModeToggle } from "@/components/mode-toggle";
7 |
8 | export const SettingsModal = () => {
9 | const settings = useSettings();
10 |
11 | return (
12 |
13 |
14 |
15 | My settings
16 |
17 |
18 |
19 | Appearance
20 |
21 | Customize how Quickflow looks on your device
22 |
23 |
24 |
25 |
26 |
27 |
28 | );
29 | };
30 |
--------------------------------------------------------------------------------
/components/mode-toggle.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { Moon, Sun } from "lucide-react";
5 | import { useTheme } from "next-themes";
6 |
7 | import { Button } from "@/components/ui/button";
8 | import {
9 | DropdownMenu,
10 | DropdownMenuContent,
11 | DropdownMenuItem,
12 | DropdownMenuTrigger,
13 | } from "@/components/ui/dropdown-menu";
14 |
15 | export function ModeToggle() {
16 | const { setTheme } = useTheme();
17 |
18 | return (
19 |
20 |
21 |
22 |
23 |
24 | Toggle theme
25 |
26 |
27 |
28 | setTheme("light")}>
29 | Light
30 |
31 | setTheme("dark")}>
32 | Dark
33 |
34 | setTheme("system")}>
35 | System
36 |
37 |
38 |
39 | );
40 | }
41 |
--------------------------------------------------------------------------------
/components/providers/convex-provider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ClerkProvider, useAuth } from "@clerk/clerk-react";
4 | import { ConvexReactClient } from "convex/react";
5 | import { ConvexProviderWithClerk } from "convex/react-clerk";
6 | import { ReactNode } from "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 { useState, useEffect } from "react";
4 |
5 | import { SettingsModal } from "@/components/modals/settings-modal";
6 | import { CoverImageModal } from "@/components/modals/cover-image-modal";
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/use-search";
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 === "q" && (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 |
60 |
61 | No results found.
62 |
63 | {documents?.map((document) => (
64 | onSelect(document._id)}
69 | >
70 | {document.icon ? (
71 |
72 | {document.icon}
73 |
74 | ) : (
75 |
76 | )}
77 |
78 | {document.title}
79 |
80 |
81 | ))}
82 |
83 |
84 |
85 | )
86 | }
--------------------------------------------------------------------------------
/components/single-image-dropzone.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { UploadCloudIcon, X } from "lucide-react";
4 | import * as React from "react";
5 | import { useDropzone, type DropzoneOptions } from "react-dropzone";
6 | import { twMerge } from "tailwind-merge";
7 | import { Spinner } from "./spinner";
8 |
9 | const variants = {
10 | base: "relative rounded-md flex justify-center items-center flex-col cursor-pointer min-h-[150px] min-w-[200px] border border-dashed border-gray-400 dark:border-gray-300 transition-colors duration-200 ease-in-out",
11 | image:
12 | "border-0 p-0 min-h-0 min-w-0 relative shadow-md bg-slate-200 dark:bg-slate-900 rounded-md",
13 | active: "border-2",
14 | disabled:
15 | "bg-gray-200 border-gray-300 cursor-default pointer-events-none bg-opacity-30 dark:bg-gray-700",
16 | accept: "border border-blue-500 bg-blue-500 bg-opacity-10",
17 | reject: "border border-red-700 bg-red-700 bg-opacity-10",
18 | };
19 |
20 | type InputProps = {
21 | width?: number;
22 | height?: number;
23 | className?: string;
24 | value?: File | string;
25 | onChange?: (file?: File) => void | Promise;
26 | disabled?: boolean;
27 | dropzoneOptions?: Omit;
28 | };
29 |
30 | const ERROR_MESSAGES = {
31 | fileTooLarge(maxSize: number) {
32 | return `The file is too large. Max size is ${formatFileSize(maxSize)}.`;
33 | },
34 | fileInvalidType() {
35 | return "Invalid file type.";
36 | },
37 | tooManyFiles(maxFiles: number) {
38 | return `You can only add ${maxFiles} file(s).`;
39 | },
40 | fileNotSupported() {
41 | return "The file is not supported.";
42 | },
43 | };
44 |
45 | const SingleImageDropzone = React.forwardRef(
46 | (
47 | { dropzoneOptions, width, height, value, className, disabled, onChange },
48 | ref,
49 | ) => {
50 | const imageUrl = React.useMemo(() => {
51 | if (typeof value === "string") {
52 | // in case a url is passed in, use it to display the image
53 | return value;
54 | } else if (value) {
55 | // in case a file is passed in, create a base64 url to display the image
56 | return URL.createObjectURL(value);
57 | }
58 | return null;
59 | }, [value]);
60 |
61 | // dropzone configuration
62 | const {
63 | getRootProps,
64 | getInputProps,
65 | acceptedFiles,
66 | fileRejections,
67 | isFocused,
68 | isDragAccept,
69 | isDragReject,
70 | } = useDropzone({
71 | accept: { "image/*": [] },
72 | multiple: false,
73 | disabled,
74 | onDrop: (acceptedFiles) => {
75 | const file = acceptedFiles[0];
76 | if (file) {
77 | void onChange?.(file);
78 | }
79 | },
80 | ...dropzoneOptions,
81 | });
82 |
83 | // styling
84 | const dropZoneClassName = React.useMemo(
85 | () =>
86 | twMerge(
87 | variants.base,
88 | isFocused && variants.active,
89 | disabled && variants.disabled,
90 | imageUrl && variants.image,
91 | (isDragReject ?? fileRejections[0]) && variants.reject,
92 | isDragAccept && variants.accept,
93 | className,
94 | ).trim(),
95 | [
96 | isFocused,
97 | imageUrl,
98 | fileRejections,
99 | isDragAccept,
100 | isDragReject,
101 | disabled,
102 | className,
103 | ],
104 | );
105 |
106 | // error validation messages
107 | const errorMessage = React.useMemo(() => {
108 | if (fileRejections[0]) {
109 | const { errors } = fileRejections[0];
110 | if (errors[0]?.code === "file-too-large") {
111 | return ERROR_MESSAGES.fileTooLarge(dropzoneOptions?.maxSize ?? 0);
112 | } else if (errors[0]?.code === "file-invalid-type") {
113 | return ERROR_MESSAGES.fileInvalidType();
114 | } else if (errors[0]?.code === "too-many-files") {
115 | return ERROR_MESSAGES.tooManyFiles(dropzoneOptions?.maxFiles ?? 0);
116 | } else {
117 | return ERROR_MESSAGES.fileNotSupported();
118 | }
119 | }
120 | return undefined;
121 | }, [fileRejections, dropzoneOptions]);
122 |
123 | return (
124 |
125 | {disabled && (
126 |
127 |
128 |
129 | )}
130 |
139 | {/* Main File Input */}
140 |
141 |
142 | {imageUrl ? (
143 | // Image Preview
144 |
149 | ) : (
150 | // Upload Icon
151 |
152 |
153 |
154 | Click or drag file here to upload
155 |
156 |
157 | )}
158 |
159 | {/* Remove Image Icon */}
160 | {imageUrl && !disabled && (
161 |
{
164 | e.stopPropagation();
165 | void onChange?.(undefined);
166 | }}
167 | >
168 |
169 |
174 |
175 |
176 | )}
177 |
178 |
179 | {/* Error Text */}
180 |
{errorMessage}
181 |
182 | );
183 | },
184 | );
185 | SingleImageDropzone.displayName = "SingleImageDropzone";
186 |
187 | const Button = React.forwardRef<
188 | HTMLButtonElement,
189 | React.ButtonHTMLAttributes
190 | >(({ className, ...props }, ref) => {
191 | return (
192 |
205 | );
206 | });
207 | Button.displayName = "Button";
208 |
209 | function formatFileSize(bytes?: number) {
210 | if (!bytes) {
211 | return "0 Bytes";
212 | }
213 | bytes = Number(bytes);
214 | if (bytes === 0) {
215 | return "0 Bytes";
216 | }
217 | const k = 1024;
218 | const dm = 2;
219 | const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
220 | const i = Math.floor(Math.log(bytes) / Math.log(k));
221 | return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
222 | }
223 |
224 | export { SingleImageDropzone };
225 |
--------------------------------------------------------------------------------
/components/spinner.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils";
2 | import { cva, type VariantProps } from "class-variance-authority";
3 | import { Loader } from "lucide-react";
4 |
5 | const spinnerVariants = cva("text-muted-foreground animate-spin", {
6 | variants: {
7 | size: {
8 | default: "h-4 w-4",
9 | sm: "h-2 w-2",
10 | lg: "h-6 w-6",
11 | icon: "h-10 w-10",
12 | },
13 | },
14 | defaultVariants: {
15 | size: "default",
16 | },
17 | });
18 |
19 | interface SpinnerProps extends VariantProps {}
20 |
21 | export const Spinner = ({ size }: SpinnerProps) => {
22 | return ;
23 | };
24 |
--------------------------------------------------------------------------------
/components/toolbar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ElementRef, useRef, useState } from "react";
4 |
5 | import { Doc } from "@/convex/_generated/dataModel";
6 | import { api } from "@/convex/_generated/api";
7 | import { Button } from "@/components/ui/button";
8 |
9 | import { useMutation } from "convex/react";
10 | import { useCoverImage } from "@/hooks/use-cover-image";
11 |
12 | import { IconPicker } from "./icon-picker";
13 | import { ImageIcon, SmileIcon, X } from "lucide-react";
14 | import TextareaAutoSize from "react-textarea-autosize";
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 |
30 | const coverImage = useCoverImage();
31 |
32 | const enableInput = () => {
33 | if (preview) return;
34 |
35 | setIsEditing(true);
36 | setTimeout(() => {
37 | setValue(initialData.title);
38 | inputRef.current?.focus();
39 | }, 0);
40 | };
41 |
42 | const disableInput = () => setIsEditing(false);
43 |
44 | const onInput = (val: string) => {
45 | setValue(val);
46 | update({
47 | id: initialData._id,
48 | title: val || "Untitled",
49 | });
50 | };
51 |
52 | const onIconSelect = (icon: string) => {
53 | update({ id: initialData._id, icon });
54 | };
55 | const onRemoveIcon = () => {
56 | removeIcon({ id: initialData._id });
57 | };
58 |
59 | const onKeyDown = (e: React.KeyboardEvent) => {
60 | if (e.key === "Enter") {
61 | e.preventDefault();
62 | disableInput();
63 | }
64 | };
65 |
66 | return (
67 |
68 | {!!initialData.icon && !preview && (
69 |
70 |
71 |
72 | {initialData.icon}
73 |
74 |
75 |
81 |
82 |
83 |
84 | )}
85 |
86 | {!!initialData.icon && preview && (
87 |
{initialData.icon}
88 | )}
89 |
90 |
91 | {!initialData.icon && !preview && (
92 |
93 |
98 |
99 | Add icon
100 |
101 |
102 | )}
103 | {!initialData.coverImage && !preview && (
104 |
110 | Add cover
111 |
112 | )}
113 |
114 |
115 | {isEditing && !preview ? (
116 |
onInput(e.target.value)}
122 | className="resize-none break-words bg-transparent text-5xl font-bold text-[#3f3f3f] outline-none dark:text-[#cfcfcf]"
123 | >
124 | ) : (
125 |
129 | {initialData.title}
130 |
131 | )}
132 |
133 | );
134 | };
135 |
--------------------------------------------------------------------------------
/components/ui/alert-dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
5 |
6 | import { cn } from "@/lib/utils"
7 | import { buttonVariants } from "@/components/ui/button"
8 |
9 | const AlertDialog = AlertDialogPrimitive.Root
10 |
11 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger
12 |
13 | const AlertDialogPortal = AlertDialogPrimitive.Portal
14 |
15 | const AlertDialogOverlay = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, children, ...props }, ref) => (
19 |
27 | ))
28 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
29 |
30 | const AlertDialogContent = React.forwardRef<
31 | React.ElementRef,
32 | React.ComponentPropsWithoutRef
33 | >(({ className, ...props }, ref) => (
34 |
35 |
36 |
44 |
45 | ))
46 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
47 |
48 | const AlertDialogHeader = ({
49 | className,
50 | ...props
51 | }: React.HTMLAttributes) => (
52 |
59 | )
60 | AlertDialogHeader.displayName = "AlertDialogHeader"
61 |
62 | const AlertDialogFooter = ({
63 | className,
64 | ...props
65 | }: React.HTMLAttributes) => (
66 |
73 | )
74 | AlertDialogFooter.displayName = "AlertDialogFooter"
75 |
76 | const AlertDialogTitle = React.forwardRef<
77 | React.ElementRef,
78 | React.ComponentPropsWithoutRef
79 | >(({ className, ...props }, ref) => (
80 |
85 | ))
86 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
87 |
88 | const AlertDialogDescription = React.forwardRef<
89 | React.ElementRef,
90 | React.ComponentPropsWithoutRef
91 | >(({ className, ...props }, ref) => (
92 |
97 | ))
98 | AlertDialogDescription.displayName =
99 | AlertDialogPrimitive.Description.displayName
100 |
101 | const AlertDialogAction = React.forwardRef<
102 | React.ElementRef,
103 | React.ComponentPropsWithoutRef
104 | >(({ className, ...props }, ref) => (
105 |
110 | ))
111 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
112 |
113 | const AlertDialogCancel = React.forwardRef<
114 | React.ElementRef,
115 | React.ComponentPropsWithoutRef
116 | >(({ className, ...props }, ref) => (
117 |
126 | ))
127 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
128 |
129 | export {
130 | AlertDialog,
131 | AlertDialogPortal,
132 | AlertDialogOverlay,
133 | AlertDialogTrigger,
134 | AlertDialogContent,
135 | AlertDialogHeader,
136 | AlertDialogFooter,
137 | AlertDialogTitle,
138 | AlertDialogDescription,
139 | AlertDialogAction,
140 | AlertDialogCancel,
141 | }
142 |
--------------------------------------------------------------------------------
/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AvatarPrimitive from "@radix-ui/react-avatar"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Avatar = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 | ))
21 | Avatar.displayName = AvatarPrimitive.Root.displayName
22 |
23 | const AvatarImage = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => (
27 |
32 | ))
33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName
34 |
35 | const AvatarFallback = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 | ))
48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
49 |
50 | export { Avatar, AvatarImage, AvatarFallback }
51 |
--------------------------------------------------------------------------------
/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default: "bg-primary text-primary-foreground hover:bg-primary/90",
13 | destructive:
14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90",
15 | outline:
16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
17 | secondary:
18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80",
19 | ghost: "hover:bg-accent hover:text-accent-foreground",
20 | link: "text-primary underline-offset-4 hover:underline",
21 | },
22 | size: {
23 | default: "h-10 px-4 py-2",
24 | sm: "h-9 rounded-md px-3",
25 | lg: "h-11 rounded-md px-8",
26 | icon: "h-10 w-10",
27 | },
28 | },
29 | defaultVariants: {
30 | variant: "default",
31 | size: "default",
32 | },
33 | }
34 | )
35 |
36 | export interface ButtonProps
37 | extends React.ButtonHTMLAttributes,
38 | VariantProps {
39 | asChild?: boolean
40 | }
41 |
42 | const Button = React.forwardRef(
43 | ({ className, variant, size, asChild = false, ...props }, ref) => {
44 | const Comp = asChild ? Slot : "button"
45 | return (
46 |
51 | )
52 | }
53 | )
54 | Button.displayName = "Button"
55 |
56 | export { Button, buttonVariants }
57 |
--------------------------------------------------------------------------------
/components/ui/command.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import { DialogProps } from "@radix-ui/react-dialog"
5 | import { Command as CommandPrimitive } from "cmdk"
6 | import { Search } from "lucide-react"
7 |
8 | import { cn } from "@/lib/utils"
9 | import { Dialog, DialogContent } from "@/components/ui/dialog"
10 |
11 | const Command = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef
14 | >(({ className, ...props }, ref) => (
15 |
23 | ))
24 | Command.displayName = CommandPrimitive.displayName
25 |
26 | interface CommandDialogProps extends DialogProps {}
27 |
28 | const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
29 | return (
30 |
31 |
32 |
33 | {children}
34 |
35 |
36 |
37 | )
38 | }
39 |
40 | const CommandInput = React.forwardRef<
41 | React.ElementRef,
42 | React.ComponentPropsWithoutRef
43 | >(({ className, ...props }, ref) => (
44 |
45 |
46 |
54 |
55 | ))
56 |
57 | CommandInput.displayName = CommandPrimitive.Input.displayName
58 |
59 | const CommandList = React.forwardRef<
60 | React.ElementRef,
61 | React.ComponentPropsWithoutRef
62 | >(({ className, ...props }, ref) => (
63 |
68 | ))
69 |
70 | CommandList.displayName = CommandPrimitive.List.displayName
71 |
72 | const CommandEmpty = React.forwardRef<
73 | React.ElementRef,
74 | React.ComponentPropsWithoutRef
75 | >((props, ref) => (
76 |
81 | ))
82 |
83 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName
84 |
85 | const CommandGroup = React.forwardRef<
86 | React.ElementRef,
87 | React.ComponentPropsWithoutRef
88 | >(({ className, ...props }, ref) => (
89 |
97 | ))
98 |
99 | CommandGroup.displayName = CommandPrimitive.Group.displayName
100 |
101 | const CommandSeparator = React.forwardRef<
102 | React.ElementRef,
103 | React.ComponentPropsWithoutRef
104 | >(({ className, ...props }, ref) => (
105 |
110 | ))
111 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName
112 |
113 | const CommandItem = React.forwardRef<
114 | React.ElementRef,
115 | React.ComponentPropsWithoutRef
116 | >(({ className, ...props }, ref) => (
117 |
125 | ))
126 |
127 | CommandItem.displayName = CommandPrimitive.Item.displayName
128 |
129 | const CommandShortcut = ({
130 | className,
131 | ...props
132 | }: React.HTMLAttributes) => {
133 | return (
134 |
141 | )
142 | }
143 | CommandShortcut.displayName = "CommandShortcut"
144 |
145 | export {
146 | Command,
147 | CommandDialog,
148 | CommandInput,
149 | CommandList,
150 | CommandEmpty,
151 | CommandGroup,
152 | CommandItem,
153 | CommandShortcut,
154 | CommandSeparator,
155 | }
156 |
--------------------------------------------------------------------------------
/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DialogPrimitive from "@radix-ui/react-dialog"
5 | import { X } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const Dialog = DialogPrimitive.Root
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger
12 |
13 | const DialogPortal = DialogPrimitive.Portal
14 |
15 | const DialogClose = DialogPrimitive.Close
16 |
17 | const DialogOverlay = React.forwardRef<
18 | React.ElementRef,
19 | React.ComponentPropsWithoutRef
20 | >(({ className, ...props }, ref) => (
21 |
29 | ))
30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
31 |
32 | const DialogContent = React.forwardRef<
33 | React.ElementRef,
34 | React.ComponentPropsWithoutRef
35 | >(({ className, children, ...props }, ref) => (
36 |
37 |
38 |
46 | {children}
47 |
48 |
49 | Close
50 |
51 |
52 |
53 | ))
54 | DialogContent.displayName = DialogPrimitive.Content.displayName
55 |
56 | const DialogHeader = ({
57 | className,
58 | ...props
59 | }: React.HTMLAttributes) => (
60 |
67 | )
68 | DialogHeader.displayName = "DialogHeader"
69 |
70 | const DialogFooter = ({
71 | className,
72 | ...props
73 | }: React.HTMLAttributes) => (
74 |
81 | )
82 | DialogFooter.displayName = "DialogFooter"
83 |
84 | const DialogTitle = React.forwardRef<
85 | React.ElementRef,
86 | React.ComponentPropsWithoutRef
87 | >(({ className, ...props }, ref) => (
88 |
96 | ))
97 | DialogTitle.displayName = DialogPrimitive.Title.displayName
98 |
99 | const DialogDescription = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ))
109 | DialogDescription.displayName = DialogPrimitive.Description.displayName
110 |
111 | export {
112 | Dialog,
113 | DialogPortal,
114 | DialogOverlay,
115 | DialogClose,
116 | DialogTrigger,
117 | DialogContent,
118 | DialogHeader,
119 | DialogFooter,
120 | DialogTitle,
121 | DialogDescription,
122 | }
123 |
--------------------------------------------------------------------------------
/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
5 | import { Check, ChevronRight, Circle } from "lucide-react"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const DropdownMenu = DropdownMenuPrimitive.Root
10 |
11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
12 |
13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group
14 |
15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal
16 |
17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub
18 |
19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
20 |
21 | const DropdownMenuSubTrigger = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef & {
24 | inset?: boolean
25 | }
26 | >(({ className, inset, children, ...props }, ref) => (
27 |
36 | {children}
37 |
38 |
39 | ))
40 | DropdownMenuSubTrigger.displayName =
41 | DropdownMenuPrimitive.SubTrigger.displayName
42 |
43 | const DropdownMenuSubContent = React.forwardRef<
44 | React.ElementRef,
45 | React.ComponentPropsWithoutRef
46 | >(({ className, ...props }, ref) => (
47 |
55 | ))
56 | DropdownMenuSubContent.displayName =
57 | DropdownMenuPrimitive.SubContent.displayName
58 |
59 | const DropdownMenuContent = React.forwardRef<
60 | React.ElementRef,
61 | React.ComponentPropsWithoutRef
62 | >(({ className, sideOffset = 4, ...props }, ref) => (
63 |
64 |
73 |
74 | ))
75 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
76 |
77 | const DropdownMenuItem = React.forwardRef<
78 | React.ElementRef,
79 | React.ComponentPropsWithoutRef & {
80 | inset?: boolean
81 | }
82 | >(({ className, inset, ...props }, ref) => (
83 |
92 | ))
93 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
94 |
95 | const DropdownMenuCheckboxItem = React.forwardRef<
96 | React.ElementRef,
97 | React.ComponentPropsWithoutRef
98 | >(({ className, children, checked, ...props }, ref) => (
99 |
108 |
109 |
110 |
111 |
112 |
113 | {children}
114 |
115 | ))
116 | DropdownMenuCheckboxItem.displayName =
117 | DropdownMenuPrimitive.CheckboxItem.displayName
118 |
119 | const DropdownMenuRadioItem = React.forwardRef<
120 | React.ElementRef,
121 | React.ComponentPropsWithoutRef
122 | >(({ className, children, ...props }, ref) => (
123 |
131 |
132 |
133 |
134 |
135 |
136 | {children}
137 |
138 | ))
139 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
140 |
141 | const DropdownMenuLabel = React.forwardRef<
142 | React.ElementRef,
143 | React.ComponentPropsWithoutRef & {
144 | inset?: boolean
145 | }
146 | >(({ className, inset, ...props }, ref) => (
147 |
156 | ))
157 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
158 |
159 | const DropdownMenuSeparator = React.forwardRef<
160 | React.ElementRef,
161 | React.ComponentPropsWithoutRef
162 | >(({ className, ...props }, ref) => (
163 |
168 | ))
169 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
170 |
171 | const DropdownMenuShortcut = ({
172 | className,
173 | ...props
174 | }: React.HTMLAttributes) => {
175 | return (
176 |
180 | )
181 | }
182 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
183 |
184 | export {
185 | DropdownMenu,
186 | DropdownMenuTrigger,
187 | DropdownMenuContent,
188 | DropdownMenuItem,
189 | DropdownMenuCheckboxItem,
190 | DropdownMenuRadioItem,
191 | DropdownMenuLabel,
192 | DropdownMenuSeparator,
193 | DropdownMenuShortcut,
194 | DropdownMenuGroup,
195 | DropdownMenuPortal,
196 | DropdownMenuSub,
197 | DropdownMenuSubContent,
198 | DropdownMenuSubTrigger,
199 | DropdownMenuRadioGroup,
200 | }
201 |
--------------------------------------------------------------------------------
/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | )
21 | }
22 | )
23 | Input.displayName = "Input"
24 |
25 | export { Input }
26 |
--------------------------------------------------------------------------------
/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11 | )
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ))
24 | Label.displayName = LabelPrimitive.Root.displayName
25 |
26 | export { Label }
27 |
--------------------------------------------------------------------------------
/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as PopoverPrimitive from "@radix-ui/react-popover";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const Popover = PopoverPrimitive.Root;
9 |
10 | const PopoverTrigger = PopoverPrimitive.Trigger;
11 |
12 | const PopoverContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
16 |
17 |
27 |
28 | ));
29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName;
30 |
31 | export { Popover, PopoverTrigger, PopoverContent };
32 |
--------------------------------------------------------------------------------
/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils"
2 |
3 | function Skeleton({
4 | className,
5 | ...props
6 | }: React.HTMLAttributes) {
7 | return (
8 |
12 | )
13 | }
14 |
15 | export { Skeleton }
16 |
--------------------------------------------------------------------------------
/convex/README.md:
--------------------------------------------------------------------------------
1 | # Welcome to your Convex functions directory!
2 |
3 | Write your Convex functions here. See
4 | https://docs.convex.dev/using/writing-convex-functions for more.
5 |
6 | A query function that takes two arguments looks like:
7 |
8 | ```ts
9 | // functions.js
10 | import { query } from "./_generated/server";
11 | import { v } from "convex/values";
12 |
13 | export const myQueryFunction = query({
14 | // Validators for arguments.
15 | args: {
16 | first: v.number(),
17 | second: v.string(),
18 | },
19 |
20 | // Function implementation.
21 | handler: async (ctx, args) => {
22 | // Read the database as many times as you need here.
23 | // See https://docs.convex.dev/database/reading-data.
24 | const documents = await ctx.db.query("tablename").collect();
25 |
26 | // Arguments passed from the client are properties of the args object.
27 | console.log(args.first, args.second);
28 |
29 | // Write arbitrary JavaScript here: filter, aggregate, build derived data,
30 | // remove non-public properties, or create new objects.
31 | return documents;
32 | },
33 | });
34 | ```
35 |
36 | Using this query function in a React component looks like:
37 |
38 | ```ts
39 | const data = useQuery(api.functions.myQueryFunction, {
40 | first: 10,
41 | second: "hello",
42 | });
43 | ```
44 |
45 | A mutation function looks like:
46 |
47 | ```ts
48 | // functions.js
49 | import { mutation } from "./_generated/server";
50 | import { v } from "convex/values";
51 |
52 | export const myMutationFunction = mutation({
53 | // Validators for arguments.
54 | args: {
55 | first: v.string(),
56 | second: v.string(),
57 | },
58 |
59 | // Function implementation.
60 | handler: async (ctx, args) => {
61 | // Insert or modify documents in the database here.
62 | // Mutations can also read from the database like queries.
63 | // See https://docs.convex.dev/database/writing-data.
64 | const message = { body: args.first, author: args.second };
65 | const id = await ctx.db.insert("messages", message);
66 |
67 | // Optionally, return a value from your mutation.
68 | return await ctx.db.get(id);
69 | },
70 | });
71 | ```
72 |
73 | Using this mutation function in a React component looks like:
74 |
75 | ```ts
76 | const mutation = useMutation(api.functions.myMutationFunction);
77 | function handleButtonPress() {
78 | // fire and forget, the most common way to use mutations
79 | mutation({ first: "Hello!", second: "me" });
80 | // OR
81 | // use the result once the mutation has completed
82 | mutation({ first: "Hello!", second: "me" }).then((result) =>
83 | console.log(result)
84 | );
85 | }
86 | ```
87 |
88 | Use the Convex CLI to push your functions to a deployment. See everything
89 | the Convex CLI can do by running `npx convex -h` in your project root
90 | directory. To learn more, launch the docs with `npx convex docs`.
91 |
--------------------------------------------------------------------------------
/convex/_generated/api.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated `api` utility.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * Generated by convex@1.4.1.
8 | * To regenerate, run `npx convex dev`.
9 | * @module
10 | */
11 |
12 | import type {
13 | ApiFromModules,
14 | FilterApi,
15 | FunctionReference,
16 | } from "convex/server";
17 | import type * as documents from "../documents";
18 |
19 | /**
20 | * A utility for referencing Convex functions in your app's API.
21 | *
22 | * Usage:
23 | * ```js
24 | * const myFunctionReference = api.myModule.myFunction;
25 | * ```
26 | */
27 | declare const fullApi: ApiFromModules<{
28 | documents: typeof documents;
29 | }>;
30 | export declare const api: FilterApi<
31 | typeof fullApi,
32 | FunctionReference
33 | >;
34 | export declare const internal: FilterApi<
35 | typeof fullApi,
36 | FunctionReference
37 | >;
38 |
--------------------------------------------------------------------------------
/convex/_generated/api.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated `api` utility.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * Generated by convex@1.4.1.
8 | * To regenerate, run `npx convex dev`.
9 | * @module
10 | */
11 |
12 | import { anyApi } from "convex/server";
13 |
14 | /**
15 | * A utility for referencing Convex functions in your app's API.
16 | *
17 | * Usage:
18 | * ```js
19 | * const myFunctionReference = api.myModule.myFunction;
20 | * ```
21 | */
22 | export const api = anyApi;
23 | export const internal = anyApi;
24 |
--------------------------------------------------------------------------------
/convex/_generated/dataModel.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated data model types.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * Generated by convex@1.4.1.
8 | * To regenerate, run `npx convex dev`.
9 | * @module
10 | */
11 |
12 | import type { DataModelFromSchemaDefinition } from "convex/server";
13 | import type { DocumentByName, TableNamesInDataModel } from "convex/server";
14 | import type { GenericId } from "convex/values";
15 | import schema from "../schema";
16 |
17 | /**
18 | * The names of all of your Convex tables.
19 | */
20 | export type TableNames = TableNamesInDataModel;
21 |
22 | /**
23 | * The type of a document stored in Convex.
24 | *
25 | * @typeParam TableName - A string literal type of the table name (like "users").
26 | */
27 | export type Doc = DocumentByName<
28 | DataModel,
29 | TableName
30 | >;
31 |
32 | /**
33 | * An identifier for a document in Convex.
34 | *
35 | * Convex documents are uniquely identified by their `Id`, which is accessible
36 | * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
37 | *
38 | * Documents can be loaded using `db.get(id)` in query and mutation functions.
39 | *
40 | * IDs are just strings at runtime, but this type can be used to distinguish them from other
41 | * strings when type checking.
42 | *
43 | * @typeParam TableName - A string literal type of the table name (like "users").
44 | */
45 | export type Id = GenericId;
46 |
47 | /**
48 | * A type describing your Convex data model.
49 | *
50 | * This type includes information about what tables you have, the type of
51 | * documents stored in those tables, and the indexes defined on them.
52 | *
53 | * This type is used to parameterize methods like `queryGeneric` and
54 | * `mutationGeneric` to make them type-safe.
55 | */
56 | export type DataModel = DataModelFromSchemaDefinition;
57 |
--------------------------------------------------------------------------------
/convex/_generated/server.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated utilities for implementing server-side Convex query and mutation functions.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * Generated by convex@1.4.1.
8 | * To regenerate, run `npx convex dev`.
9 | * @module
10 | */
11 |
12 | import {
13 | ActionBuilder,
14 | HttpActionBuilder,
15 | MutationBuilder,
16 | QueryBuilder,
17 | GenericActionCtx,
18 | GenericMutationCtx,
19 | GenericQueryCtx,
20 | GenericDatabaseReader,
21 | GenericDatabaseWriter,
22 | } from "convex/server";
23 | import type { DataModel } from "./dataModel.js";
24 |
25 | /**
26 | * Define a query in this Convex app's public API.
27 | *
28 | * This function will be allowed to read your Convex database and will be accessible from the client.
29 | *
30 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
31 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
32 | */
33 | export declare const query: QueryBuilder;
34 |
35 | /**
36 | * Define a query that is only accessible from other Convex functions (but not from the client).
37 | *
38 | * This function will be allowed to read from your Convex database. It will not be accessible from the client.
39 | *
40 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
41 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
42 | */
43 | export declare const internalQuery: QueryBuilder;
44 |
45 | /**
46 | * Define a mutation in this Convex app's public API.
47 | *
48 | * This function will be allowed to modify your Convex database and will be accessible from the client.
49 | *
50 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
51 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
52 | */
53 | export declare const mutation: MutationBuilder;
54 |
55 | /**
56 | * Define a mutation that is only accessible from other Convex functions (but not from the client).
57 | *
58 | * This function will be allowed to modify your Convex database. It will not be accessible from the client.
59 | *
60 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
61 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
62 | */
63 | export declare const internalMutation: MutationBuilder;
64 |
65 | /**
66 | * Define an action in this Convex app's public API.
67 | *
68 | * An action is a function which can execute any JavaScript code, including non-deterministic
69 | * code and code with side-effects, like calling third-party services.
70 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
71 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
72 | *
73 | * @param func - The action. It receives an {@link ActionCtx} as its first argument.
74 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible.
75 | */
76 | export declare const action: ActionBuilder;
77 |
78 | /**
79 | * Define an action that is only accessible from other Convex functions (but not from the client).
80 | *
81 | * @param func - The function. It receives an {@link ActionCtx} as its first argument.
82 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible.
83 | */
84 | export declare const internalAction: ActionBuilder;
85 |
86 | /**
87 | * Define an HTTP action.
88 | *
89 | * This function will be used to respond to HTTP requests received by a Convex
90 | * deployment if the requests matches the path and method where this action
91 | * is routed. Be sure to route your action in `convex/http.js`.
92 | *
93 | * @param func - The function. It receives an {@link ActionCtx} as its first argument.
94 | * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
95 | */
96 | export declare const httpAction: HttpActionBuilder;
97 |
98 | /**
99 | * A set of services for use within Convex query functions.
100 | *
101 | * The query context is passed as the first argument to any Convex query
102 | * function run on the server.
103 | *
104 | * This differs from the {@link MutationCtx} because all of the services are
105 | * read-only.
106 | */
107 | export type QueryCtx = GenericQueryCtx;
108 |
109 | /**
110 | * A set of services for use within Convex mutation functions.
111 | *
112 | * The mutation context is passed as the first argument to any Convex mutation
113 | * function run on the server.
114 | */
115 | export type MutationCtx = GenericMutationCtx;
116 |
117 | /**
118 | * A set of services for use within Convex action functions.
119 | *
120 | * The action context is passed as the first argument to any Convex action
121 | * function run on the server.
122 | */
123 | export type ActionCtx = GenericActionCtx;
124 |
125 | /**
126 | * An interface to read from the database within Convex query functions.
127 | *
128 | * The two entry points are {@link DatabaseReader.get}, which fetches a single
129 | * document by its {@link Id}, or {@link DatabaseReader.query}, which starts
130 | * building a query.
131 | */
132 | export type DatabaseReader = GenericDatabaseReader;
133 |
134 | /**
135 | * An interface to read from and write to the database within Convex mutation
136 | * functions.
137 | *
138 | * Convex guarantees that all writes within a single mutation are
139 | * executed atomically, so you never have to worry about partial writes leaving
140 | * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
141 | * for the guarantees Convex provides your functions.
142 | */
143 | export type DatabaseWriter = GenericDatabaseWriter;
144 |
--------------------------------------------------------------------------------
/convex/_generated/server.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated utilities for implementing server-side Convex query and mutation functions.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * Generated by convex@1.4.1.
8 | * To regenerate, run `npx convex dev`.
9 | * @module
10 | */
11 |
12 | import {
13 | actionGeneric,
14 | httpActionGeneric,
15 | queryGeneric,
16 | mutationGeneric,
17 | internalActionGeneric,
18 | internalMutationGeneric,
19 | internalQueryGeneric,
20 | } from "convex/server";
21 |
22 | /**
23 | * Define a query in this Convex app's public API.
24 | *
25 | * This function will be allowed to read your Convex database and will be accessible from the client.
26 | *
27 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
28 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
29 | */
30 | export const query = queryGeneric;
31 |
32 | /**
33 | * Define a query that is only accessible from other Convex functions (but not from the client).
34 | *
35 | * This function will be allowed to read from your Convex database. It will not be accessible from the client.
36 | *
37 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
38 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
39 | */
40 | export const internalQuery = internalQueryGeneric;
41 |
42 | /**
43 | * Define a mutation in this Convex app's public API.
44 | *
45 | * This function will be allowed to modify your Convex database and will be accessible from the client.
46 | *
47 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
48 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
49 | */
50 | export const mutation = mutationGeneric;
51 |
52 | /**
53 | * Define a mutation that is only accessible from other Convex functions (but not from the client).
54 | *
55 | * This function will be allowed to modify your Convex database. It will not be accessible from the client.
56 | *
57 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
58 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
59 | */
60 | export const internalMutation = internalMutationGeneric;
61 |
62 | /**
63 | * Define an action in this Convex app's public API.
64 | *
65 | * An action is a function which can execute any JavaScript code, including non-deterministic
66 | * code and code with side-effects, like calling third-party services.
67 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
68 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
69 | *
70 | * @param func - The action. It receives an {@link ActionCtx} as its first argument.
71 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible.
72 | */
73 | export const action = actionGeneric;
74 |
75 | /**
76 | * Define an action that is only accessible from other Convex functions (but not from the client).
77 | *
78 | * @param func - The function. It receives an {@link ActionCtx} as its first argument.
79 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible.
80 | */
81 | export const internalAction = internalActionGeneric;
82 |
83 | /**
84 | * Define a Convex HTTP action.
85 | *
86 | * @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object
87 | * as its second.
88 | * @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`.
89 | */
90 | export const httpAction = httpActionGeneric;
91 |
--------------------------------------------------------------------------------
/convex/auth.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | providers: [
3 | {
4 | domain: "https://engaged-bear-98.clerk.accounts.dev",
5 | applicationID: "convex",
6 | },
7 | ],
8 | };
9 |
--------------------------------------------------------------------------------
/convex/documents.ts:
--------------------------------------------------------------------------------
1 | import { mutation, query } from "./_generated/server";
2 | import { Doc, Id } from "./_generated/dataModel";
3 | import { v } from "convex/values";
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 existingDocument = await ctx.db.get(args.id);
17 | if (!existingDocument) {
18 | throw new Error("Not found");
19 | }
20 |
21 | if (existingDocument.userId != userId) {
22 | throw new Error("Unauthorized");
23 | }
24 |
25 | const recursiveArchive = async (documentId: Id<"documents">) => {
26 | const children = await ctx.db
27 | .query("documents")
28 | .withIndex("by_user_parent", (q) =>
29 | q.eq("userId", userId).eq("parentDocument", documentId),
30 | )
31 | .collect();
32 |
33 | for (const child of children) {
34 | await ctx.db.patch(child._id, {
35 | isArchived: true,
36 | });
37 |
38 | await recursiveArchive(child._id);
39 | }
40 | };
41 |
42 | const document = await ctx.db.patch(args.id, {
43 | isArchived: true,
44 | });
45 |
46 | recursiveArchive(args.id);
47 |
48 | return document;
49 | },
50 | });
51 |
52 | export const getSidebar = query({
53 | args: {
54 | parentDocument: v.optional(v.id("documents")),
55 | },
56 | handler: async (ctx, args) => {
57 | const identity = await ctx.auth.getUserIdentity();
58 |
59 | if (!identity) {
60 | throw new Error("Not authenticated");
61 | }
62 |
63 | const userId = identity.subject;
64 |
65 | const documents = await ctx.db
66 | .query("documents")
67 | .withIndex("by_user_parent", (q) =>
68 | q.eq("userId", userId).eq("parentDocument", args.parentDocument),
69 | )
70 | .filter((q) => q.eq(q.field("isArchived"), false))
71 | .order("desc")
72 | .collect();
73 |
74 | return documents;
75 | },
76 | });
77 |
78 | export const create = mutation({
79 | args: {
80 | title: v.string(),
81 | parentDocument: v.optional(v.id("documents")),
82 | },
83 |
84 | handler: async (ctx, args) => {
85 | const identity = await ctx.auth.getUserIdentity();
86 | if (!identity) {
87 | throw new Error("Not authenticated");
88 | }
89 |
90 | const userId = identity.subject;
91 | const document = await ctx.db.insert("documents", {
92 | title: args.title,
93 | parentDocument: args.parentDocument,
94 | userId,
95 | isArchived: false,
96 | isPublished: false,
97 | });
98 |
99 | return document;
100 | },
101 | });
102 |
103 | export const getTrash = query({
104 | handler: async (ctx) => {
105 | const identity = await ctx.auth.getUserIdentity();
106 |
107 | if (!identity) {
108 | throw new Error("Not authenticated");
109 | }
110 |
111 | const userId = identity.subject;
112 |
113 | const documents = await ctx.db
114 | .query("documents")
115 | .withIndex("by_user", (q) => q.eq("userId", userId))
116 | .filter((q) => q.eq(q.field("isArchived"), true))
117 | .order("desc")
118 | .collect();
119 |
120 | return documents;
121 | },
122 | });
123 |
124 | export const restore = mutation({
125 | args: { id: v.id("documents") },
126 | handler: async (ctx, args) => {
127 | const identity = await ctx.auth.getUserIdentity();
128 |
129 | if (!identity) {
130 | throw new Error("Not authenticated");
131 | }
132 |
133 | const userId = identity.subject;
134 |
135 | const existingDocument = await ctx.db.get(args.id);
136 |
137 | if (!existingDocument) {
138 | throw new Error("Not found");
139 | }
140 |
141 | if (existingDocument.userId != userId) {
142 | throw new Error("Unauthorized");
143 | }
144 |
145 | const recursiveRestore = async (documentId: Id<"documents">) => {
146 | const children = await ctx.db
147 | .query("documents")
148 | .withIndex("by_user_parent", (q) =>
149 | q.eq("userId", userId).eq("parentDocument", documentId),
150 | )
151 | .collect();
152 |
153 | for (const child of children) {
154 | await recursiveRestore(child._id);
155 | }
156 | };
157 |
158 | const options: Partial> = {
159 | isArchived: false,
160 | };
161 |
162 | if (existingDocument.parentDocument) {
163 | const parent = await ctx.db.get(existingDocument.parentDocument);
164 | if (parent?.isArchived) {
165 | options.parentDocument = undefined;
166 | }
167 | }
168 |
169 | const document = await ctx.db.patch(args.id, options);
170 |
171 | recursiveRestore(args.id);
172 |
173 | return document;
174 | },
175 | });
176 |
177 | export const remove = mutation({
178 | args: { id: v.id("documents") },
179 | handler: async (ctx, args) => {
180 | const identity = await ctx.auth.getUserIdentity();
181 |
182 | if (!identity) {
183 | throw new Error("Not authenticated");
184 | }
185 |
186 | const userId = identity.subject;
187 |
188 | const existingDocument = await ctx.db.get(args.id);
189 |
190 | if (!existingDocument) {
191 | throw new Error("Not found");
192 | }
193 |
194 | if (existingDocument.userId != userId) {
195 | throw new Error("Unauthorized");
196 | }
197 |
198 | const document = await ctx.db.delete(args.id);
199 |
200 | return document;
201 | },
202 | });
203 |
204 | export const getSearch = query({
205 | handler: async (ctx) => {
206 | const identity = await ctx.auth.getUserIdentity();
207 |
208 | if (!identity) {
209 | throw new Error("Not authenticated");
210 | }
211 |
212 | const userId = identity.subject;
213 |
214 | const documents = await ctx.db
215 | .query("documents")
216 | .withIndex("by_user", (q) => q.eq("userId", userId))
217 | .filter((q) => q.eq(q.field("isArchived"), false))
218 | .order("desc")
219 | .collect();
220 |
221 | return documents;
222 | },
223 | });
224 |
225 | export const getById = query({
226 | args: { documentId: v.id("documents") },
227 | handler: async (ctx, args) => {
228 | const identity = await ctx.auth.getUserIdentity();
229 | const document = await ctx.db.get(args.documentId);
230 |
231 | if (!document) {
232 | throw new Error("Not found");
233 | }
234 |
235 | // return doc if doc is published and not archived
236 | if (document.isPublished && !document.isArchived) {
237 | return document;
238 | }
239 |
240 | if (!identity) {
241 | throw new Error("Not authenticated");
242 | }
243 |
244 | const userId = identity.subject;
245 |
246 | if (document.userId !== userId) {
247 | throw new Error("Unauthorized");
248 | }
249 |
250 | return document;
251 | },
252 | });
253 |
254 | export const update = mutation({
255 | args: {
256 | id: v.id("documents"),
257 | title: v.optional(v.string()),
258 | content: v.optional(v.string()),
259 | coverImage: v.optional(v.string()),
260 | icon: v.optional(v.string()),
261 | isPublished: v.optional(v.boolean()),
262 | },
263 |
264 | handler: async (ctx, args) => {
265 | const identity = await ctx.auth.getUserIdentity();
266 |
267 | if (!identity) {
268 | throw new Error("Unauthenticated");
269 | }
270 |
271 | const userId = identity.subject;
272 |
273 | const { id, ...rest } = args;
274 |
275 | const existingDocument = await ctx.db.get(args.id);
276 |
277 | if (!existingDocument) {
278 | throw new Error("Not found");
279 | }
280 |
281 | if (existingDocument.userId !== userId) {
282 | throw new Error("Unauthorized");
283 | }
284 |
285 | const document = await ctx.db.patch(args.id, {
286 | ...rest,
287 | });
288 |
289 | return document;
290 | },
291 | });
292 |
293 | export const removeIcon = mutation({
294 | args: { id: v.id("documents") },
295 |
296 | handler: async (ctx, args) => {
297 | const identity = await ctx.auth.getUserIdentity();
298 |
299 | if (!identity) {
300 | throw new Error("Unauthenticated");
301 | }
302 |
303 | const userId = identity.subject;
304 |
305 | const existingDocument = await ctx.db.get(args.id);
306 |
307 | if (!existingDocument) {
308 | throw new Error("Not found");
309 | }
310 |
311 | if (existingDocument.userId !== userId) {
312 | throw new Error("Unauthorized");
313 | }
314 |
315 | const document = await ctx.db.patch(args.id, {
316 | icon: undefined,
317 | });
318 |
319 | return document;
320 | },
321 | });
322 |
323 | export const removeCoverImage = mutation({
324 | args: { id: v.id("documents") },
325 |
326 | handler: async (ctx, args) => {
327 | const identity = await ctx.auth.getUserIdentity();
328 |
329 | if (!identity) {
330 | throw new Error("Unauthenticated");
331 | }
332 |
333 | const userId = identity.subject;
334 |
335 | const existingDocument = await ctx.db.get(args.id);
336 |
337 | if (!existingDocument) {
338 | throw new Error("Not found");
339 | }
340 |
341 | if (existingDocument.userId !== userId) {
342 | throw new Error("Unauthorized");
343 | }
344 |
345 | const document = await ctx.db.patch(args.id, {
346 | coverImage: undefined,
347 | });
348 |
349 | return document;
350 | },
351 | });
352 |
353 | export const copy = mutation({
354 | args: {
355 | title: v.string(),
356 | parentDocument: v.optional(v.id("documents")),
357 | content: v.optional(v.string()),
358 | coverImage: v.optional(v.string()),
359 | icon: v.optional(v.string()),
360 | },
361 |
362 | handler: async (ctx, args) => {
363 | const identity = await ctx.auth.getUserIdentity();
364 | if (!identity) {
365 | throw new Error("Not authenticated");
366 | }
367 |
368 | const userId = identity.subject;
369 | const document = await ctx.db.insert("documents", {
370 | userId,
371 | isArchived: false,
372 | isPublished: false,
373 | ...args,
374 | });
375 |
376 | return document;
377 | },
378 | });
379 |
--------------------------------------------------------------------------------
/convex/schema.ts:
--------------------------------------------------------------------------------
1 | import { defineSchema, defineTable } from "convex/server";
2 | import { v } from "convex/values";
3 |
4 | export default defineSchema({
5 | documents: defineTable({
6 | title: v.string(),
7 | userId: v.string(),
8 | isArchived: v.boolean(),
9 | parentDocument: v.optional(v.id("documents")),
10 | content: v.optional(v.string()),
11 | coverImage: v.optional(v.string()),
12 | icon: v.optional(v.string()),
13 | isPublished: v.boolean(),
14 | })
15 | .index("by_user", ["userId"])
16 | .index("by_user_parent", ["userId", "parentDocument"]),
17 | });
18 |
--------------------------------------------------------------------------------
/convex/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | /* This TypeScript project config describes the environment that
3 | * Convex functions run in and is used to typecheck them.
4 | * You can modify it, but some settings required to use Convex.
5 | */
6 | "compilerOptions": {
7 | /* These settings are not required by Convex and can be modified. */
8 | "allowJs": true,
9 | "strict": true,
10 |
11 | /* These compiler options are required by Convex */
12 | "target": "ESNext",
13 | "lib": ["ES2021", "dom"],
14 | "forceConsistentCasingInFileNames": true,
15 | "allowSyntheticDefaultImports": true,
16 | "module": "ESNext",
17 | "moduleResolution": "Node",
18 | "isolatedModules": true,
19 | "skipLibCheck": true,
20 | "noEmit": true
21 | },
22 | "include": ["./**/*"],
23 | "exclude": ["./_generated"]
24 | }
25 |
--------------------------------------------------------------------------------
/hooks/use-cover-image.tsx:
--------------------------------------------------------------------------------
1 | import { create } from "zustand";
2 |
3 | type CoverImageStore = {
4 | url?: string;
5 | isOpen: boolean;
6 | onOpen: () => void;
7 | onClose: () => void;
8 | onReplace: (url: string) => void;
9 | };
10 |
11 | export const useCoverImage = create((set) => ({
12 | url: undefined,
13 | isOpen: false,
14 | onOpen: () => set({ isOpen: true, url: undefined }),
15 | onClose: () => set({ isOpen: false, url: undefined }),
16 | onReplace: (url: string) => set({ isOpen: true, url }),
17 | }));
18 |
--------------------------------------------------------------------------------
/hooks/use-origin.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 |
3 | export const useOrigin = () => {
4 | const [mounted, setMounted] = useState(false);
5 | const origin =
6 | typeof window !== "undefined" && window.location.origin
7 | ? window.location.origin
8 | : "";
9 |
10 | useEffect(() => {
11 | setMounted(true);
12 | }, []);
13 |
14 | if (!mounted) {
15 | return "";
16 | }
17 |
18 | return origin;
19 | };
20 |
--------------------------------------------------------------------------------
/hooks/use-scroll-top.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 |
3 | export const useScrollTop = (threshold = 10) => {
4 | const [scrolled, setScrolled] = useState(false);
5 |
6 | useEffect(() => {
7 | const handleScroll = () => {
8 | if (window.scrollY > threshold) {
9 | setScrolled(true);
10 | } else {
11 | setScrolled(false);
12 | }
13 | };
14 |
15 | window.addEventListener("scroll", handleScroll);
16 |
17 | return () => window.removeEventListener("scroll", handleScroll);
18 | }, [threshold]);
19 |
20 | return scrolled;
21 | };
22 |
--------------------------------------------------------------------------------
/hooks/use-search.tsx:
--------------------------------------------------------------------------------
1 | import { create } from "zustand";
2 |
3 | type SearchStore = {
4 | isOpen: boolean;
5 | onOpen: () => void;
6 | onClose: () => void;
7 | toggle: () => void;
8 | };
9 |
10 | export const useSearch = create((set, get) => ({
11 | isOpen: false,
12 | onOpen: () => set({ isOpen: true }),
13 | onClose: () => set({ isOpen: false }),
14 | toggle: () => set({ isOpen: !get().isOpen }),
15 | }));
16 |
--------------------------------------------------------------------------------
/hooks/use-settings.tsx:
--------------------------------------------------------------------------------
1 | import { create } from "zustand";
2 |
3 | type SettingsStore = {
4 | isOpen: boolean;
5 | onOpen: () => void;
6 | onClose: () => void;
7 | };
8 |
9 | export const useSettings = create((set) => ({
10 | isOpen: false,
11 | onOpen: () => set({ isOpen: true }),
12 | onClose: () => set({ isOpen: false }),
13 | }));
14 |
--------------------------------------------------------------------------------
/image-2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nick-h4ck3r/quickflow/cbbef714cd1128a9ff5f75c8f286b839615de824/image-2.png
--------------------------------------------------------------------------------
/image-3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nick-h4ck3r/quickflow/cbbef714cd1128a9ff5f75c8f286b839615de824/image-3.png
--------------------------------------------------------------------------------
/image-4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nick-h4ck3r/quickflow/cbbef714cd1128a9ff5f75c8f286b839615de824/image-4.png
--------------------------------------------------------------------------------
/image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nick-h4ck3r/quickflow/cbbef714cd1128a9ff5f75c8f286b839615de824/image.png
--------------------------------------------------------------------------------
/lib/edgestore.ts:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { type EdgeStoreRouter } from '../app/api/edgestore/[...edgestore]/route';
4 | import { createEdgeStoreProvider } from '@edgestore/react';
5 |
6 | const { EdgeStoreProvider, useEdgeStore } =
7 | createEdgeStoreProvider();
8 |
9 | export { EdgeStoreProvider, useEdgeStore };
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx"
2 | import { twMerge } from "tailwind-merge"
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs))
6 | }
7 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | images: {
4 | domains: ["files.edgestore.dev"],
5 | },
6 | };
7 |
8 | module.exports = nextConfig;
9 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "notion-clone",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@blocknote/core": "^0.9.6",
13 | "@blocknote/react": "^0.9.6",
14 | "@clerk/clerk-react": "^4.27.0",
15 | "@edgestore/react": "^0.1.4",
16 | "@edgestore/server": "^0.1.4",
17 | "@radix-ui/react-alert-dialog": "^1.0.5",
18 | "@radix-ui/react-avatar": "^1.0.4",
19 | "@radix-ui/react-dialog": "^1.0.5",
20 | "@radix-ui/react-dropdown-menu": "^2.0.6",
21 | "@radix-ui/react-label": "^2.0.2",
22 | "@radix-ui/react-popover": "^1.0.7",
23 | "@radix-ui/react-slot": "^1.0.2",
24 | "class-variance-authority": "^0.7.0",
25 | "clsx": "^2.0.0",
26 | "cmdk": "^0.2.0",
27 | "convex": "^1.4.1",
28 | "emoji-picker-react": "^4.5.7",
29 | "lucide-react": "^0.288.0",
30 | "next": "13.5.6",
31 | "next-themes": "^0.2.1",
32 | "react": "^18",
33 | "react-dom": "^18",
34 | "react-dropzone": "^14.2.3",
35 | "react-textarea-autosize": "^8.5.3",
36 | "sonner": "^1.0.3",
37 | "tailwind-merge": "^1.14.0",
38 | "tailwindcss-animate": "^1.0.7",
39 | "usehooks-ts": "^2.9.1",
40 | "zod": "^3.22.4",
41 | "zustand": "^4.4.4"
42 | },
43 | "devDependencies": {
44 | "@types/node": "^20",
45 | "@types/react": "^18",
46 | "@types/react-dom": "^18",
47 | "autoprefixer": "^10",
48 | "eslint": "^8",
49 | "eslint-config-next": "13.5.6",
50 | "postcss": "^8",
51 | "prettier": "^3.0.3",
52 | "prettier-plugin-tailwindcss": "^0.5.6",
53 | "tailwind-scrollbar": "^3.0.5",
54 | "tailwindcss": "^3",
55 | "typescript": "^5"
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/prettier.config.js:
--------------------------------------------------------------------------------
1 | // prettier.config.js
2 | module.exports = {
3 | plugins: ['prettier-plugin-tailwindcss'],
4 | }
--------------------------------------------------------------------------------
/public/empty-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nick-h4ck3r/quickflow/cbbef714cd1128a9ff5f75c8f286b839615de824/public/empty-dark.png
--------------------------------------------------------------------------------
/public/empty.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nick-h4ck3r/quickflow/cbbef714cd1128a9ff5f75c8f286b839615de824/public/empty.png
--------------------------------------------------------------------------------
/public/error-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nick-h4ck3r/quickflow/cbbef714cd1128a9ff5f75c8f286b839615de824/public/error-dark.png
--------------------------------------------------------------------------------
/public/error.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nick-h4ck3r/quickflow/cbbef714cd1128a9ff5f75c8f286b839615de824/public/error.png
--------------------------------------------------------------------------------
/public/logo-dark.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nick-h4ck3r/quickflow/cbbef714cd1128a9ff5f75c8f286b839615de824/public/logo-dark.jpg
--------------------------------------------------------------------------------
/public/logo.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nick-h4ck3r/quickflow/cbbef714cd1128a9ff5f75c8f286b839615de824/public/logo.jpg
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/notes-pana-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nick-h4ck3r/quickflow/cbbef714cd1128a9ff5f75c8f286b839615de824/public/notes-pana-dark.png
--------------------------------------------------------------------------------
/public/notes-pana.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Nick-h4ck3r/quickflow/cbbef714cd1128a9ff5f75c8f286b839615de824/public/notes-pana.png
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | darkMode: ["class"],
4 | content: [
5 | "./pages/**/*.{ts,tsx}",
6 | "./components/**/*.{ts,tsx}",
7 | "./app/**/*.{ts,tsx}",
8 | "./src/**/*.{ts,tsx}",
9 | ],
10 | theme: {
11 | container: {
12 | center: true,
13 | padding: "2rem",
14 | screens: {
15 | "2xl": "1400px",
16 | },
17 | },
18 | extend: {
19 | colors: {
20 | border: "hsl(var(--border))",
21 | input: "hsl(var(--input))",
22 | ring: "hsl(var(--ring))",
23 | background: "hsl(var(--background))",
24 | foreground: "hsl(var(--foreground))",
25 | primary: {
26 | DEFAULT: "hsl(var(--primary))",
27 | foreground: "hsl(var(--primary-foreground))",
28 | },
29 | secondary: {
30 | DEFAULT: "hsl(var(--secondary))",
31 | foreground: "hsl(var(--secondary-foreground))",
32 | },
33 | destructive: {
34 | DEFAULT: "hsl(var(--destructive))",
35 | foreground: "hsl(var(--destructive-foreground))",
36 | },
37 | muted: {
38 | DEFAULT: "hsl(var(--muted))",
39 | foreground: "hsl(var(--muted-foreground))",
40 | },
41 | accent: {
42 | DEFAULT: "hsl(var(--accent))",
43 | foreground: "hsl(var(--accent-foreground))",
44 | },
45 | popover: {
46 | DEFAULT: "hsl(var(--popover))",
47 | foreground: "hsl(var(--popover-foreground))",
48 | },
49 | card: {
50 | DEFAULT: "hsl(var(--card))",
51 | foreground: "hsl(var(--card-foreground))",
52 | },
53 | },
54 | borderRadius: {
55 | lg: "var(--radius)",
56 | md: "calc(var(--radius) - 2px)",
57 | sm: "calc(var(--radius) - 4px)",
58 | },
59 | keyframes: {
60 | "accordion-down": {
61 | from: { height: 0 },
62 | to: { height: "var(--radix-accordion-content-height)" },
63 | },
64 | "accordion-up": {
65 | from: { height: "var(--radix-accordion-content-height)" },
66 | to: { height: 0 },
67 | },
68 | },
69 | animation: {
70 | "accordion-down": "accordion-down 0.2s ease-out",
71 | "accordion-up": "accordion-up 0.2s ease-out",
72 | },
73 | },
74 | },
75 | plugins: [require("tailwindcss-animate"), require("tailwind-scrollbar")],
76 | };
77 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------