├── .eslintrc.json ├── public ├── empty.png ├── error.png ├── reading.png ├── documents.png ├── empty-dark.png ├── error-dark.png ├── reading-dark.png ├── documents-dark.png ├── vercel.svg ├── logo.svg ├── logo-dark.svg └── next.svg ├── postcss.config.js ├── convex ├── auth.config.js ├── _generated │ ├── api.js │ ├── api.d.ts │ ├── dataModel.d.ts │ ├── server.js │ └── server.d.ts ├── schema.ts ├── tsconfig.json ├── README.md └── documents.ts ├── lib ├── utils.ts └── edgestore.ts ├── next.config.js ├── app ├── (public) │ ├── layout.tsx │ └── (routes) │ │ └── preview │ │ └── [documentId] │ │ └── page.tsx ├── (marketing) │ ├── layout.tsx │ ├── page.tsx │ └── _components │ │ ├── footer.tsx │ │ ├── logo.tsx │ │ ├── heroes.tsx │ │ ├── heading.tsx │ │ └── navbar.tsx ├── api │ └── edgestore │ │ └── [...edgestore] │ │ └── route.ts ├── error.tsx ├── (main) │ ├── layout.tsx │ ├── (routes) │ │ └── documents │ │ │ ├── page.tsx │ │ │ └── [documentId] │ │ │ └── page.tsx │ └── _components │ │ ├── navbar.tsx │ │ ├── banner.tsx │ │ ├── menu.tsx │ │ ├── user-item.tsx │ │ ├── title.tsx │ │ ├── document-list.tsx │ │ ├── trash-box.tsx │ │ ├── publish.tsx │ │ ├── item.tsx │ │ └── navigation.tsx ├── layout.tsx └── globals.css ├── components ├── ui │ ├── skeleton.tsx │ ├── label.tsx │ ├── input.tsx │ ├── popover.tsx │ ├── avatar.tsx │ ├── button.tsx │ ├── dialog.tsx │ ├── alert-dialog.tsx │ ├── command.tsx │ └── dropdown-menu.tsx ├── providers │ ├── theme-provider.tsx │ ├── modal-provider.tsx │ └── convex-provider.tsx ├── spinner.tsx ├── modals │ ├── settings-modal.tsx │ ├── confirm-modal.tsx │ └── cover-image-modal.tsx ├── icon-picker.tsx ├── mode-toggle.tsx ├── editor.tsx ├── cover.tsx ├── search-command.tsx ├── toolbar.tsx └── single-image-dropzone.tsx ├── hooks ├── use-settings.tsx ├── use-origin.tsx ├── use-search.tsx ├── use-cover-image.tsx └── use-scroll-top.tsx ├── components.json ├── .gitignore ├── tsconfig.json ├── LICENSE ├── README.md ├── package.json └── tailwind.config.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /public/empty.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Davronov-Alimardon/notion-clone/HEAD/public/empty.png -------------------------------------------------------------------------------- /public/error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Davronov-Alimardon/notion-clone/HEAD/public/error.png -------------------------------------------------------------------------------- /public/reading.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Davronov-Alimardon/notion-clone/HEAD/public/reading.png -------------------------------------------------------------------------------- /public/documents.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Davronov-Alimardon/notion-clone/HEAD/public/documents.png -------------------------------------------------------------------------------- /public/empty-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Davronov-Alimardon/notion-clone/HEAD/public/empty-dark.png -------------------------------------------------------------------------------- /public/error-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Davronov-Alimardon/notion-clone/HEAD/public/error-dark.png -------------------------------------------------------------------------------- /public/reading-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Davronov-Alimardon/notion-clone/HEAD/public/reading-dark.png -------------------------------------------------------------------------------- /public/documents-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Davronov-Alimardon/notion-clone/HEAD/public/documents-dark.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /convex/auth.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | providers: [ 3 | { 4 | domain: "https://brief-bream-40.clerk.accounts.dev", 5 | applicationID: "convex", 6 | }, 7 | ], 8 | }; 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 | domains: [ 5 | "files.edgestore.dev" 6 | ] 7 | } 8 | } 9 | 10 | module.exports = nextConfig 11 | -------------------------------------------------------------------------------- /app/(public)/layout.tsx: -------------------------------------------------------------------------------- 1 | const PublicLayout = ({ 2 | children 3 | }: { 4 | children: React.ReactNode; 5 | }) => { 6 | return ( 7 |
8 | {children} 9 |
10 | ); 11 | } 12 | 13 | export default PublicLayout; -------------------------------------------------------------------------------- /lib/edgestore.ts: -------------------------------------------------------------------------------- 1 | 'use client'; 2 |   3 | import { type EdgeStoreRouter } from '../app/api/edgestore/[...edgestore]/route'; 4 | import { createEdgeStoreProvider } from '@edgestore/react'; 5 |   6 | const { EdgeStoreProvider, useEdgeStore } = 7 | createEdgeStoreProvider(); 8 |   9 | export { EdgeStoreProvider, useEdgeStore }; -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /hooks/use-settings.tsx: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | type SettingsStore = { 4 | isOpen: boolean; 5 | onOpen: () => void; 6 | onClose: () => void; 7 | }; 8 | 9 | export const useSettings = create((set) => ({ 10 | isOpen: false, 11 | onOpen: () => set({ isOpen: true }), 12 | onClose: () => set({ isOpen: false }), 13 | })); 14 | -------------------------------------------------------------------------------- /components/providers/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { ThemeProvider as NextThemesProvider } from "next-themes" 5 | import { type ThemeProviderProps } from "next-themes/dist/types" 6 | 7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 8 | return {children} 9 | } 10 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } -------------------------------------------------------------------------------- /app/(marketing)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Navbar } from "./_components/navbar"; 2 | 3 | const MarketingLayout = ({ 4 | children 5 | }: { 6 | children: React.ReactNode; 7 | }) => { 8 | return ( 9 |
10 | 11 |
12 | {children} 13 |
14 |
15 | ); 16 | } 17 | 18 | export default MarketingLayout; -------------------------------------------------------------------------------- /hooks/use-origin.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | export const useOrigin = () => { 4 | const [mounted, setMounted] = useState(false); 5 | const origin = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; 6 | 7 | useEffect(() => { 8 | setMounted(true); 9 | }, []); 10 | 11 | if (!mounted) { 12 | return ""; 13 | } 14 | 15 | return origin; 16 | }; 17 | -------------------------------------------------------------------------------- /hooks/use-search.tsx: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | type SearchStore = { 4 | isOpen: boolean; 5 | onOpen: () => void; 6 | onClose: () => void; 7 | toggle: () => void; 8 | }; 9 | 10 | export const useSearch = create((set, get) => ({ 11 | isOpen: false, 12 | onOpen: () => set({ isOpen: true }), 13 | onClose: () => set({ isOpen: false }), 14 | toggle: () => set({ isOpen: !get().isOpen }), 15 | })); 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | .env* 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | -------------------------------------------------------------------------------- /hooks/use-cover-image.tsx: -------------------------------------------------------------------------------- 1 | import { create } from "zustand"; 2 | 3 | type CoverImageStore = { 4 | url?: string; 5 | isOpen: boolean; 6 | onOpen: () => void; 7 | onClose: () => void; 8 | onReplace: (url: string) => void; 9 | }; 10 | 11 | export const useCoverImage = create((set) => ({ 12 | url: undefined, 13 | isOpen: false, 14 | onOpen: () => set({ isOpen: true, url: undefined }), 15 | onClose: () => set({ isOpen: false, url: undefined }), 16 | onReplace: (url: string) => set({ isOpen: true, url }) 17 | })); 18 | -------------------------------------------------------------------------------- /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.12.2. 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 | -------------------------------------------------------------------------------- /app/(marketing)/page.tsx: -------------------------------------------------------------------------------- 1 | import { Footer } from "./_components/footer"; 2 | import { Heading } from "./_components/heading"; 3 | import { Heroes } from "./_components/heroes"; 4 | 5 | const MarketingPage = () => { 6 | return ( 7 |
8 |
9 | 10 | 11 |
12 |
13 |
14 | ); 15 | } 16 | 17 | export default MarketingPage; 18 | 19 | -------------------------------------------------------------------------------- /hooks/use-scroll-top.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | export const useScrollTop = (threshold = 10) => { 4 | const [scrolled, setScrolled] = useState(false); 5 | 6 | useEffect(() => { 7 | const handleScroll = () => { 8 | if (window.scrollY > threshold) { 9 | setScrolled(true); 10 | } else { 11 | setScrolled(false); 12 | } 13 | }; 14 | 15 | window.addEventListener("scroll", handleScroll); 16 | return () => window.removeEventListener("scroll", handleScroll); 17 | }, [threshold]); 18 | 19 | return scrolled; 20 | } -------------------------------------------------------------------------------- /convex/schema.ts: -------------------------------------------------------------------------------- 1 | import { defineSchema, defineTable } from "convex/server"; 2 | import { v } from "convex/values"; 3 | 4 | export default defineSchema({ 5 | documents: defineTable({ 6 | title: v.string(), 7 | userId: v.string(), 8 | isArchived: v.boolean(), 9 | parentDocument: v.optional(v.id("documents")), 10 | content: v.optional(v.string()), 11 | coverImage: v.optional(v.string()), 12 | icon: v.optional(v.string()), 13 | isPublished: v.boolean(), 14 | }) 15 | .index("by_user", ["userId"]) 16 | .index("by_user_parent", ["userId", "parentDocument"]) 17 | }); 18 | -------------------------------------------------------------------------------- /components/providers/modal-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState } from "react"; 4 | 5 | import { SettingsModal } from "@/components/modals/settings-modal"; 6 | import { CoverImageModal } from "@/components/modals/cover-image-modal"; 7 | 8 | export const ModalProvider = () => { 9 | const [isMounted, setIsMounted] = useState(false); 10 | 11 | useEffect(() => { 12 | setIsMounted(true); 13 | }, []); 14 | 15 | if (!isMounted) { 16 | return null; 17 | } 18 | 19 | return ( 20 | <> 21 | 22 | 23 | 24 | ); 25 | }; 26 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/(marketing)/_components/footer.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button" 2 | 3 | import { Logo } from "./logo" 4 | 5 | export const Footer = () => { 6 | return ( 7 |
8 | 9 |
10 | 13 | 16 |
17 |
18 | ) 19 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /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() 11 | .beforeDelete(() => { 12 | return true; 13 | }), 14 | }); 15 |   16 | const handler = createEdgeStoreNextHandler({ 17 | router: edgeStoreRouter, 18 | }); 19 |   20 | export { handler as GET, handler as POST }; 21 |   22 | /** 23 | * This type is used to create the type-safe client for the frontend. 24 | */ 25 | export type EdgeStoreRouter = typeof edgeStoreRouter; -------------------------------------------------------------------------------- /components/spinner.tsx: -------------------------------------------------------------------------------- 1 | import { Loader } from "lucide-react"; 2 | 3 | import { cva, type VariantProps } from "class-variance-authority"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const spinnerVariants = cva( 8 | "text-muted-foreground animate-spin", 9 | { 10 | variants: { 11 | size: { 12 | default: "h-4 w-4", 13 | sm: "h-2 w-2", 14 | lg: "h-6 w-6", 15 | icon: "h-10 w-10" 16 | } 17 | }, 18 | defaultVariants: { 19 | size: "default", 20 | }, 21 | }, 22 | ); 23 | 24 | interface SpinnerProps extends VariantProps {} 25 | 26 | export const Spinner = ({ 27 | size, 28 | }: SpinnerProps) => { 29 | return ( 30 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /components/providers/convex-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ReactNode } from "react"; 4 | import { ConvexReactClient } from "convex/react"; 5 | import { ConvexProviderWithClerk } from "convex/react-clerk"; 6 | import { ClerkProvider, useAuth } from "@clerk/clerk-react"; 7 | 8 | const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!); 9 | 10 | export const ConvexClientProvider = ({ 11 | children 12 | }: { 13 | children: ReactNode; 14 | }) => { 15 | return ( 16 | 19 | 23 | {children} 24 | 25 | 26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /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 | "noEmit": true 20 | }, 21 | "include": ["./**/*"], 22 | "exclude": ["./_generated"] 23 | } 24 | -------------------------------------------------------------------------------- /app/(marketing)/_components/logo.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | import { Poppins } from "next/font/google"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | const font = Poppins({ 7 | subsets: ["latin"], 8 | weight: ["400", "600"] 9 | }); 10 | 11 | export const Logo = () => { 12 | return ( 13 |
14 | Logo 21 | Logo 28 |

29 | Jotion 30 |

31 |
32 | ) 33 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/error.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | import Link from "next/link"; 5 | 6 | import { Button } from "@/components/ui/button"; 7 | 8 | const Error = () => { 9 | return ( 10 |
11 | Error 18 | Error 25 |

26 | Something went wrong! 27 |

28 | 33 |
34 | ); 35 | } 36 | 37 | export default Error; -------------------------------------------------------------------------------- /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.12.2. 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/logo-dark.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/(main)/layout.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useConvexAuth } from "convex/react"; 4 | import { redirect } from "next/navigation"; 5 | 6 | import { Spinner } from "@/components/spinner"; 7 | import { SearchCommand } from "@/components/search-command"; 8 | 9 | import { Navigation } from "./_components/navigation"; 10 | 11 | const MainLayout = ({ 12 | children 13 | }: { 14 | children: React.ReactNode; 15 | }) => { 16 | const { isAuthenticated, isLoading } = useConvexAuth(); 17 | 18 | if (isLoading) { 19 | return ( 20 |
21 | 22 |
23 | ); 24 | } 25 | 26 | if (!isAuthenticated) { 27 | return redirect("/"); 28 | } 29 | 30 | return ( 31 |
32 | 33 |
34 | 35 | {children} 36 |
37 |
38 | ); 39 | } 40 | 41 | export default MainLayout; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Alimardon Davronov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /components/modals/settings-modal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | Dialog, 5 | DialogContent, 6 | DialogHeader 7 | } from "@/components/ui/dialog"; 8 | import { useSettings } from "@/hooks/use-settings"; 9 | import { Label } from "@/components/ui/label"; 10 | import { ModeToggle } from "@/components/mode-toggle"; 11 | 12 | export const SettingsModal = () => { 13 | const settings = useSettings(); 14 | 15 | return ( 16 | 17 | 18 | 19 |

20 | My settings 21 |

22 |
23 |
24 |
25 | 28 | 29 | Customize how Jotion looks on your device 30 | 31 |
32 | 33 |
34 |
35 |
36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /components/icon-picker.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import EmojiPicker, { Theme } from "emoji-picker-react"; 4 | import { useTheme } from "next-themes"; 5 | 6 | import { 7 | Popover, 8 | PopoverContent, 9 | PopoverTrigger 10 | } from "@/components/ui/popover"; 11 | 12 | interface IconPickerProps { 13 | onChange: (icon: string) => void; 14 | children: React.ReactNode; 15 | asChild?: boolean; 16 | }; 17 | 18 | export const IconPicker = ({ 19 | onChange, 20 | children, 21 | asChild 22 | }: IconPickerProps) => { 23 | const { resolvedTheme } = useTheme(); 24 | const currentTheme = (resolvedTheme || "light") as keyof typeof themeMap 25 | 26 | const themeMap = { 27 | "dark": Theme.DARK, 28 | "light": Theme.LIGHT 29 | }; 30 | 31 | const theme = themeMap[currentTheme]; 32 | 33 | return ( 34 | 35 | 36 | {children} 37 | 38 | 39 | onChange(data.emoji)} 43 | /> 44 | 45 | 46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /app/(marketing)/_components/heroes.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image"; 2 | 3 | export const Heroes = () => { 4 | return ( 5 |
6 |
7 |
8 | Documents 14 | Documents 20 |
21 |
22 | Reading 28 | Reading 34 |
35 |
36 |
37 | ) 38 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fullstack Notion Clone: Next.js 13, React, Convex, TailwindCSS 2 | 3 | Key Features: 4 | 5 | - Real-time database 🔗 6 | - Notion-style editor 📝 7 | - Light and Dark mode 🌓 8 | - Infinite children documents 🌲 9 | - Trash can & soft delete 🗑️ 10 | - Authentication 🔐 11 | - File upload 12 | - File deletion 13 | - File replacement 14 | - Icons for each document (changes in real-time) 🌠 15 | - Expandable sidebar ➡️🔀⬅️ 16 | - Full mobile responsiveness 📱 17 | - Publish your note to the web 🌐 18 | - Fully collapsable sidebar ↕️ 19 | - Landing page 🛬 20 | - Cover image of each document 🖼️ 21 | - Recover deleted files 🔄📄 22 | 23 | ### Prerequisites 24 | 25 | **Node version 18.x.x** 26 | 27 | ### Cloning the repository 28 | 29 | ```shell 30 | git clone https://github.com/Davronov-Alimardon/notion-clone.git 31 | ``` 32 | 33 | ### Install packages 34 | 35 | ```shell 36 | npm i 37 | ``` 38 | 39 | ### Setup .env file 40 | 41 | ```js 42 | # Deployment used by `npx convex dev` 43 | CONVEX_DEPLOYMENT= 44 | NEXT_PUBLIC_CONVEX_URL= 45 | 46 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= 47 | CLERK_SECRET_KEY= 48 | 49 | EDGE_STORE_ACCESS_KEY= 50 | EDGE_STORE_SECRET_KEY= 51 | ``` 52 | 53 | ### Setup Convex 54 | 55 | ```shell 56 | npx convex dev 57 | 58 | ``` 59 | 60 | ### Start the app 61 | 62 | ```shell 63 | npm run dev 64 | ``` 65 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /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/mode-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { Moon, Sun } from "lucide-react" 5 | import { useTheme } from "next-themes" 6 | 7 | import { Button } from "@/components/ui/button" 8 | import { 9 | DropdownMenu, 10 | DropdownMenuContent, 11 | DropdownMenuItem, 12 | DropdownMenuTrigger, 13 | } from "@/components/ui/dropdown-menu" 14 | 15 | export function ModeToggle() { 16 | const { setTheme } = useTheme() 17 | 18 | return ( 19 | 20 | 21 | 26 | 27 | 28 | setTheme("light")}> 29 | Light 30 | 31 | setTheme("dark")}> 32 | Dark 33 | 34 | setTheme("system")}> 35 | System 36 | 37 | 38 | 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /components/editor.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useTheme } from "next-themes"; 4 | import { 5 | BlockNoteEditor, 6 | PartialBlock 7 | } from "@blocknote/core"; 8 | import { 9 | BlockNoteView, 10 | useBlockNote 11 | } from "@blocknote/react"; 12 | import "@blocknote/core/style.css"; 13 | 14 | import { useEdgeStore } from "@/lib/edgestore"; 15 | 16 | interface EditorProps { 17 | onChange: (value: string) => void; 18 | initialContent?: string; 19 | editable?: boolean; 20 | }; 21 | 22 | const Editor = ({ 23 | onChange, 24 | initialContent, 25 | editable 26 | }: EditorProps) => { 27 | const { resolvedTheme } = useTheme(); 28 | const { edgestore } = useEdgeStore(); 29 | 30 | const handleUpload = async (file: File) => { 31 | const response = await edgestore.publicFiles.upload({ 32 | file 33 | }); 34 | 35 | return response.url; 36 | } 37 | 38 | const editor: BlockNoteEditor = useBlockNote({ 39 | editable, 40 | initialContent: 41 | initialContent 42 | ? JSON.parse(initialContent) as PartialBlock[] 43 | : undefined, 44 | onEditorContentChange: (editor) => { 45 | onChange(JSON.stringify(editor.topLevelBlocks, null, 2)); 46 | }, 47 | uploadFile: handleUpload 48 | }) 49 | 50 | return ( 51 |
52 | 56 |
57 | ) 58 | } 59 | 60 | export default Editor; 61 | -------------------------------------------------------------------------------- /components/modals/confirm-modal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { 4 | AlertDialog, 5 | AlertDialogAction, 6 | AlertDialogCancel, 7 | AlertDialogContent, 8 | AlertDialogDescription, 9 | AlertDialogFooter, 10 | AlertDialogHeader, 11 | AlertDialogTitle, 12 | AlertDialogTrigger 13 | } from "@/components/ui/alert-dialog"; 14 | 15 | interface ConfirmModalProps { 16 | children: React.ReactNode; 17 | onConfirm: () => void; 18 | }; 19 | 20 | export const ConfirmModal = ({ 21 | children, 22 | onConfirm 23 | }: ConfirmModalProps) => { 24 | const handleConfirm = ( 25 | e: React.MouseEvent 26 | ) => { 27 | e.stopPropagation(); 28 | onConfirm(); 29 | }; 30 | 31 | return ( 32 | 33 | e.stopPropagation()} asChild> 34 | {children} 35 | 36 | 37 | 38 | 39 | Are you absolutely sure? 40 | 41 | 42 | This action cannot be undone. 43 | 44 | 45 | 46 | e.stopPropagation()}> 47 | Cancel 48 | 49 | 50 | Confirm 51 | 52 | 53 | 54 | 55 | ) 56 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "notion-clone", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@blocknote/core": "^0.9.4", 13 | "@blocknote/react": "^0.9.4", 14 | "@clerk/clerk-react": "^4.26.3", 15 | "@edgestore/react": "^0.1.4", 16 | "@edgestore/server": "^0.1.4", 17 | "@radix-ui/react-alert-dialog": "^1.0.5", 18 | "@radix-ui/react-avatar": "^1.0.4", 19 | "@radix-ui/react-dialog": "^1.0.5", 20 | "@radix-ui/react-dropdown-menu": "^2.0.6", 21 | "@radix-ui/react-label": "^2.0.2", 22 | "@radix-ui/react-popover": "^1.0.7", 23 | "@radix-ui/react-slot": "^1.0.2", 24 | "class-variance-authority": "^0.7.0", 25 | "clsx": "^2.0.0", 26 | "cmdk": "^0.2.0", 27 | "convex": "^1.12.2", 28 | "emoji-picker-react": "^4.5.2", 29 | "lucide-react": "^0.284.0", 30 | "next": "13.5.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.0.3", 37 | "tailwind-merge": "^1.14.0", 38 | "tailwindcss-animate": "^1.0.7", 39 | "usehooks-ts": "^2.9.1", 40 | "zod": "^3.22.4", 41 | "zustand": "^4.4.3" 42 | }, 43 | "devDependencies": { 44 | "@types/node": "^20", 45 | "@types/react": "^18", 46 | "@types/react-dom": "^18", 47 | "autoprefixer": "^10", 48 | "eslint": "^8", 49 | "eslint-config-next": "13.5.4", 50 | "postcss": "^8", 51 | "tailwindcss": "^3", 52 | "typescript": "^5" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /app/(main)/(routes)/documents/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | import { useUser } from "@clerk/clerk-react"; 5 | import { PlusCircle } from "lucide-react"; 6 | import { useMutation } from "convex/react"; 7 | import { toast } from "sonner"; 8 | import { useRouter } from "next/navigation"; 9 | 10 | import { api } from "@/convex/_generated/api"; 11 | import { Button } from "@/components/ui/button"; 12 | 13 | const DocumentsPage = () => { 14 | const router = useRouter(); 15 | const { user } = useUser(); 16 | const create = useMutation(api.documents.create); 17 | 18 | const onCreate = () => { 19 | const promise = create({ title: "Untitled" }) 20 | .then((documentId) => router.push(`/documents/${documentId}`)) 21 | 22 | toast.promise(promise, { 23 | loading: "Creating a new note...", 24 | success: "New note created!", 25 | error: "Failed to create a new note." 26 | }); 27 | }; 28 | 29 | return ( 30 |
31 | Empty 38 | Empty 45 |

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

