├── .eslintrc.json ├── src ├── config │ ├── infinite-query.ts │ └── stripe.ts ├── components │ ├── Icons.tsx │ ├── ui │ │ ├── skeleton.tsx │ │ ├── input.tsx │ │ ├── textarea.tsx │ │ ├── toaster.tsx │ │ ├── progress.tsx │ │ ├── tooltip.tsx │ │ ├── avatar.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── dialog.tsx │ │ ├── use-toast.ts │ │ ├── toast.tsx │ │ └── dropdown-menu.tsx │ ├── ThemeProvider.tsx │ ├── MaxWidthWrapper.tsx │ ├── UpgradeButton.tsx │ ├── Footer.tsx │ ├── Providers.tsx │ ├── UploadButton.tsx │ ├── FileCardSkeleton.tsx │ ├── ModeToggle.tsx │ ├── chat │ │ ├── ChatInput.tsx │ │ ├── Message.tsx │ │ ├── ChatWrapper.tsx │ │ └── Messages.tsx │ ├── PdfFullScreen.tsx │ ├── BillingForm.tsx │ ├── Navbar.tsx │ ├── UserAccountNav.tsx │ ├── MobileNav.tsx │ ├── Dashboard.tsx │ ├── UploadDropzone.tsx │ └── PdfRenderer.tsx ├── app │ ├── _trpc │ │ └── client.ts │ ├── api │ │ ├── uploadthing │ │ │ ├── route.ts │ │ │ └── core.ts │ │ ├── auth │ │ │ └── [kindeAuth] │ │ │ │ └── route.ts │ │ ├── trpc │ │ │ └── [trpc] │ │ │ │ └── route.ts │ │ ├── webhooks │ │ │ └── stripe │ │ │ │ └── route.ts │ │ └── message │ │ │ └── route.ts │ ├── dashboard │ │ ├── billing │ │ │ └── page.tsx │ │ ├── page.tsx │ │ └── [fileId] │ │ │ └── page.tsx │ ├── auth-callback │ │ └── page.tsx │ ├── layout.tsx │ ├── globals.css │ ├── context │ │ └── chat-context.tsx │ ├── page.tsx │ └── pricing │ │ └── page.tsx ├── lib │ ├── validators │ │ └── SendMessageValidator.ts │ ├── openai.ts │ ├── pinecone.ts │ ├── uploadThing.ts │ ├── utils.ts │ └── stripe.ts ├── middleware.ts ├── types │ └── message.ts ├── db │ └── index.ts └── trpc │ ├── trpc.ts │ └── index.ts ├── public ├── favicon.ico ├── scribe-logo.png ├── social-preview.png ├── dashboard-preview.jpg └── file-upload-preview.jpg ├── screenshots ├── screenshot-1.png ├── screenshot-2.png ├── screenshot-3.png ├── screenshot-4.png ├── screenshot-5.png ├── screenshot-6.png └── screenshot-7.png ├── postcss.config.js ├── components.json ├── .gitignore ├── tsconfig.json ├── .env.example ├── next.config.js ├── README.md ├── prisma └── schema.prisma ├── package.json └── tailwind.config.ts /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /src/config/infinite-query.ts: -------------------------------------------------------------------------------- 1 | export const INFINITE_QUERY_LIMIT = 10; 2 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salimi-my/scribe/master/public/favicon.ico -------------------------------------------------------------------------------- /public/scribe-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salimi-my/scribe/master/public/scribe-logo.png -------------------------------------------------------------------------------- /public/social-preview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salimi-my/scribe/master/public/social-preview.png -------------------------------------------------------------------------------- /public/dashboard-preview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salimi-my/scribe/master/public/dashboard-preview.jpg -------------------------------------------------------------------------------- /screenshots/screenshot-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salimi-my/scribe/master/screenshots/screenshot-1.png -------------------------------------------------------------------------------- /screenshots/screenshot-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salimi-my/scribe/master/screenshots/screenshot-2.png -------------------------------------------------------------------------------- /screenshots/screenshot-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salimi-my/scribe/master/screenshots/screenshot-3.png -------------------------------------------------------------------------------- /screenshots/screenshot-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salimi-my/scribe/master/screenshots/screenshot-4.png -------------------------------------------------------------------------------- /screenshots/screenshot-5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salimi-my/scribe/master/screenshots/screenshot-5.png -------------------------------------------------------------------------------- /screenshots/screenshot-6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salimi-my/scribe/master/screenshots/screenshot-6.png -------------------------------------------------------------------------------- /screenshots/screenshot-7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salimi-my/scribe/master/screenshots/screenshot-7.png -------------------------------------------------------------------------------- /public/file-upload-preview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/salimi-my/scribe/master/public/file-upload-preview.jpg -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /src/components/Icons.tsx: -------------------------------------------------------------------------------- 1 | import { Feather, User2 } from 'lucide-react'; 2 | 3 | export const Icons = { 4 | user: User2, 5 | logo: Feather 6 | }; 7 | -------------------------------------------------------------------------------- /src/app/_trpc/client.ts: -------------------------------------------------------------------------------- 1 | import { createTRPCReact } from '@trpc/react-query'; 2 | 3 | import { AppRouter } from '@/trpc'; 4 | 5 | export const trpc = createTRPCReact({}); 6 | -------------------------------------------------------------------------------- /src/lib/validators/SendMessageValidator.ts: -------------------------------------------------------------------------------- 1 | import { z } from 'zod'; 2 | 3 | export const SendMessageValidator = z.object({ 4 | fileId: z.string(), 5 | message: z.string() 6 | }); 7 | -------------------------------------------------------------------------------- /src/lib/openai.ts: -------------------------------------------------------------------------------- 1 | import { Configuration, OpenAIApi } from 'openai-edge'; 2 | 3 | const config = new Configuration({ 4 | apiKey: process.env.OPENAI_API_KEY 5 | }); 6 | 7 | export const openai = new OpenAIApi(config); 8 | -------------------------------------------------------------------------------- /src/lib/pinecone.ts: -------------------------------------------------------------------------------- 1 | import { Pinecone } from '@pinecone-database/pinecone'; 2 | 3 | export const pinecone = new Pinecone({ 4 | apiKey: process.env.PINECONE_API_KEY!, 5 | environment: 'asia-southeast1-gcp-free' 6 | }); 7 | -------------------------------------------------------------------------------- /src/middleware.ts: -------------------------------------------------------------------------------- 1 | import { authMiddleware } from '@kinde-oss/kinde-auth-nextjs/server'; 2 | 3 | export const config = { 4 | matcher: ['/dashboard/:path*', '/auth-callback'] 5 | }; 6 | 7 | export default authMiddleware; 8 | -------------------------------------------------------------------------------- /src/lib/uploadThing.ts: -------------------------------------------------------------------------------- 1 | import { generateReactHelpers } from '@uploadthing/react/hooks'; 2 | 3 | import type { OurFileRouter } from '@/app/api/uploadthing/core'; 4 | 5 | export const { useUploadThing } = generateReactHelpers(); 6 | -------------------------------------------------------------------------------- /src/app/api/uploadthing/route.ts: -------------------------------------------------------------------------------- 1 | import { createNextRouteHandler } from 'uploadthing/next'; 2 | 3 | import { ourFileRouter } from './core'; 4 | 5 | // Export routes for Next App Router 6 | export const { GET, POST } = createNextRouteHandler({ 7 | router: ourFileRouter 8 | }); 9 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /src/app/dashboard/billing/page.tsx: -------------------------------------------------------------------------------- 1 | import BillingForm from '@/components/BillingForm'; 2 | import { getUserSubscriptionPlan } from '@/lib/stripe'; 3 | 4 | const BillingPage = async () => { 5 | const subscriptionPlan = await getUserSubscriptionPlan(); 6 | 7 | return ; 8 | }; 9 | 10 | export default BillingPage; 11 | -------------------------------------------------------------------------------- /src/app/api/trpc/[trpc]/route.ts: -------------------------------------------------------------------------------- 1 | import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; 2 | 3 | import { appRouter } from '@/trpc'; 4 | 5 | const handler = (req: Request) => 6 | fetchRequestHandler({ 7 | endpoint: '/api/trpc', 8 | req, 9 | router: appRouter, 10 | createContext: () => ({}) 11 | }); 12 | 13 | export { handler as GET, handler as POST }; 14 | -------------------------------------------------------------------------------- /src/components/ThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import { ThemeProvider as NextThemesProvider } from 'next-themes'; 5 | import { type ThemeProviderProps } from 'next-themes/dist/types'; 6 | 7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 8 | return {children}; 9 | } 10 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } -------------------------------------------------------------------------------- /src/types/message.ts: -------------------------------------------------------------------------------- 1 | import { AppRouter } from '@/trpc'; 2 | import { inferRouterOutputs } from '@trpc/server'; 3 | 4 | type RouterOutput = inferRouterOutputs; 5 | 6 | type Messages = RouterOutput['getFileMessages']['messages']; 7 | 8 | type OmitText = Omit; 9 | 10 | type ExtendedText = { 11 | text: string | JSX.Element; 12 | }; 13 | 14 | export type ExtendedMessage = OmitText & ExtendedText; 15 | -------------------------------------------------------------------------------- /src/components/MaxWidthWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | import { cn } from '@/lib/utils'; 4 | 5 | const MaxWidthWrapper = ({ 6 | className, 7 | children 8 | }: { 9 | className?: string; 10 | children: ReactNode; 11 | }) => { 12 | return ( 13 |
19 | {children} 20 |
21 | ); 22 | }; 23 | 24 | export default MaxWidthWrapper; 25 | -------------------------------------------------------------------------------- /.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 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /src/config/stripe.ts: -------------------------------------------------------------------------------- 1 | export const PLANS = [ 2 | { 3 | name: 'Free', 4 | slug: 'free', 5 | quota: 10, 6 | pagesPerPdf: 5, 7 | price: { 8 | amount: 0, 9 | priceIds: { 10 | test: '', 11 | production: '' 12 | } 13 | } 14 | }, 15 | { 16 | name: 'Pro', 17 | slug: 'pro', 18 | quota: 50, 19 | pagesPerPdf: 25, 20 | price: { 21 | amount: 50, 22 | priceIds: { 23 | test: 'price_1O0QifDTCGMxRHSuM25K0Imu', 24 | production: '' 25 | } 26 | } 27 | } 28 | ]; 29 | -------------------------------------------------------------------------------- /src/db/index.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client/edge'; 2 | import { withAccelerate } from '@prisma/extension-accelerate'; 3 | 4 | const prismaClientSingleton = () => { 5 | return new PrismaClient().$extends(withAccelerate()); 6 | }; 7 | 8 | type PrismaClientSingleton = ReturnType; 9 | 10 | const globalForPrisma = globalThis as unknown as { 11 | prisma: PrismaClientSingleton | undefined; 12 | }; 13 | 14 | const prisma = globalForPrisma.prisma ?? prismaClientSingleton(); 15 | 16 | export const db = prisma; 17 | 18 | if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma; 19 | -------------------------------------------------------------------------------- /src/components/UpgradeButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { ArrowRight } from 'lucide-react'; 4 | 5 | import { trpc } from '@/app/_trpc/client'; 6 | import { Button } from '@/components/ui/button'; 7 | 8 | const UpgradeButton = () => { 9 | const { mutate: createStripeSession } = trpc.createStripeSession.useMutation({ 10 | onSuccess: ({ url }) => { 11 | window.location.href = url ?? '/dashboard/billing'; 12 | } 13 | }); 14 | 15 | return ( 16 | 19 | ); 20 | }; 21 | 22 | export default UpgradeButton; 23 | -------------------------------------------------------------------------------- /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 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /src/trpc/trpc.ts: -------------------------------------------------------------------------------- 1 | import { TRPCError, initTRPC } from '@trpc/server'; 2 | import { getKindeServerSession } from '@kinde-oss/kinde-auth-nextjs/server'; 3 | 4 | const t = initTRPC.create(); 5 | const middleware = t.middleware; 6 | 7 | const isAuth = middleware(async (opts) => { 8 | const { getUser } = getKindeServerSession(); 9 | const user = getUser(); 10 | 11 | if (!user || !user.id) { 12 | throw new TRPCError({ code: 'UNAUTHORIZED' }); 13 | } 14 | 15 | return opts.next({ 16 | ctx: { 17 | userId: user.id, 18 | user 19 | } 20 | }); 21 | }); 22 | 23 | export const router = t.router; 24 | export const publicProcedure = t.procedure; 25 | export const privateProcedure = t.procedure.use(isAuth); 26 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Kinde configuration 2 | KINDE_CLIENT_ID=AddKindeClientID 3 | KINDE_CLIENT_SECRET=AddKindeClientSecret 4 | KINDE_ISSUER_URL=AddKindeIssuerURL 5 | KINDE_SITE_URL=http://localhost:3000 6 | KINDE_POST_LOGOUT_REDIRECT_URL=http://localhost:3000 7 | KINDE_POST_LOGIN_REDIRECT_URL=http://localhost:3000/dashboard 8 | 9 | # Prisma configuration 10 | DATABASE_URL="AddPrismaAccelerateURL" 11 | DIRECT_DATABASE_URL="AddMongoDBURL" 12 | 13 | # UploadThing configuration 14 | UPLOADTHING_SECRET=AddUploadThingSecret 15 | UPLOADTHING_APP_ID=AddUploadThingAppID 16 | 17 | # Pinecone configuration 18 | PINECONE_API_KEY=AddPineconeApiKey 19 | 20 | # OpenAI configuration 21 | OPENAI_API_KEY=AddOpenAiApiKey 22 | 23 | #Stripe configuration 24 | STRIPE_SECRET_KEY=AddStripeSecretKey 25 | STRIPE_WEBHOOK_SECRET=AddStripeWebhookSecret -------------------------------------------------------------------------------- /src/components/Footer.tsx: -------------------------------------------------------------------------------- 1 | const Footer = () => { 2 | return ( 3 |
4 |
5 | 6 | Created by{' '} 7 | 14 | Salimi 15 | {' '} 16 | © {new Date().getFullYear()}. All right reserved. 17 | 18 |
19 |
20 | ); 21 | }; 22 | 23 | export default Footer; 24 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | domains: ['lh3.googleusercontent.com'] 5 | }, 6 | async redirects() { 7 | return [ 8 | { 9 | source: '/sign-in', 10 | destination: '/api/auth/login', 11 | permanent: true 12 | }, 13 | { 14 | source: '/sign-up', 15 | destination: '/api/auth/register', 16 | permanent: true 17 | }, 18 | { 19 | source: '/sign-out', 20 | destination: '/api/auth/logout', 21 | permanent: true 22 | } 23 | ]; 24 | }, 25 | webpack: (config, { buildId, dev, isServer, defaultLoaders, webpack }) => { 26 | config.resolve.alias.canvas = false; 27 | config.resolve.alias.encoding = false; 28 | 29 | return config; 30 | } 31 | }; 32 | 33 | module.exports = nextConfig; 34 | -------------------------------------------------------------------------------- /src/app/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from 'next/navigation'; 2 | 3 | import { db } from '@/db'; 4 | import Dashboard from '@/components/Dashboard'; 5 | import { getUserSubscriptionPlan } from '@/lib/stripe'; 6 | import { getKindeServerSession } from '@kinde-oss/kinde-auth-nextjs/server'; 7 | 8 | const DashboardPage = async () => { 9 | const { getUser } = getKindeServerSession(); 10 | const user = getUser(); 11 | 12 | if (!user || !user.id) { 13 | redirect('/auth-callback?origin=dashboard'); 14 | } 15 | 16 | const dbUser = await db.user.findFirst({ 17 | where: { 18 | id: user.id 19 | } 20 | }); 21 | 22 | if (!dbUser) { 23 | redirect('/auth-callback?origin=dashboard'); 24 | } 25 | 26 | const subscriptionPlan = await getUserSubscriptionPlan(); 27 | 28 | return ; 29 | }; 30 | 31 | export default DashboardPage; 32 | -------------------------------------------------------------------------------- /src/components/Providers.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { httpBatchLink } from '@trpc/client'; 4 | import { PropsWithChildren, useState } from 'react'; 5 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 6 | 7 | import { trpc } from '@/app/_trpc/client'; 8 | import { absoluteUrl } from '@/lib/utils'; 9 | 10 | const Providers = ({ children }: PropsWithChildren) => { 11 | const [queryClient] = useState(() => new QueryClient()); 12 | const [trpcClient] = useState(() => 13 | trpc.createClient({ 14 | links: [ 15 | httpBatchLink({ 16 | url: absoluteUrl('/api/trpc') 17 | }) 18 | ] 19 | }) 20 | ); 21 | 22 | return ( 23 | 24 | {children} 25 | 26 | ); 27 | }; 28 | 29 | export default Providers; 30 | -------------------------------------------------------------------------------- /src/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 | -------------------------------------------------------------------------------- /src/components/UploadButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState } from 'react'; 4 | 5 | import { Button } from '@/components/ui/button'; 6 | import UploadDropzone from '@/components/UploadDropzone'; 7 | import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog'; 8 | 9 | const UploadButton = ({ isSubscribed }: { isSubscribed: boolean }) => { 10 | const [isOpen, setIsOpen] = useState(false); 11 | 12 | return ( 13 | { 16 | if (!visible) { 17 | setIsOpen(visible); 18 | } 19 | }} 20 | > 21 | setIsOpen(true)} asChild> 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | ); 30 | }; 31 | 32 | export default UploadButton; 33 | -------------------------------------------------------------------------------- /src/components/ui/textarea.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import TextareaAutosize, { 3 | TextareaAutosizeProps 4 | } from 'react-textarea-autosize'; 5 | 6 | import { cn } from '@/lib/utils'; 7 | 8 | export interface TextareaProps 9 | extends React.TextareaHTMLAttributes {} 10 | 11 | const Textarea = React.forwardRef( 12 | ({ className, ...props }, ref) => { 13 | return ( 14 | 22 | ); 23 | } 24 | ); 25 | Textarea.displayName = 'Textarea'; 26 | 27 | export { Textarea }; 28 | -------------------------------------------------------------------------------- /src/components/ui/toaster.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { 4 | Toast, 5 | ToastClose, 6 | ToastDescription, 7 | ToastProvider, 8 | ToastTitle, 9 | ToastViewport 10 | } from '@/components/ui/toast'; 11 | import { useToast } from '@/components/ui/use-toast'; 12 | 13 | export function Toaster() { 14 | const { toasts } = useToast(); 15 | 16 | return ( 17 | 18 | {toasts.map(function ({ id, title, description, action, ...props }) { 19 | return ( 20 | 21 |
22 | {title && {title}} 23 | {description && ( 24 | {description} 25 | )} 26 |
27 | {action} 28 | 29 |
30 | ); 31 | })} 32 | 33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/components/FileCardSkeleton.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from '@/components/ui/skeleton'; 2 | 3 | const FileCardSkeleton = () => { 4 | return ( 5 |
  • 6 |
    7 |
    8 | 9 |
    10 |
    11 | 12 |
    13 |
    14 |
    15 |
    16 |
    17 | 18 | 19 | 20 |
    21 |
  • 22 | ); 23 | }; 24 | 25 | export default FileCardSkeleton; 26 | -------------------------------------------------------------------------------- /src/components/ui/progress.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import * as ProgressPrimitive from '@radix-ui/react-progress'; 5 | 6 | import { cn } from '@/lib/utils'; 7 | 8 | type ProgressProps = React.ComponentPropsWithoutRef< 9 | typeof ProgressPrimitive.Root 10 | > & { 11 | indicatorColor?: string; 12 | }; 13 | 14 | const Progress = React.forwardRef< 15 | React.ElementRef, 16 | ProgressProps 17 | >(({ className, value, indicatorColor, ...props }, ref) => ( 18 | 26 | 33 | 34 | )); 35 | Progress.displayName = ProgressPrimitive.Root.displayName; 36 | 37 | export { Progress }; 38 | -------------------------------------------------------------------------------- /src/app/auth-callback/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Loader2 } from 'lucide-react'; 4 | import { useRouter, useSearchParams } from 'next/navigation'; 5 | 6 | import { trpc } from '@/app/_trpc/client'; 7 | 8 | const AuthCallbackPage = () => { 9 | const router = useRouter(); 10 | const searchParams = useSearchParams(); 11 | 12 | const origin = searchParams.get('origin'); 13 | 14 | trpc.authCallback.useQuery(undefined, { 15 | onSuccess: ({ success }) => { 16 | if (success) { 17 | // User is synced to db 18 | router.push(origin ? `/${origin}` : '/dashboard'); 19 | } 20 | }, 21 | onError: (error) => { 22 | if (error.data?.code === 'UNAUTHORIZED') { 23 | router.push('/sign-in'); 24 | } 25 | }, 26 | retry: true, 27 | retryDelay: 500 28 | }); 29 | 30 | return ( 31 |
    32 |
    33 | 34 |

    Setting up your account...

    35 |

    You will be redirected automatically.

    36 |
    37 |
    38 | ); 39 | }; 40 | 41 | export default AuthCallbackPage; 42 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Inter } from 'next/font/google'; 2 | 3 | import Navbar from '@/components/Navbar'; 4 | import Providers from '@/components/Providers'; 5 | import { Toaster } from '@/components/ui/toaster'; 6 | import { cn, constructMetadata } from '@/lib/utils'; 7 | import { ThemeProvider } from '@/components/ThemeProvider'; 8 | 9 | import './globals.css'; 10 | import 'simplebar-react/dist/simplebar.min.css'; 11 | 12 | const inter = Inter({ subsets: ['latin'] }); 13 | 14 | export const metadata = constructMetadata(); 15 | 16 | export default function RootLayout({ 17 | children 18 | }: { 19 | children: React.ReactNode; 20 | }) { 21 | return ( 22 | 23 | 24 | 30 | 36 | 37 | 38 | {children} 39 | 40 | 41 | 42 | 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /src/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import * as TooltipPrimitive from '@radix-ui/react-tooltip'; 5 | 6 | import { cn } from '@/lib/utils'; 7 | 8 | const TooltipProvider = TooltipPrimitive.Provider; 9 | 10 | const Tooltip = TooltipPrimitive.Root; 11 | 12 | const TooltipTrigger = TooltipPrimitive.Trigger; 13 | 14 | const TooltipContent = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef 17 | >(({ className, sideOffset = 4, ...props }, ref) => ( 18 | 27 | )); 28 | TooltipContent.displayName = TooltipPrimitive.Content.displayName; 29 | 30 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; 31 | -------------------------------------------------------------------------------- /src/app/dashboard/[fileId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { notFound, redirect } from 'next/navigation'; 2 | import { getKindeServerSession } from '@kinde-oss/kinde-auth-nextjs/server'; 3 | 4 | import { db } from '@/db'; 5 | import PdfRenderer from '@/components/PdfRenderer'; 6 | import ChatWrapper from '@/components/chat/ChatWrapper'; 7 | 8 | interface FilePageProps { 9 | params: { 10 | fileId: string; 11 | }; 12 | } 13 | 14 | const FilePage = async ({ params }: FilePageProps) => { 15 | const { fileId } = params; 16 | 17 | const { getUser } = getKindeServerSession(); 18 | const user = getUser(); 19 | 20 | if (!user || !user.id) { 21 | redirect(`/auth-callback?origin=dashboard/${fileId}`); 22 | } 23 | 24 | const file = await db.file.findFirst({ 25 | where: { 26 | id: fileId, 27 | userId: user.id 28 | } 29 | }); 30 | 31 | if (!file) { 32 | notFound(); 33 | } 34 | 35 | return ( 36 |
    37 |
    38 |
    39 |
    40 | 41 |
    42 |
    43 | 44 |
    45 | 46 |
    47 |
    48 |
    49 | ); 50 | }; 51 | 52 | export default FilePage; 53 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { Metadata } from 'next'; 2 | import { twMerge } from 'tailwind-merge'; 3 | import { type ClassValue, clsx } from 'clsx'; 4 | 5 | export function cn(...inputs: ClassValue[]) { 6 | return twMerge(clsx(inputs)); 7 | } 8 | 9 | export function absoluteUrl(path: string) { 10 | if (typeof window !== 'undefined') return path; 11 | if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}${path}`; 12 | return `http://localhost:${process.env.PORT ?? 3000}${path}`; 13 | } 14 | 15 | export function constructMetadata({ 16 | title = 'Scribe — Chat with Your Documents', 17 | description = 'Enhance your document experience with Scribe: Upload PDFs and chat with them through AI for seamless productivity. Unlock the power of interactive PDFs today!', 18 | image = '/social-preview.png', 19 | icons = '/favicon.ico', 20 | noIndex = false 21 | }: { 22 | title?: string; 23 | description?: string; 24 | image?: string; 25 | icons?: string; 26 | noIndex?: boolean; 27 | } = {}): Metadata { 28 | return { 29 | title, 30 | description, 31 | openGraph: { 32 | title, 33 | description, 34 | images: [ 35 | { 36 | url: image 37 | } 38 | ] 39 | }, 40 | twitter: { 41 | card: 'summary_large_image', 42 | title, 43 | description, 44 | images: [image], 45 | creator: '@mysalimi' 46 | }, 47 | icons, 48 | metadataBase: new URL('https://scribe.salimi.my'), 49 | themeColor: '#FFF', 50 | ...(noIndex && { 51 | robots: { 52 | index: false, 53 | follow: false 54 | } 55 | }) 56 | }; 57 | } 58 | -------------------------------------------------------------------------------- /src/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 | -------------------------------------------------------------------------------- /src/lib/stripe.ts: -------------------------------------------------------------------------------- 1 | import { db } from '@/db'; 2 | import Stripe from 'stripe'; 3 | import { PLANS } from '@/config/stripe'; 4 | import { getKindeServerSession } from '@kinde-oss/kinde-auth-nextjs/server'; 5 | 6 | export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? '', { 7 | apiVersion: '2023-08-16', 8 | typescript: true 9 | }); 10 | 11 | export async function getUserSubscriptionPlan() { 12 | const { getUser } = getKindeServerSession(); 13 | const user = getUser(); 14 | 15 | if (!user.id) { 16 | return { 17 | ...PLANS[0], 18 | isSubscribed: false, 19 | isCanceled: false, 20 | stripeCurrentPeriodEnd: null 21 | }; 22 | } 23 | 24 | const dbUser = await db.user.findFirst({ 25 | where: { 26 | id: user.id 27 | } 28 | }); 29 | 30 | if (!dbUser) { 31 | return { 32 | ...PLANS[0], 33 | isSubscribed: false, 34 | isCanceled: false, 35 | stripeCurrentPeriodEnd: null 36 | }; 37 | } 38 | 39 | const isSubscribed = Boolean( 40 | dbUser.stripePriceId && 41 | dbUser.stripeCurrentPeriodEnd && // 86400000 = 1 day 42 | dbUser.stripeCurrentPeriodEnd.getTime() + 86_400_000 > Date.now() 43 | ); 44 | 45 | const plan = isSubscribed 46 | ? PLANS.find((plan) => plan.price.priceIds.test === dbUser.stripePriceId) 47 | : null; 48 | 49 | let isCanceled = false; 50 | if (isSubscribed && dbUser.stripeSubscriptionId) { 51 | const stripePlan = await stripe.subscriptions.retrieve( 52 | dbUser.stripeSubscriptionId 53 | ); 54 | isCanceled = stripePlan.cancel_at_period_end; 55 | } 56 | 57 | return { 58 | ...plan, 59 | stripeSubscriptionId: dbUser.stripeSubscriptionId, 60 | stripeCurrentPeriodEnd: dbUser.stripeCurrentPeriodEnd, 61 | stripeCustomerId: dbUser.stripeCustomerId, 62 | isSubscribed, 63 | isCanceled 64 | }; 65 | } 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # [Scribe](https://scribe.salimi.my) · [![Author Salimi](https://img.shields.io/badge/Author-Salimi-%3C%3E)](https://www.linkedin.com/in/mohamad-salimi/) 2 | 3 | Scribe offers you the remarkable ability to engage in insightful conversations with any PDF document of your choice. With the simple act of uploading your file, you can seamlessly embark on a journey of exploration, posing your inquiries and gaining valuable insights in real-time with AI. 4 | 5 | ## Chat with your documents 6 | 7 | - Light & dark mode 8 | - Drag & drop file upload 9 | - Chat with PDF using AI 10 | - Authentication using Kinde 11 | - Subscription using Stripe 12 | - MongoDB & Prisma for database 13 | - Hosted in Vercel 14 | 15 | ## Tech/framework used 16 | 17 | - Next.js 13 App Dir 18 | - Shadcn/ui 19 | - Kinde 20 | - Tailwind CSS 21 | - UploadThing 22 | - TypeScript 23 | - MongoDB 24 | - OpenAI 25 | - Pinecone 26 | - Prisma 27 | - Stripe 28 | - Vercel 29 | 30 | ## Starting the project 31 | 32 | Open the [.env.example](/.env.example) and fill in your Database URL, Kinde Auth, UploadThing, Pinecone, OpenAI & Stripe configurations then save it as .env then run the following command: 33 | 34 | ```bash 35 | npm install 36 | npx prisma db push 37 | npx prisma generate 38 | npm run dev 39 | ``` 40 | 41 | ## Demo 42 | 43 | The app is hosted on Vercel. [Click here](https://scribe.salimi.my) to visit. 44 |
    45 | Direct link: `https://scribe.salimi.my` 46 | 47 | ## Screenshots 48 | 49 | #### Landing Page 50 | 51 | ![Landing Page](/screenshots/screenshot-1.png) 52 | 53 | #### Sign in 54 | 55 | ![Sign in](/screenshots/screenshot-2.png) 56 | 57 | #### Dashboard 58 | 59 | ![Dashboard](/screenshots/screenshot-3.png) 60 | 61 | #### Chat with PDF 62 | 63 | ![Chat with PDF](/screenshots/screenshot-4.png) 64 | 65 | #### Upload File 66 | 67 | ![Upload File](/screenshots/screenshot-5.png) 68 | 69 | #### Payment Page 70 | 71 | ![Payment Page](/screenshots/screenshot-6.png) 72 | 73 | #### Manage Subscription 74 | 75 | ![Manage Subscription](/screenshots/screenshot-7.png) 76 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Slot } from '@radix-ui/react-slot'; 3 | import { cva, type VariantProps } from 'class-variance-authority'; 4 | 5 | import { cn } from '@/lib/utils'; 6 | 7 | const buttonVariants = cva( 8 | 'inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50', 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | 'bg-primary text-primary-foreground shadow hover:bg-primary/90', 14 | destructive: 15 | 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90', 16 | outline: 17 | 'border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground', 18 | secondary: 19 | 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80', 20 | ghost: 'hover:bg-accent hover:text-accent-foreground', 21 | link: 'text-primary underline-offset-4 hover:underline' 22 | }, 23 | size: { 24 | default: 'h-9 px-4 py-2', 25 | sm: 'h-8 rounded-md px-3 text-xs', 26 | lg: 'h-10 rounded-md px-8', 27 | icon: 'h-9 w-9' 28 | } 29 | }, 30 | defaultVariants: { 31 | variant: 'default', 32 | size: 'default' 33 | } 34 | } 35 | ); 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean; 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : 'button'; 46 | return ( 47 | 52 | ); 53 | } 54 | ); 55 | Button.displayName = 'Button'; 56 | 57 | export { Button, buttonVariants }; 58 | -------------------------------------------------------------------------------- /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 = "mongodb" 10 | url = env("DATABASE_URL") 11 | directUrl = env("DIRECT_DATABASE_URL") 12 | } 13 | 14 | model User { 15 | id String @id @map("_id") // Matches kinde user id 16 | email String @unique 17 | 18 | File File[] 19 | Message Message[] 20 | 21 | createdAt DateTime @default(now()) 22 | updatedAt DateTime @updatedAt 23 | 24 | stripeCustomerId String? @unique @map(name: "stripe_customer_id") 25 | stripeSubscriptionId String? @unique @map(name: "stripe_subscription_id") 26 | stripePriceId String? @map(name: "stripe_price_id") 27 | stripeCurrentPeriodEnd DateTime? @map(name: "stripe_current_period_end") 28 | } 29 | 30 | enum UploadStatus { 31 | PENDING 32 | PROCESSING 33 | FAILED 34 | SUCCESS 35 | } 36 | 37 | model File { 38 | id String @id @default(auto()) @map("_id") @db.ObjectId 39 | name String 40 | 41 | uploadStatus UploadStatus @default(PENDING) 42 | 43 | url String 44 | key String 45 | 46 | messages Message[] 47 | 48 | createdAt DateTime @default(now()) 49 | updatedAt DateTime @updatedAt 50 | 51 | userId String? 52 | User User? @relation(fields: [userId], references: [id]) 53 | 54 | @@index([userId]) 55 | } 56 | 57 | model Message { 58 | id String @id @default(auto()) @map("_id") @db.ObjectId 59 | text String 60 | 61 | isUserMessage Boolean 62 | 63 | createdAt DateTime @default(now()) 64 | updatedAt DateTime @updatedAt 65 | 66 | userId String? 67 | User User? @relation(fields: [userId], references: [id]) 68 | 69 | fileId String? @db.ObjectId 70 | File File? @relation(fields: [fileId], references: [id]) 71 | } -------------------------------------------------------------------------------- /src/app/api/webhooks/stripe/route.ts: -------------------------------------------------------------------------------- 1 | import type Stripe from 'stripe'; 2 | import { headers } from 'next/headers'; 3 | 4 | import { db } from '@/db'; 5 | import { stripe } from '@/lib/stripe'; 6 | 7 | export async function POST(request: Request) { 8 | const body = await request.text(); 9 | const signature = headers().get('Stripe-Signature') ?? ''; 10 | 11 | let event: Stripe.Event; 12 | 13 | try { 14 | event = stripe.webhooks.constructEvent( 15 | body, 16 | signature, 17 | process.env.STRIPE_WEBHOOK_SECRET || '' 18 | ); 19 | } catch (err) { 20 | return new Response( 21 | `Webhook Error: ${err instanceof Error ? err.message : 'Unknown Error'}`, 22 | { status: 400 } 23 | ); 24 | } 25 | 26 | const session = event.data.object as Stripe.Checkout.Session; 27 | 28 | if (!session?.metadata?.userId) { 29 | return new Response(null, { 30 | status: 200 31 | }); 32 | } 33 | 34 | if (event.type === 'checkout.session.completed') { 35 | const subscription = await stripe.subscriptions.retrieve( 36 | session.subscription as string 37 | ); 38 | 39 | await db.user.update({ 40 | where: { 41 | id: session.metadata.userId 42 | }, 43 | data: { 44 | stripeSubscriptionId: subscription.id, 45 | stripeCustomerId: subscription.customer as string, 46 | stripePriceId: subscription.items.data[0]?.price.id, 47 | stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000) 48 | } 49 | }); 50 | } 51 | 52 | if (event.type === 'invoice.payment_succeeded') { 53 | // Retrieve the subscription details from Stripe. 54 | const subscription = await stripe.subscriptions.retrieve( 55 | session.subscription as string 56 | ); 57 | 58 | await db.user.update({ 59 | where: { 60 | stripeSubscriptionId: subscription.id 61 | }, 62 | data: { 63 | stripePriceId: subscription.items.data[0]?.price.id, 64 | stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000) 65 | } 66 | }); 67 | } 68 | 69 | return new Response(null, { status: 200 }); 70 | } 71 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | import { cn } from '@/lib/utils'; 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
    17 | )); 18 | Card.displayName = 'Card'; 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
    29 | )); 30 | CardHeader.displayName = 'CardHeader'; 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

    41 | )); 42 | CardTitle.displayName = 'CardTitle'; 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLParagraphElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |

    53 | )); 54 | CardDescription.displayName = 'CardDescription'; 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |

    61 | )); 62 | CardContent.displayName = 'CardContent'; 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
    73 | )); 74 | CardFooter.displayName = 'CardFooter'; 75 | 76 | export { 77 | Card, 78 | CardHeader, 79 | CardFooter, 80 | CardTitle, 81 | CardDescription, 82 | CardContent 83 | }; 84 | -------------------------------------------------------------------------------- /src/components/ModeToggle.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useTheme } from 'next-themes'; 4 | import { MixerVerticalIcon, MoonIcon, SunIcon } from '@radix-ui/react-icons'; 5 | 6 | import { Button } from '@/components/ui/button'; 7 | import { 8 | Tooltip, 9 | TooltipContent, 10 | TooltipProvider, 11 | TooltipTrigger 12 | } from '@/components/ui/tooltip'; 13 | import { 14 | DropdownMenu, 15 | DropdownMenuContent, 16 | DropdownMenuItem, 17 | DropdownMenuTrigger 18 | } from '@/components/ui/dropdown-menu'; 19 | 20 | export function ModeToggle() { 21 | const { setTheme } = useTheme(); 22 | 23 | return ( 24 | 25 | 26 | 27 | 28 | 29 | 38 | 39 | 40 | Switch Theme 41 | 42 | 43 | 44 | setTheme('light')}> 45 | 46 | Light 47 | 48 | setTheme('dark')}> 49 | 50 | Dark 51 | 52 | setTheme('system')}> 53 | 54 | System 55 | 56 | 57 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /src/components/chat/ChatInput.tsx: -------------------------------------------------------------------------------- 1 | import { useContext, useRef } from 'react'; 2 | import { SendHorizonal } from 'lucide-react'; 3 | 4 | import { Button } from '@/components/ui/button'; 5 | import { Textarea } from '@/components/ui/textarea'; 6 | import { ChatContext } from '@/app/context/chat-context'; 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 | return ( 19 |
    20 |
    21 |
    22 |
    23 |
    24 |