├── .env.example ├── .eslintrc.json ├── .gitignore ├── README.md ├── next.config.js ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── prisma └── schema.prisma ├── public ├── dashboard-preview.jpg ├── file-upload-preview.jpg ├── icon.png └── thumbnail.png ├── screenshot.png ├── src ├── app │ ├── _trpc │ │ └── client.ts │ ├── api │ │ ├── auth │ │ │ └── [kindeAuth] │ │ │ │ └── route.ts │ │ ├── message │ │ │ └── route.ts │ │ ├── trpc │ │ │ └── [trpc] │ │ │ │ └── route.ts │ │ ├── uploadthing │ │ │ ├── core.ts │ │ │ └── route.ts │ │ └── webhooks │ │ │ └── stripe │ │ │ └── route.ts │ ├── auth-callback │ │ └── page.tsx │ ├── dashboard │ │ ├── [fileId] │ │ │ └── page.tsx │ │ ├── billing │ │ │ └── page.tsx │ │ └── page.tsx │ ├── favicon.ico │ ├── globals.css │ ├── layout.tsx │ ├── page.tsx │ └── pricing │ │ └── page.tsx ├── components │ ├── BillingForm.tsx │ ├── Dashboard.tsx │ ├── Icons.tsx │ ├── Main.tsx │ ├── MaxWidthWrapper.tsx │ ├── MobileSlideover.tsx │ ├── Navbar.tsx │ ├── PdfFileCard.tsx │ ├── PdfFullscreen.tsx │ ├── PdfRenderer.tsx │ ├── ProfileMenu.tsx │ ├── Providers.tsx │ ├── UpgradeButton.tsx │ ├── UploadButton.tsx │ ├── chat │ │ ├── ChatInput.tsx │ │ ├── ChatWrapper.tsx │ │ ├── CodeRenderer.tsx │ │ ├── LinkRenderer.tsx │ │ ├── Message.tsx │ │ ├── Messages.tsx │ │ └── index.ts │ ├── index.ts │ └── ui │ │ ├── Avatar.tsx │ │ ├── Button.tsx │ │ ├── Card.tsx │ │ ├── Dialog.tsx │ │ ├── DropdownMenu.tsx │ │ ├── Input.tsx │ │ ├── Progress.tsx │ │ ├── Textarea.tsx │ │ ├── Toast.tsx │ │ ├── Toaster.tsx │ │ ├── Tooltip.tsx │ │ ├── UseToast.tsx │ │ └── index.ts ├── config │ ├── infinite-query.ts │ ├── max-query.ts │ └── stripe.ts ├── context │ ├── chat.tsx │ └── document.tsx ├── db │ └── index.ts ├── lib │ ├── openai.ts │ ├── pinecone.ts │ ├── rate-limiter.ts │ ├── redis.ts │ ├── shadcn-plugin.ts │ ├── shadcn-preset.ts │ ├── stripe.ts │ ├── uploadthing.ts │ ├── utils.ts │ └── validators │ │ └── message.ts ├── middleware.ts ├── trpc │ ├── index.ts │ └── trpc.ts └── types │ └── message.ts ├── tailwind.config.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # ------------------------ 2 | # Kinde Configuration 3 | # ------------------------ 4 | 5 | KINDE_CLIENT_ID= 6 | KINDE_CLIENT_SECRET= 7 | KINDE_ISSUER_URL= 8 | KINDE_SITE_URL= 9 | KINDE_POST_LOGOUT_REDIRECT_URL= 10 | KINDE_POST_LOGIN_REDIRECT_URL= 11 | 12 | # ------------------------ 13 | # Aiven 14 | # ------------------------ 15 | 16 | DATABASE_URL= 17 | 18 | # ------------------------ 19 | # UploadThing 20 | # ------------------------ 21 | 22 | UPLOADTHING_TOKEN= 23 | 24 | # ------------------------ 25 | # Pinecone 26 | # ------------------------ 27 | 28 | PINECONE_API_KEY= 29 | 30 | # ------------------------ 31 | # OpenAI 32 | # ------------------------ 33 | 34 | OPENAI_API_KEY= 35 | 36 | # ------------------------ 37 | # Stripe 38 | # ------------------------ 39 | 40 | PRICING_API_ID= 41 | STRIPE_SECRET_KEY= 42 | STRIPE_WEBHOOK_SECRET= 43 | 44 | # ------------------------ 45 | # Upstash 46 | # ------------------------ 47 | 48 | UPSTASH_REDIS_REST_URL= 49 | UPSTASH_REDIS_REST_TOKEN= -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | .history 23 | 24 | # debug 25 | npm-debug.log* 26 | yarn-debug.log* 27 | yarn-error.log* 28 | 29 | # local env files 30 | .env 31 | .env*.local 32 | 33 | # vercel 34 | .vercel 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | next-env.d.ts 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Documon 2 | 3 | A web application that harnesses the power of artificial intelligence to transform the way you interact with PDF documents. Documon enables you to seamlessly engage in a conversation with your PDFs, enjoy smart context summarization, and benefit from annotation features. This makes document exploration and information retrieval a breeze. 4 | 5 | ## Screenshot 6 | 7 | 8 | 9 |

10 | View Project » 11 |