48 | 52 |
53 | ); 54 | } 55 | 56 | export default DocumentsPage; -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Toaster } from "sonner"; 2 | import { Inter } from 'next/font/google' 3 | import type { Metadata } from 'next' 4 | 5 | import { ThemeProvider } from '@/components/providers/theme-provider' 6 | import { ConvexClientProvider } from '@/components/providers/convex-provider' 7 | import { ModalProvider } from "@/components/providers/modal-provider"; 8 | import { EdgeStoreProvider } from "@/lib/edgestore"; 9 | 10 | import './globals.css' 11 | 12 | const inter = Inter({ subsets: ['latin'] }) 13 | 14 | export const metadata: Metadata = { 15 | title: 'Jotion', 16 | description: 'The connected workspace where better, faster work happens.', 17 | icons: { 18 | icon: [ 19 | { 20 | media: "(prefers-color-scheme: light)", 21 | url: "/logo.svg", 22 | href: "/logo.svg", 23 | }, 24 | { 25 | media: "(prefers-color-scheme: dark)", 26 | url: "/logo-dark.svg", 27 | href: "/logo-dark.svg", 28 | } 29 | ] 30 | } 31 | } 32 | 33 | export default function RootLayout({ 34 | children, 35 | }: { 36 | children: React.ReactNode 37 | }) { 38 | return ( 39 | 40 | 41 | 42 | 43 | 50 | 51 | 52 | {children} 53 | 54 | 55 | 56 | 57 | 58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /app/(marketing)/_components/heading.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useConvexAuth } from "convex/react"; 4 | import { ArrowRight } from "lucide-react"; 5 | import { SignInButton } from "@clerk/clerk-react"; 6 | import Link from "next/link"; 7 | 8 | import { Button } from "@/components/ui/button"; 9 | import { Spinner } from "@/components/spinner"; 10 | 11 | export const Heading = () => { 12 | const { isAuthenticated, isLoading } = useConvexAuth(); 13 | 14 | return ( 15 |
16 |

17 | Your Ideas, Documents, & Plans. Unified. Welcome to{" "} 18 | Jotion 19 |

20 |

21 | Jotion is the connected workspace where
22 | better, faster work happens. 23 |

24 | 25 | {isLoading && ( 26 |
27 | 28 |
29 | )} 30 | {isAuthenticated && !isLoading && ( 31 | 37 | )} 38 |

39 | Developed by (Alimardon Davronov) 40 |

41 | {!isAuthenticated && !isLoading && ( 42 | 43 | 47 | 48 | )} 49 |
50 | ); 51 | }; 52 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /app/(marketing)/_components/navbar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useConvexAuth } from "convex/react"; 4 | import { SignInButton, UserButton } from "@clerk/clerk-react"; 5 | import Link from "next/link"; 6 | 7 | import { useScrollTop } from "@/hooks/use-scroll-top"; 8 | import { ModeToggle } from "@/components/mode-toggle"; 9 | import { Button } from "@/components/ui/button"; 10 | import { Spinner } from "@/components/spinner"; 11 | import { cn } from "@/lib/utils"; 12 | 13 | import { Logo } from "./logo"; 14 | 15 | export const Navbar = () => { 16 | const { isAuthenticated, isLoading } = useConvexAuth(); 17 | const scrolled = useScrollTop(); 18 | 19 | return ( 20 |
24 | 25 |
26 | {isLoading && ( 27 | 28 | )} 29 | {!isAuthenticated && !isLoading && ( 30 | <> 31 | 32 | 35 | 36 | 37 | 40 | 41 | 42 | )} 43 | {isAuthenticated && !isLoading && ( 44 | <> 45 | 50 | 53 | 54 | )} 55 | 56 |
57 |
58 | ) 59 | } -------------------------------------------------------------------------------- /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.12.2. 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 | -------------------------------------------------------------------------------- /app/(main)/_components/navbar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useQuery } from "convex/react"; 4 | import { useParams } from "next/navigation"; 5 | import { MenuIcon } from "lucide-react"; 6 | 7 | import { api } from "@/convex/_generated/api"; 8 | import { Id } from "@/convex/_generated/dataModel"; 9 | 10 | import { Title } from "./title"; 11 | import { Banner } from "./banner"; 12 | import { Menu } from "./menu"; 13 | import { Publish } from "./publish"; 14 | 15 | interface NavbarProps { 16 | isCollapsed: boolean; 17 | onResetWidth: () => void; 18 | }; 19 | 20 | export const Navbar = ({ 21 | isCollapsed, 22 | onResetWidth 23 | }: NavbarProps) => { 24 | const params = useParams(); 25 | 26 | const document = useQuery(api.documents.getById, { 27 | documentId: params.documentId as Id<"documents">, 28 | }); 29 | 30 | if (document === undefined) { 31 | return ( 32 | 38 | ) 39 | } 40 | 41 | if (document === null) { 42 | return null; 43 | } 44 | 45 | return ( 46 | <> 47 |