53 | {!!initialData.icon &&
{initialData.icon}
}
54 | {isEditing ? (
55 |
64 | ) : (
65 |
75 | )}
76 |
77 | );
78 | };
79 |
80 | Title.Skeleton = function TitleSkeleton() {
81 | return ,
39 | documentId: Id<"documents">
40 | ) => {
41 | event.stopPropagation();
42 | const promise = restore({ id: documentId });
43 | toast.promise(promise, {
44 | loading: "Restoring note...",
45 | success: "Note Restored.",
46 | error: "Failed to restore note!",
47 | });
48 | };
49 |
50 | const onRemove = async (document: Doc<"documents">) => {
51 | //removes the file from the edge store bucket as well.
52 | if (document.coverImage) {
53 | await edgestore.publicFiles.delete({
54 | url: document.coverImage,
55 | });
56 | }
57 |
58 | const promise = remove({ id: document._id });
59 | toast.promise(promise, {
60 | loading: "Deleting note...",
61 | success: "Note Deleted.",
62 | error: "Failed to delete note!",
63 | });
64 |
65 | if (params.documentId === document._id) {
66 | router.push("/documents");
67 | }
68 | };
69 |
70 | //loading state
71 | if (documents === undefined) {
72 | return (
73 |
74 |
75 |
76 | );
77 | }
78 |
79 | return (
80 |
81 |
82 |
83 | setSearch(e.target.value)}
86 | className="h-7 px-2 focus-visible:ring-transparent bg-secondary"
87 | placeholder="Filter by page title."
88 | />
89 |
90 |
91 |
92 | No documents found!
93 |
94 | {filteredDocuments?.map((document) => (
95 |
onClick(document._id)}
99 | className="text-sm rounded-sm w-full hover:bg-primary/5 flex items-center text-primary justify-between"
100 | >
101 |
{document.title}
102 |
103 |
onRestore(e, document._id)}
105 | role="button"
106 | className="rounded-sm p-2 hover:bg-neutral-200 dark:hover:bg-neutral-600"
107 | >
108 |
109 |
110 |
onRemove(document)}>
111 |
115 |
116 |
117 |
118 |
119 |
120 | ))}
121 |
122 |
123 | );
124 | };
125 |
126 | export default TrashBox;
127 |
--------------------------------------------------------------------------------
/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 | const UserItem = () => {
16 | const { user } = useUser();
17 |
18 | return (
19 |
20 |
21 |
25 |
26 |
27 |
28 |
29 |
30 | {user?.firstName}'s Jotion
31 |
32 |
33 |
34 |
35 |
36 |
42 |
43 |
48 |
49 |
{user?.fullName}
50 |
51 | {user?.emailAddresses[0].emailAddress}
52 |
53 |
54 |
55 |
56 |
60 | Log Out
61 |
62 |
63 |
64 | );
65 | };
66 |
67 | export default UserItem;
68 |
--------------------------------------------------------------------------------
/app/(main)/layout.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useConvexAuth } from "convex/react";
4 |
5 | import { redirect } from "next/navigation";
6 |
7 | import Spinner from "@/components/Spinner";
8 | import Navigation from "./_components/Navigation";
9 | import SearchCommand from "@/components/SearchCommand";
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 | //every route inside main is now protected
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/Footer.tsx:
--------------------------------------------------------------------------------
1 | const Footer = () => {
2 | return (
3 |
6 | );
7 | };
8 |
9 | export default Footer;
10 |
--------------------------------------------------------------------------------
/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 { ArrowRightIcon } from "lucide-react";
8 | import Link from "next/link";
9 |
10 | const Heading = () => {
11 | const { isAuthenticated, isLoading } = useConvexAuth();
12 |
13 | return (
14 |
15 |
16 | Your Ideas, Documents, & Plans unified.
17 |
18 |
19 | Jotion is the connected workspace
20 | where
21 |
22 | better and faster work happens.
23 |
24 | {isLoading && (
25 |
26 |
27 |
28 | )}
29 | {isAuthenticated && !isLoading && (
30 |
33 | )}
34 | {!isAuthenticated && !isLoading && (
35 |
36 |
39 |
40 | )}
41 |
42 | );
43 | };
44 |
45 | export default Heading;
46 |
--------------------------------------------------------------------------------
/app/(marketing)/_components/Hero.tsx:
--------------------------------------------------------------------------------
1 | import Image from "next/image";
2 |
3 | const Hero = () => {
4 | return (
5 |
6 |
7 |
8 |
14 |
20 |
21 |
22 |
28 |
34 |
35 |
36 |
37 | );
38 | };
39 |
40 | export default Hero;
41 |
--------------------------------------------------------------------------------
/app/(marketing)/_components/Logo.tsx:
--------------------------------------------------------------------------------
1 | import { Poppins } from "next/font/google";
2 | import { cn } from "@/lib/utils";
3 | import Image from "next/image";
4 |
5 | const font = Poppins({
6 | subsets: ["latin"],
7 | weight: ["400", "600"],
8 | });
9 |
10 | const Logo = () => {
11 | return (
12 |
13 |
20 |
27 |
Jotion
28 |
29 | );
30 | };
31 |
32 | export default Logo;
33 |
--------------------------------------------------------------------------------
/app/(marketing)/_components/Navbar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useConvexAuth } from "convex/react";
4 | import Logo from "./Logo";
5 | import { ModeToggle } from "@/components/ModeToggle";
6 | import { SignInButton, UserButton } from "@clerk/clerk-react";
7 | import { Button } from "@/components/ui/button";
8 | import Spinner from "@/components/Spinner";
9 | import Link from "next/link";
10 |
11 | const Navbar = () => {
12 | const { isAuthenticated, isLoading } = useConvexAuth();
13 |
14 | return (
15 |
16 |
17 |
18 | {isLoading &&
}
19 |
20 | {!isAuthenticated && !isLoading && (
21 |
22 |
23 |
26 |
27 |
28 |
31 |
32 |
33 | )}
34 | {isAuthenticated && !isLoading && (
35 |
36 |
37 |
45 |
46 | )}
47 |
48 |
49 |
50 | );
51 | };
52 |
53 | export default Navbar;
54 |
--------------------------------------------------------------------------------
/app/(marketing)/layout.tsx:
--------------------------------------------------------------------------------
1 | import Navbar from "./_components/Navbar";
2 |
3 | const MarketingLayout = ({ children }: { children: React.ReactNode }) => {
4 | return (
5 |
6 |
7 |
8 | {children}
9 |
10 |
11 | );
12 | };
13 |
14 | export default MarketingLayout;
15 |
--------------------------------------------------------------------------------
/app/(marketing)/page.tsx:
--------------------------------------------------------------------------------
1 | import Footer from "./_components/Footer";
2 | import Heading from "./_components/Heading";
3 | import Hero from "./_components/Hero";
4 |
5 | const MarketingPage = () => {
6 | return (
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | );
15 | };
16 |
17 | export default MarketingPage;
18 |
--------------------------------------------------------------------------------
/app/(public)/(routes)/preview/[documentId]/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import Cover from "@/components/Cover";
4 | import Toolbar from "@/components/Toolbar";
5 | import { Skeleton } from "@/components/ui/skeleton";
6 |
7 | import { api } from "@/convex/_generated/api";
8 | import { Id } from "@/convex/_generated/dataModel";
9 | import { useMutation, useQuery } from "convex/react";
10 |
11 | import dynamic from "next/dynamic";
12 | import { useMemo } from "react";
13 |
14 | interface Props {
15 | params: {
16 | documentId: Id<"documents">;
17 | };
18 | }
19 |
20 | const Page = ({ params }: Props) => {
21 | const Editor = useMemo(
22 | () => dynamic(() => import("@/components/Editor/Editor"), { ssr: false }),
23 | []
24 | );
25 |
26 | const document = useQuery(api.documents.getById, {
27 | documentId: params.documentId,
28 | });
29 |
30 | const update = useMutation(api.documents.update);
31 |
32 | const onChange = (content: string) => {
33 | update({
34 | id: params.documentId,
35 | content,
36 | });
37 | };
38 |
39 | if (document === undefined) {
40 | return (
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 | );
53 | }
54 |
55 | if (document === null) {
56 | Not Found
;
57 | }
58 |
59 | return (
60 |
61 |
62 |
63 |
64 |
69 |
70 |
71 | );
72 | };
73 |
74 | export default Page;
75 |
--------------------------------------------------------------------------------
/app/(public)/layout.tsx:
--------------------------------------------------------------------------------
1 | const PublicLayout = ({ children }: { children: React.ReactNode }) => {
2 | return {children}
;
3 | };
4 |
5 | export default PublicLayout;
6 |
--------------------------------------------------------------------------------
/app/api/edgestore/[...edgestore]/route.ts:
--------------------------------------------------------------------------------
1 | import { initEdgeStore } from "@edgestore/server";
2 | import { createEdgeStoreNextHandler } from "@edgestore/server/adapters/next/app";
3 |
4 | const es = initEdgeStore.create();
5 |
6 | /**
7 | * This is the main router for the Edge Store buckets.
8 | */
9 | const edgeStoreRouter = es.router({
10 | publicFiles: es.fileBucket().beforeDelete(() => {
11 | return true; // allow delete
12 | }),
13 | });
14 |
15 | const handler = createEdgeStoreNextHandler({
16 | router: edgeStoreRouter,
17 | });
18 | export { handler as GET, handler as POST };
19 | /**
20 | * This type is used to create the type-safe client for the frontend.
21 | */
22 | export type EdgeStoreRouter = typeof edgeStoreRouter;
23 |
--------------------------------------------------------------------------------
/app/error.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { Button } from "@/components/ui/button";
4 |
5 | import Image from "next/image";
6 | import Link from "next/link";
7 |
8 | const Error = () => {
9 | return (
10 |
11 |
18 |
25 |
Something went wrong!
26 |
29 |
30 | );
31 | };
32 |
33 | export default Error;
34 |
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | html,
6 | body,
7 | :root {
8 | height: 100%;
9 | }
10 |
11 | @layer base {
12 | :root {
13 | --background: 0 0% 100%;
14 | --foreground: 0 0% 3.9%;
15 |
16 | --card: 0 0% 100%;
17 | --card-foreground: 0 0% 3.9%;
18 |
19 | --popover: 0 0% 100%;
20 | --popover-foreground: 0 0% 3.9%;
21 |
22 | --primary: 0 0% 9%;
23 | --primary-foreground: 0 0% 98%;
24 |
25 | --secondary: 0 0% 96.1%;
26 | --secondary-foreground: 0 0% 9%;
27 |
28 | --muted: 0 0% 96.1%;
29 | --muted-foreground: 0 0% 45.1%;
30 |
31 | --accent: 0 0% 96.1%;
32 | --accent-foreground: 0 0% 9%;
33 |
34 | --destructive: 0 84.2% 60.2%;
35 | --destructive-foreground: 0 0% 98%;
36 |
37 | --border: 0 0% 89.8%;
38 | --input: 0 0% 89.8%;
39 | --ring: 0 0% 3.9%;
40 |
41 | --radius: 0.5rem;
42 | }
43 |
44 | .dark {
45 | --background: 0 0% 3.9%;
46 | --foreground: 0 0% 98%;
47 |
48 | --card: 0 0% 3.9%;
49 | --card-foreground: 0 0% 98%;
50 |
51 | --popover: 0 0% 3.9%;
52 | --popover-foreground: 0 0% 98%;
53 |
54 | --primary: 0 0% 98%;
55 | --primary-foreground: 0 0% 9%;
56 |
57 | --secondary: 0 0% 14.9%;
58 | --secondary-foreground: 0 0% 98%;
59 |
60 | --muted: 0 0% 14.9%;
61 | --muted-foreground: 0 0% 63.9%;
62 |
63 | --accent: 0 0% 14.9%;
64 | --accent-foreground: 0 0% 98%;
65 |
66 | --destructive: 0 62.8% 30.6%;
67 | --destructive-foreground: 0 0% 98%;
68 |
69 | --border: 0 0% 14.9%;
70 | --input: 0 0% 14.9%;
71 | --ring: 0 0% 83.1%;
72 | }
73 | }
74 |
75 | @layer base {
76 | * {
77 | @apply border-border;
78 | }
79 | body {
80 | @apply bg-background text-foreground;
81 | }
82 | }
83 |
84 | @layer utilities {
85 | .custom-scrollbar::-webkit-scrollbar {
86 | width: 5px;
87 | height: 3px;
88 | border-radius: 2px;
89 | }
90 |
91 | .custom-scrollbar::-webkit-scrollbar-track {
92 | background: transparent;
93 | border-radius: 50px;
94 | }
95 |
96 | .custom-scrollbar::-webkit-scrollbar-thumb {
97 | background: #888;
98 | border-radius: 50px;
99 | }
100 |
101 | .scrollbar-hidden::-webkit-scrollbar {
102 | display: none;
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Inter } from "next/font/google";
3 | import "./globals.css";
4 |
5 | import { Toaster } from "sonner";
6 |
7 | import { ThemeProvider } from "@/components/providers/ThemeProvider";
8 | import { ConvexClientProvider } from "@/components/providers/ConvexProvider";
9 | import { ModalProvider } from "@/components/providers/ModalProvider";
10 | import { EdgeStoreProvider } from "@/lib/edgestore";
11 | import { cn } from "@/lib/utils";
12 |
13 | const inter = Inter({ subsets: ["latin"] });
14 |
15 | export const metadata: Metadata = {
16 | title: "Jotion",
17 | description: "The connected workspace where better, faster work happens.",
18 | icons: {
19 | icon: [
20 | {
21 | media: "(prefers-color-scheme: light)",
22 | url: "/assets/logo.svg",
23 | href: "/assets/logo.svg",
24 | },
25 | {
26 | media: "(prefers-color-scheme: dark)",
27 | url: "/assets/logo-dark.svg",
28 | href: "/assets/logo-dark.svg",
29 | },
30 | ],
31 | },
32 | };
33 |
34 | export default function RootLayout({
35 | children,
36 | }: {
37 | children: React.ReactNode;
38 | }) {
39 | return (
40 |
41 |
42 |
43 |
44 |
51 |
52 |
53 | {children}
54 |
55 |
56 |
57 |
58 |
59 | );
60 | }
61 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "default",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.ts",
8 | "css": "app/globals.css",
9 | "baseColor": "neutral",
10 | "cssVariables": true,
11 | "prefix": ""
12 | },
13 | "aliases": {
14 | "components": "@/components",
15 | "utils": "@/lib/utils"
16 | }
17 | }
--------------------------------------------------------------------------------
/components/Cover.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { cn } from "@/lib/utils";
4 | import Image from "next/image";
5 | import { toast } from "sonner";
6 | import { Button } from "./ui/button";
7 | import { Skeleton } from "./ui/skeleton";
8 | import { ImageIcon, X } from "lucide-react";
9 |
10 | import { useCoverImage } from "@/hooks/useCoverImage";
11 | import { useParams } from "next/navigation";
12 |
13 | import { useMutation } from "convex/react";
14 | import { api } from "@/convex/_generated/api";
15 | import { Id } from "@/convex/_generated/dataModel";
16 | import { useEdgeStore } from "@/lib/edgestore";
17 |
18 | interface CoverProps {
19 | url?: string;
20 | preview?: boolean;
21 | }
22 |
23 | const Cover = ({ url, preview }: CoverProps) => {
24 | const params = useParams();
25 |
26 | const coverImage = useCoverImage();
27 | const { edgestore } = useEdgeStore();
28 | const removeCoverImage = useMutation(api.documents.removeCoverImage);
29 |
30 | const onRemove = async () => {
31 | //removes the file from the edge store bucket as well.
32 | if (url) {
33 | await edgestore.publicFiles.delete({
34 | url: url,
35 | });
36 | }
37 |
38 | const promise = removeCoverImage({
39 | id: params.documentId as Id<"documents">,
40 | });
41 |
42 | toast.promise(promise, {
43 | loading: "Removing cover image",
44 | success: "Cover image removed.",
45 | error: "Failed to remove cover image!",
46 | });
47 | };
48 |
49 | return (
50 |
56 | {!!url &&
}
57 | {url && !preview && (
58 |
59 |
68 |
77 |
78 | )}
79 |
80 | );
81 | };
82 |
83 | Cover.Skeleton = function CoverSkeleton() {
84 | return ;
85 | };
86 |
87 | export default Cover;
88 |
--------------------------------------------------------------------------------
/components/Editor/Editor.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { BlockNoteEditor, PartialBlock } from "@blocknote/core";
4 | import {
5 | BlockNoteView,
6 | FormattingToolbarPositioner,
7 | HyperlinkToolbarPositioner,
8 | ImageToolbarPositioner,
9 | SideMenuPositioner,
10 | SlashMenuPositioner,
11 | useBlockNote,
12 | } from "@blocknote/react";
13 | import "@blocknote/react/style.css";
14 | import "./styles.css";
15 |
16 | import { useTheme } from "next-themes";
17 | import { useMediaQuery } from "usehooks-ts";
18 | import { useEdgeStore } from "@/lib/edgestore";
19 |
20 | interface EditorProps {
21 | onChange: (value: string) => void;
22 | initialContent?: string;
23 | editable?: boolean;
24 | }
25 |
26 | const Editor = ({ onChange, initialContent, editable }: EditorProps) => {
27 | const { resolvedTheme } = useTheme();
28 | const isSmallScreen = useMediaQuery("(max-width: 800px)");
29 |
30 | const { edgestore } = useEdgeStore();
31 |
32 | const handleUpload = async (file: File) => {
33 | const response = await edgestore.publicFiles.upload({
34 | file,
35 | });
36 |
37 | return response.url;
38 | };
39 |
40 | const editor: BlockNoteEditor = useBlockNote({
41 | editable,
42 | initialContent: initialContent //@ts-ignore
43 | ? (JSON.parse(initialContent) as PartialBlock[])
44 | : undefined,
45 | onEditorContentChange: (editor) => {
46 | onChange(JSON.stringify(editor.topLevelBlocks, null, 2));
47 | },
48 | uploadFile: handleUpload,
49 | });
50 |
51 | return (
52 |
53 |
57 | {isSmallScreen ? (
58 | <>
59 |
60 | >
61 | ) : (
62 | <>
63 |
64 |
65 |
66 |
67 |
68 | >
69 | )}
70 |
71 |
72 | );
73 | };
74 |
75 | export default Editor;
76 |
--------------------------------------------------------------------------------
/components/Editor/styles.css:
--------------------------------------------------------------------------------
1 | /* Placeholder styling */
2 | .bn-is-empty .bn-inline-content:before,
3 | .bn-is-filter .bn-inline-content:before {
4 | color: var(--bn-colors-side-menu);
5 | }
6 |
7 | /* Slash Menu styling*/
8 | .bn-container .bn-slash-menu {
9 | margin-bottom: 40px;
10 | height: 180px;
11 | padding: 2px 4px;
12 | position: relative;
13 | }
14 |
15 | .bn-container .bn-slash-menu::-webkit-scrollbar {
16 | display: none;
17 | }
18 |
19 | .bn-container .bn-slash-menu .mantine-Menu-item {
20 | height: 50px;
21 | margin-bottom: 2px;
22 | }
23 |
24 | .bn-container .bn-slash-menu .mantine-Menu-itemSection {
25 | color: var(--bn-colors-tooltip-text);
26 | }
27 |
28 | .bn-container .bn-slash-menu .mantine-Menu-itemSection[data-position="left"] {
29 | background-color: var(--bn-colors-tooltip-background);
30 | border-radius: var(--bn-border-radius-small);
31 | padding: 8px;
32 | }
33 |
34 | .bn-container .bn-slash-menu .mantine-Menu-itemLabel {
35 | padding-right: 16px;
36 | }
37 |
38 | .bn-container .bn-slash-menu .mantine-Menu-itemLabel .mantine-Stack-root {
39 | gap: 0;
40 | }
41 |
42 | /* Side Menu & Drag Handle styling */
43 | .bn-container .bn-side-menu {
44 | background-color: transparent;
45 | }
46 |
47 | .bn-container .bn-side-menu .mantine-UnstyledButton-root {
48 | background-color: transparent;
49 | color: grey;
50 | }
51 |
52 | .bn-container .bn-side-menu .mantine-UnstyledButton-root:hover {
53 | background-color: var(--bn-colors-hovered-background);
54 | }
55 |
56 | .bn-container .bn-drag-handle {
57 | height: 24px;
58 | width: 24px;
59 | }
60 |
61 | @media screen and (max-width: 800px) {
62 | .bn-container .bn-slash-menu {
63 | height: 140px;
64 | }
65 |
66 | .bn-is-empty .bn-inline-content:before {
67 | text-wrap: nowrap;
68 | width: 75px;
69 | overflow-x: clip;
70 | }
71 |
72 | .bn-container .bn-slash-menu .mantine-Menu-itemSection {
73 | display: none;
74 | }
75 | }
76 |
77 | @media screen and (max-width: 556px) {
78 | .bn-container .bn-slash-menu {
79 | padding: 4px 6px;
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/components/IconPicker.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import EmojiPicker, { Theme } from "emoji-picker-react";
4 |
5 | import { useTheme } from "next-themes";
6 |
7 | import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
8 |
9 | interface IconPickerProps {
10 | onChange: (icon: string) => void;
11 | children: React.ReactNode;
12 | asChild?: boolean;
13 | }
14 |
15 | const IconPicker = ({ onChange, children, asChild }: IconPickerProps) => {
16 | const { resolvedTheme } = useTheme();
17 | const currentTheme = (resolvedTheme || "light") as keyof typeof themeMap;
18 | const themeMap = {
19 | dark: Theme.DARK,
20 | light: Theme.LIGHT,
21 | };
22 | const theme = themeMap[currentTheme];
23 |
24 | return (
25 |
26 | {children}
27 |
28 | onChange(data.emoji)}
32 | />
33 |
34 |
35 | );
36 | };
37 |
38 | export default IconPicker;
39 |
--------------------------------------------------------------------------------
/components/ModeToggle.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 |
26 |
27 |
31 | setTheme("light")}
33 | className="cursor-pointer"
34 | >
35 | Light
36 |
37 | setTheme("dark")}
39 | className="cursor-pointer"
40 | >
41 | Dark
42 |
43 | setTheme("system")}
45 | className="cursor-pointer"
46 | >
47 | System
48 |
49 |
50 |
51 | );
52 | }
53 |
--------------------------------------------------------------------------------
/components/SearchCommand.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { api } from "@/convex/_generated/api";
4 | import { useSearch } from "@/hooks/useSearch";
5 | import { useUser } from "@clerk/clerk-react";
6 | import { useQuery } from "convex/react";
7 | import { useRouter } from "next/navigation";
8 | import { useEffect, useState } from "react";
9 |
10 | import {
11 | CommandDialog,
12 | CommandEmpty,
13 | CommandGroup,
14 | CommandInput,
15 | CommandItem,
16 | CommandList,
17 | } from "./ui/command";
18 | import { File } from "lucide-react";
19 |
20 | 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 | //to prevent hydration errors
31 | useEffect(() => {
32 | setIsMounted(true);
33 | }, []);
34 |
35 | useEffect(() => {
36 | const down = (e: KeyboardEvent) => {
37 | if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
38 | e.preventDefault();
39 | toggle();
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) return null;
52 |
53 | return (
54 |
55 |
56 |
57 | No results found!
58 |
61 | {documents?.map((document) => (
62 | onSelect(document._id)}
67 | className="cursor-pointer mt-1"
68 | >
69 | {document.icon ? (
70 | {document.icon}
71 | ) : (
72 |
73 | )}
74 | {document.title}
75 |
76 | ))}
77 |
78 |
79 |
80 | );
81 | };
82 |
83 | export default SearchCommand;
84 |
--------------------------------------------------------------------------------
/components/SingleImageDropzone.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { UploadCloudIcon, X } from "lucide-react";
4 | import * as React from "react";
5 | import { useDropzone, type DropzoneOptions } from "react-dropzone";
6 | import { twMerge } from "tailwind-merge";
7 |
8 | import Spinner from "./Spinner";
9 |
10 | const variants = {
11 | base: "relative rounded-md flex justify-center items-center flex-col cursor-pointer min-h-[150px] min-w-[200px] border border-dashed border-gray-400 dark:border-gray-300 transition-colors duration-200 ease-in-out",
12 | image:
13 | "border-0 p-0 min-h-0 min-w-0 relative shadow-md bg-slate-200 dark:bg-slate-900 rounded-md",
14 | active: "border-2",
15 | disabled:
16 | "bg-gray-200 border-gray-300 cursor-default pointer-events-none bg-opacity-30 dark:bg-gray-700",
17 | accept: "border border-blue-500 bg-blue-500 bg-opacity-10",
18 | reject: "border border-red-700 bg-red-700 bg-opacity-10",
19 | };
20 |
21 | type InputProps = {
22 | width?: number;
23 | height?: number;
24 | className?: string;
25 | value?: File | string;
26 | onChange?: (file?: File) => void | Promise;
27 | disabled?: boolean;
28 | dropzoneOptions?: Omit;
29 | };
30 |
31 | const ERROR_MESSAGES = {
32 | fileTooLarge(maxSize: number) {
33 | return `The file is too large. Max size is ${formatFileSize(maxSize)}.`;
34 | },
35 | fileInvalidType() {
36 | return "Invalid file type.";
37 | },
38 | tooManyFiles(maxFiles: number) {
39 | return `You can only add ${maxFiles} file(s).`;
40 | },
41 | fileNotSupported() {
42 | return "The file is not supported.";
43 | },
44 | };
45 |
46 | const SingleImageDropzone = React.forwardRef(
47 | (
48 | { dropzoneOptions, width, height, value, className, disabled, onChange },
49 | ref
50 | ) => {
51 | const imageUrl = React.useMemo(() => {
52 | if (typeof value === "string") {
53 | // in case a url is passed in, use it to display the image
54 | return value;
55 | } else if (value) {
56 | // in case a file is passed in, create a base64 url to display the image
57 | return URL.createObjectURL(value);
58 | }
59 | return null;
60 | }, [value]);
61 |
62 | // dropzone configuration
63 | const {
64 | getRootProps,
65 | getInputProps,
66 | acceptedFiles,
67 | fileRejections,
68 | isFocused,
69 | isDragAccept,
70 | isDragReject,
71 | } = useDropzone({
72 | accept: { "image/*": [] },
73 | multiple: false,
74 | disabled,
75 | onDrop: (acceptedFiles) => {
76 | const file = acceptedFiles[0];
77 | if (file) {
78 | void onChange?.(file);
79 | }
80 | },
81 | ...dropzoneOptions,
82 | });
83 |
84 | // styling
85 | const dropZoneClassName = React.useMemo(
86 | () =>
87 | twMerge(
88 | variants.base,
89 | isFocused && variants.active,
90 | disabled && variants.disabled,
91 | imageUrl && variants.image,
92 | (isDragReject ?? fileRejections[0]) && variants.reject,
93 | isDragAccept && variants.accept,
94 | className
95 | ).trim(),
96 | [
97 | isFocused,
98 | imageUrl,
99 | fileRejections,
100 | isDragAccept,
101 | isDragReject,
102 | disabled,
103 | className,
104 | ]
105 | );
106 |
107 | // error validation messages
108 | const errorMessage = React.useMemo(() => {
109 | if (fileRejections[0]) {
110 | const { errors } = fileRejections[0];
111 | if (errors[0]?.code === "file-too-large") {
112 | return ERROR_MESSAGES.fileTooLarge(dropzoneOptions?.maxSize ?? 0);
113 | } else if (errors[0]?.code === "file-invalid-type") {
114 | return ERROR_MESSAGES.fileInvalidType();
115 | } else if (errors[0]?.code === "too-many-files") {
116 | return ERROR_MESSAGES.tooManyFiles(dropzoneOptions?.maxFiles ?? 0);
117 | } else {
118 | return ERROR_MESSAGES.fileNotSupported();
119 | }
120 | }
121 | return undefined;
122 | }, [fileRejections, dropzoneOptions]);
123 |
124 | return (
125 |
126 | {disabled && (
127 |
128 |
129 |
130 | )}
131 |
140 | {/* Main File Input */}
141 |
142 |
143 | {imageUrl ? (
144 | // Image Preview
145 |
![{acceptedFiles[0]?.name}]({imageUrl})
150 | ) : (
151 | // Upload Icon
152 |
153 |
154 |
155 | Click or drag file to this area to upload.
156 |
157 |
158 | )}
159 |
160 | {/* Remove Image Icon */}
161 | {imageUrl && !disabled && (
162 |
{
165 | e.stopPropagation();
166 | void onChange?.(undefined);
167 | }}
168 | >
169 |
170 |
175 |
176 |
177 | )}
178 |
179 |
180 | {/* Error Text */}
181 |
{errorMessage}
182 |
183 | );
184 | }
185 | );
186 | SingleImageDropzone.displayName = "SingleImageDropzone";
187 |
188 | const Button = React.forwardRef<
189 | HTMLButtonElement,
190 | React.ButtonHTMLAttributes
191 | >(({ className, ...props }, ref) => {
192 | return (
193 |
206 | );
207 | });
208 | Button.displayName = "Button";
209 |
210 | function formatFileSize(bytes?: number) {
211 | if (!bytes) {
212 | return "0 Bytes";
213 | }
214 | bytes = Number(bytes);
215 | if (bytes === 0) {
216 | return "0 Bytes";
217 | }
218 | const k = 1024;
219 | const dm = 2;
220 | const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
221 | const i = Math.floor(Math.log(bytes) / Math.log(k));
222 | return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
223 | }
224 |
225 | export { SingleImageDropzone };
226 |
--------------------------------------------------------------------------------
/components/Spinner.tsx:
--------------------------------------------------------------------------------
1 | import { Loader } from "lucide-react";
2 |
3 | import { cva, type VariantProps } from "class-variance-authority";
4 | import { cn } from "@/lib/utils";
5 |
6 | const spinnerVariants = cva("text-muted-foreground animate-spin", {
7 | variants: {
8 | size: {
9 | default: "h-4 w-4",
10 | sm: "h-2 w-2",
11 | lg: "h-6 w-6",
12 | icon: "h-10 w-10",
13 | },
14 | },
15 | defaultVariants: {
16 | size: "default",
17 | },
18 | });
19 |
20 | interface SpinnerProps extends VariantProps {}
21 |
22 | const Spinner = ({ size }: SpinnerProps) => {
23 | return ;
24 | };
25 |
26 | export default Spinner;
27 |
--------------------------------------------------------------------------------
/components/Toolbar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import IconPicker from "./IconPicker";
4 | import { Button } from "./ui/button";
5 | import ReactTextareaAutosize from "react-textarea-autosize";
6 | import { ImageIcon, Smile, X } from "lucide-react";
7 |
8 | import React, { ElementRef, useRef, useState } from "react";
9 | import { useCoverImage } from "@/hooks/useCoverImage";
10 |
11 | import { Doc } from "@/convex/_generated/dataModel";
12 | import { api } from "@/convex/_generated/api";
13 | import { useMutation } from "convex/react";
14 |
15 | interface ToolBarProps {
16 | initialData: Doc<"documents">;
17 | preview?: boolean;
18 | }
19 |
20 | const Toolbar = ({ initialData, preview }: ToolBarProps) => {
21 | const inputRef = useRef>(null);
22 |
23 | const [isEditing, setIsEditing] = useState(false);
24 | const [value, setValue] = useState(initialData.title);
25 |
26 | const update = useMutation(api.documents.update);
27 | const removeIcon = useMutation(api.documents.removeIcon);
28 |
29 | const coverImage = useCoverImage();
30 |
31 | const enableInput = () => {
32 | if (preview) {
33 | return;
34 | }
35 |
36 | setIsEditing(true);
37 | setTimeout(() => {
38 | setValue(initialData.title);
39 | inputRef.current?.focus();
40 | inputRef.current?.setSelectionRange(
41 | inputRef.current.value.length,
42 | inputRef.current.value.length
43 | );
44 | }, 0);
45 | };
46 |
47 | const disableInput = () => {
48 | setIsEditing(false);
49 | };
50 |
51 | const onInput = (value: string) => {
52 | setValue(value);
53 | update({
54 | id: initialData._id,
55 | title: value || "Untitled",
56 | });
57 | };
58 |
59 | const onKeyDown = (event: React.KeyboardEvent) => {
60 | if (event.key === "Enter") {
61 | event.preventDefault();
62 | disableInput();
63 | }
64 | };
65 |
66 | const onIconSelect = (icon: string) => {
67 | update({
68 | id: initialData._id,
69 | icon,
70 | });
71 | };
72 |
73 | const onRemoveIcon = () => {
74 | removeIcon({
75 | id: initialData._id,
76 | });
77 | };
78 |
79 | return (
80 |
81 | {!!initialData.icon && !preview && (
82 |
83 |
84 |
85 | {initialData.icon}
86 |
87 |
88 |
96 |
97 | )}
98 | {!!initialData.icon && preview && (
99 |
{initialData.icon}
100 | )}
101 |
102 | {!initialData.icon && !preview && (
103 |
104 |
112 |
113 | )}
114 | {!initialData.coverImage && !preview && (
115 |
124 | )}
125 |
126 | {isEditing && !preview ? (
127 |
onInput(e.target.value)}
133 | className="p-0 text-5xl bg-transparent font-bold break-words outline-none text-[#3F3F3F]dark:text-[#CFCFCF] resize-none cursor-end w-[250px] md:w-[500px] lg:w-[600px]"
134 | />
135 | ) : (
136 |
140 | {initialData.title}
141 |
142 | )}
143 |
144 | );
145 | };
146 |
147 | export default Toolbar;
148 |
--------------------------------------------------------------------------------
/components/modals/ConfirmModal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import {
4 | AlertDialog,
5 | AlertDialogAction,
6 | AlertDialogCancel,
7 | AlertDialogContent,
8 | AlertDialogDescription,
9 | AlertDialogFooter,
10 | AlertDialogHeader,
11 | AlertDialogTitle,
12 | AlertDialogTrigger,
13 | } from "../ui/alert-dialog";
14 |
15 | interface ConfirmModalProps {
16 | children: React.ReactNode;
17 | onConfirm: () => void;
18 | }
19 |
20 | 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. This note be deleted forever!
38 |
39 |
40 |
41 | e.stopPropagation()}>
42 | Cancel
43 |
44 | Confirm
45 |
46 |
47 |
48 | );
49 | };
50 |
51 | export default ConfirmModal;
52 |
--------------------------------------------------------------------------------
/components/modals/CoverImageModal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useCoverImage } from "@/hooks/useCoverImage";
4 | import { useEdgeStore } from "@/lib/edgestore";
5 | import { useParams } from "next/navigation";
6 | import { useState } from "react";
7 |
8 | import { Id } from "@/convex/_generated/dataModel";
9 | import { api } from "@/convex/_generated/api";
10 | import { useMutation } from "convex/react";
11 |
12 | import { Dialog, DialogContent, DialogHeader } from "../ui/dialog";
13 | import { SingleImageDropzone } from "../SingleImageDropzone";
14 | import { toast } from "sonner";
15 |
16 | const CoverImageModal = () => {
17 | const params = useParams();
18 |
19 | const [file, setFile] = useState();
20 | const [isSubmitting, setIsSubmitting] = useState(false);
21 |
22 | const coverImage = useCoverImage();
23 | const { edgestore } = useEdgeStore();
24 |
25 | const update = useMutation(api.documents.update);
26 |
27 | const onClose = () => {
28 | setFile(undefined);
29 | setIsSubmitting(false);
30 | coverImage.onClose();
31 | };
32 |
33 | const onChange = async (file?: File) => {
34 | if (file) {
35 | setIsSubmitting(true);
36 | setFile(file);
37 |
38 | //smart upload/update of image -> the previous url will be replaced with the new url
39 | //no new image url will be created.
40 | const res = await edgestore.publicFiles.upload({
41 | file,
42 | options: {
43 | replaceTargetUrl: coverImage.url,
44 | },
45 | });
46 |
47 | const promise = update({
48 | id: params.documentId as Id<"documents">,
49 | coverImage: res.url,
50 | });
51 |
52 | toast.promise(promise, {
53 | loading: "Uploading cover image",
54 | success: "Cover image added successfully.",
55 | error: "Failed to add cover image",
56 | });
57 |
58 | onClose();
59 | }
60 | };
61 |
62 | return (
63 |
76 | );
77 | };
78 |
79 | export default CoverImageModal;
80 |
--------------------------------------------------------------------------------
/components/modals/SettingsModal.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useSettings } from "@/hooks/useSettings";
4 |
5 | import { Dialog, DialogContent, DialogHeader } from "../ui/dialog";
6 | import { Label } from "../ui/label";
7 | import { ModeToggle } from "../ModeToggle";
8 |
9 | const SettingsModal = () => {
10 | const settings = useSettings();
11 |
12 | return (
13 |
29 | );
30 | };
31 |
32 | export default SettingsModal;
33 |
--------------------------------------------------------------------------------
/components/providers/ConvexProvider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { ReactNode } from "react";
4 | import { ConvexReactClient } from "convex/react";
5 | import { ConvexProviderWithClerk } from "convex/react-clerk";
6 | import { ClerkProvider, useAuth } from "@clerk/clerk-react";
7 |
8 | const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
9 |
10 | export const ConvexClientProvider = ({ children }: { children: ReactNode }) => {
11 | return (
12 |
15 |
16 | {children}
17 |
18 |
19 | );
20 | };
21 |
22 | //convex as DB and Clerk as Auth Provider.
23 |
--------------------------------------------------------------------------------
/components/providers/ModalProvider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useEffect, useState } from "react";
4 |
5 | import SettingsModal from "../modals/SettingsModal";
6 | import CoverImageModal from "../modals/CoverImageModal";
7 |
8 | export const ModalProvider = () => {
9 | const [isMounted, setIsMounted] = useState(false);
10 |
11 | //none of the modals will be rendered unless we are fully on the
12 | //client side.
13 | useEffect(() => {
14 | setIsMounted(true);
15 | }, []);
16 |
17 | if (!isMounted) return null;
18 |
19 | return (
20 | <>
21 |
22 |
23 | >
24 | );
25 | };
26 |
--------------------------------------------------------------------------------
/components/providers/ThemeProvider.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/ui/alert-dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
5 |
6 | import { cn } from "@/lib/utils";
7 | import { buttonVariants } from "@/components/ui/button";
8 |
9 | const AlertDialog = AlertDialogPrimitive.Root;
10 |
11 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger;
12 |
13 | const AlertDialogPortal = AlertDialogPrimitive.Portal;
14 |
15 | const AlertDialogOverlay = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, ...props }, ref) => (
19 |
27 | ));
28 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName;
29 |
30 | const AlertDialogContent = React.forwardRef<
31 | React.ElementRef,
32 | React.ComponentPropsWithoutRef
33 | >(({ className, ...props }, ref) => (
34 |
35 |
36 |
44 |
45 | ));
46 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName;
47 |
48 | const AlertDialogHeader = ({
49 | className,
50 | ...props
51 | }: React.HTMLAttributes) => (
52 |
59 | );
60 | AlertDialogHeader.displayName = "AlertDialogHeader";
61 |
62 | const AlertDialogFooter = ({
63 | className,
64 | ...props
65 | }: React.HTMLAttributes) => (
66 |
73 | );
74 | AlertDialogFooter.displayName = "AlertDialogFooter";
75 |
76 | const AlertDialogTitle = React.forwardRef<
77 | React.ElementRef,
78 | React.ComponentPropsWithoutRef
79 | >(({ className, ...props }, ref) => (
80 |
85 | ));
86 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName;
87 |
88 | const AlertDialogDescription = React.forwardRef<
89 | React.ElementRef,
90 | React.ComponentPropsWithoutRef
91 | >(({ className, ...props }, ref) => (
92 |
97 | ));
98 | AlertDialogDescription.displayName =
99 | AlertDialogPrimitive.Description.displayName;
100 |
101 | const AlertDialogAction = React.forwardRef<
102 | React.ElementRef,
103 | React.ComponentPropsWithoutRef
104 | >(({ className, ...props }, ref) => (
105 |
110 | ));
111 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName;
112 |
113 | const AlertDialogCancel = React.forwardRef<
114 | React.ElementRef,
115 | React.ComponentPropsWithoutRef
116 | >(({ className, ...props }, ref) => (
117 |
126 | ));
127 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName;
128 |
129 | export {
130 | AlertDialog,
131 | AlertDialogPortal,
132 | AlertDialogOverlay,
133 | AlertDialogTrigger,
134 | AlertDialogContent,
135 | AlertDialogHeader,
136 | AlertDialogFooter,
137 | AlertDialogTitle,
138 | AlertDialogDescription,
139 | AlertDialogAction,
140 | AlertDialogCancel,
141 | };
142 |
--------------------------------------------------------------------------------
/components/ui/avatar.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AvatarPrimitive from "@radix-ui/react-avatar"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Avatar = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 | ))
21 | Avatar.displayName = AvatarPrimitive.Root.displayName
22 |
23 | const AvatarImage = React.forwardRef<
24 | React.ElementRef,
25 | React.ComponentPropsWithoutRef
26 | >(({ className, ...props }, ref) => (
27 |
32 | ))
33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName
34 |
35 | const AvatarFallback = React.forwardRef<
36 | React.ElementRef,
37 | React.ComponentPropsWithoutRef
38 | >(({ className, ...props }, ref) => (
39 |
47 | ))
48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
49 |
50 | export { Avatar, AvatarImage, AvatarFallback }
51 |
--------------------------------------------------------------------------------
/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-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 { type DialogProps } from "@radix-ui/react-dialog";
5 | import { Command as CommandPrimitive } from "cmdk";
6 | import { Search } from "lucide-react";
7 |
8 | import { cn } from "@/lib/utils";
9 | import { Dialog, DialogContent } from "@/components/ui/dialog";
10 |
11 | const Command = React.forwardRef<
12 | React.ElementRef,
13 | React.ComponentPropsWithoutRef
14 | >(({ className, ...props }, ref) => (
15 |
23 | ));
24 | Command.displayName = CommandPrimitive.displayName;
25 |
26 | interface CommandDialogProps extends DialogProps {}
27 |
28 | const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
29 | return (
30 |
37 | );
38 | };
39 |
40 | const CommandInput = React.forwardRef<
41 | React.ElementRef,
42 | React.ComponentPropsWithoutRef
43 | >(({ className, ...props }, ref) => (
44 |
45 |
46 |
54 |
55 | ));
56 |
57 | CommandInput.displayName = CommandPrimitive.Input.displayName;
58 |
59 | const CommandList = React.forwardRef<
60 | React.ElementRef,
61 | React.ComponentPropsWithoutRef
62 | >(({ className, ...props }, ref) => (
63 |
68 | ));
69 |
70 | CommandList.displayName = CommandPrimitive.List.displayName;
71 |
72 | const CommandEmpty = React.forwardRef<
73 | React.ElementRef,
74 | React.ComponentPropsWithoutRef
75 | >((props, ref) => (
76 |
81 | ));
82 |
83 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
84 |
85 | const CommandGroup = React.forwardRef<
86 | React.ElementRef,
87 | React.ComponentPropsWithoutRef
88 | >(({ className, ...props }, ref) => (
89 |
97 | ));
98 |
99 | CommandGroup.displayName = CommandPrimitive.Group.displayName;
100 |
101 | const CommandSeparator = React.forwardRef<
102 | React.ElementRef,
103 | React.ComponentPropsWithoutRef
104 | >(({ className, ...props }, ref) => (
105 |
110 | ));
111 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
112 |
113 | const CommandItem = React.forwardRef<
114 | React.ElementRef,
115 | React.ComponentPropsWithoutRef
116 | >(({ className, ...props }, ref) => (
117 |
125 | ));
126 |
127 | CommandItem.displayName = CommandPrimitive.Item.displayName;
128 |
129 | const CommandShortcut = ({
130 | className,
131 | ...props
132 | }: React.HTMLAttributes) => {
133 | return (
134 |
141 | );
142 | };
143 | CommandShortcut.displayName = "CommandShortcut";
144 |
145 | export {
146 | Command,
147 | CommandDialog,
148 | CommandInput,
149 | CommandList,
150 | CommandEmpty,
151 | CommandGroup,
152 | CommandItem,
153 | CommandShortcut,
154 | CommandSeparator,
155 | };
156 |
--------------------------------------------------------------------------------
/components/ui/dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as DialogPrimitive from "@radix-ui/react-dialog";
5 | import { X } from "lucide-react";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | const Dialog = DialogPrimitive.Root;
10 |
11 | const DialogTrigger = DialogPrimitive.Trigger;
12 |
13 | const DialogPortal = DialogPrimitive.Portal;
14 |
15 | const DialogClose = DialogPrimitive.Close;
16 |
17 | const DialogOverlay = React.forwardRef<
18 | React.ElementRef,
19 | React.ComponentPropsWithoutRef
20 | >(({ className, ...props }, ref) => (
21 |
29 | ));
30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
31 |
32 | const DialogContent = React.forwardRef<
33 | React.ElementRef,
34 | React.ComponentPropsWithoutRef
35 | >(({ className, children, ...props }, ref) => (
36 |
37 |
38 |
46 | {children}
47 |
48 |
49 | Close
50 |
51 |
52 |
53 | ));
54 | DialogContent.displayName = DialogPrimitive.Content.displayName;
55 |
56 | const DialogHeader = ({
57 | className,
58 | ...props
59 | }: React.HTMLAttributes) => (
60 |
67 | );
68 | DialogHeader.displayName = "DialogHeader";
69 |
70 | const DialogFooter = ({
71 | className,
72 | ...props
73 | }: React.HTMLAttributes) => (
74 |
81 | );
82 | DialogFooter.displayName = "DialogFooter";
83 |
84 | const DialogTitle = React.forwardRef<
85 | React.ElementRef,
86 | React.ComponentPropsWithoutRef
87 | >(({ className, ...props }, ref) => (
88 |
96 | ));
97 | DialogTitle.displayName = DialogPrimitive.Title.displayName;
98 |
99 | const DialogDescription = React.forwardRef<
100 | React.ElementRef,
101 | React.ComponentPropsWithoutRef
102 | >(({ className, ...props }, ref) => (
103 |
108 | ));
109 | DialogDescription.displayName = DialogPrimitive.Description.displayName;
110 |
111 | export {
112 | Dialog,
113 | DialogPortal,
114 | DialogOverlay,
115 | DialogClose,
116 | DialogTrigger,
117 | DialogContent,
118 | DialogHeader,
119 | DialogFooter,
120 | DialogTitle,
121 | DialogDescription,
122 | };
123 |
--------------------------------------------------------------------------------
/components/ui/dropdown-menu.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
5 | import { Check, ChevronRight, Circle } from "lucide-react";
6 |
7 | import { cn } from "@/lib/utils";
8 |
9 | const DropdownMenu = DropdownMenuPrimitive.Root;
10 |
11 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
12 |
13 | const DropdownMenuGroup = DropdownMenuPrimitive.Group;
14 |
15 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
16 |
17 | const DropdownMenuSub = DropdownMenuPrimitive.Sub;
18 |
19 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
20 |
21 | const DropdownMenuSubTrigger = React.forwardRef<
22 | React.ElementRef,
23 | React.ComponentPropsWithoutRef & {
24 | inset?: boolean;
25 | }
26 | >(({ className, inset, children, ...props }, ref) => (
27 |
36 | {children}
37 |
38 |
39 | ));
40 | DropdownMenuSubTrigger.displayName =
41 | DropdownMenuPrimitive.SubTrigger.displayName;
42 |
43 | const DropdownMenuSubContent = React.forwardRef<
44 | React.ElementRef,
45 | React.ComponentPropsWithoutRef
46 | >(({ className, ...props }, ref) => (
47 |
55 | ));
56 | DropdownMenuSubContent.displayName =
57 | DropdownMenuPrimitive.SubContent.displayName;
58 |
59 | const DropdownMenuContent = React.forwardRef<
60 | React.ElementRef,
61 | React.ComponentPropsWithoutRef
62 | >(({ className, sideOffset = 4, ...props }, ref) => (
63 |
64 |
73 |
74 | ));
75 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
76 |
77 | const DropdownMenuItem = React.forwardRef<
78 | React.ElementRef,
79 | React.ComponentPropsWithoutRef & {
80 | inset?: boolean;
81 | }
82 | >(({ className, inset, ...props }, ref) => (
83 |
92 | ));
93 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
94 |
95 | const DropdownMenuCheckboxItem = React.forwardRef<
96 | React.ElementRef,
97 | React.ComponentPropsWithoutRef
98 | >(({ className, children, checked, ...props }, ref) => (
99 |
108 |
109 |
110 |
111 |
112 |
113 | {children}
114 |
115 | ));
116 | DropdownMenuCheckboxItem.displayName =
117 | DropdownMenuPrimitive.CheckboxItem.displayName;
118 |
119 | const DropdownMenuRadioItem = React.forwardRef<
120 | React.ElementRef,
121 | React.ComponentPropsWithoutRef
122 | >(({ className, children, ...props }, ref) => (
123 |
131 |
132 |
133 |
134 |
135 |
136 | {children}
137 |
138 | ));
139 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
140 |
141 | const DropdownMenuLabel = React.forwardRef<
142 | React.ElementRef,
143 | React.ComponentPropsWithoutRef & {
144 | inset?: boolean;
145 | }
146 | >(({ className, inset, ...props }, ref) => (
147 |
156 | ));
157 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
158 |
159 | const DropdownMenuSeparator = React.forwardRef<
160 | React.ElementRef,
161 | React.ComponentPropsWithoutRef
162 | >(({ className, ...props }, ref) => (
163 |
168 | ));
169 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
170 |
171 | const DropdownMenuShortcut = ({
172 | className,
173 | ...props
174 | }: React.HTMLAttributes) => {
175 | return (
176 |
180 | );
181 | };
182 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
183 |
184 | export {
185 | DropdownMenu,
186 | DropdownMenuTrigger,
187 | DropdownMenuContent,
188 | DropdownMenuItem,
189 | DropdownMenuCheckboxItem,
190 | DropdownMenuRadioItem,
191 | DropdownMenuLabel,
192 | DropdownMenuSeparator,
193 | DropdownMenuShortcut,
194 | DropdownMenuGroup,
195 | DropdownMenuPortal,
196 | DropdownMenuSub,
197 | DropdownMenuSubContent,
198 | DropdownMenuSubTrigger,
199 | DropdownMenuRadioGroup,
200 | };
201 |
--------------------------------------------------------------------------------
/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | )
21 | }
22 | )
23 | Input.displayName = "Input"
24 |
25 | export { Input }
26 |
--------------------------------------------------------------------------------
/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11 | )
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ))
24 | Label.displayName = LabelPrimitive.Root.displayName
25 |
26 | export { Label }
27 |
--------------------------------------------------------------------------------
/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as PopoverPrimitive from "@radix-ui/react-popover";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const Popover = PopoverPrimitive.Root;
9 |
10 | const PopoverTrigger = PopoverPrimitive.Trigger;
11 |
12 | const PopoverContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
16 |
17 |
27 |
28 | ));
29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName;
30 |
31 | export { Popover, PopoverTrigger, PopoverContent };
32 |
--------------------------------------------------------------------------------
/components/ui/skeleton.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils";
2 |
3 | function Skeleton({
4 | className,
5 | ...props
6 | }: React.HTMLAttributes) {
7 | return (
8 |
12 | );
13 | }
14 |
15 | export { Skeleton };
16 |
--------------------------------------------------------------------------------
/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 |
3 | import { cn } from "@/lib/utils"
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | )
20 | }
21 | )
22 | Textarea.displayName = "Textarea"
23 |
24 | export { Textarea }
25 |
--------------------------------------------------------------------------------
/convex/README.md:
--------------------------------------------------------------------------------
1 | # Welcome to your Convex functions directory!
2 |
3 | Write your Convex functions here. See
4 | https://docs.convex.dev/using/writing-convex-functions for more.
5 |
6 | A query function that takes two arguments looks like:
7 |
8 | ```ts
9 | // functions.js
10 | import { query } from "./_generated/server";
11 | import { v } from "convex/values";
12 |
13 | export const myQueryFunction = query({
14 | // Validators for arguments.
15 | args: {
16 | first: v.number(),
17 | second: v.string(),
18 | },
19 |
20 | // Function implementation.
21 | handler: async (ctx, args) => {
22 | // Read the database as many times as you need here.
23 | // See https://docs.convex.dev/database/reading-data.
24 | const documents = await ctx.db.query("tablename").collect();
25 |
26 | // Arguments passed from the client are properties of the args object.
27 | console.log(args.first, args.second);
28 |
29 | // Write arbitrary JavaScript here: filter, aggregate, build derived data,
30 | // remove non-public properties, or create new objects.
31 | return documents;
32 | },
33 | });
34 | ```
35 |
36 | Using this query function in a React component looks like:
37 |
38 | ```ts
39 | const data = useQuery(api.functions.myQueryFunction, {
40 | first: 10,
41 | second: "hello",
42 | });
43 | ```
44 |
45 | A mutation function looks like:
46 |
47 | ```ts
48 | // functions.js
49 | import { mutation } from "./_generated/server";
50 | import { v } from "convex/values";
51 |
52 | export const myMutationFunction = mutation({
53 | // Validators for arguments.
54 | args: {
55 | first: v.string(),
56 | second: v.string(),
57 | },
58 |
59 | // Function implementation.
60 | handler: async (ctx, args) => {
61 | // Insert or modify documents in the database here.
62 | // Mutations can also read from the database like queries.
63 | // See https://docs.convex.dev/database/writing-data.
64 | const message = { body: args.first, author: args.second };
65 | const id = await ctx.db.insert("messages", message);
66 |
67 | // Optionally, return a value from your mutation.
68 | return await ctx.db.get(id);
69 | },
70 | });
71 | ```
72 |
73 | Using this mutation function in a React component looks like:
74 |
75 | ```ts
76 | const mutation = useMutation(api.functions.myMutationFunction);
77 | function handleButtonPress() {
78 | // fire and forget, the most common way to use mutations
79 | mutation({ first: "Hello!", second: "me" });
80 | // OR
81 | // use the result once the mutation has completed
82 | mutation({ first: "Hello!", second: "me" }).then((result) =>
83 | console.log(result)
84 | );
85 | }
86 | ```
87 |
88 | Use the Convex CLI to push your functions to a deployment. See everything
89 | the Convex CLI can do by running `npx convex -h` in your project root
90 | directory. To learn more, launch the docs with `npx convex docs`.
91 |
--------------------------------------------------------------------------------
/convex/_generated/api.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated `api` utility.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * Generated by convex@1.8.0.
8 | * To regenerate, run `npx convex dev`.
9 | * @module
10 | */
11 |
12 | import type {
13 | ApiFromModules,
14 | FilterApi,
15 | FunctionReference,
16 | } from "convex/server";
17 | import type * as documents from "../documents.js";
18 |
19 | /**
20 | * A utility for referencing Convex functions in your app's API.
21 | *
22 | * Usage:
23 | * ```js
24 | * const myFunctionReference = api.myModule.myFunction;
25 | * ```
26 | */
27 | declare const fullApi: ApiFromModules<{
28 | documents: typeof documents;
29 | }>;
30 | export declare const api: FilterApi<
31 | typeof fullApi,
32 | FunctionReference
33 | >;
34 | export declare const internal: FilterApi<
35 | typeof fullApi,
36 | FunctionReference
37 | >;
38 |
--------------------------------------------------------------------------------
/convex/_generated/api.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated `api` utility.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * Generated by convex@1.8.0.
8 | * To regenerate, run `npx convex dev`.
9 | * @module
10 | */
11 |
12 | import { anyApi } from "convex/server";
13 |
14 | /**
15 | * A utility for referencing Convex functions in your app's API.
16 | *
17 | * Usage:
18 | * ```js
19 | * const myFunctionReference = api.myModule.myFunction;
20 | * ```
21 | */
22 | export const api = anyApi;
23 | export const internal = anyApi;
24 |
--------------------------------------------------------------------------------
/convex/_generated/dataModel.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated data model types.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * Generated by convex@1.8.0.
8 | * To regenerate, run `npx convex dev`.
9 | * @module
10 | */
11 |
12 | import type {
13 | DataModelFromSchemaDefinition,
14 | DocumentByName,
15 | TableNamesInDataModel,
16 | SystemTableNames,
17 | } from "convex/server";
18 | import type { GenericId } from "convex/values";
19 | import schema from "../schema.js";
20 |
21 | /**
22 | * The names of all of your Convex tables.
23 | */
24 | export type TableNames = TableNamesInDataModel;
25 |
26 | /**
27 | * The type of a document stored in Convex.
28 | *
29 | * @typeParam TableName - A string literal type of the table name (like "users").
30 | */
31 | export type Doc = DocumentByName<
32 | DataModel,
33 | TableName
34 | >;
35 |
36 | /**
37 | * An identifier for a document in Convex.
38 | *
39 | * Convex documents are uniquely identified by their `Id`, which is accessible
40 | * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids).
41 | *
42 | * Documents can be loaded using `db.get(id)` in query and mutation functions.
43 | *
44 | * IDs are just strings at runtime, but this type can be used to distinguish them from other
45 | * strings when type checking.
46 | *
47 | * @typeParam TableName - A string literal type of the table name (like "users").
48 | */
49 | export type Id =
50 | GenericId;
51 |
52 | /**
53 | * A type describing your Convex data model.
54 | *
55 | * This type includes information about what tables you have, the type of
56 | * documents stored in those tables, and the indexes defined on them.
57 | *
58 | * This type is used to parameterize methods like `queryGeneric` and
59 | * `mutationGeneric` to make them type-safe.
60 | */
61 | export type DataModel = DataModelFromSchemaDefinition;
62 |
--------------------------------------------------------------------------------
/convex/_generated/server.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated utilities for implementing server-side Convex query and mutation functions.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * Generated by convex@1.8.0.
8 | * To regenerate, run `npx convex dev`.
9 | * @module
10 | */
11 |
12 | import {
13 | ActionBuilder,
14 | HttpActionBuilder,
15 | MutationBuilder,
16 | QueryBuilder,
17 | GenericActionCtx,
18 | GenericMutationCtx,
19 | GenericQueryCtx,
20 | GenericDatabaseReader,
21 | GenericDatabaseWriter,
22 | } from "convex/server";
23 | import type { DataModel } from "./dataModel.js";
24 |
25 | /**
26 | * Define a query in this Convex app's public API.
27 | *
28 | * This function will be allowed to read your Convex database and will be accessible from the client.
29 | *
30 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
31 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
32 | */
33 | export declare const query: QueryBuilder;
34 |
35 | /**
36 | * Define a query that is only accessible from other Convex functions (but not from the client).
37 | *
38 | * This function will be allowed to read from your Convex database. It will not be accessible from the client.
39 | *
40 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
41 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
42 | */
43 | export declare const internalQuery: QueryBuilder;
44 |
45 | /**
46 | * Define a mutation in this Convex app's public API.
47 | *
48 | * This function will be allowed to modify your Convex database and will be accessible from the client.
49 | *
50 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
51 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
52 | */
53 | export declare const mutation: MutationBuilder;
54 |
55 | /**
56 | * Define a mutation that is only accessible from other Convex functions (but not from the client).
57 | *
58 | * This function will be allowed to modify your Convex database. It will not be accessible from the client.
59 | *
60 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
61 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
62 | */
63 | export declare const internalMutation: MutationBuilder;
64 |
65 | /**
66 | * Define an action in this Convex app's public API.
67 | *
68 | * An action is a function which can execute any JavaScript code, including non-deterministic
69 | * code and code with side-effects, like calling third-party services.
70 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
71 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
72 | *
73 | * @param func - The action. It receives an {@link ActionCtx} as its first argument.
74 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible.
75 | */
76 | export declare const action: ActionBuilder;
77 |
78 | /**
79 | * Define an action that is only accessible from other Convex functions (but not from the client).
80 | *
81 | * @param func - The function. It receives an {@link ActionCtx} as its first argument.
82 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible.
83 | */
84 | export declare const internalAction: ActionBuilder;
85 |
86 | /**
87 | * Define an HTTP action.
88 | *
89 | * This function will be used to respond to HTTP requests received by a Convex
90 | * deployment if the requests matches the path and method where this action
91 | * is routed. Be sure to route your action in `convex/http.js`.
92 | *
93 | * @param func - The function. It receives an {@link ActionCtx} as its first argument.
94 | * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
95 | */
96 | export declare const httpAction: HttpActionBuilder;
97 |
98 | /**
99 | * A set of services for use within Convex query functions.
100 | *
101 | * The query context is passed as the first argument to any Convex query
102 | * function run on the server.
103 | *
104 | * This differs from the {@link MutationCtx} because all of the services are
105 | * read-only.
106 | */
107 | export type QueryCtx = GenericQueryCtx;
108 |
109 | /**
110 | * A set of services for use within Convex mutation functions.
111 | *
112 | * The mutation context is passed as the first argument to any Convex mutation
113 | * function run on the server.
114 | */
115 | export type MutationCtx = GenericMutationCtx;
116 |
117 | /**
118 | * A set of services for use within Convex action functions.
119 | *
120 | * The action context is passed as the first argument to any Convex action
121 | * function run on the server.
122 | */
123 | export type ActionCtx = GenericActionCtx;
124 |
125 | /**
126 | * An interface to read from the database within Convex query functions.
127 | *
128 | * The two entry points are {@link DatabaseReader.get}, which fetches a single
129 | * document by its {@link Id}, or {@link DatabaseReader.query}, which starts
130 | * building a query.
131 | */
132 | export type DatabaseReader = GenericDatabaseReader;
133 |
134 | /**
135 | * An interface to read from and write to the database within Convex mutation
136 | * functions.
137 | *
138 | * Convex guarantees that all writes within a single mutation are
139 | * executed atomically, so you never have to worry about partial writes leaving
140 | * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
141 | * for the guarantees Convex provides your functions.
142 | */
143 | export type DatabaseWriter = GenericDatabaseWriter;
144 |
--------------------------------------------------------------------------------
/convex/_generated/server.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /**
3 | * Generated utilities for implementing server-side Convex query and mutation functions.
4 | *
5 | * THIS CODE IS AUTOMATICALLY GENERATED.
6 | *
7 | * Generated by convex@1.8.0.
8 | * To regenerate, run `npx convex dev`.
9 | * @module
10 | */
11 |
12 | import {
13 | actionGeneric,
14 | httpActionGeneric,
15 | queryGeneric,
16 | mutationGeneric,
17 | internalActionGeneric,
18 | internalMutationGeneric,
19 | internalQueryGeneric,
20 | } from "convex/server";
21 |
22 | /**
23 | * Define a query in this Convex app's public API.
24 | *
25 | * This function will be allowed to read your Convex database and will be accessible from the client.
26 | *
27 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
28 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
29 | */
30 | export const query = queryGeneric;
31 |
32 | /**
33 | * Define a query that is only accessible from other Convex functions (but not from the client).
34 | *
35 | * This function will be allowed to read from your Convex database. It will not be accessible from the client.
36 | *
37 | * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
38 | * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
39 | */
40 | export const internalQuery = internalQueryGeneric;
41 |
42 | /**
43 | * Define a mutation in this Convex app's public API.
44 | *
45 | * This function will be allowed to modify your Convex database and will be accessible from the client.
46 | *
47 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
48 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
49 | */
50 | export const mutation = mutationGeneric;
51 |
52 | /**
53 | * Define a mutation that is only accessible from other Convex functions (but not from the client).
54 | *
55 | * This function will be allowed to modify your Convex database. It will not be accessible from the client.
56 | *
57 | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
58 | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
59 | */
60 | export const internalMutation = internalMutationGeneric;
61 |
62 | /**
63 | * Define an action in this Convex app's public API.
64 | *
65 | * An action is a function which can execute any JavaScript code, including non-deterministic
66 | * code and code with side-effects, like calling third-party services.
67 | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
68 | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
69 | *
70 | * @param func - The action. It receives an {@link ActionCtx} as its first argument.
71 | * @returns The wrapped action. Include this as an `export` to name it and make it accessible.
72 | */
73 | export const action = actionGeneric;
74 |
75 | /**
76 | * Define an action that is only accessible from other Convex functions (but not from the client).
77 | *
78 | * @param func - The function. It receives an {@link ActionCtx} as its first argument.
79 | * @returns The wrapped function. Include this as an `export` to name it and make it accessible.
80 | */
81 | export const internalAction = internalActionGeneric;
82 |
83 | /**
84 | * Define a Convex HTTP action.
85 | *
86 | * @param func - The function. It receives an {@link ActionCtx} as its first argument, and a `Request` object
87 | * as its second.
88 | * @returns The wrapped endpoint function. Route a URL path to this function in `convex/http.js`.
89 | */
90 | export const httpAction = httpActionGeneric;
91 |
--------------------------------------------------------------------------------
/convex/auth.config.js:
--------------------------------------------------------------------------------
1 | export default {
2 | providers: [
3 | {
4 | domain: "https://viable-fish-32.clerk.accounts.dev",
5 | applicationID: "convex",
6 | },
7 | ],
8 | };
9 |
--------------------------------------------------------------------------------
/convex/documents.ts:
--------------------------------------------------------------------------------
1 | import { v } from "convex/values";
2 |
3 | import { mutation, query } from "./_generated/server";
4 | import { Doc, Id } from "./_generated/dataModel";
5 |
6 | export const archive = mutation({
7 | args: {
8 | id: v.id("documents"),
9 | },
10 | handler: async (ctx, args) => {
11 | const identity = await ctx.auth.getUserIdentity();
12 |
13 | if (!identity) {
14 | throw new Error("Not Authenticated");
15 | }
16 |
17 | const userId = identity.subject;
18 |
19 | const existingDocument = await ctx.db.get(args.id);
20 | if (!existingDocument) {
21 | throw new Error("Not found!");
22 | }
23 | if (existingDocument.userId !== userId) {
24 | throw new Error("Unauthorized");
25 | }
26 |
27 | //for deleting all the children documents of a specific document and it's children
28 | const recursiveArchive = async (documentId: Id<"documents">) => {
29 | const children = await ctx.db
30 | .query("documents")
31 | .withIndex("by_user_parent", (q) =>
32 | q.eq("userId", userId).eq("parentDocument", documentId)
33 | )
34 | .collect();
35 |
36 | for (const child of children) {
37 | await ctx.db.patch(child._id, {
38 | isArchived: true,
39 | });
40 |
41 | //passing the current document id as the parent id
42 | await recursiveArchive(child._id);
43 | }
44 | };
45 |
46 | const document = await ctx.db.patch(args.id, {
47 | isArchived: true,
48 | });
49 |
50 | //for deleting the children
51 | recursiveArchive(args.id);
52 |
53 | return document;
54 | },
55 | });
56 |
57 | export const getSidebar = query({
58 | args: {
59 | parentDocument: v.optional(v.id("documents")),
60 | },
61 | handler: async (ctx, args) => {
62 | const identity = await ctx.auth.getUserIdentity();
63 |
64 | if (!identity) {
65 | throw new Error("Not Authenticated");
66 | }
67 |
68 | const userId = identity.subject;
69 |
70 | const documents = await ctx.db
71 | .query("documents")
72 | .withIndex("by_user_parent", (q) =>
73 | q.eq("userId", userId).eq("parentDocument", args.parentDocument)
74 | )
75 | .filter((q) => q.eq(q.field("isArchived"), false)) //show documents that are not archived.
76 | .order("desc")
77 | .collect();
78 |
79 | return documents;
80 | },
81 | });
82 |
83 | export const create = mutation({
84 | args: {
85 | title: v.string(),
86 | parentDocument: v.optional(v.id("documents")),
87 | },
88 | handler: async (ctx, args) => {
89 | const identity = await ctx.auth.getUserIdentity();
90 |
91 | if (!identity) {
92 | throw new Error("Not Authenticated");
93 | }
94 |
95 | const userId = identity.subject;
96 |
97 | const document = await ctx.db.insert("documents", {
98 | title: args.title,
99 | parentDocument: args.parentDocument,
100 | userId,
101 | isArchived: false,
102 | isPublished: false,
103 | });
104 | return document;
105 | },
106 | });
107 |
108 | export const getTrash = query({
109 | handler: async (ctx) => {
110 | const identity = await ctx.auth.getUserIdentity();
111 |
112 | if (!identity) {
113 | throw new Error("Not Authenticated");
114 | }
115 |
116 | const userId = identity.subject;
117 |
118 | const documents = await ctx.db
119 | .query("documents")
120 | .withIndex("by_user", (q) => q.eq("userId", userId))
121 | .filter((q) => q.eq(q.field("isArchived"), true))
122 | .order("desc")
123 | .collect();
124 |
125 | return documents;
126 | },
127 | });
128 |
129 | export const restore = mutation({
130 | args: { id: v.id("documents") },
131 | handler: async (ctx, args) => {
132 | const identity = await ctx.auth.getUserIdentity();
133 |
134 | if (!identity) {
135 | throw new Error("Not Authenticated");
136 | }
137 |
138 | const userId = identity.subject;
139 |
140 | const existingDocument = await ctx.db.get(args.id);
141 |
142 | if (!existingDocument) {
143 | throw new Error("Not found!");
144 | }
145 |
146 | if (existingDocument.userId !== userId) {
147 | throw new Error("Unauthorized");
148 | }
149 |
150 | const recursiveRestore = async (documentId: Id<"documents">) => {
151 | const children = await ctx.db
152 | .query("documents")
153 | .withIndex("by_user_parent", (q) =>
154 | q.eq("userId", userId).eq("parentDocument", documentId)
155 | )
156 | .collect();
157 |
158 | for (const child of children) {
159 | await ctx.db.patch(child._id, {
160 | isArchived: false,
161 | });
162 |
163 | await recursiveRestore(child._id);
164 | }
165 | };
166 |
167 | const options: Partial> = {
168 | isArchived: false,
169 | };
170 |
171 | if (existingDocument.parentDocument) {
172 | const parent = await ctx.db.get(existingDocument.parentDocument);
173 | if (parent?.isArchived || !parent) {
174 | //when parent document is still archived or when the parent document has been deleted
175 | //but you have to restore the child.
176 | options.parentDocument = undefined;
177 | }
178 | }
179 |
180 | const document = await ctx.db.patch(args.id, options);
181 |
182 | //to restore the child documents as well.
183 | recursiveRestore(args.id);
184 |
185 | return document;
186 | },
187 | });
188 |
189 | export const remove = mutation({
190 | args: {
191 | id: v.id("documents"),
192 | },
193 | handler: async (ctx, args) => {
194 | const identity = await ctx.auth.getUserIdentity();
195 |
196 | if (!identity) {
197 | throw new Error("Not Authenticated");
198 | }
199 |
200 | const userId = identity.subject;
201 |
202 | const existingDocument = await ctx.db.get(args.id);
203 | if (!existingDocument) {
204 | throw new Error("Not found!");
205 | }
206 | if (existingDocument.userId !== userId) {
207 | throw new Error("Unauthorized");
208 | }
209 |
210 | const document = await ctx.db.delete(args.id);
211 |
212 | return document;
213 | },
214 | });
215 |
216 | export const getSearch = query({
217 | handler: async (ctx) => {
218 | const identity = await ctx.auth.getUserIdentity();
219 |
220 | if (!identity) {
221 | throw new Error("Not Authenticated");
222 | }
223 |
224 | const userId = identity.subject;
225 |
226 | const documents = await ctx.db
227 | .query("documents")
228 | .withIndex("by_user", (q) => q.eq("userId", userId))
229 | .filter((q) => q.eq(q.field("isArchived"), false))
230 | .order("desc")
231 | .collect();
232 |
233 | return documents;
234 | },
235 | });
236 |
237 | export const getById = query({
238 | args: { documentId: v.id("documents") },
239 | handler: async (ctx, args) => {
240 | const identity = await ctx.auth.getUserIdentity();
241 |
242 | const document = await ctx.db.get(args.documentId);
243 |
244 | if (!document) {
245 | throw new Error("Not Found");
246 | }
247 |
248 | if (document.isPublished && !document.isArchived) {
249 | return document;
250 | }
251 |
252 | if (!identity) {
253 | throw new Error("Not Authenticated!");
254 | }
255 |
256 | const userId = identity.subject;
257 |
258 | if (document.userId !== userId) {
259 | throw new Error("Unauthorized");
260 | }
261 |
262 | return document;
263 | },
264 | });
265 |
266 | export const update = mutation({
267 | args: {
268 | id: v.id("documents"),
269 | title: v.optional(v.string()),
270 | content: v.optional(v.string()),
271 | coverImage: v.optional(v.string()),
272 | icon: v.optional(v.string()),
273 | isPublished: v.optional(v.boolean()),
274 | },
275 | handler: async (ctx, args) => {
276 | const identity = await ctx.auth.getUserIdentity();
277 |
278 | if (!identity) {
279 | throw new Error("Not Authenticated");
280 | }
281 |
282 | const userId = identity.subject;
283 |
284 | //id won't be updated, but the rest, whatever will be sent (patch) will get updated.
285 | const { id, ...rest } = args;
286 |
287 | const existingDocument = await ctx.db.get(args.id);
288 |
289 | if (!existingDocument) {
290 | throw new Error("Not found!");
291 | }
292 |
293 | if (existingDocument.userId !== userId) {
294 | throw new Error("Unauthorized.");
295 | }
296 |
297 | const document = await ctx.db.patch(args.id, { ...rest });
298 |
299 | return document;
300 | },
301 | });
302 |
303 | export const removeIcon = mutation({
304 | args: { id: v.id("documents") },
305 | handler: async (ctx, args) => {
306 | const identity = await ctx.auth.getUserIdentity();
307 |
308 | if (!identity) {
309 | throw new Error("Not Authenticated");
310 | }
311 |
312 | const userId = identity.subject;
313 |
314 | const existingDocument = await ctx.db.get(args.id);
315 |
316 | if (!existingDocument) {
317 | throw new Error("Not found!");
318 | }
319 |
320 | if (existingDocument.userId !== userId) {
321 | throw new Error("Unauthorized.");
322 | }
323 |
324 | const document = await ctx.db.patch(args.id, {
325 | icon: undefined,
326 | });
327 |
328 | return document;
329 | },
330 | });
331 |
332 | export const removeCoverImage = mutation({
333 | args: { id: v.id("documents") },
334 | handler: async (ctx, args) => {
335 | const identity = await ctx.auth.getUserIdentity();
336 |
337 | if (!identity) {
338 | throw new Error("Not Authenticated");
339 | }
340 |
341 | const userId = identity.subject;
342 |
343 | const existingDocument = await ctx.db.get(args.id);
344 |
345 | if (!existingDocument) {
346 | throw new Error("Not found!");
347 | }
348 |
349 | if (existingDocument.userId !== userId) {
350 | throw new Error("Unauthorized.");
351 | }
352 |
353 | const document = await ctx.db.patch(args.id, {
354 | coverImage: undefined,
355 | });
356 |
357 | return document;
358 | },
359 | });
360 |
--------------------------------------------------------------------------------
/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(), //clerkId
8 | isArchived: v.boolean(),
9 | parentDocument: v.optional(v.id("documents")),
10 | content: v.optional(v.string()),
11 | coverImage: v.optional(v.string()),
12 | icon: v.optional(v.string()),
13 | isPublished: v.boolean(),
14 | })
15 | .index("by_user", ["userId"])
16 | .index("by_user_parent", ["userId", "parentDocument"]),
17 | });
18 |
--------------------------------------------------------------------------------
/convex/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | /* This TypeScript project config describes the environment that
3 | * Convex functions run in and is used to typecheck them.
4 | * You can modify it, but some settings required to use Convex.
5 | */
6 | "compilerOptions": {
7 | /* These settings are not required by Convex and can be modified. */
8 | "allowJs": true,
9 | "strict": true,
10 |
11 | /* These compiler options are required by Convex */
12 | "target": "ESNext",
13 | "lib": ["ES2021", "dom"],
14 | "forceConsistentCasingInFileNames": true,
15 | "allowSyntheticDefaultImports": true,
16 | "module": "ESNext",
17 | "moduleResolution": "Node",
18 | "isolatedModules": true,
19 | "skipLibCheck": true,
20 | "noEmit": true
21 | },
22 | "include": ["./**/*"],
23 | "exclude": ["./_generated"]
24 | }
25 |
--------------------------------------------------------------------------------
/hooks/useCoverImage.tsx:
--------------------------------------------------------------------------------
1 | import { create } from "zustand";
2 |
3 | type CoverImageStore = {
4 | url?: string;
5 | isOpen: boolean;
6 | onOpen: () => void;
7 | onClose: () => void;
8 | onReplace: (url: string) => void;
9 | };
10 |
11 | export const useCoverImage = create((set) => ({
12 | url: undefined,
13 | isOpen: false,
14 | onOpen: () => set({ isOpen: true, url: undefined }),
15 | onClose: () => set({ isOpen: false, url: undefined }),
16 | onReplace: (url: string) => set({ isOpen: true, url }),
17 | }));
18 |
--------------------------------------------------------------------------------
/hooks/useOrigin.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 |
3 | export const useOrigin = () => {
4 | const [mounted, setMounted] = useState(false);
5 | 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/useSearch.tsx:
--------------------------------------------------------------------------------
1 | import { create } from "zustand";
2 |
3 | type SearchStore = {
4 | isOpen: boolean;
5 | onOpen: () => void;
6 | onClose: () => void;
7 | toggle: () => void;
8 | };
9 |
10 | export const useSearch = create((set, get) => ({
11 | isOpen: false,
12 | onOpen: () => set({ isOpen: true }),
13 | onClose: () => set({ isOpen: false }),
14 | toggle: () => set({ isOpen: !get().isOpen }),
15 | }));
16 |
--------------------------------------------------------------------------------
/hooks/useSettings.tsx:
--------------------------------------------------------------------------------
1 | import { create } from "zustand";
2 |
3 | type SettingsStore = {
4 | isOpen: boolean;
5 | onOpen: () => void;
6 | onClose: () => void;
7 | };
8 |
9 | export const useSettings = create((set) => ({
10 | isOpen: false,
11 | onOpen: () => set({ isOpen: true }),
12 | onClose: () => set({ isOpen: false }),
13 | }));
14 |
--------------------------------------------------------------------------------
/lib/edgestore.ts:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { type EdgeStoreRouter } from "../app/api/edgestore/[...edgestore]/route";
4 | import { createEdgeStoreProvider } from "@edgestore/react";
5 |
6 | const { EdgeStoreProvider, useEdgeStore } =
7 | createEdgeStoreProvider();
8 | export { EdgeStoreProvider, useEdgeStore };
9 |
--------------------------------------------------------------------------------
/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 | remotePatterns: [
5 | {
6 | protocol: "http",
7 | hostname: "files.edgestore.dev",
8 | },
9 | {
10 | protocol: "https",
11 | hostname: "files.edgestore.dev",
12 | },
13 | ],
14 | },
15 | };
16 |
17 | module.exports = nextConfig;
18 |
--------------------------------------------------------------------------------
/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.11.2",
13 | "@blocknote/react": "^0.11.2",
14 | "@clerk/clerk-react": "^4.30.3",
15 | "@edgestore/react": "^0.1.6",
16 | "@edgestore/server": "^0.1.6",
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.1.0",
26 | "cmdk": "^0.2.1",
27 | "convex": "^1.8.0",
28 | "emoji-picker-react": "^4.7.12",
29 | "lucide-react": "^0.310.0",
30 | "next": "14.0.4",
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.3.1",
37 | "tailwind-merge": "^2.2.1",
38 | "tailwindcss-animate": "^1.0.7",
39 | "usehooks-ts": "^2.9.5",
40 | "zod": "^3.22.4",
41 | "zustand": "^4.5.0"
42 | },
43 | "devDependencies": {
44 | "@types/node": "^20",
45 | "@types/react": "^18",
46 | "@types/react-dom": "^18",
47 | "autoprefixer": "^10.0.1",
48 | "eslint": "^8",
49 | "eslint-config-next": "14.0.4",
50 | "postcss": "^8",
51 | "tailwindcss": "^3.3.0",
52 | "typescript": "^5"
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/public/assets/documents-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sougata-github/Jotion/7089903accabcd087e5d1220fc9ad60c9ca55c08/public/assets/documents-dark.png
--------------------------------------------------------------------------------
/public/assets/documents.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sougata-github/Jotion/7089903accabcd087e5d1220fc9ad60c9ca55c08/public/assets/documents.png
--------------------------------------------------------------------------------
/public/assets/empty-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sougata-github/Jotion/7089903accabcd087e5d1220fc9ad60c9ca55c08/public/assets/empty-dark.png
--------------------------------------------------------------------------------
/public/assets/empty.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sougata-github/Jotion/7089903accabcd087e5d1220fc9ad60c9ca55c08/public/assets/empty.png
--------------------------------------------------------------------------------
/public/assets/error-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sougata-github/Jotion/7089903accabcd087e5d1220fc9ad60c9ca55c08/public/assets/error-dark.png
--------------------------------------------------------------------------------
/public/assets/error.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sougata-github/Jotion/7089903accabcd087e5d1220fc9ad60c9ca55c08/public/assets/error.png
--------------------------------------------------------------------------------
/public/assets/logo-dark.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/assets/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/assets/reading-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sougata-github/Jotion/7089903accabcd087e5d1220fc9ad60c9ca55c08/public/assets/reading-dark.png
--------------------------------------------------------------------------------
/public/assets/reading.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/sougata-github/Jotion/7089903accabcd087e5d1220fc9ad60c9ca55c08/public/assets/reading.png
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss"
2 |
3 | const config = {
4 | darkMode: ["class"],
5 | content: [
6 | './pages/**/*.{ts,tsx}',
7 | './components/**/*.{ts,tsx}',
8 | './app/**/*.{ts,tsx}',
9 | './src/**/*.{ts,tsx}',
10 | ],
11 | prefix: "",
12 | theme: {
13 | container: {
14 | center: true,
15 | padding: "2rem",
16 | screens: {
17 | "2xl": "1400px",
18 | },
19 | },
20 | extend: {
21 | colors: {
22 | border: "hsl(var(--border))",
23 | input: "hsl(var(--input))",
24 | ring: "hsl(var(--ring))",
25 | background: "hsl(var(--background))",
26 | foreground: "hsl(var(--foreground))",
27 | primary: {
28 | DEFAULT: "hsl(var(--primary))",
29 | foreground: "hsl(var(--primary-foreground))",
30 | },
31 | secondary: {
32 | DEFAULT: "hsl(var(--secondary))",
33 | foreground: "hsl(var(--secondary-foreground))",
34 | },
35 | destructive: {
36 | DEFAULT: "hsl(var(--destructive))",
37 | foreground: "hsl(var(--destructive-foreground))",
38 | },
39 | muted: {
40 | DEFAULT: "hsl(var(--muted))",
41 | foreground: "hsl(var(--muted-foreground))",
42 | },
43 | accent: {
44 | DEFAULT: "hsl(var(--accent))",
45 | foreground: "hsl(var(--accent-foreground))",
46 | },
47 | popover: {
48 | DEFAULT: "hsl(var(--popover))",
49 | foreground: "hsl(var(--popover-foreground))",
50 | },
51 | card: {
52 | DEFAULT: "hsl(var(--card))",
53 | foreground: "hsl(var(--card-foreground))",
54 | },
55 | },
56 | borderRadius: {
57 | lg: "var(--radius)",
58 | md: "calc(var(--radius) - 2px)",
59 | sm: "calc(var(--radius) - 4px)",
60 | },
61 | keyframes: {
62 | "accordion-down": {
63 | from: { height: "0" },
64 | to: { height: "var(--radix-accordion-content-height)" },
65 | },
66 | "accordion-up": {
67 | from: { height: "var(--radix-accordion-content-height)" },
68 | to: { height: "0" },
69 | },
70 | },
71 | animation: {
72 | "accordion-down": "accordion-down 0.2s ease-out",
73 | "accordion-up": "accordion-up 0.2s ease-out",
74 | },
75 | },
76 | },
77 | plugins: [require("tailwindcss-animate")],
78 | } satisfies Config
79 |
80 | export default config
--------------------------------------------------------------------------------
/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": "node",
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 |
--------------------------------------------------------------------------------