12 | 13 | ## Running Locally 14 | 15 | This application requires Node.js v16.13+. 16 | 17 | ### Cloning the repository to the local machine: 18 | 19 | ```bash 20 | git clone https://github.com/nabarvn/documon.git 21 | cd documon 22 | ``` 23 | 24 | ### Installing the dependencies: 25 | 26 | ```bash 27 | pnpm install 28 | ``` 29 | 30 | ### Setting up the `.env` file: 31 | 32 | ```bash 33 | cp .env.example .env 34 | ``` 35 | 36 | > [!IMPORTANT] 37 | > Ensure you populate the variables with your respective API keys and configuration values before proceeding. 38 | 39 | ### Configuring Prisma: 40 | 41 | ```bash 42 | pnpm prisma generate 43 | ``` 44 | 45 | ```bash 46 | pnpm prisma db push 47 | ``` 48 | 49 | ### Running the application: 50 | 51 | ```bash 52 | pnpm dev 53 | ``` 54 | 55 | ## Tech Stack 56 | 57 | - **Language**: [TypeScript](https://www.typescriptlang.org) 58 | - **Framework**: [Next.js](https://nextjs.org) 59 | - **Styling**: [Tailwind CSS](https://tailwindcss.com) 60 | - **Analytics**: [Vercel Analytics](https://vercel.com/analytics) 61 | - **State Management**: [React Query](https://www.npmjs.com/package/@tanstack/react-query) 62 | - **ORM Toolkit**: [Prisma](https://www.prisma.io/docs/concepts/overview/what-is-prisma) 63 | - **LLM Provider**: [OpenAI](https://platform.openai.com/docs/overview) 64 | - **Vector Database**: [Pinecone](https://docs.pinecone.io/docs/overview) 65 | - **Memory Builder**: [LangChain.js](https://js.langchain.com/docs/get_started/introduction) 66 | - **Rate Limiter**: [Upstash](https://docs.upstash.com/redis) 67 | - **MySQL Database**: [Aiven](https://aiven.io/docs/get-started) 68 | - **Authentication**: [Kinde](https://kinde.com/docs/developer-tools/nextjs-sdk) 69 | - **File Hosting**: [UploadThing](https://docs.uploadthing.com) 70 | - **API Typesafety**: [tRPC](https://trpc.io/docs) 71 | - **Payments**: [Stripe](https://stripe.com/docs/payments) 72 | - **Deployment**: [Vercel](https://vercel.com) 73 | 74 | ## Credits 75 | 76 | Learned a ton while building this project. All thanks to Josh for the next level (no pun intended) tutorial! 77 | 78 |
79 | 80 |
Don't forget to leave a STAR 🌟
81 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | async redirects() { 4 | return [ 5 | { 6 | source: "/sign-in", 7 | destination: "/api/auth/login", 8 | permanent: true, 9 | }, 10 | { 11 | source: "/sign-up", 12 | destination: "/api/auth/register", 13 | permanent: true, 14 | }, 15 | { 16 | source: "/sign-out", 17 | destination: "/api/auth/logout", 18 | permanent: true, 19 | }, 20 | ]; 21 | }, 22 | 23 | webpack: (config) => { 24 | config.resolve.alias.canvas = false; 25 | config.resolve.alias.encoding = false; 26 | return config; 27 | }, 28 | }; 29 | 30 | module.exports = nextConfig; 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "documon", 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 | "postinstall": "prisma generate" 11 | }, 12 | "dependencies": { 13 | "@hookform/resolvers": "^3.3.2", 14 | "@kinde-oss/kinde-auth-nextjs": "^1.8.25", 15 | "@langchain/community": "^0.3.1", 16 | "@langchain/core": "^0.3.3", 17 | "@langchain/openai": "^0.3.0", 18 | "@langchain/pinecone": "^0.1.0", 19 | "@mantine/hooks": "^7.3.1", 20 | "@pinecone-database/pinecone": "^3.0.3", 21 | "@prisma/client": "^5.7.1", 22 | "@radix-ui/react-avatar": "^1.0.4", 23 | "@radix-ui/react-dialog": "^1.0.5", 24 | "@radix-ui/react-dropdown-menu": "^2.0.6", 25 | "@radix-ui/react-progress": "^1.0.3", 26 | "@radix-ui/react-slot": "^1.0.2", 27 | "@radix-ui/react-toast": "^1.1.5", 28 | "@radix-ui/react-tooltip": "^1.0.7", 29 | "@tailwindcss/typography": "^0.5.10", 30 | "@tanstack/react-query": "^4.35.3", 31 | "@trpc/client": "^10.38.4", 32 | "@trpc/next": "^10.38.4", 33 | "@trpc/react-query": "^10.38.4", 34 | "@trpc/server": "^10.38.4", 35 | "@uploadthing/react": "^7.0.2", 36 | "@upstash/ratelimit": "^1.0.0", 37 | "@upstash/redis": "^1.27.1", 38 | "ai": "^2.2.27", 39 | "class-variance-authority": "^0.7.0", 40 | "clsx": "^2.0.0", 41 | "date-fns": "^2.30.0", 42 | "lucide-react": "^0.290.0", 43 | "next": "14.0.0", 44 | "openai": "^4.20.1", 45 | "pdf-parse": "^1.1.1", 46 | "prisma": "^5.7.1", 47 | "react": "^18", 48 | "react-dom": "^18", 49 | "react-dropzone": "^14.2.3", 50 | "react-hook-form": "^7.48.2", 51 | "react-loading-skeleton": "^3.3.1", 52 | "react-markdown": "^9.0.1", 53 | "react-pdf": "^7.5.1", 54 | "react-resize-detector": "^9.1.0", 55 | "react-textarea-autosize": "^8.5.3", 56 | "rehype-highlight": "^7.0.0", 57 | "remark-gfm": "^4.0.0", 58 | "simplebar-react": "^3.2.4", 59 | "stripe": "^14.9.0", 60 | "tailwind-merge": "^2.0.0", 61 | "tailwind-scrollbar": "^3.0.5", 62 | "tailwindcss-animate": "^1.0.7", 63 | "uploadthing": "^7.0.2", 64 | "zod": "^3.22.4" 65 | }, 66 | "devDependencies": { 67 | "@types/node": "^20", 68 | "@types/react": "^18", 69 | "@types/react-dom": "^18", 70 | "autoprefixer": "^10", 71 | "eslint": "^8", 72 | "eslint-config-next": "14.0.0", 73 | "postcss": "^8", 74 | "tailwindcss": "^3", 75 | "typescript": "^5" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "mysql" 10 | url = env("DATABASE_URL") 11 | relationMode = "prisma" 12 | } 13 | 14 | model User { 15 | id String @id @unique //matches kinde user id 16 | email String @unique 17 | 18 | File File[] 19 | Message Message[] 20 | 21 | stripeCustomerId String? @unique @map(name: "stripe_customer_id") 22 | stripeSubscriptionId String? @unique @map(name: "stripe_subscription_id") 23 | stripePriceId String? @map(name: "stripe_price_id") 24 | stripeCurrentPeriodEnd DateTime? @map(name: "stripe_current_period_end") 25 | } 26 | 27 | enum UploadStatus { 28 | PENDING 29 | PROCESSING 30 | FAILED 31 | SUCCESS 32 | } 33 | 34 | model File { 35 | id String @id @default(cuid()) 36 | name String 37 | hash String 38 | 39 | uploadStatus UploadStatus @default(PENDING) 40 | 41 | url String 42 | key String 43 | messages Message[] 44 | 45 | createdAt DateTime @default(now()) 46 | updatedAt DateTime @updatedAt 47 | User User? @relation(fields: [userId], references: [id], onDelete: Cascade) 48 | userId String? 49 | 50 | @@index([userId]) 51 | } 52 | 53 | model Message { 54 | id String @id @default(cuid()) 55 | text String @db.Text() 56 | 57 | isUserMessage Boolean 58 | 59 | createdAt DateTime @default(now()) 60 | updatedAt DateTime @updatedAt 61 | User User? @relation(fields: [userId], references: [id], onDelete: Cascade) 62 | userId String? 63 | File File? @relation(fields: [fileId], references: [id], onDelete: Cascade) 64 | fileId String? 65 | 66 | @@index([userId]) 67 | @@index([fileId]) 68 | } 69 | -------------------------------------------------------------------------------- /public/dashboard-preview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nabarvn/documon/793742411d2c8934438a14bb4e87f18b545d63bd/public/dashboard-preview.jpg -------------------------------------------------------------------------------- /public/file-upload-preview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nabarvn/documon/793742411d2c8934438a14bb4e87f18b545d63bd/public/file-upload-preview.jpg -------------------------------------------------------------------------------- /public/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nabarvn/documon/793742411d2c8934438a14bb4e87f18b545d63bd/public/icon.png -------------------------------------------------------------------------------- /public/thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nabarvn/documon/793742411d2c8934438a14bb4e87f18b545d63bd/public/thumbnail.png -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nabarvn/documon/793742411d2c8934438a14bb4e87f18b545d63bd/screenshot.png -------------------------------------------------------------------------------- /src/app/_trpc/client.ts: -------------------------------------------------------------------------------- 1 | import { AppRouter } from "@/trpc"; 2 | import { createTRPCReact } from "@trpc/react-query"; 3 | 4 | export const trpc = createTRPCReact({}); 5 | -------------------------------------------------------------------------------- /src/app/api/auth/[kindeAuth]/route.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest } from "next/server"; 2 | import { handleAuth } from "@kinde-oss/kinde-auth-nextjs/server"; 3 | 4 | export async function GET(request: NextRequest, { params }: any) { 5 | const endpoint = params.kindeAuth; 6 | return handleAuth(request, endpoint); 7 | } 8 | -------------------------------------------------------------------------------- /src/app/api/message/route.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/db"; 2 | import { openai } from "@/lib/openai"; 3 | import { NextRequest } from "next/server"; 4 | import { pinecone } from "@/lib/pinecone"; 5 | import { OpenAIEmbeddings } from "@langchain/openai"; 6 | import { PineconeStore } from "@langchain/pinecone"; 7 | import { OpenAIStream, StreamingTextResponse } from "ai"; 8 | import { MessageValidator } from "@/lib/validators/message"; 9 | import { getKindeServerSession } from "@kinde-oss/kinde-auth-nextjs/server"; 10 | 11 | export const maxDuration = 60; 12 | 13 | export const POST = async (req: NextRequest) => { 14 | // endpoint for asking questions to a PDF file 15 | 16 | const body = await req.json(); 17 | 18 | const { getUser } = getKindeServerSession(); 19 | const user = getUser(); 20 | 21 | // renaming to avoid conflict later 22 | const { id: userId } = user; 23 | 24 | if (!userId) return new Response("Unauthorized", { status: 401 }); 25 | 26 | const { fileId, message } = MessageValidator.parse(body); 27 | 28 | const file = await db.file.findFirst({ 29 | where: { 30 | id: fileId, 31 | userId, 32 | }, 33 | }); 34 | 35 | if (!file) return new Response("Not found", { status: 404 }); 36 | 37 | await db.message.create({ 38 | data: { 39 | text: message, 40 | isUserMessage: true, 41 | userId, 42 | fileId, 43 | }, 44 | }); 45 | 46 | // 1: vectorize message 47 | const pineconeIndex = pinecone.Index("documon"); 48 | 49 | const embeddings = new OpenAIEmbeddings({ 50 | openAIApiKey: process.env.OPENAI_API_KEY, 51 | }); 52 | 53 | // 2: search vector store for the most relevant PDF page to the message 54 | const vectorStore = await PineconeStore.fromExistingIndex(embeddings, { 55 | pineconeIndex, 56 | namespace: file.id, 57 | }); 58 | 59 | const results = await vectorStore.similaritySearch(message, 4); 60 | 61 | // 3: access chat history 62 | const prevMessages = await db.message.findMany({ 63 | where: { 64 | fileId, 65 | }, 66 | orderBy: { 67 | createdAt: "asc", 68 | }, 69 | take: 6, 70 | }); 71 | 72 | // 4: making the message structure OpenAI ready 73 | const formattedPrevMessages = prevMessages.map((msg) => ({ 74 | role: msg.isUserMessage ? ("user" as const) : ("assistant" as const), 75 | content: msg.text, 76 | })); 77 | 78 | // 5: interaction with OpenAI LLM 79 | const response = await openai.chat.completions.create({ 80 | model: "gpt-4o", 81 | temperature: 0, 82 | stream: true, 83 | messages: [ 84 | { 85 | role: "system", 86 | content: 87 | "Use the following pieces of context (or previous conversation if needed) to answer the user's question in markdown format. Also, ensure that all links are formatted using markdown syntax, for instance, [example link description](https://example.com).", 88 | }, 89 | { 90 | role: "user", 91 | content: `Use the following pieces of context (or previous conversation if needed) to answer the user's question in markdown format. Also, ensure that all links are formatted using markdown syntax, for instance, [example link description](https://example.com). \nIf you don't know the answer, just say that you don't know, refrain from making up an answer. 92 | 93 | \n----------------\n 94 | 95 | PREVIOUS CONVERSATION: 96 | ${formattedPrevMessages.map((message) => { 97 | if (message.role === "user") return `User: ${message.content}\n`; 98 | return `Assistant: ${message.content}\n`; 99 | })} 100 | 101 | \n----------------\n 102 | 103 | CONTEXT: 104 | ${results.map((r) => r.pageContent).join("\n\n")} 105 | 106 | USER INPUT: ${message}`, 107 | }, 108 | ], 109 | }); 110 | 111 | // what we are doing here is the main reason why a custom API route has been preferred over tRPC in this case 112 | const stream = OpenAIStream(response, { 113 | async onCompletion(completion) { 114 | await db.message.create({ 115 | data: { 116 | text: completion, 117 | isUserMessage: false, 118 | userId, 119 | fileId, 120 | }, 121 | }); 122 | }, 123 | }); 124 | 125 | // accessible in the `onSuccess` method 126 | return new StreamingTextResponse(stream); 127 | }; 128 | -------------------------------------------------------------------------------- /src/app/api/trpc/[trpc]/route.ts: -------------------------------------------------------------------------------- 1 | import { appRouter } from "@/trpc"; 2 | import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; 3 | 4 | const handler = (req: Request) => 5 | fetchRequestHandler({ 6 | endpoint: "/api/trpc", 7 | req, 8 | router: appRouter, 9 | createContext: () => ({}), 10 | }); 11 | 12 | export { handler as GET, handler as POST }; 13 | -------------------------------------------------------------------------------- /src/app/api/uploadthing/core.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/db"; 2 | import { createHash } from "crypto"; 3 | import { File } from "@prisma/client"; 4 | import { PLANS } from "@/config/stripe"; 5 | import { pinecone } from "@/lib/pinecone"; 6 | import { getUserSubscriptionPlan } from "@/lib/stripe"; 7 | import { OpenAIEmbeddings } from "@langchain/openai"; 8 | import { PineconeStore } from "@langchain/pinecone"; 9 | import { PDFLoader } from "@langchain/community/document_loaders/fs/pdf"; 10 | import { createUploadthing, type FileRouter } from "uploadthing/server"; 11 | import { getKindeServerSession } from "@kinde-oss/kinde-auth-nextjs/server"; 12 | 13 | const f = createUploadthing(); 14 | 15 | // set permissions and file types for this file route 16 | const middleware = async () => { 17 | const { getUser } = getKindeServerSession(); 18 | const user = getUser(); 19 | 20 | if (!user || !user.id) throw new Error("Unauthorized"); 21 | 22 | const subscriptionPlan = await getUserSubscriptionPlan(); 23 | 24 | // whatever is returned here is accessible in `onUploadComplete` as `metadata` 25 | return { subscriptionPlan, userId: user.id }; 26 | }; 27 | 28 | // this code RUNS ON SERVER after upload 29 | const onUploadComplete = async ({ 30 | metadata, 31 | file, 32 | }: { 33 | metadata: Awaited>; 34 | file: { 35 | key: string; 36 | name: string; 37 | url: string; 38 | }; 39 | }) => { 40 | let createdFile: File | null = null; 41 | 42 | try { 43 | // to access the PDF file in memory 44 | const response = await fetch(file.url); 45 | 46 | if (!response.ok) return { error: "UploadThingError" }; 47 | 48 | // get raw binary data of the file as ArrayBuffer 49 | const arrayBuffer = await response.arrayBuffer(); 50 | 51 | // convert ArrayBuffer to Buffer 52 | const buffer = Buffer.from(arrayBuffer); 53 | 54 | // calculate the SHA-256 hash 55 | const fileHash = createHash("sha256").update(buffer).digest("hex"); 56 | 57 | const isFileExist = await db.file.findFirst({ 58 | where: { 59 | hash: fileHash, 60 | userId: metadata.userId, 61 | }, 62 | }); 63 | 64 | if (!!isFileExist) return { duplicate: true }; 65 | 66 | createdFile = await db.file.create({ 67 | data: { 68 | key: file.key, 69 | name: file.name, 70 | hash: fileHash, 71 | userId: metadata.userId, 72 | url: file.url, 73 | uploadStatus: "PROCESSING", 74 | }, 75 | }); 76 | 77 | // convert ArrayBuffer to Blob 78 | const blob = new Blob([arrayBuffer], { 79 | type: "application/pdf", 80 | }); 81 | 82 | // loading the PDF into memory 83 | const loader = new PDFLoader(blob); 84 | 85 | // extracting the page-level text of the PDF 86 | const pageLevelDocs = await loader.load(); 87 | 88 | // each document in the array is a page 89 | const pagesAmt = pageLevelDocs.length; 90 | 91 | // enforce page limit 92 | const { 93 | subscriptionPlan: { isSubscribed }, 94 | } = metadata; 95 | 96 | const isProExceeded = 97 | pagesAmt > PLANS.find((plan) => plan.name === "Pro")!.pagesPerPdf; 98 | 99 | const isFreeExceeded = 100 | pagesAmt > PLANS.find((plan) => plan.name === "Free")!.pagesPerPdf; 101 | 102 | if ((isSubscribed && isProExceeded) || (!isSubscribed && isFreeExceeded)) { 103 | await db.file.update({ 104 | data: { 105 | uploadStatus: "FAILED", 106 | }, 107 | where: { 108 | id: createdFile.id, 109 | }, 110 | }); 111 | } 112 | 113 | // vectorize and index entire document 114 | const pineconeIndex = pinecone.Index("documon"); 115 | 116 | const embeddings = new OpenAIEmbeddings({ 117 | openAIApiKey: process.env.OPENAI_API_KEY, 118 | }); 119 | 120 | await PineconeStore.fromDocuments(pageLevelDocs, embeddings, { 121 | pineconeIndex, 122 | namespace: createdFile.id, 123 | maxRetries: 3, 124 | }); 125 | 126 | await db.file.update({ 127 | data: { 128 | uploadStatus: "SUCCESS", 129 | }, 130 | where: { 131 | id: createdFile.id, 132 | }, 133 | }); 134 | } catch (error) { 135 | createdFile && 136 | (await db.file.delete({ 137 | where: { 138 | id: createdFile.id, 139 | }, 140 | })); 141 | 142 | return { error: "PineconeBadRequestError" }; 143 | } 144 | }; 145 | 146 | // `FileRouter` for the app, can contain multiple file routes 147 | // one can define as many file routes as they would like, each with a unique `routeSlug` 148 | export const ourFileRouter = { 149 | freePlanUploader: f({ pdf: { maxFileSize: "4MB" } }) 150 | .middleware(middleware) 151 | .onUploadComplete(onUploadComplete), 152 | 153 | proPlanUploader: f({ pdf: { maxFileSize: "16MB" } }) 154 | .middleware(middleware) 155 | .onUploadComplete(onUploadComplete), 156 | } satisfies FileRouter; 157 | 158 | export type OurFileRouter = typeof ourFileRouter; 159 | -------------------------------------------------------------------------------- /src/app/api/uploadthing/route.ts: -------------------------------------------------------------------------------- 1 | import { createRouteHandler } from "uploadthing/next"; 2 | 3 | import { ourFileRouter } from "./core"; 4 | 5 | // export routes for Next App Router 6 | export const { GET, POST } = createRouteHandler({ 7 | router: ourFileRouter, 8 | config: { 9 | token: process.env.UPLOADTHING_TOKEN, 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /src/app/api/webhooks/stripe/route.ts: -------------------------------------------------------------------------------- 1 | import { db } from "@/db"; 2 | import { stripe } from "@/lib/stripe"; 3 | import { headers } from "next/headers"; 4 | import type Stripe from "stripe"; 5 | 6 | export async function POST(request: Request) { 7 | const body = await request.text(); 8 | const signature = headers().get("Stripe-Signature") ?? ""; 9 | 10 | let event: Stripe.Event; 11 | 12 | try { 13 | // validate if this event actually comes from Stripe 14 | // no user should be able to trigger this event 15 | event = stripe.webhooks.constructEvent( 16 | body, 17 | signature, 18 | process.env.STRIPE_WEBHOOK_SECRET || "" 19 | ); 20 | } catch (err) { 21 | return new Response( 22 | `Webhook Error: ${err instanceof Error ? err.message : "Unknown Error"}`, 23 | { status: 400 } 24 | ); 25 | } 26 | 27 | const session = event.data.object as Stripe.Checkout.Session; 28 | 29 | if (!session?.metadata?.userId) { 30 | return new Response(null, { 31 | status: 200, 32 | }); 33 | } 34 | 35 | if (event.type === "checkout.session.completed") { 36 | // retrieve the subscription details from Stripe 37 | const subscription = await stripe.subscriptions.retrieve( 38 | session.subscription as string 39 | ); 40 | 41 | // for new subscription 42 | await db.user.update({ 43 | where: { 44 | id: session.metadata.userId, 45 | }, 46 | data: { 47 | stripeSubscriptionId: subscription.id, 48 | stripeCustomerId: subscription.customer as string, 49 | stripePriceId: subscription.items.data[0]?.price.id, 50 | stripeCurrentPeriodEnd: new Date( 51 | subscription.current_period_end * 1000 52 | ), 53 | }, 54 | }); 55 | } 56 | 57 | if (event.type === "invoice.payment_succeeded") { 58 | // retrieve the subscription details from Stripe 59 | const subscription = await stripe.subscriptions.retrieve( 60 | session.subscription as string 61 | ); 62 | 63 | // for subscription renewal 64 | await db.user.update({ 65 | where: { 66 | stripeSubscriptionId: subscription.id, 67 | }, 68 | data: { 69 | stripePriceId: subscription.items.data[0]?.price.id, 70 | stripeCurrentPeriodEnd: new Date( 71 | subscription.current_period_end * 1000 72 | ), 73 | }, 74 | }); 75 | } 76 | 77 | return new Response(null, { status: 200 }); 78 | } 79 | -------------------------------------------------------------------------------- /src/app/auth-callback/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { trpc } from "../_trpc/client"; 4 | import { Loader2 } from "lucide-react"; 5 | import { MAX_QUERY_COUNT } from "@/config/max-query"; 6 | import { redirect, useRouter, useSearchParams } from "next/navigation"; 7 | 8 | const AuthCallbackPage = () => { 9 | const router = useRouter(); 10 | 11 | const searchParams = useSearchParams(); 12 | const origin = searchParams.get("origin"); 13 | 14 | // not compatible with react query v5 15 | const { failureCount } = trpc.authCallback.useQuery(undefined, { 16 | onSuccess: ({ success }) => { 17 | if (success) { 18 | // user is synced to db 19 | router.push(origin ? `/${origin}` : "/dashboard"); 20 | } 21 | }, 22 | 23 | onError: (err) => { 24 | if (err.data?.code === "UNAUTHORIZED") { 25 | router.push("/sign-in"); 26 | } 27 | }, 28 | 29 | retry: MAX_QUERY_COUNT, 30 | retryDelay: 500, 31 | }); 32 | 33 | if (failureCount >= MAX_QUERY_COUNT) { 34 | redirect("/sign-in"); 35 | } 36 | 37 | return ( 38 |
39 |
40 | 41 | 42 |

43 | Setting up your account... 44 |

45 | 46 |

47 | You will be redirected automatically. 48 |

49 |
50 |
51 | ); 52 | }; 53 | 54 | export default AuthCallbackPage; 55 | -------------------------------------------------------------------------------- /src/app/dashboard/[fileId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { db } from "@/db"; 2 | import { Main } from "@/components"; 3 | import { notFound, redirect } from "next/navigation"; 4 | import { getUserSubscriptionPlan } from "@/lib/stripe"; 5 | import { getKindeServerSession } from "@kinde-oss/kinde-auth-nextjs/server"; 6 | 7 | interface ChatPageProps { 8 | params: { 9 | fileId: string; 10 | }; 11 | } 12 | 13 | const ChatPage = async ({ params }: ChatPageProps) => { 14 | const { fileId } = params; 15 | 16 | const { getUser } = getKindeServerSession(); 17 | const user = getUser(); 18 | 19 | // guard clause 20 | if (!user || !user.id) redirect(`/auth-callback?origin=dashboard/${fileId}`); 21 | 22 | const file = await db.file.findFirst({ 23 | where: { 24 | id: fileId, 25 | userId: user.id, 26 | }, 27 | }); 28 | 29 | if (!file) notFound(); 30 | 31 | const plan = await getUserSubscriptionPlan(); 32 | 33 | return ( 34 |
35 |
36 |
37 |
38 |
39 | ); 40 | }; 41 | 42 | export default ChatPage; 43 | -------------------------------------------------------------------------------- /src/app/dashboard/billing/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation"; 2 | import { BillingForm } from "@/components"; 3 | import { getUserSubscriptionPlan } from "@/lib/stripe"; 4 | import { getKindeServerSession } from "@kinde-oss/kinde-auth-nextjs/server"; 5 | 6 | const BillingPage = async () => { 7 | const { getUser } = getKindeServerSession(); 8 | const user = getUser(); 9 | 10 | if (!user || !user.id) redirect("/auth-callback?origin=dashboard/billing"); 11 | 12 | const subscriptionPlan = await getUserSubscriptionPlan(); 13 | 14 | return ; 15 | }; 16 | 17 | export default BillingPage; 18 | -------------------------------------------------------------------------------- /src/app/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | import { db } from "@/db"; 2 | import { redirect } from "next/navigation"; 3 | import { getUserSubscriptionPlan } from "@/lib/stripe"; 4 | import { Dashboard } from "@/components"; 5 | import { getKindeServerSession } from "@kinde-oss/kinde-auth-nextjs/server"; 6 | 7 | const DashboardPage = async () => { 8 | const { getUser } = getKindeServerSession(); 9 | const user = getUser(); 10 | 11 | if (!user || !user.id) redirect("/auth-callback?origin=dashboard"); 12 | 13 | const dbUser = await db.user.findFirst({ 14 | where: { 15 | id: user.id, 16 | }, 17 | }); 18 | 19 | // for new users only 20 | if (!dbUser) redirect("/auth-callback?origin=dashboard"); 21 | 22 | const subscriptionPlan = await getUserSubscriptionPlan(); 23 | 24 | return ; 25 | }; 26 | 27 | export default DashboardPage; 28 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nabarvn/documon/793742411d2c8934438a14bb4e87f18b545d63bd/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss/base"; 2 | @import "tailwindcss/components"; 3 | @import "tailwindcss/utilities"; 4 | 5 | .grainy-light { 6 | background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwBAMAAAClLOS0AAAAElBMVEUAAAD8/vz08vT09vT8+vzs7uxH16TeAAAAAXRSTlMAQObYZgAAAAlwSFlzAAAOxAAADsQBlSsOGwAAAuFJREFUOI0Vk+3NLiEIRG1B8ClAYAsQ2AIEt4D9ePtv5Xp/mZgYJ2fOFJKEfInkVWY2aglmQFkimRTV7MblYyVqD7HXyhKsSuPX12MeDhRHLtGvRG+P+B/S0Vu4OswR9tmvwNPyhdCDbVayJGads/WiUWcjCvCnruTBNHS9gmX2VzVbk7ZvB1gb1hkWFGl+A/n+/FowcO34U/XvKqZ/fHY+6vgRfU92XrOBUbGeeDfQmjWjdrK+frc6FdGReQhfSF5JvR29O2QrfNw1huTwlgsyXLo0u+5So82sgv7tsFZR2nxB6lXiquHrfD8nfYZ9SeT0LiuvSoVrxGY16pCNRZKqvwWsn5OHypPBELzohMCaRaa0ceTHYqe7X/gfJEEtKFbJpWoNqO+aS1cuTykGPpK5Ga48m6L3NefTr013KqYBQu929iP1oQ/7UwSR+i3zqruUmT84qmhzLpxyj7pr9kg7LKvqaXxZmdpn+6o8sHqSqojy02gU3U8q9PnpidiaLks0mbMYz+q2uVXsoBQ8bfURULYxRgZVYCHMv9F4OA7qxT2NPPpvGQ/sTDH2yznKh7E2AcErfcNsaIoN1izzbJiaY63x4QjUFdBSvDCvugPpu5xDny0jzEeuUQbcP1aGT9V90uixngTRLYNEIIZ6yOF1H8tm7rj2JxiefsVy53zGVy3ag5uuPsdufYOzYxLRxngKe7nhx3VAq54pmz/DK9/Q3aDam2Yt3hNXB4HuU87jKNd/CKZn77Qdn5QkXPfqSkhk7hGOXXB+7v09KbBbqdvxGqa0AqfK/atIrL2WXdAgXAJ43Wtwe/aIoacXezeGPMlhDOHDbSfHnaXsL2QzbT82GRwZuezdwcoWzx5pnOnGMUdHuiY7lhdyWzWiHnucLZQxYStMJbtcydHaQ6vtMbe0AcDbxG+QG14AL94xry4297xpy9Cpf1OoxZ740gHDfrK+gtsy0xabwJmfgtCeii79B6aj0SJeLbd7AAAAAElFTkSuQmCC); 7 | } 8 | 9 | .grainy-dark { 10 | background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAADAAAAAwCAIAAADYYG7QAAAACXBIWXMAAAsTAAALEwEAmpwYAAAFyGlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNi4wLWMwMDUgNzkuMTY0NTkwLCAyMDIwLzEyLzA5LTExOjU3OjQ0ICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOnhtcE1NPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvbW0vIiB4bWxuczpzdEV2dD0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL3NUeXBlL1Jlc291cmNlRXZlbnQjIiB4bWxuczpkYz0iaHR0cDovL3B1cmwub3JnL2RjL2VsZW1lbnRzLzEuMS8iIHhtbG5zOnBob3Rvc2hvcD0iaHR0cDovL25zLmFkb2JlLmNvbS9waG90b3Nob3AvMS4wLyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgMjIuMSAoV2luZG93cykiIHhtcDpDcmVhdGVEYXRlPSIyMDI0LTA0LTIxVDE2OjAwOjQ1KzAyOjAwIiB4bXA6TWV0YWRhdGFEYXRlPSIyMDI0LTA0LTIxVDE2OjAwOjQ1KzAyOjAwIiB4bXA6TW9kaWZ5RGF0ZT0iMjAyNC0wNC0yMVQxNjowMDo0NSswMjowMCIgeG1wTU06SW5zdGFuY2VJRD0ieG1wLmlpZDpiNTU4MWVlNy1mZjFiLTFjNGUtYTMxMy02YjQ0Mzc2MGZjN2YiIHhtcE1NOkRvY3VtZW50SUQ9ImFkb2JlOmRvY2lkOnBob3Rvc2hvcDpmYTM5Y2U4MS1hZGViLTE5NDgtYjdhYi1i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"); 11 | } 12 | 13 | .scrollbar-w-2::-webkit-scrollbar { 14 | width: 0.25rem; 15 | height: 0.25rem; 16 | } 17 | 18 | .chat-scrollbar-track-gray-lighter::-webkit-scrollbar-track { 19 | --bg-opacity: 0.5; 20 | background-color: #00000015; 21 | } 22 | 23 | .chat-scrollbar-thumb-gray::-webkit-scrollbar-thumb { 24 | --bg-opacity: 0.5; 25 | background-color: #13131374; 26 | } 27 | 28 | .chat-scrollbar-thumb-rounded::-webkit-scrollbar-thumb { 29 | border-radius: 7px; 30 | } 31 | 32 | @media screen and (min-width: 750px) { 33 | .scrollbar-w-4::-webkit-scrollbar { 34 | width: 0.5rem; 35 | height: 0.5rem; 36 | } 37 | 38 | .scrollbar-track-gray-lighter::-webkit-scrollbar-track { 39 | --bg-opacity: 0.5; 40 | background-color: #00000015; 41 | } 42 | 43 | .scrollbar-thumb-gray::-webkit-scrollbar-thumb { 44 | --bg-opacity: 0.5; 45 | background-color: #13131374; 46 | } 47 | 48 | .scrollbar-thumb-rounded::-webkit-scrollbar-thumb { 49 | border-radius: 3px; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "./globals.css"; 2 | import { Inter } from "next/font/google"; 3 | import { cn, constructMetadata } from "@/lib/utils"; 4 | import { Navbar, Providers } from "@/components"; 5 | import { Toaster } from "@/components/ui"; 6 | 7 | import "simplebar-react/dist/simplebar.min.css"; 8 | import "react-loading-skeleton/dist/skeleton.css"; 9 | 10 | const inter = Inter({ subsets: ["latin"] }); 11 | 12 | export const metadata = constructMetadata(); 13 | 14 | export const viewport = { 15 | themeColor: "#FFF", 16 | }; 17 | 18 | export default function RootLayout({ 19 | children, 20 | }: { 21 | children: React.ReactNode; 22 | }) { 23 | return ( 24 | 25 | 32 | 33 | 34 | 35 |
36 | {children} 37 |
38 | 39 | 40 |
41 | 42 | 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import Image from "next/image"; 3 | import { cn } from "@/lib/utils"; 4 | import { MaxWidthWrapper } from "@/components"; 5 | import { ArrowRight, PartyPopper } from "lucide-react"; 6 | import { buttonVariants } from "@/components/ui/Button"; 7 | import { getKindeServerSession } from "@kinde-oss/kinde-auth-nextjs/server"; 8 | 9 | export default function Home() { 10 | const { getUser } = getKindeServerSession(); 11 | const user = getUser(); 12 | 13 | return ( 14 | <> 15 | 16 |
17 | 18 | 19 |

20 | Documon is now live! 21 |

22 |
23 | 24 |

25 | Chat with your documents in 26 | seconds. 27 |

28 | 29 |

30 | Documon allows you to have conversations with any PDF document. Simply 31 | upload your file and start asking questions right away. 32 |

33 | 34 | 43 | Get started 44 | 45 | 46 |
47 | 48 | {/* value proposition section */} 49 |
50 |
51 | 95 | 96 | {/* feature section */} 97 |
98 |
99 |
100 |

101 | Start chatting in seconds. 102 |

103 | 104 |

105 | Interacting with your PDF files has never been easier. 106 |

107 |
108 |
109 | 110 | {/* steps */} 111 |
    112 |
  1. 113 |
    114 | Step 1 115 | 116 | 117 | Sign up for an account 118 | 119 | 120 | 121 | Either starting out with a free plan or choose our{" "} 122 | 126 | pro plan 127 | 128 | . 129 | 130 |
    131 |
  2. 132 | 133 |
  3. 134 |
    135 | Step 2 136 | 137 | 138 | Upload your PDF file 139 | 140 | 141 | 142 | We'll process your file and make it ready for you to chat 143 | with. 144 | 145 |
    146 |
  4. 147 | 148 |
  5. 149 |
    150 | Step 3 151 | 152 | 153 | Start asking questions 154 | 155 | 156 | 157 | It's that simple. Try out Documon today - it really takes 158 | less than a minute. 159 | 160 |
    161 |
  6. 162 |
163 | 164 |
165 |
166 |
167 | uploading preview 175 |
176 |
177 |
178 |
179 | 180 | ); 181 | } 182 | -------------------------------------------------------------------------------- /src/app/pricing/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { cn } from "@/lib/utils"; 3 | import { buttonVariants } from "@/components/ui/Button"; 4 | import { MaxWidthWrapper, UpgradeButton } from "@/components"; 5 | 6 | import { 7 | Tooltip, 8 | TooltipContent, 9 | TooltipProvider, 10 | TooltipTrigger, 11 | } from "@/components/ui/Tooltip"; 12 | 13 | import { PLANS } from "@/config/stripe"; 14 | import { ArrowRight, Check, HelpCircle, Minus } from "lucide-react"; 15 | import { getKindeServerSession } from "@kinde-oss/kinde-auth-nextjs/server"; 16 | import { getUserSubscriptionPlan } from "@/lib/stripe"; 17 | import { redirect } from "next/navigation"; 18 | 19 | const PricingPage = async () => { 20 | const { getUser } = getKindeServerSession(); 21 | const user = getUser(); 22 | 23 | const plan = await getUserSubscriptionPlan(); 24 | 25 | if (plan.isSubscribed) redirect("/dashboard/billing"); 26 | 27 | const pricingItems = [ 28 | { 29 | plan: "Free", 30 | tagline: "For small side projects.", 31 | quota: PLANS.find((p) => p.slug === "free")!.quota, 32 | features: [ 33 | { 34 | text: "5 pages per PDF", 35 | footnote: "The maximum amount of pages per PDF file.", 36 | }, 37 | { 38 | text: "4MB file size limit", 39 | footnote: "The maximum file size of a single PDF file.", 40 | }, 41 | { 42 | text: "Mobile-friendly interface", 43 | }, 44 | { 45 | text: "Higher-quality responses", 46 | footnote: 47 | "Better algorithmic responses for enhanced content quality.", 48 | negative: true, 49 | }, 50 | { 51 | text: "Priority support", 52 | negative: true, 53 | }, 54 | ], 55 | }, 56 | { 57 | plan: "Pro", 58 | tagline: "For larger projects with higher needs.", 59 | quota: PLANS.find((p) => p.slug === "pro")!.quota, 60 | features: [ 61 | { 62 | text: "25 pages per PDF", 63 | footnote: "The maximum amount of pages per PDF file.", 64 | }, 65 | { 66 | text: "16MB file size limit", 67 | footnote: "The maximum file size of a single PDF file.", 68 | }, 69 | { 70 | text: "Mobile-friendly interface", 71 | }, 72 | { 73 | text: "Higher-quality responses", 74 | footnote: 75 | "Better algorithmic responses for enhanced content quality.", 76 | }, 77 | { 78 | text: "Priority support", 79 | }, 80 | ], 81 | }, 82 | ]; 83 | 84 | return ( 85 | <> 86 | 87 |
88 |

89 | Pricing 90 |

91 | 92 |

93 | Whether you're just trying out our service or need more, 94 | we've got you covered. 95 |

96 |
97 | 98 |
99 | 100 | {pricingItems.map(({ plan, tagline, quota, features }) => { 101 | const price = 102 | PLANS.find((p) => p.slug === plan.toLowerCase())?.price 103 | .amount || 0; 104 | 105 | return ( 106 |
113 | {plan === "Pro" && ( 114 |
115 | Upgrade now 116 |
117 | )} 118 | 119 |
120 |

121 | {plan} 122 |

123 | 124 |

{tagline}

125 | 126 |

127 | ${price} 128 |

129 | 130 |

per month

131 |
132 | 133 |
134 |
135 |

{quota.toLocaleString()} PDFs/mo included

136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | Number of PDFs you can upload per month. 144 | 145 | 146 |
147 |
148 | 149 |
    150 | {features.map(({ text, footnote, negative }) => ( 151 |
  • 152 |
    153 | {negative ? ( 154 | 155 | ) : ( 156 | 157 | )} 158 |
    159 | 160 | {footnote ? ( 161 |
    162 |

    167 | {text} 168 |

    169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | {footnote} 177 | 178 | 179 |
    180 | ) : ( 181 |

    186 | {text} 187 |

    188 | )} 189 |
  • 190 | ))} 191 |
192 | 193 |
194 | 195 |
196 | {plan === "Free" ? ( 197 | 204 | {user ? "Upgrade now" : "Sign up"} 205 | 206 | 207 | ) : user ? ( 208 | 209 | ) : ( 210 | 216 | {user ? "Upgrade now" : "Sign up"} 217 | 218 | 219 | )} 220 |
221 |
222 | ); 223 | })} 224 | 225 |
226 | 227 | 228 | ); 229 | }; 230 | 231 | export default PricingPage; 232 | -------------------------------------------------------------------------------- /src/components/BillingForm.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { format } from "date-fns"; 4 | import { trpc } from "@/app/_trpc/client"; 5 | import MaxWidthWrapper from "./MaxWidthWrapper"; 6 | import { getUserSubscriptionPlan } from "@/lib/stripe"; 7 | import { useToast } from "@/components/ui/UseToast"; 8 | 9 | import { 10 | Card, 11 | CardDescription, 12 | CardFooter, 13 | CardHeader, 14 | CardTitle, 15 | } from "@/components/ui/Card"; 16 | 17 | import { Loader2 } from "lucide-react"; 18 | import { Button } from "@/components/ui"; 19 | 20 | interface BillingFormProps { 21 | subscriptionPlan: Awaited>; 22 | } 23 | 24 | const BillingForm = ({ subscriptionPlan }: BillingFormProps) => { 25 | const { toast } = useToast(); 26 | 27 | const { mutate: createStripeSession, isLoading } = 28 | trpc.createStripeSession.useMutation({ 29 | onSuccess: ({ url }) => { 30 | if (url) { 31 | window.location.href = url; 32 | } 33 | 34 | if (!url) { 35 | toast({ 36 | title: "There was a problem...", 37 | description: "Please try again in a moment.", 38 | variant: "destructive", 39 | }); 40 | } 41 | }, 42 | }); 43 | 44 | return ( 45 | 46 |
{ 49 | e.preventDefault(); 50 | createStripeSession(); 51 | }} 52 | > 53 | 54 | 55 | Subscription Plan 56 | 57 | 58 | You are currently on the{" "} 59 | {subscriptionPlan.name ?? "Free"} plan. 60 | 61 | 62 | 63 | 64 | 73 | 74 | {subscriptionPlan.isSubscribed ? ( 75 |

76 | {subscriptionPlan.isCanceled 77 | ? "Your plan will be canceled on " 78 | : "Your plan renews on "} 79 | {format(subscriptionPlan.stripeCurrentPeriodEnd!, "dd.MM.yyyy")} 80 | . 81 |

82 | ) : null} 83 |
84 |
85 |
86 |
87 | ); 88 | }; 89 | 90 | export default BillingForm; 91 | -------------------------------------------------------------------------------- /src/components/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | import { Ghost } from "lucide-react"; 5 | import { trpc } from "@/app/_trpc/client"; 6 | import Skeleton from "react-loading-skeleton"; 7 | import { getUserSubscriptionPlan } from "@/lib/stripe"; 8 | import { UploadButton, PdfFileCard, MaxWidthWrapper } from "@/components"; 9 | 10 | interface DashboardProps { 11 | subscriptionPlan: Awaited>; 12 | } 13 | 14 | const Dashboard = ({ subscriptionPlan }: DashboardProps) => { 15 | const { data: files, isLoading } = trpc.getUserFiles.useQuery(); 16 | 17 | return ( 18 | 19 |
20 |

21 | My Files 22 |

23 | 24 | 25 |
26 | 27 | {/* display all user files */} 28 | {files && files?.length !== 0 ? ( 29 |
    30 | {files 31 | .sort( 32 | (a, b) => 33 | new Date(b.createdAt).getTime() - 34 | new Date(a.createdAt).getTime() 35 | ) 36 | .map((file) => ( 37 | 38 | 39 | 40 | ))} 41 |
42 | ) : isLoading ? ( 43 | 44 | ) : ( 45 |
46 | 47 | 48 |

49 | Pretty empty around here 50 |

51 | 52 |

53 | Let's upload your first PDF. 54 |

55 |
56 | )} 57 |
58 | ); 59 | }; 60 | 61 | export default Dashboard; 62 | -------------------------------------------------------------------------------- /src/components/Icons.tsx: -------------------------------------------------------------------------------- 1 | import { LucideProps, User } from "lucide-react"; 2 | 3 | export const Icons = { 4 | user: User, 5 | logo: (props: LucideProps) => ( 6 | 7 | 8 | 9 | ), 10 | }; 11 | -------------------------------------------------------------------------------- /src/components/Main.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { File } from "@prisma/client"; 4 | import { PdfRenderer } from "@/components"; 5 | import { ChatWrapper } from "@/components/chat"; 6 | import { DocumentContextProvider } from "@/context/document"; 7 | 8 | interface MainProps { 9 | file: File; 10 | plan: { 11 | isSubscribed: boolean; 12 | }; 13 | } 14 | 15 | const Main = ({ file, plan: { isSubscribed } }: MainProps) => ( 16 | 17 |
18 |
19 | 20 |
21 |
22 | 23 |
24 | 25 |
26 |
27 | ); 28 | 29 | export default Main; 30 | -------------------------------------------------------------------------------- /src/components/MaxWidthWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from "react"; 2 | import { cn } from "@/lib/utils"; 3 | 4 | const MaxWidthWrapper = ({ 5 | className, 6 | children, 7 | }: { 8 | className?: string; 9 | children: ReactNode; 10 | }) => { 11 | return ( 12 |
15 | {children} 16 |
17 | ); 18 | }; 19 | 20 | export default MaxWidthWrapper; 21 | -------------------------------------------------------------------------------- /src/components/MobileSlideover.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { cn } from "@/lib/utils"; 5 | import { useEffect, useRef, useState } from "react"; 6 | 7 | import { 8 | ArrowRight, 9 | CircleDollarSign, 10 | CreditCard, 11 | Gem, 12 | Github, 13 | LayoutDashboard, 14 | LogIn, 15 | LogOut, 16 | Menu, 17 | } from "lucide-react"; 18 | 19 | interface MobileSlideoverProps { 20 | isAuth: boolean; 21 | isSubscribed: boolean; 22 | } 23 | 24 | const MobileSlideover = ({ isAuth, isSubscribed }: MobileSlideoverProps) => { 25 | const menuRef = useRef(null); 26 | const [isOpen, setIsOpen] = useState(false); 27 | 28 | const toggleIsOpen = () => setIsOpen((prev) => !prev); 29 | 30 | useEffect(() => { 31 | const handleMouseDown = (e: MouseEvent) => { 32 | if (menuRef.current && !menuRef.current.contains(e.target as Node)) { 33 | setIsOpen(false); 34 | } 35 | }; 36 | 37 | window.addEventListener("mousedown", handleMouseDown); 38 | 39 | return () => { 40 | window.removeEventListener("mousedown", handleMouseDown); 41 | }; 42 | }, [isOpen]); 43 | 44 | useEffect(() => { 45 | const handleScroll = () => { 46 | setIsOpen(false); 47 | }; 48 | 49 | window.addEventListener("scroll", handleScroll); 50 | 51 | return () => { 52 | window.removeEventListener("scroll", handleScroll); 53 | }; 54 | }, []); 55 | 56 | return ( 57 | <> 58 |
59 | 63 | 64 | {isOpen ? ( 65 |
66 |
    67 | {!isAuth ? ( 68 | <> 69 |
  • 70 | setIsOpen(false)} 73 | className="flex items-center w-full font-semibold text-green-600" 74 | > 75 | Get started 76 | 77 | 78 |
  • 79 | 80 |
  • 81 | 82 |
  • 83 | setIsOpen(false)} 86 | className="flex items-center w-full font-semibold" 87 | > 88 | 89 | Sign in 90 | 91 |
  • 92 | 93 |
  • 94 | 95 |
  • 96 | setIsOpen(false)} 99 | className="flex items-center w-full font-semibold" 100 | > 101 | 102 | Pricing 103 | 104 |
  • 105 | 106 | ) : ( 107 | <> 108 |
  • 109 | setIsOpen(false)} 112 | className="flex items-center w-full font-semibold" 113 | > 114 | 115 | Dashboard 116 | 117 |
  • 118 | 119 |
  • 120 | 121 |
  • 122 | {isSubscribed ? ( 123 | setIsOpen(false)} 126 | className="flex items-center w-full font-semibold" 127 | > 128 | 129 | Manage Subscription 130 | 131 | ) : ( 132 | setIsOpen(false)} 135 | className="flex items-center w-full font-semibold" 136 | > 137 | 138 | Upgrade 139 | 140 | )} 141 |
  • 142 | 143 |
  • 144 | 145 |
  • 146 | setIsOpen(false)} 150 | className="flex items-center w-full font-semibold" 151 | > 152 | 153 | Star on GitHub 154 | 155 |
  • 156 | 157 |
  • 158 | 159 |
  • 160 | setIsOpen(false)} 163 | className="flex items-center w-full font-semibold" 164 | > 165 | 166 | Sign out 167 | 168 |
  • 169 | 170 | )} 171 |
