├── .env.example ├── .eslintrc.json ├── .gitignore ├── README.md ├── actions ├── edit-actions.ts └── upload-actions.ts ├── app ├── (logged-in) │ ├── dashboard │ │ └── page.tsx │ ├── posts │ │ ├── [id] │ │ │ └── page.tsx │ │ └── page.tsx │ ├── sign-in │ │ └── [[...sign-in]] │ │ │ └── page.tsx │ └── sign-up │ │ └── [[...sign-up]] │ │ └── page.tsx ├── api │ ├── payments │ │ └── route.ts │ └── uploadthing │ │ ├── core.ts │ │ └── route.ts ├── globals.css ├── icon.ico ├── layout.tsx └── page.tsx ├── components.json ├── components ├── common │ └── bg-gradient.tsx ├── content │ ├── content-editor.tsx │ ├── forward-ref-editor.tsx │ └── mdx-editor.tsx ├── home │ ├── banner.tsx │ ├── header.tsx │ ├── howitworks.tsx │ └── pricing.tsx ├── ui │ ├── badge.tsx │ ├── button.tsx │ ├── input.tsx │ ├── toast.tsx │ └── toaster.tsx └── upload │ ├── upgrade-your-plan.tsx │ └── upload-form.tsx ├── hooks └── use-toast.ts ├── lib ├── constants.ts ├── db.ts ├── payment-helpers.ts ├── user-helpers.ts └── utils.ts ├── middleware.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── public ├── next.svg └── vercel.svg ├── tailwind.config.ts ├── tsconfig.json └── utils └── uploadthing.ts /.env.example: -------------------------------------------------------------------------------- 1 | # CLERK 2 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= 3 | CLERK_SECRET_KEY= 4 | NEXT_PUBLIC_CLERK_SIGN_IN_URL= 5 | NEXT_PUBLIC_CLERK_SIGN_UP_URL= 6 | 7 | # STRIPE 8 | STRIPE_SECRET_KEY= 9 | STRIPE_WEBHOOK_SECRET= 10 | 11 | # NEONDB 12 | DATABASE_URL= 13 | 14 | # UPLOADTHING 15 | UPLOADTHING_SECRET= 16 | UPLOADTHING_APP_ID= 17 | 18 | # OPENAI 19 | OPENAI_API_KEY= -------------------------------------------------------------------------------- /.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 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SpeakEasyAI - Convert your video or voice into a Blog Post in seconds with the power of AI! 2 | 3 | Built with the Next.js 14 App Router, Clerk for Auth - with Passkeys, Github and Google Sign in, React, OpenAI - Whisper API, ShadCN UI library for components, React Markdown, NeonDb, UploadThing, Stripe for payments, Webhooks, TypeScript, TailwindCSS and more. 4 | 5 | ![Project Image](https://www.speakeasyai.dev/og-image.png) 6 | 7 | ## Features 8 | 9 | - 🌐 Next.js 14 App Router & Server Actions 10 | - 🤖 OpenAI API for audio transcription and blog post generation 11 | - 🔐 Authentication with Clerk (Passkeys, Github, and Google Sign-in) 12 | - 📝 AI-powered blog post generation based on audio transcription 13 | - 💳 Stripe integration for payments and subscription management 14 | - 💾 NeonDb for database management 15 | - 📤 UploadThing for file uploads 16 | - 🎙️ Audio and video file processing (up to 25MB) 17 | - 📜 TypeScript for type safety 18 | - 💅 TailwindCSS for styling 19 | - 🎨 ShadCN UI library for beautiful components 20 | - 🔒 Secure file handling and processing 21 | - 🪝 Webhook implementation for Stripe events 22 | - 💰 Stripe integration for custom pricing table, payment links, and subscriptions 23 | - 📊 User dashboard for managing blog posts 24 | - 🖋️ Markdown editor for blog post editing 25 | - 📱 Responsive design for mobile and desktop 26 | - 🔄 Real-time updates and path revalidation 27 | - 🚀 Deployment-ready (likely for Vercel) 28 | - 🔔 Toast notifications for user feedback 29 | - 📈 Performance optimizations 30 | - 🔍 SEO-friendly blog post generation 31 | - 📊 Recent blog posts display 32 | - 🔐 Protected routes and API endpoints 33 | 34 | ## Getting started 35 | 36 | To get started with this project, you need to do the following, 37 | 38 | 1. Please fork the repo 39 | 2. Copy the .env.example variables into a separate .env.local file 40 | 3. Create the credentials mentioned in the Youtube tutorial to get started! 41 | 42 | ## 1. How to fork and clone 43 | 44 | - If you want to make changes and contribute to this project, you'll need to create a fork first. Forking creates a copy of the original project in your own GitHub account. This lets you experiment with edits without affecting the main project. 45 | 46 | - Look for the "Fork" button in the top right corner of the project on GitHub. Clicking it will create a copy under your account with the same name. 47 | 48 | - After forking the project, you can clone it just like you usually do. 49 | 50 | ## Acknowledgements 51 | 52 | - [Clerk](https://go.clerk.com/5qOWrFA) for making this project possible 53 | 54 | ## License 55 | 56 | [MIT](https://choosealicense.com/licenses/mit/) 57 | -------------------------------------------------------------------------------- /actions/edit-actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | 3 | import getDbConnection from "@/lib/db"; 4 | import { currentUser } from "@clerk/nextjs/server"; 5 | import { revalidatePath } from "next/cache"; 6 | import { redirect } from "next/navigation"; 7 | 8 | export async function updatePostAction(data: { 9 | postId: string; 10 | content: string; 11 | }) { 12 | const { postId, content } = data; 13 | 14 | const user = await currentUser(); 15 | if (!user) { 16 | redirect("/sign-in"); 17 | } 18 | 19 | try { 20 | const sql = await getDbConnection(); 21 | 22 | const [title, ...contentParts] = content?.split("\n\n") || []; 23 | const updatedTitle = title.split("#")[1].trim(); 24 | 25 | await sql`UPDATE posts SET content = ${content}, title = ${updatedTitle} where id = ${postId}`; 26 | } catch (error) { 27 | console.error("Error occurred in updating the post", postId); 28 | return { 29 | success: false, 30 | }; 31 | } 32 | 33 | revalidatePath(`/posts/${postId}`); 34 | return { 35 | success: true, 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /actions/upload-actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | import getDbConnection from "@/lib/db"; 3 | import { revalidatePath } from "next/cache"; 4 | import { redirect } from "next/navigation"; 5 | import OpenAI from "openai"; 6 | 7 | const openai = new OpenAI({ 8 | apiKey: process.env.OPENAI_API_KEY, 9 | }); 10 | 11 | export async function transcribeUploadedFile( 12 | resp: { 13 | serverData: { userId: string; file: any }; 14 | }[] 15 | ) { 16 | if (!resp) { 17 | return { 18 | success: false, 19 | message: "File upload failed", 20 | data: null, 21 | }; 22 | } 23 | 24 | const { 25 | serverData: { 26 | userId, 27 | file: { url: fileUrl, name: fileName }, 28 | }, 29 | } = resp[0]; 30 | 31 | if (!fileUrl || !fileName) { 32 | return { 33 | success: false, 34 | message: "File upload failed", 35 | data: null, 36 | }; 37 | } 38 | 39 | const response = await fetch(fileUrl); 40 | 41 | try { 42 | const transcriptions = await openai.audio.transcriptions.create({ 43 | model: "whisper-1", 44 | file: response, 45 | }); 46 | 47 | console.log({ transcriptions }); 48 | return { 49 | success: true, 50 | message: "File uploaded successfully!", 51 | data: { transcriptions, userId }, 52 | }; 53 | } catch (error) { 54 | console.error("Error processing file", error); 55 | 56 | if (error instanceof OpenAI.APIError && error.status === 413) { 57 | return { 58 | success: false, 59 | message: "File size exceeds the max limit of 20MB", 60 | data: null, 61 | }; 62 | } 63 | 64 | return { 65 | success: false, 66 | message: error instanceof Error ? error.message : "Error processing file", 67 | data: null, 68 | }; 69 | } 70 | } 71 | 72 | async function saveBlogPost(userId: string, title: string, content: string) { 73 | try { 74 | const sql = await getDbConnection(); 75 | const [insertedPost] = await sql` 76 | INSERT INTO posts (user_id, title, content) 77 | VALUES (${userId}, ${title}, ${content}) 78 | RETURNING id 79 | `; 80 | return insertedPost.id; 81 | } catch (error) { 82 | console.error("Error saving blog post", error); 83 | throw error; 84 | } 85 | } 86 | 87 | async function getUserBlogPosts(userId: string) { 88 | try { 89 | const sql = await getDbConnection(); 90 | const posts = await sql` 91 | SELECT content FROM posts 92 | WHERE user_id = ${userId} 93 | ORDER BY created_at DESC 94 | LIMIT 3 95 | `; 96 | return posts.map((post) => post.content).join("\n\n"); 97 | } catch (error) { 98 | console.error("Error getting user blog posts", error); 99 | throw error; 100 | } 101 | } 102 | 103 | async function generateBlogPost({ 104 | transcriptions, 105 | userPosts, 106 | }: { 107 | transcriptions: string; 108 | userPosts: string; 109 | }) { 110 | const completion = await openai.chat.completions.create({ 111 | messages: [ 112 | { 113 | role: "system", 114 | content: 115 | "You are a skilled content writer that converts audio transcriptions into well-structured, engaging blog posts in Markdown format. Create a comprehensive blog post with a catchy title, introduction, main body with multiple sections, and a conclusion. Analyze the user's writing style from their previous posts and emulate their tone and style in the new post. Keep the tone casual and professional.", 116 | }, 117 | { 118 | role: "user", 119 | content: `Here are some of my previous blog posts for reference: 120 | 121 | ${userPosts} 122 | 123 | Please convert the following transcription into a well-structured blog post using Markdown formatting. Follow this structure: 124 | 125 | 1. Start with a SEO friendly catchy title on the first line. 126 | 2. Add two newlines after the title. 127 | 3. Write an engaging introduction paragraph. 128 | 4. Create multiple sections for the main content, using appropriate headings (##, ###). 129 | 5. Include relevant subheadings within sections if needed. 130 | 6. Use bullet points or numbered lists where appropriate. 131 | 7. Add a conclusion paragraph at the end. 132 | 8. Ensure the content is informative, well-organized, and easy to read. 133 | 9. Emulate my writing style, tone, and any recurring patterns you notice from my previous posts. 134 | 135 | Here's the transcription to convert: ${transcriptions}`, 136 | }, 137 | ], 138 | model: "gpt-4o-mini", 139 | temperature: 0.7, 140 | max_tokens: 1000, 141 | }); 142 | 143 | return completion.choices[0].message.content; 144 | } 145 | export async function generateBlogPostAction({ 146 | transcriptions, 147 | userId, 148 | }: { 149 | transcriptions: { text: string }; 150 | userId: string; 151 | }) { 152 | const userPosts = await getUserBlogPosts(userId); 153 | 154 | let postId = null; 155 | 156 | if (transcriptions) { 157 | const blogPost = await generateBlogPost({ 158 | transcriptions: transcriptions.text, 159 | userPosts, 160 | }); 161 | 162 | if (!blogPost) { 163 | return { 164 | success: false, 165 | message: "Blog post generation failed, please try again...", 166 | }; 167 | } 168 | 169 | const [title, ...contentParts] = blogPost?.split("\n\n") || []; 170 | 171 | //database connection 172 | 173 | if (blogPost) { 174 | postId = await saveBlogPost(userId, title, blogPost); 175 | } 176 | } 177 | 178 | //navigate 179 | revalidatePath(`/posts/${postId}`); 180 | redirect(`/posts/${postId}`); 181 | } 182 | -------------------------------------------------------------------------------- /app/(logged-in)/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | import BgGradient from "@/components/common/bg-gradient"; 2 | import { Badge } from "@/components/ui/badge"; 3 | import UpgradeYourPlan from "@/components/upload/upgrade-your-plan"; 4 | import UploadForm from "@/components/upload/upload-form"; 5 | import getDbConnection from "@/lib/db"; 6 | import { 7 | doesUserExist, 8 | getPlanType, 9 | hasCancelledSubscription, 10 | updateUser, 11 | } from "@/lib/user-helpers"; 12 | import { currentUser } from "@clerk/nextjs/server"; 13 | import { redirect } from "next/navigation"; 14 | 15 | export default async function Dashboard() { 16 | const clerkUser = await currentUser(); 17 | 18 | if (!clerkUser) { 19 | return redirect("/sign-in"); 20 | } 21 | 22 | const email = clerkUser?.emailAddresses?.[0].emailAddress ?? ""; 23 | 24 | const sql = await getDbConnection(); 25 | 26 | //updatethe user id 27 | let userId = null; 28 | let priceId = null; 29 | 30 | const hasUserCancelled = await hasCancelledSubscription(sql, email); 31 | const user = await doesUserExist(sql, email); 32 | 33 | if (user) { 34 | //update the user_id in users table 35 | userId = clerkUser?.id; 36 | if (userId) { 37 | await updateUser(sql, userId, email); 38 | } 39 | 40 | priceId = user[0].price_id; 41 | } 42 | 43 | const { id: planTypeId = "starter", name: planTypeName } = 44 | getPlanType(priceId); 45 | 46 | const isBasicPlan = planTypeId === "basic"; 47 | const isProPlan = planTypeId === "pro"; 48 | 49 | // check number of posts per plan 50 | const posts = await sql`SELECT * FROM posts WHERE user_id = ${userId}`; 51 | 52 | const isValidBasicPlan = isBasicPlan && posts.length < 3; 53 | 54 | return ( 55 | 56 |
57 |
58 | 59 | {planTypeName} Plan 60 | 61 | 62 |

