├── .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 | 
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 |
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 |
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 |
22 | {children}
23 |
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 |
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 |
18 |
19 |
20 | How it works
21 |
22 |
23 |
24 | Easily repurpose your content into SEO focused blog posts
25 |
26 |
27 |
28 |
29 |
🎥
30 |
Upload a Video
31 |
32 |
33 |
34 |
35 |
36 |
37 |
AI Magic ✨
38 |
39 |
40 |
44 |
45 |
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 |
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 |
--------------------------------------------------------------------------------