172 |
173 | ) : null} 174 |
175 | 176 |
setIsOpen(false)} 178 | className={cn("md:hidden fixed inset-0 z-20 bg-gray-900/25", { 179 | hidden: !isOpen, 180 | })} 181 | style={{ height: "100svh" }} 182 | /> 183 | 184 | ); 185 | }; 186 | 187 | export default MobileSlideover; 188 | -------------------------------------------------------------------------------- /src/components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { getUserSubscriptionPlan } from "@/lib/stripe"; 3 | import { buttonVariants } from "@/components/ui/Button"; 4 | import { MaxWidthWrapper, MobileSlideover, ProfileMenu } from "@/components"; 5 | 6 | import { 7 | LoginLink, 8 | RegisterLink, 9 | getKindeServerSession, 10 | } from "@kinde-oss/kinde-auth-nextjs/server"; 11 | 12 | const Navbar = async () => { 13 | const { getUser } = getKindeServerSession(); 14 | const user = getUser(); 15 | 16 | const plan = await getUserSubscriptionPlan(); 17 | 18 | return ( 19 | 84 | ); 85 | }; 86 | 87 | export default Navbar; 88 | -------------------------------------------------------------------------------- /src/components/PdfFileCard.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import { useState } from "react"; 5 | import { format } from "date-fns"; 6 | import { File } from "@prisma/client"; 7 | import { Button } from "@/components/ui"; 8 | import { trpc } from "@/app/_trpc/client"; 9 | import { Loader2, MessageSquare, Plus, Trash } from "lucide-react"; 10 | 11 | type CustomFile = Omit & { 12 | createdAt: string; 13 | updatedAt: string; 14 | }; 15 | 16 | interface PdfFileCardProps { 17 | file: CustomFile; 18 | } 19 | 20 | const PdfFileCard = ({ file }: PdfFileCardProps) => { 21 | const utils = trpc.useContext(); 22 | 23 | const { data } = trpc.getFileMessages.useQuery({ 24 | fileId: file.id, 25 | limit: "all", 26 | }); 27 | 28 | const [currentlyDeletingFile, setCurrentlyDeletingFile] = useState< 29 | string | null 30 | >(null); 31 | 32 | const { mutate: deleteFile, isLoading: isDeleting } = 33 | trpc.deleteFile.useMutation({ 34 | onSuccess: () => { 35 | // force refresh 36 | utils.getUserFiles.invalidate(); 37 | }, 38 | 39 | onMutate({ id }) { 40 | setCurrentlyDeletingFile(id); 41 | }, 42 | 43 | onSettled() { 44 | setCurrentlyDeletingFile(null); 45 | }, 46 | }); 47 | 48 | return ( 49 |
  • 50 | 51 |
    52 |
    53 | 54 |
    55 |
    56 |

    57 | {file.name} 58 |

    59 |
    60 |
    61 |
    62 | 63 | 64 |
    65 |
    66 | 67 | {format(new Date(file.createdAt), "MMM yyyy")} 68 |
    69 | 70 |
    71 | 72 | 73 | {data ? ( 74 | data?.messages.length 75 | ) : ( 76 | 77 | )} 78 |
    79 | 80 | 94 |
    95 |
  • 96 | ); 97 | }; 98 | 99 | export default PdfFileCard; 100 | -------------------------------------------------------------------------------- /src/components/PdfFullscreen.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useState } from "react"; 4 | import SimpleBar from "simplebar-react"; 5 | import { Button } from "@/components/ui"; 6 | import { Document, Page } from "react-pdf"; 7 | import { Expand, Loader2 } from "lucide-react"; 8 | import { useToast } from "@/components/ui/UseToast"; 9 | import { useResizeDetector } from "react-resize-detector"; 10 | import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/Dialog"; 11 | 12 | interface PdfFullscreenProps { 13 | fileUrl: string; 14 | } 15 | 16 | const PdfFullscreen = ({ fileUrl }: PdfFullscreenProps) => { 17 | const { toast } = useToast(); 18 | 19 | const [isOpen, setIsOpen] = useState(false); 20 | const [numPages, setNumPages] = useState(); 21 | 22 | const { width, ref } = useResizeDetector(); 23 | 24 | return ( 25 | { 28 | // `v` stands for visibility 29 | if (!v) { 30 | setIsOpen(v); 31 | } 32 | }} 33 | > 34 | setIsOpen(true)} asChild> 35 | 38 | 39 | 40 | 41 | 45 |
    46 | 51 | 52 |
    53 | } 54 | onLoadError={() => { 55 | toast({ 56 | title: "Error loading PDF", 57 | description: "Please try again later.", 58 | variant: "destructive", 59 | }); 60 | }} 61 | onLoadSuccess={({ numPages }) => setNumPages(numPages)} 62 | > 63 | {new Array(numPages).fill(0).map((_, i) => ( 64 | 65 | ))} 66 | 67 |
    68 | 69 | 70 | 71 | ); 72 | }; 73 | 74 | export default PdfFullscreen; 75 | -------------------------------------------------------------------------------- /src/components/PdfRenderer.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import SimpleBar from "simplebar-react"; 4 | import { useState, useContext } from "react"; 5 | import { Document, Page, pdfjs } from "react-pdf"; 6 | import { useToast } from "@/components/ui/UseToast"; 7 | 8 | import { 9 | ChevronDown, 10 | ChevronUp, 11 | Loader2, 12 | RotateCw, 13 | Search, 14 | } from "lucide-react"; 15 | 16 | // stylesheet for some edge cases to work 17 | import "react-pdf/dist/Page/AnnotationLayer.css"; 18 | import "react-pdf/dist/Page/TextLayer.css"; 19 | 20 | // worker code is needed to properly render the pdf file 21 | pdfjs.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjs.version}/pdf.worker.js`; 22 | 23 | import { z } from "zod"; 24 | import { cn } from "@/lib/utils"; 25 | import { useForm } from "react-hook-form"; 26 | import { PdfFullscreen } from "@/components"; 27 | import { Button, Input } from "@/components/ui"; 28 | import { zodResolver } from "@hookform/resolvers/zod"; 29 | import { useResizeDetector } from "react-resize-detector"; 30 | import { DocumentContext } from "@/context/document"; 31 | 32 | import { 33 | DropdownMenu, 34 | DropdownMenuContent, 35 | DropdownMenuItem, 36 | DropdownMenuTrigger, 37 | } from "@/components/ui/DropdownMenu"; 38 | 39 | interface PdfRendererProps { 40 | url: string; 41 | } 42 | 43 | const PdfRenderer = ({ url }: PdfRendererProps) => { 44 | const { toast } = useToast(); 45 | const { width, ref } = useResizeDetector(); 46 | 47 | const { numPages, setNumPages } = useContext(DocumentContext); 48 | 49 | const [currPage, setCurrPage] = useState(1); 50 | const [scale, setScale] = useState(1); 51 | const [rotation, setRotation] = useState(0); 52 | const [renderedScale, setRenderedScale] = useState(null); 53 | 54 | const isLoading = renderedScale !== scale; 55 | 56 | const PageNumberValidator = z.object({ 57 | page: z 58 | .string() // whatever we type in an input field is a string value by default 59 | .refine((num) => Number(num) > 0 && Number(num) <= numPages!), 60 | }); 61 | 62 | type TPageNumberValidator = z.infer; 63 | 64 | const { 65 | register, 66 | handleSubmit, 67 | formState: { errors }, 68 | setValue, 69 | } = useForm({ 70 | defaultValues: { 71 | page: "1", 72 | }, 73 | // links `useForm` to `PageNumberValidator` 74 | resolver: zodResolver(PageNumberValidator), 75 | }); 76 | 77 | const handlePageSubmit = ({ page }: TPageNumberValidator) => { 78 | setCurrPage(Number(page)); 79 | setValue("page", String(page)); 80 | }; 81 | 82 | return ( 83 |
    84 |
    85 |
    86 | 97 | 98 |
    99 | { 106 | if (e.key === "Enter") { 107 | handleSubmit(handlePageSubmit)(); 108 | } 109 | }} 110 | /> 111 | 112 |

    113 | / 114 | {numPages ?? "x"} 115 |

    116 |
    117 | 118 | 131 |
    132 | 133 |
    134 | 135 | 136 | 141 | 142 | 143 | 144 | setScale(1)}> 145 | 100% 146 | 147 | 148 | setScale(1.5)}> 149 | 150% 150 | 151 | 152 | setScale(2)}> 153 | 200% 154 | 155 | 156 | setScale(2.5)}> 157 | 250% 158 | 159 | 160 | 161 | 162 | 170 | 171 | 172 |
    173 |
    174 | 175 |
    176 | 177 |
    178 | { 182 | toast({ 183 | title: "Error loading PDF", 184 | description: "Please try again later.", 185 | variant: "destructive", 186 | }); 187 | }} 188 | loading={ 189 |
    190 | 191 |
    192 | } 193 | onLoadSuccess={({ numPages }) => setNumPages(numPages)} 194 | > 195 | {isLoading && renderedScale ? ( 196 | 203 | ) : null} 204 | 205 | setRenderedScale(scale)} 213 | loading={ 214 |
    215 | 216 |
    217 | } 218 | /> 219 |
    220 |
    221 |
    222 |
    223 |
    224 | ); 225 | }; 226 | 227 | export default PdfRenderer; 228 | -------------------------------------------------------------------------------- /src/components/ProfileMenu.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { Icons } from "@/components"; 3 | import { Button } from "@/components/ui"; 4 | import { getUserSubscriptionPlan } from "@/lib/stripe"; 5 | import { Avatar, AvatarFallback } from "@/components/ui/Avatar"; 6 | import { LogoutLink } from "@kinde-oss/kinde-auth-nextjs/server"; 7 | import { CreditCard, Gem, Github, LayoutDashboard, LogOut } from "lucide-react"; 8 | 9 | import { 10 | DropdownMenu, 11 | DropdownMenuContent, 12 | DropdownMenuItem, 13 | DropdownMenuSeparator, 14 | DropdownMenuTrigger, 15 | } from "@/components/ui/DropdownMenu"; 16 | 17 | interface ProfileMenuProps { 18 | name: string; 19 | email: string | undefined; 20 | } 21 | 22 | const ProfileMenu = async ({ name, email }: ProfileMenuProps) => { 23 | const subscriptionPlan = await getUserSubscriptionPlan(); 24 | 25 | return ( 26 | 27 | 28 | 36 | 37 | 38 | 39 |
    40 |
    41 | {name &&

    {name}

    } 42 | 43 | {email && ( 44 |

    45 | {email} 46 |

    47 | )} 48 |
    49 |
    50 | 51 | 52 | 53 | 54 | 55 | 56 | Dashboard 57 | 58 | 59 | 60 | 61 | {subscriptionPlan?.isSubscribed ? ( 62 | 63 | 64 | Manage Subscription 65 | 66 | ) : ( 67 | 68 | 69 | Upgrade 70 | 71 | )} 72 | 73 | 74 | 75 | 76 | 77 | Star on GitHub 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | Log out 87 | 88 | 89 |
    90 |
    91 | ); 92 | }; 93 | 94 | export default ProfileMenu; 95 | -------------------------------------------------------------------------------- /src/components/Providers.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { trpc } from "@/app/_trpc/client"; 4 | import { absoluteUrl } from "@/lib/utils"; 5 | import { httpBatchLink } from "@trpc/client"; 6 | import { PropsWithChildren, useState } from "react"; 7 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 8 | 9 | const Providers = ({ children }: PropsWithChildren) => { 10 | const [queryClient] = useState(() => new QueryClient()); 11 | 12 | // typesafe wrapper around react query 13 | const [trpcClient] = useState(() => 14 | trpc.createClient({ 15 | links: [ 16 | httpBatchLink({ 17 | url: absoluteUrl("/api/trpc"), 18 | }), 19 | ], 20 | }) 21 | ); 22 | 23 | return ( 24 | 25 | {children} 26 | 27 | ); 28 | }; 29 | 30 | export default Providers; 31 | -------------------------------------------------------------------------------- /src/components/UpgradeButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Button } from "@/components/ui"; 4 | import { ArrowRight } from "lucide-react"; 5 | import { trpc } from "@/app/_trpc/client"; 6 | 7 | const UpgradeButton = () => { 8 | const { mutate: createStripeSession } = trpc.createStripeSession.useMutation({ 9 | onSuccess: ({ url }) => { 10 | window.location.href = url ?? "/dashboard/billing"; 11 | }, 12 | }); 13 | 14 | return ( 15 | 18 | ); 19 | }; 20 | 21 | export default UpgradeButton; 22 | -------------------------------------------------------------------------------- /src/components/UploadButton.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | import Dropzone from "react-dropzone"; 5 | import { trpc } from "@/app/_trpc/client"; 6 | import { useRouter } from "next/navigation"; 7 | import { useToast } from "@/components/ui/UseToast"; 8 | import { Button, Progress } from "@/components/ui"; 9 | import { useUploadThing } from "@/lib/uploadthing"; 10 | import { Cloud, File, Loader2 } from "lucide-react"; 11 | import { MAX_QUERY_COUNT } from "@/config/max-query"; 12 | import { Dispatch, SetStateAction, useState } from "react"; 13 | import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/Dialog"; 14 | 15 | interface UploadDropzoneProps { 16 | isSubscribed: boolean; 17 | isUploading: boolean; 18 | setIsOpen: Dispatch>; 19 | setIsUploading: Dispatch>; 20 | } 21 | 22 | const UploadDropzone = ({ 23 | isSubscribed, 24 | isUploading, 25 | setIsOpen, 26 | setIsUploading, 27 | }: UploadDropzoneProps) => { 28 | const router = useRouter(); 29 | const { toast } = useToast(); 30 | 31 | const [uploadProgress, setUploadProgress] = useState(0); 32 | 33 | const { startUpload } = useUploadThing( 34 | isSubscribed ? "proPlanUploader" : "freePlanUploader" 35 | ); 36 | 37 | const { data } = trpc.getQuotaLimit.useQuery(); 38 | 39 | const { mutate: startPolling } = trpc.getFile.useMutation({ 40 | onSuccess: (file) => { 41 | router.push(`/dashboard/${file.id}`); 42 | }, 43 | retry: MAX_QUERY_COUNT, 44 | retryDelay: 500, 45 | }); 46 | 47 | // creating a determinate progress bar 48 | const startSimulatedProgress = () => { 49 | setUploadProgress(0); 50 | 51 | const interval = setInterval(() => { 52 | setUploadProgress((prevProgress) => { 53 | if (prevProgress >= 95) { 54 | clearInterval(interval); 55 | return prevProgress; 56 | } 57 | 58 | return prevProgress + 5; 59 | }); 60 | }, 900); 61 | 62 | return interval; 63 | }; 64 | 65 | return ( 66 | { 76 | setIsUploading(true); 77 | 78 | const progressInterval = startSimulatedProgress(); 79 | 80 | if (data?.isQuotaExceeded) { 81 | return toast({ 82 | title: "Quota Error", 83 | description: `You've exceeded the ${data.planName} plan quota limit.`, 84 | variant: "destructive", 85 | }); 86 | } 87 | 88 | // handle file uploading 89 | const res = await startUpload(acceptedFile); 90 | 91 | if (!res) { 92 | setIsOpen(false); 93 | setIsUploading(false); 94 | 95 | return toast({ 96 | title: "PDF Upload Error", 97 | description: 98 | "Please ensure your file size is within the specified limit and try again.", 99 | variant: "destructive", 100 | }); 101 | } 102 | 103 | // destructuring the first array element from response 104 | const [fileResponse] = res; 105 | 106 | if (fileResponse.serverData?.duplicate) { 107 | setIsOpen(false); 108 | setIsUploading(false); 109 | 110 | return toast({ 111 | title: "Identical File Detected", 112 | description: 113 | "Uh-oh! It appears that the PDF you're trying to upload is already in your dashboard. You can either use the existing file or delete it before attempting to upload again.", 114 | variant: "destructive", 115 | }); 116 | } 117 | 118 | if (fileResponse.serverData?.error === "PineconeBadRequestError") { 119 | setIsOpen(false); 120 | setIsUploading(false); 121 | 122 | router.refresh(); 123 | 124 | return toast({ 125 | title: "Pinecone Index Error", 126 | description: 127 | "Documon has reached the namespace limit of its current vector database plan. Our team is working on this issue. Please try again later.", 128 | variant: "destructive", 129 | }); 130 | } 131 | 132 | if (fileResponse.serverData?.error === "UploadThingError") { 133 | setIsOpen(false); 134 | setIsUploading(false); 135 | 136 | return toast({ 137 | title: "UploadThing Error", 138 | description: 139 | "We encountered an issue while accessing the uploaded file. Our team has been notified and it will be resolved soon. Please try again shortly.", 140 | variant: "destructive", 141 | }); 142 | } 143 | 144 | // key is generated by uploadthing 145 | const key = fileResponse?.key; 146 | 147 | if (!key) { 148 | setIsOpen(false); 149 | setIsUploading(false); 150 | 151 | return toast({ 152 | title: "Something went wrong", 153 | description: "Please try again later.", 154 | variant: "destructive", 155 | }); 156 | } 157 | 158 | clearInterval(progressInterval); 159 | setUploadProgress(100); 160 | 161 | // asking the api if the file is there in the database 162 | // check if the pdf has been uploaded successfully 163 | startPolling({ key }); 164 | }} 165 | > 166 | {({ getRootProps, getInputProps, acceptedFiles }) => ( 167 |
    171 |
    172 | 244 |
    245 |
    246 | )} 247 |
    248 | ); 249 | }; 250 | 251 | const UploadButton = ({ isSubscribed }: { isSubscribed: boolean }) => { 252 | const [isOpen, setIsOpen] = useState(false); 253 | const [isUploading, setIsUploading] = useState(false); 254 | 255 | return ( 256 | { 259 | if (!v && !isUploading) { 260 | setIsOpen(v); 261 | } 262 | }} 263 | > 264 | setIsOpen(true)} asChild> 265 | 266 | 267 | 268 | 272 | 278 | 279 | 280 | ); 281 | }; 282 | 283 | export default UploadButton; 284 | -------------------------------------------------------------------------------- /src/components/chat/ChatInput.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { Send } from "lucide-react"; 4 | import { useContext, useRef } from "react"; 5 | import { ChatContext } from "@/context/chat"; 6 | import { Button, Textarea } from "@/components/ui"; 7 | 8 | interface ChatInputProps { 9 | isDisabled?: boolean; 10 | } 11 | 12 | const ChatInput = ({ isDisabled }: ChatInputProps) => { 13 | const textareaRef = useRef(null); 14 | 15 | const { addMessage, handleInputChange, isLoading, message } = 16 | useContext(ChatContext); 17 | 18 | const focusTextarea = () => { 19 | setTimeout(() => { 20 | textareaRef.current?.focus(); 21 | }, 10); 22 | }; 23 | 24 | return ( 25 |
    26 |
    27 |
    28 |
    29 |
    30 |