63 | Start creating amazing content 64 |

65 | 66 |

67 | Upload your audio or video file and let our AI do the magic! 68 |

69 | 70 | {(isBasicPlan || isProPlan) && ( 71 |

72 | You get{" "} 73 | 74 | {isBasicPlan ? "3" : "Unlimited"} blog posts 75 | {" "} 76 | as part of the{" "} 77 | {planTypeName} Plan. 78 |

79 | )} 80 | 81 | {isValidBasicPlan || isProPlan ? ( 82 | 83 | 84 | 85 | ) : ( 86 | 87 | )} 88 |
89 |
90 |
91 | ); 92 | } 93 | -------------------------------------------------------------------------------- /app/(logged-in)/posts/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import ContentEditor from "@/components/content/content-editor"; 2 | import getDbConnection from "@/lib/db"; 3 | import { currentUser } from "@clerk/nextjs/server"; 4 | import { redirect } from "next/navigation"; 5 | 6 | export default async function PostsPage({ 7 | params: { id }, 8 | }: { 9 | params: { id: string }; 10 | }) { 11 | const user = await currentUser(); 12 | 13 | if (!user) { 14 | return redirect("/sign-in"); 15 | } 16 | 17 | const sql = await getDbConnection(); 18 | 19 | const posts: any = 20 | await sql`SELECT * from posts where user_id = ${user.id} and id = ${id}`; 21 | 22 | return ( 23 |
24 | 25 |
26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /app/(logged-in)/posts/page.tsx: -------------------------------------------------------------------------------- 1 | import BgGradient from "@/components/common/bg-gradient"; 2 | import getDbConnection from "@/lib/db"; 3 | import { currentUser } from "@clerk/nextjs/server"; 4 | import { ArrowRight } from "lucide-react"; 5 | import Link from "next/link"; 6 | import { redirect } from "next/navigation"; 7 | 8 | export default async function Page() { 9 | const user = await currentUser(); 10 | 11 | if (!user) { 12 | return redirect("/sign-in"); 13 | } 14 | 15 | const sql = await getDbConnection(); 16 | const posts = await sql`SELECT * from posts where user_id = ${user.id}`; 17 | 18 | return ( 19 |
20 |

21 | Your posts ✍️ 22 |

23 | 24 | {posts.length === 0 && ( 25 |
26 |

27 | You have no posts yet. Upload a video or audio to get started. 28 |

29 | 33 | Go to Dashboard 34 | 35 |
36 | )} 37 | 38 |
39 | {posts.map((post) => ( 40 | 41 |
42 |

43 | {post.title} 44 |

45 |

46 | {post.content.split("\n").slice(1).join("\n")} 47 |

48 | 52 | Read more 53 | 54 |
55 |
56 | ))} 57 |
58 |
59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /app/(logged-in)/sign-in/[[...sign-in]]/page.tsx: -------------------------------------------------------------------------------- 1 | import BgGradient from "@/components/common/bg-gradient"; 2 | import { SignIn } from "@clerk/nextjs"; 3 | 4 | export default function Page() { 5 | return ( 6 |
7 | 8 | 9 | 10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /app/(logged-in)/sign-up/[[...sign-up]]/page.tsx: -------------------------------------------------------------------------------- 1 | import BgGradient from "@/components/common/bg-gradient"; 2 | import { SignUp } from "@clerk/nextjs"; 3 | 4 | export default function Page() { 5 | return ( 6 |
7 | 8 | 9 | 10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /app/api/payments/route.ts: -------------------------------------------------------------------------------- 1 | import { 2 | handleCheckoutSessionCompleted, 3 | handleSubscriptionDeleted, 4 | } from "@/lib/payment-helpers"; 5 | import { NextRequest, NextResponse } from "next/server"; 6 | import Stripe from "stripe"; 7 | 8 | const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); 9 | 10 | export async function POST(req: NextRequest) { 11 | //webhook functionality 12 | const payload = await req.text(); 13 | 14 | const sig = req.headers.get("stripe-signature"); 15 | 16 | let event; 17 | 18 | try { 19 | event = stripe.webhooks.constructEvent( 20 | payload, 21 | sig!, 22 | process.env.STRIPE_WEBHOOK_SECRET! 23 | ); 24 | 25 | // Handle the event 26 | switch (event.type) { 27 | case "checkout.session.completed": { 28 | const session = await stripe.checkout.sessions.retrieve( 29 | event.data.object.id, 30 | { 31 | expand: ["line_items"], 32 | } 33 | ); 34 | console.log({ session }); 35 | 36 | //connect to the db create or update user 37 | await handleCheckoutSessionCompleted({ session, stripe }); 38 | break; 39 | } 40 | case "customer.subscription.deleted": { 41 | // connect to db 42 | const subscriptionId = event.data.object.id; 43 | 44 | await handleSubscriptionDeleted({ subscriptionId, stripe }); 45 | break; 46 | } 47 | default: 48 | console.log(`Unhandled event type ${event.type}`); 49 | } 50 | return NextResponse.json({ 51 | status: "success", 52 | }); 53 | } catch (err) { 54 | return NextResponse.json({ status: "Failed", err }); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /app/api/uploadthing/core.ts: -------------------------------------------------------------------------------- 1 | import { currentUser } from "@clerk/nextjs/server"; 2 | import { createUploadthing, type FileRouter } from "uploadthing/next"; 3 | import { UploadThingError } from "uploadthing/server"; 4 | 5 | const f = createUploadthing(); 6 | 7 | export const ourFileRouter = { 8 | videoOrAudioUploader: f({ video: { maxFileSize: "32MB" } }) 9 | .middleware(async ({ req }) => { 10 | const user = await currentUser(); 11 | 12 | console.log({ user }); 13 | 14 | if (!user) throw new UploadThingError("Unauthorized"); 15 | 16 | return { userId: user.id }; 17 | }) 18 | .onUploadComplete(async ({ metadata, file }) => { 19 | // This code RUNS ON YOUR SERVER after upload 20 | console.log("Upload complete for userId:", metadata.userId); 21 | 22 | console.log("file url", file.url); 23 | 24 | // !!! Whatever is returned here is sent to the clientside `onClientUploadComplete` callback 25 | return { userId: metadata.userId, file }; 26 | }), 27 | } satisfies FileRouter; 28 | 29 | export type OurFileRouter = typeof ourFileRouter; 30 | -------------------------------------------------------------------------------- /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 | 9 | // Apply an (optional) custom config: 10 | // config: { ... }, 11 | }); 12 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | --card: 0 0% 100%; 10 | --card-foreground: 222.2 84% 4.9%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 222.2 84% 4.9%; 13 | --primary: 222.2 47.4% 11.2%; 14 | --primary-foreground: 210 40% 98%; 15 | --secondary: 210 40% 96.1%; 16 | --secondary-foreground: 222.2 47.4% 11.2%; 17 | --muted: 210 40% 96.1%; 18 | --muted-foreground: 215.4 16.3% 46.9%; 19 | --accent: 210 40% 96.1%; 20 | --accent-foreground: 222.2 47.4% 11.2%; 21 | --destructive: 0 84.2% 60.2%; 22 | --destructive-foreground: 210 40% 98%; 23 | --border: 214.3 31.8% 91.4%; 24 | --input: 214.3 31.8% 91.4%; 25 | --ring: 222.2 84% 4.9%; 26 | --radius: 0.5rem; 27 | --chart-1: 12 76% 61%; 28 | --chart-2: 173 58% 39%; 29 | --chart-3: 197 37% 24%; 30 | --chart-4: 43 74% 66%; 31 | --chart-5: 27 87% 67%; 32 | } 33 | 34 | .dark { 35 | --background: 222.2 84% 4.9%; 36 | --foreground: 210 40% 98%; 37 | --card: 222.2 84% 4.9%; 38 | --card-foreground: 210 40% 98%; 39 | --popover: 222.2 84% 4.9%; 40 | --popover-foreground: 210 40% 98%; 41 | --primary: 210 40% 98%; 42 | --primary-foreground: 222.2 47.4% 11.2%; 43 | --secondary: 217.2 32.6% 17.5%; 44 | --secondary-foreground: 210 40% 98%; 45 | --muted: 217.2 32.6% 17.5%; 46 | --muted-foreground: 215 20.2% 65.1%; 47 | --accent: 217.2 32.6% 17.5%; 48 | --accent-foreground: 210 40% 98%; 49 | --destructive: 0 62.8% 30.6%; 50 | --destructive-foreground: 210 40% 98%; 51 | --border: 217.2 32.6% 17.5%; 52 | --input: 217.2 32.6% 17.5%; 53 | --ring: 212.7 26.8% 83.9%; 54 | --chart-1: 220 70% 50%; 55 | --chart-2: 160 60% 45%; 56 | --chart-3: 30 80% 55%; 57 | --chart-4: 280 65% 60%; 58 | --chart-5: 340 75% 55%; 59 | } 60 | } 61 | 62 | @layer base { 63 | * { 64 | @apply border-border; 65 | } 66 | 67 | h1 { 68 | @apply text-4xl md:text-6xl xl:text-7xl font-bold; 69 | } 70 | 71 | h2 { 72 | @apply text-xl lg:text-4xl font-medium; 73 | } 74 | 75 | h3 { 76 | @apply text-2xl lg:text-4xl font-medium; 77 | } 78 | 79 | a { 80 | @apply hover:cursor-pointer; 81 | } 82 | 83 | button { 84 | @apply hover:cursor-pointer bg-purple-600 hover:bg-purple-700; 85 | } 86 | 87 | /* Markdown content styles */ 88 | .markdown-content { 89 | @apply mx-auto; 90 | } 91 | 92 | .markdown-content h1 { 93 | @apply text-3xl font-bold mt-8 mb-4; 94 | } 95 | 96 | .markdown-content h2 { 97 | @apply text-2xl font-semibold mt-6 mb-3; 98 | } 99 | 100 | .markdown-content h3 { 101 | @apply text-xl font-medium mt-4 mb-2; 102 | } 103 | 104 | .markdown-content p { 105 | @apply mb-4 leading-7; 106 | } 107 | 108 | .markdown-content ul, 109 | .markdown-content ol { 110 | @apply mb-4 pl-6; 111 | } 112 | 113 | .markdown-content li { 114 | @apply mb-2 list-disc; 115 | } 116 | 117 | .markdown-content a { 118 | @apply text-blue-600 hover:underline; 119 | } 120 | 121 | .markdown-content blockquote { 122 | @apply border-l-4 border-gray-300 pl-4 italic my-4; 123 | } 124 | 125 | .markdown-content code { 126 | @apply bg-gray-100 rounded px-1 py-0.5 font-mono text-sm; 127 | } 128 | 129 | .markdown-content pre { 130 | @apply bg-gray-100 rounded p-4 overflow-x-auto my-4; 131 | } 132 | 133 | .markdown-content pre code { 134 | @apply bg-transparent p-0; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /app/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kulkarniankita/speakeasyai/0248bfa856162e09eba82b8023a5426c9bfee637/app/icon.ico -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { IBM_Plex_Sans as FontSans } from "next/font/google"; 3 | import "./globals.css"; 4 | import { cn } from "@/lib/utils"; 5 | import Header from "@/components/home/header"; 6 | import { ClerkProvider } from "@clerk/nextjs"; 7 | import { Toaster } from "@/components/ui/toaster"; 8 | import { ORIGIN_URL } from "@/lib/constants"; 9 | 10 | const fontSans = FontSans({ 11 | subsets: ["latin"], 12 | weight: ["300", "400", "500", "700"], 13 | variable: "--font-sans", 14 | }); 15 | 16 | export const metadata: Metadata = { 17 | title: "SpeakEasyAI Demo", 18 | description: 19 | "Convert your video or voice into a Blog Post in seconds with the power of AI!", 20 | icons: { 21 | icon: "/icon.ico", 22 | }, 23 | metadataBase: new URL(ORIGIN_URL), 24 | alternates: { 25 | canonical: ORIGIN_URL, 26 | }, 27 | }; 28 | 29 | export default function RootLayout({ 30 | children, 31 | }: Readonly<{ 32 | children: React.ReactNode; 33 | }>) { 34 | return ( 35 | 36 | 37 | 43 |
44 |
{children}
45 | 46 | 47 | 48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import BgGradient from "@/components/common/bg-gradient"; 2 | import Banner from "@/components/home/banner"; 3 | import HowItWorks from "@/components/home/howitworks"; 4 | import Pricing from "@/components/home/pricing"; 5 | import { Dot } from "lucide-react"; 6 | 7 | export default function Home() { 8 | return ( 9 |
10 | 11 | 12 |
13 | 14 | 15 | 16 |
17 | 18 |
19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 |
27 | 28 | 29 | 30 |
31 | 32 | 38 |
39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils" 16 | } 17 | } -------------------------------------------------------------------------------- /components/common/bg-gradient.tsx: -------------------------------------------------------------------------------- 1 | export default function BgGradient({ 2 | children, 3 | className, 4 | }: { 5 | children?: React.ReactNode; 6 | className?: string; 7 | }) { 8 | return ( 9 |
10 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /components/content/content-editor.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useCallback, useState } from "react"; 4 | import BgGradient from "../common/bg-gradient"; 5 | import { ForwardRefEditor } from "./forward-ref-editor"; 6 | import { useFormState, useFormStatus } from "react-dom"; 7 | import { updatePostAction } from "@/actions/edit-actions"; 8 | import { Button } from "../ui/button"; 9 | import { Download, Edit2, Loader2 } from "lucide-react"; 10 | 11 | function SubmitButton() { 12 | const { pending } = useFormStatus(); 13 | return ( 14 | 30 | ); 31 | } 32 | 33 | const initialState = { 34 | success: false, 35 | }; 36 | 37 | type UploadState = { 38 | success: boolean; 39 | }; 40 | 41 | type UploadAction = ( 42 | state: UploadState, 43 | formData: FormData 44 | ) => Promise; 45 | 46 | export default function ContentEditor({ 47 | posts, 48 | }: { 49 | posts: Array<{ content: string; title: string; id: string }>; 50 | }) { 51 | const [content, setContent] = useState(posts[0].content); 52 | const [isChanged, setIsChanged] = useState(false); 53 | 54 | const updatedPostActionWithId = updatePostAction.bind(null, { 55 | postId: posts[0].id, 56 | content, 57 | }); 58 | 59 | const [state, formAction] = useFormState( 60 | updatedPostActionWithId as unknown as UploadAction, 61 | initialState 62 | ); 63 | 64 | const handleContentChange = (value: string) => { 65 | setContent(value); 66 | setIsChanged(true); 67 | }; 68 | 69 | const handleExport = useCallback(() => { 70 | const filename = `${posts[0].title || "blog-post"}.md`; 71 | 72 | const blob = new Blob([content], { type: "text/markdown;charset=utf-8" }); 73 | const url = URL.createObjectURL(blob); 74 | const link = document.createElement("a"); 75 | link.href = url; 76 | link.download = filename; 77 | document.body.appendChild(link); 78 | link.click(); 79 | document.body.removeChild(link); 80 | URL.revokeObjectURL(url); 81 | }, [content, posts]); 82 | 83 | return ( 84 |
85 |
86 |
87 |

88 | 📝 Edit your post 89 |

90 |

Start editing your blog post below...

91 |
92 |
93 | 94 | 101 |
102 |
103 | 104 | 109 | 110 |
111 | ); 112 | } 113 | -------------------------------------------------------------------------------- /components/content/forward-ref-editor.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { MDXEditorMethods, MDXEditorProps } from "@mdxeditor/editor"; 4 | import dynamic from "next/dynamic"; 5 | import { forwardRef } from "react"; 6 | 7 | // ForwardRefEditor.tsx 8 | 9 | // This is the only place InitializedMDXEditor is imported directly. 10 | const Editor = dynamic(() => import("./mdx-editor"), { 11 | // Make sure we turn SSR off 12 | ssr: false, 13 | }); 14 | 15 | // This is what is imported by other components. Pre-initialized with plugins, and ready 16 | // to accept other props, including a ref. 17 | export const ForwardRefEditor = forwardRef( 18 | (props, ref) => 19 | ); 20 | 21 | // TS complains without the following line 22 | ForwardRefEditor.displayName = "ForwardRefEditor"; 23 | -------------------------------------------------------------------------------- /components/content/mdx-editor.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | // InitializedMDXEditor.tsx 3 | import type { ForwardedRef } from "react"; 4 | import { 5 | headingsPlugin, 6 | listsPlugin, 7 | quotePlugin, 8 | thematicBreakPlugin, 9 | markdownShortcutPlugin, 10 | MDXEditor, 11 | type MDXEditorMethods, 12 | type MDXEditorProps, 13 | } from "@mdxeditor/editor"; 14 | 15 | // Only import this to the next file 16 | export default function InitializedMDXEditor({ 17 | editorRef, 18 | ...props 19 | }: { editorRef: ForwardedRef | null } & MDXEditorProps) { 20 | return ( 21 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /components/home/banner.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { Button } from "../ui/button"; 3 | import { ArrowRight } from "lucide-react"; 4 | 5 | export default function Banner() { 6 | return ( 7 |
8 |

9 | Turn your words into{" "} 10 | 11 | captivating 12 | {" "} 13 | blog posts 14 |

15 |

16 | Convert your video or voice into a Blog Post in seconds with the power 17 | of AI! 18 |

19 | 20 | 29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /components/home/header.tsx: -------------------------------------------------------------------------------- 1 | import { SignedIn, SignedOut, SignInButton, UserButton } from "@clerk/nextjs"; 2 | import Image from "next/image"; 3 | import Link from "next/link"; 4 | 5 | const NavLink = ({ 6 | href, 7 | children, 8 | }: { 9 | href: string; 10 | children: React.ReactNode; 11 | }) => { 12 | return ( 13 | 17 | {children} 18 | 19 | ); 20 | }; 21 | 22 | export default function Header() { 23 | return ( 24 | 62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /components/home/howitworks.tsx: -------------------------------------------------------------------------------- 1 | import { BrainIcon, MoveRight } from "lucide-react"; 2 | 3 | export default function HowItWorks() { 4 | return ( 5 |
6 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /components/home/pricing.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowRight, CheckIcon } from "lucide-react"; 2 | import { Button } from "../ui/button"; 3 | import Link from "next/link"; 4 | import { cn } from "@/lib/utils"; 5 | import { plansMap } from "@/lib/constants"; 6 | 7 | export default function Pricing() { 8 | return ( 9 |
10 |
11 |
12 |

13 | Pricing 14 |

15 |
16 |
17 | {plansMap.map( 18 | ({ name, price, description, items, id, paymentLink }, idx) => ( 19 |
20 |
26 |
27 |
28 |

29 | {name} 30 |

31 |

{description}

32 |
33 |
34 |
35 |

36 | ${price} 37 |

38 |
39 |

40 | USD 41 |

42 |

/month

43 |
44 |
45 |
    46 | {items.map((item, idx) => ( 47 |
  • 48 | 49 | {item} 50 |
  • 51 | ))} 52 |
53 |
54 | 68 |
69 |
70 |
71 | ) 72 | )} 73 |
74 |
75 |
76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ) 21 | } 22 | ) 23 | Input.displayName = "Input" 24 | 25 | export { Input } 26 | -------------------------------------------------------------------------------- /components/ui/toast.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import * as React from "react"; 4 | import * as ToastPrimitives from "@radix-ui/react-toast"; 5 | import { cva, type VariantProps } from "class-variance-authority"; 6 | import { X } from "lucide-react"; 7 | 8 | import { cn } from "@/lib/utils"; 9 | 10 | const ToastProvider = ToastPrimitives.Provider; 11 | 12 | const ToastViewport = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, ...props }, ref) => ( 16 | 24 | )); 25 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName; 26 | 27 | const toastVariants = cva( 28 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", 29 | { 30 | variants: { 31 | variant: { 32 | default: "border bg-background text-foreground", 33 | destructive: 34 | "destructive group border-destructive bg-red-100 text-gray-900", 35 | }, 36 | }, 37 | defaultVariants: { 38 | variant: "default", 39 | }, 40 | } 41 | ); 42 | 43 | const Toast = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef & 46 | VariantProps 47 | >(({ className, variant, ...props }, ref) => { 48 | return ( 49 | 54 | ); 55 | }); 56 | Toast.displayName = ToastPrimitives.Root.displayName; 57 | 58 | const ToastAction = React.forwardRef< 59 | React.ElementRef, 60 | React.ComponentPropsWithoutRef 61 | >(({ className, ...props }, ref) => ( 62 | 70 | )); 71 | ToastAction.displayName = ToastPrimitives.Action.displayName; 72 | 73 | const ToastClose = React.forwardRef< 74 | React.ElementRef, 75 | React.ComponentPropsWithoutRef 76 | >(({ className, ...props }, ref) => ( 77 | 86 | 87 | 88 | )); 89 | ToastClose.displayName = ToastPrimitives.Close.displayName; 90 | 91 | const ToastTitle = React.forwardRef< 92 | React.ElementRef, 93 | React.ComponentPropsWithoutRef 94 | >(({ className, ...props }, ref) => ( 95 | 100 | )); 101 | ToastTitle.displayName = ToastPrimitives.Title.displayName; 102 | 103 | const ToastDescription = React.forwardRef< 104 | React.ElementRef, 105 | React.ComponentPropsWithoutRef 106 | >(({ className, ...props }, ref) => ( 107 | 112 | )); 113 | ToastDescription.displayName = ToastPrimitives.Description.displayName; 114 | 115 | type ToastProps = React.ComponentPropsWithoutRef; 116 | 117 | type ToastActionElement = React.ReactElement; 118 | 119 | export { 120 | type ToastProps, 121 | type ToastActionElement, 122 | ToastProvider, 123 | ToastViewport, 124 | Toast, 125 | ToastTitle, 126 | ToastDescription, 127 | ToastClose, 128 | ToastAction, 129 | }; 130 | -------------------------------------------------------------------------------- /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 "@/hooks/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 | -------------------------------------------------------------------------------- /components/upload/upgrade-your-plan.tsx: -------------------------------------------------------------------------------- 1 | import { ArrowRight } from "lucide-react"; 2 | import Link from "next/link"; 3 | 4 | export default function UpgradeYourPlan() { 5 | return ( 6 |
7 |

8 | You need to upgrade to the Basic Plan or the Pro Plan to create blog 9 | posts with the power of AI 💖. 10 |

11 | 15 | Go to Pricing 16 | 17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /components/upload/upload-form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { z } from "zod"; 4 | import { Button } from "../ui/button"; 5 | import { Input } from "../ui/input"; 6 | import { useToast } from "@/hooks/use-toast"; 7 | import { useUploadThing } from "@/utils/uploadthing"; 8 | import { 9 | generateBlogPostAction, 10 | transcribeUploadedFile, 11 | } from "@/actions/upload-actions"; 12 | 13 | const schema = z.object({ 14 | file: z 15 | .instanceof(File, { message: "Invalid file" }) 16 | .refine( 17 | (file) => file.size <= 20 * 1024 * 1024, 18 | "File size must not exceed 20MB" 19 | ) 20 | .refine( 21 | (file) => 22 | file.type.startsWith("audio/") || file.type.startsWith("video/"), 23 | "File must be an audio or a video file" 24 | ), 25 | }); 26 | 27 | export default function UploadForm() { 28 | const { toast } = useToast(); 29 | 30 | const { startUpload } = useUploadThing("videoOrAudioUploader", { 31 | onClientUploadComplete: () => { 32 | toast({ title: "uploaded successfully!" }); 33 | }, 34 | onUploadError: (err) => { 35 | console.error("Error occurred", err); 36 | }, 37 | onUploadBegin: () => { 38 | toast({ title: "Upload has begun 🚀!" }); 39 | }, 40 | }); 41 | 42 | const handleTranscribe = async (formData: FormData) => { 43 | const file = formData.get("file") as File; 44 | 45 | const validatedFields = schema.safeParse({ file }); 46 | 47 | if (!validatedFields.success) { 48 | console.log( 49 | "validatedFields", 50 | validatedFields.error.flatten().fieldErrors 51 | ); 52 | toast({ 53 | title: "❌ Something went wrong", 54 | variant: "destructive", 55 | description: 56 | validatedFields.error.flatten().fieldErrors.file?.[0] ?? 57 | "Invalid file", 58 | }); 59 | } 60 | 61 | if (file) { 62 | const resp: any = await startUpload([file]); 63 | console.log({ resp }); 64 | 65 | if (!resp) { 66 | toast({ 67 | title: "Something went wrong", 68 | description: "Please use a different file", 69 | variant: "destructive", 70 | }); 71 | } 72 | toast({ 73 | title: "🎙️ Transcription is in progress...", 74 | description: 75 | "Hang tight! Our digital wizards are sprinkling magic dust on your file! ✨", 76 | }); 77 | 78 | const result = await transcribeUploadedFile(resp); 79 | const { data = null, message = null } = result || {}; 80 | 81 | if (!result || (!data && !message)) { 82 | toast({ 83 | title: "An unexpected error occurred", 84 | description: 85 | "An error occurred during transcription. Please try again.", 86 | }); 87 | } 88 | 89 | if (data) { 90 | toast({ 91 | title: "🤖 Generating AI blog post...", 92 | description: "Please wait while we generate your blog post.", 93 | }); 94 | 95 | await generateBlogPostAction({ 96 | transcriptions: data.transcriptions, 97 | userId: data.userId, 98 | }); 99 | 100 | toast({ 101 | title: "🎉 Woohoo! Your AI blog is created! 🎊", 102 | description: 103 | "Time to put on your editor hat, Click the post and edit it!", 104 | }); 105 | } 106 | } 107 | }; 108 | return ( 109 |
110 |
111 | 118 | 119 |
120 |
121 | ); 122 | } 123 | -------------------------------------------------------------------------------- /hooks/use-toast.ts: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | // Inspired by react-hot-toast library 4 | import * as React from "react" 5 | 6 | import type { 7 | ToastActionElement, 8 | ToastProps, 9 | } from "@/components/ui/toast" 10 | 11 | const TOAST_LIMIT = 1 12 | const TOAST_REMOVE_DELAY = 1000000 13 | 14 | type ToasterToast = ToastProps & { 15 | id: string 16 | title?: React.ReactNode 17 | description?: React.ReactNode 18 | action?: ToastActionElement 19 | } 20 | 21 | const actionTypes = { 22 | ADD_TOAST: "ADD_TOAST", 23 | UPDATE_TOAST: "UPDATE_TOAST", 24 | DISMISS_TOAST: "DISMISS_TOAST", 25 | REMOVE_TOAST: "REMOVE_TOAST", 26 | } as const 27 | 28 | let count = 0 29 | 30 | function genId() { 31 | count = (count + 1) % Number.MAX_SAFE_INTEGER 32 | return count.toString() 33 | } 34 | 35 | type ActionType = typeof actionTypes 36 | 37 | type Action = 38 | | { 39 | type: ActionType["ADD_TOAST"] 40 | toast: ToasterToast 41 | } 42 | | { 43 | type: ActionType["UPDATE_TOAST"] 44 | toast: Partial 45 | } 46 | | { 47 | type: ActionType["DISMISS_TOAST"] 48 | toastId?: ToasterToast["id"] 49 | } 50 | | { 51 | type: ActionType["REMOVE_TOAST"] 52 | toastId?: ToasterToast["id"] 53 | } 54 | 55 | interface State { 56 | toasts: ToasterToast[] 57 | } 58 | 59 | const toastTimeouts = new Map>() 60 | 61 | const addToRemoveQueue = (toastId: string) => { 62 | if (toastTimeouts.has(toastId)) { 63 | return 64 | } 65 | 66 | const timeout = setTimeout(() => { 67 | toastTimeouts.delete(toastId) 68 | dispatch({ 69 | type: "REMOVE_TOAST", 70 | toastId: toastId, 71 | }) 72 | }, TOAST_REMOVE_DELAY) 73 | 74 | toastTimeouts.set(toastId, timeout) 75 | } 76 | 77 | export const reducer = (state: State, action: Action): State => { 78 | switch (action.type) { 79 | case "ADD_TOAST": 80 | return { 81 | ...state, 82 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), 83 | } 84 | 85 | case "UPDATE_TOAST": 86 | return { 87 | ...state, 88 | toasts: state.toasts.map((t) => 89 | t.id === action.toast.id ? { ...t, ...action.toast } : t 90 | ), 91 | } 92 | 93 | case "DISMISS_TOAST": { 94 | const { toastId } = action 95 | 96 | // ! Side effects ! - This could be extracted into a dismissToast() action, 97 | // but I'll keep it here for simplicity 98 | if (toastId) { 99 | addToRemoveQueue(toastId) 100 | } else { 101 | state.toasts.forEach((toast) => { 102 | addToRemoveQueue(toast.id) 103 | }) 104 | } 105 | 106 | return { 107 | ...state, 108 | toasts: state.toasts.map((t) => 109 | t.id === toastId || toastId === undefined 110 | ? { 111 | ...t, 112 | open: false, 113 | } 114 | : t 115 | ), 116 | } 117 | } 118 | case "REMOVE_TOAST": 119 | if (action.toastId === undefined) { 120 | return { 121 | ...state, 122 | toasts: [], 123 | } 124 | } 125 | return { 126 | ...state, 127 | toasts: state.toasts.filter((t) => t.id !== action.toastId), 128 | } 129 | } 130 | } 131 | 132 | const listeners: Array<(state: State) => void> = [] 133 | 134 | let memoryState: State = { toasts: [] } 135 | 136 | function dispatch(action: Action) { 137 | memoryState = reducer(memoryState, action) 138 | listeners.forEach((listener) => { 139 | listener(memoryState) 140 | }) 141 | } 142 | 143 | type Toast = Omit 144 | 145 | function toast({ ...props }: Toast) { 146 | const id = genId() 147 | 148 | const update = (props: ToasterToast) => 149 | dispatch({ 150 | type: "UPDATE_TOAST", 151 | toast: { ...props, id }, 152 | }) 153 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) 154 | 155 | dispatch({ 156 | type: "ADD_TOAST", 157 | toast: { 158 | ...props, 159 | id, 160 | open: true, 161 | onOpenChange: (open) => { 162 | if (!open) dismiss() 163 | }, 164 | }, 165 | }) 166 | 167 | return { 168 | id: id, 169 | dismiss, 170 | update, 171 | } 172 | } 173 | 174 | function useToast() { 175 | const [state, setState] = React.useState(memoryState) 176 | 177 | React.useEffect(() => { 178 | listeners.push(setState) 179 | return () => { 180 | const index = listeners.indexOf(setState) 181 | if (index > -1) { 182 | listeners.splice(index, 1) 183 | } 184 | } 185 | }, [state]) 186 | 187 | return { 188 | ...state, 189 | toast, 190 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), 191 | } 192 | } 193 | 194 | export { useToast, toast } 195 | -------------------------------------------------------------------------------- /lib/constants.ts: -------------------------------------------------------------------------------- 1 | export const plansMap = [ 2 | { 3 | id: "basic", 4 | name: "Basic", 5 | description: "Get started with SpeakEasy!", 6 | price: "10", 7 | items: ["3 Blog Posts", "3 Transcription"], 8 | paymentLink: "https://buy.stripe.com/test_aEU9D35X65fH0MMeUW", 9 | priceId: 10 | process.env.NODE_ENV === "development" 11 | ? "price_1PtLVqBPnsISnc82CW4au1uq" 12 | : "", 13 | }, 14 | { 15 | id: "pro", 16 | name: "Pro", 17 | description: "All Blog Posts, let’s go!", 18 | price: "19.99", 19 | items: ["Unlimited Blog Posts", "Unlimited Transcriptions"], 20 | paymentLink: "https://buy.stripe.com/test_cN26qRclufUl9jibIL", 21 | priceId: 22 | process.env.NODE_ENV === "development" 23 | ? "price_1PtLVqBPnsISnc82bspCVu5e" 24 | : "", 25 | }, 26 | ]; 27 | 28 | export const ORIGIN_URL = 29 | process.env.NODE_ENV === "development" 30 | ? "http://localhost:3000" 31 | : "https://speakeasyai-demo.vercel.app"; 32 | -------------------------------------------------------------------------------- /lib/db.ts: -------------------------------------------------------------------------------- 1 | import { neon } from "@neondatabase/serverless"; 2 | 3 | export default async function getDbConnection() { 4 | if (!process.env.DATABASE_URL) { 5 | throw new Error("Neon Database URL is not defined"); 6 | } 7 | const sql = neon(process.env.DATABASE_URL); 8 | return sql; 9 | } 10 | -------------------------------------------------------------------------------- /lib/payment-helpers.ts: -------------------------------------------------------------------------------- 1 | import Stripe from "stripe"; 2 | import getDbConnection from "./db"; 3 | 4 | export async function handleSubscriptionDeleted({ 5 | subscriptionId, 6 | stripe, 7 | }: { 8 | subscriptionId: string; 9 | stripe: Stripe; 10 | }) { 11 | try { 12 | const subscription = await stripe.subscriptions.retrieve(subscriptionId); 13 | const sql = await getDbConnection(); 14 | await sql`UPDATE users SET status = 'cancelled' WHERE customer_id = ${subscription.customer}`; 15 | } catch (error) { 16 | console.error("Error handling subscription deletion", error); 17 | throw error; 18 | } 19 | } 20 | 21 | export async function handleCheckoutSessionCompleted({ 22 | session, 23 | stripe, 24 | }: { 25 | session: Stripe.Checkout.Session; 26 | stripe: Stripe; 27 | }) { 28 | const customerId = session.customer as string; 29 | const customer = await stripe.customers.retrieve(customerId); 30 | const priceId = session.line_items?.data[0].price?.id; 31 | 32 | const sql = await getDbConnection(); 33 | 34 | if ("email" in customer && priceId) { 35 | await createOrUpdateUser(sql, customer, customerId); 36 | //update user subscription 37 | await updateUserSubscription(sql, priceId, customer.email as string); 38 | //insert the payment 39 | await insertPayment(sql, session, priceId, customer.email as string); 40 | } 41 | } 42 | 43 | async function insertPayment( 44 | sql: any, 45 | session: Stripe.Checkout.Session, 46 | priceId: string, 47 | customerEmail: string 48 | ) { 49 | try { 50 | await sql`INSERT INTO payments (amount, status, stripe_payment_id, price_id, user_email) VALUES (${session.amount_total}, ${session.status}, ${session.id}, ${priceId}, ${customerEmail})`; 51 | } catch (err) { 52 | console.error("Error in inserting payment", err); 53 | } 54 | } 55 | 56 | async function createOrUpdateUser( 57 | sql: any, 58 | customer: Stripe.Customer, 59 | customerId: string 60 | ) { 61 | try { 62 | const user = await sql`SELECT * FROM users WHERE email = ${customer.email}`; 63 | if (user.length === 0) { 64 | await sql`INSERT INTO users (email, full_name, customer_id) VALUES (${customer.email}, ${customer.name}, ${customerId})`; 65 | } 66 | } catch (err) { 67 | console.error("Error in inserting user", err); 68 | } 69 | } 70 | 71 | async function updateUserSubscription( 72 | sql: any, 73 | priceId: string, 74 | email: string 75 | ) { 76 | try { 77 | await sql`UPDATE users SET price_id = ${priceId}, status = 'active' where email = ${email}`; 78 | } catch (err) { 79 | console.error("Error in updating user", err); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /lib/user-helpers.ts: -------------------------------------------------------------------------------- 1 | import { NeonQueryFunction } from "@neondatabase/serverless"; 2 | import { plansMap } from "./constants"; 3 | 4 | export async function hasCancelledSubscription( 5 | sql: NeonQueryFunction, 6 | email: string 7 | ) { 8 | const query = 9 | await sql`SELECT * FROM users where email = ${email} AND status = 'cancelled'`; 10 | 11 | return query && query.length > 0; 12 | } 13 | 14 | export async function doesUserExist( 15 | sql: NeonQueryFunction, 16 | email: string 17 | ) { 18 | const query = await sql`SELECT * FROM users where email = ${email}`; 19 | if (query && query.length > 0) { 20 | return query; 21 | } 22 | return null; 23 | } 24 | 25 | export async function updateUser( 26 | sql: NeonQueryFunction, 27 | userId: string, 28 | email: string 29 | ) { 30 | return sql`UPDATE users SET user_id = ${userId} WHERE email = ${email}`; 31 | } 32 | 33 | export function getPlanType(priceId: string) { 34 | if (priceId === null) return { id: "starter", name: "Starter" }; 35 | 36 | const checkPlanType = plansMap.filter((plan) => plan.priceId === priceId); 37 | return checkPlanType?.[0]; 38 | } 39 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; 2 | 3 | const isProtectedRoute = createRouteMatcher(["/dashboard(.*)", "/posts(.*)"]); 4 | 5 | export default clerkMiddleware((auth, req) => { 6 | if (isProtectedRoute(req)) auth().protect(); 7 | }); 8 | 9 | export const config = { 10 | matcher: [ 11 | // Skip Next.js internals and all static files, unless found in search params 12 | "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)", 13 | // Always run for API routes 14 | "/(api|trpc)(.*)", 15 | ], 16 | }; 17 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "speakeasyai", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@clerk/nextjs": "^5.3.7", 13 | "@mdxeditor/editor": "^3.11.3", 14 | "@neondatabase/serverless": "^0.9.4", 15 | "@radix-ui/react-slot": "^1.1.0", 16 | "@radix-ui/react-toast": "^1.2.1", 17 | "@uploadthing/react": "^6.7.2", 18 | "class-variance-authority": "^0.7.0", 19 | "clsx": "^2.1.1", 20 | "lucide-react": "^0.436.0", 21 | "next": "14.2.7", 22 | "openai": "^4.57.0", 23 | "react": "^18", 24 | "react-dom": "^18", 25 | "stripe": "^16.9.0", 26 | "tailwind-merge": "^2.5.2", 27 | "tailwindcss-animate": "^1.0.7", 28 | "uploadthing": "^6.13.2", 29 | "zod": "^3.23.8" 30 | }, 31 | "devDependencies": { 32 | "@types/node": "^20", 33 | "@types/react": "^18", 34 | "@types/react-dom": "^18", 35 | "eslint": "^8", 36 | "eslint-config-next": "14.2.7", 37 | "postcss": "^8", 38 | "tailwindcss": "^3.4.1", 39 | "typescript": "^5" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | const { fontFamily } = require("tailwindcss/defaultTheme"); 3 | 4 | const config = { 5 | darkMode: ["class"], 6 | content: [ 7 | "./pages/**/*.{ts,tsx}", 8 | "./components/**/*.{ts,tsx}", 9 | "./app/**/*.{ts,tsx}", 10 | "./src/**/*.{ts,tsx}", 11 | ], 12 | prefix: "", 13 | theme: { 14 | container: { 15 | center: true, 16 | padding: "2rem", 17 | screens: { 18 | "2xl": "1400px", 19 | }, 20 | }, 21 | extend: { 22 | fontFamily: { 23 | sans: ["var(--font-sans)", ...fontFamily.sans], 24 | }, 25 | colors: { 26 | border: "hsl(var(--border))", 27 | input: "hsl(var(--input))", 28 | ring: "hsl(var(--ring))", 29 | background: "hsl(var(--background))", 30 | foreground: "hsl(var(--foreground))", 31 | primary: { 32 | DEFAULT: "hsl(var(--primary))", 33 | foreground: "hsl(var(--primary-foreground))", 34 | }, 35 | secondary: { 36 | DEFAULT: "hsl(var(--secondary))", 37 | foreground: "hsl(var(--secondary-foreground))", 38 | }, 39 | destructive: { 40 | DEFAULT: "hsl(var(--destructive))", 41 | foreground: "hsl(var(--destructive-foreground))", 42 | }, 43 | muted: { 44 | DEFAULT: "hsl(var(--muted))", 45 | foreground: "hsl(var(--muted-foreground))", 46 | }, 47 | accent: { 48 | DEFAULT: "hsl(var(--accent))", 49 | foreground: "hsl(var(--accent-foreground))", 50 | }, 51 | popover: { 52 | DEFAULT: "hsl(var(--popover))", 53 | foreground: "hsl(var(--popover-foreground))", 54 | }, 55 | card: { 56 | DEFAULT: "hsl(var(--card))", 57 | foreground: "hsl(var(--card-foreground))", 58 | }, 59 | }, 60 | borderRadius: { 61 | lg: "var(--radius)", 62 | md: "calc(var(--radius) - 2px)", 63 | sm: "calc(var(--radius) - 4px)", 64 | }, 65 | keyframes: { 66 | "accordion-down": { 67 | from: { height: "0" }, 68 | to: { height: "var(--radix-accordion-content-height)" }, 69 | }, 70 | "accordion-up": { 71 | from: { height: "var(--radix-accordion-content-height)" }, 72 | to: { height: "0" }, 73 | }, 74 | }, 75 | animation: { 76 | "accordion-down": "accordion-down 0.2s ease-out", 77 | "accordion-up": "accordion-up 0.2s ease-out", 78 | }, 79 | }, 80 | }, 81 | plugins: [require("tailwindcss-animate")], 82 | } satisfies Config; 83 | 84 | export default config; 85 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /utils/uploadthing.ts: -------------------------------------------------------------------------------- 1 | import type { OurFileRouter } from "@/app/api/uploadthing/core"; 2 | import { generateReactHelpers } from "@uploadthing/react"; 3 | 4 | export const { useUploadThing } = generateReactHelpers(); 5 | --------------------------------------------------------------------------------