├── .env.example ├── .gitignore ├── README.md ├── components.json ├── eslint.config.mjs ├── jest.config.js ├── jest.setup.js ├── next.config.ts ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── prisma └── schema.prisma ├── public ├── file.svg ├── globe.svg ├── next.svg ├── vercel.svg └── window.svg ├── src ├── app │ ├── api │ │ ├── ai │ │ │ ├── enhance │ │ │ │ └── route.ts │ │ │ ├── generate │ │ │ │ └── route.ts │ │ │ └── translate │ │ │ │ └── route.ts │ │ ├── auth │ │ │ ├── [...nextauth] │ │ │ │ └── route.ts │ │ │ └── register │ │ │ │ └── route.ts │ │ ├── billing │ │ │ ├── check-subscription │ │ │ │ └── route.ts │ │ │ ├── portal │ │ │ │ └── route.ts │ │ │ └── subscription │ │ │ │ └── route.ts │ │ └── webhooks │ │ │ └── stripe │ │ │ └── route.ts │ ├── auth │ │ ├── register │ │ │ └── page.tsx │ │ └── signin │ │ │ └── page.tsx │ ├── billing │ │ └── page.tsx │ ├── favicon.ico │ ├── globals.css │ ├── layout.tsx │ ├── page.tsx │ └── providers.tsx ├── components │ ├── auth │ │ ├── logout-button.tsx │ │ ├── register-form.tsx │ │ ├── sign-in-button.tsx │ │ ├── signin-form.tsx │ │ └── user-menu.tsx │ ├── billing │ │ └── billing-form.tsx │ ├── editor │ │ ├── __tests__ │ │ │ └── markdown-editor.test.tsx │ │ ├── ai-toolbar.tsx │ │ └── markdown-editor.tsx │ ├── preview │ │ ├── __tests__ │ │ │ └── slide-preview.test.tsx │ │ └── slide-preview.tsx │ ├── slidemaker.tsx │ └── ui │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── dialog.tsx │ │ ├── dropdown-menu.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── select.tsx │ │ └── textarea.tsx ├── lib │ ├── api │ │ └── response.ts │ ├── auth.ts │ ├── constants │ │ └── example-slides.ts │ ├── env.ts │ ├── env │ │ ├── index.ts │ │ └── schema.ts │ ├── openai.ts │ ├── prisma.ts │ ├── stripe.ts │ ├── subscription-utils.ts │ ├── subscription.ts │ ├── utils.ts │ └── utils │ │ ├── exports.ts │ │ └── styles.ts ├── middleware.ts └── types │ ├── jest.d.ts │ ├── next-auth.d.ts │ └── theme.ts ├── tailwind.config.ts └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # App 2 | NEXT_PUBLIC_APP_URL="http://localhost:3000" 3 | 4 | # Database 5 | DATABASE_URL="postgresql://user:password@host:port/database?pgbouncer=true&connection_limit=1" 6 | DIRECT_URL="postgresql://user:password@host:port/database" 7 | 8 | # Auth 9 | NEXTAUTH_URL="http://localhost:3000" 10 | NEXTAUTH_SECRET="your-nextauth-secret" 11 | 12 | # OpenAI 13 | OPENAI_API_KEY="your-openai-api-key" 14 | 15 | # Stripe 16 | STRIPE_SECRET_KEY="your-stripe-secret-key" 17 | STRIPE_WEBHOOK_SECRET="your-stripe-webhook-secret" 18 | STRIPE_PRICE_ID="your-stripe-price-id" -------------------------------------------------------------------------------- /.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.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # local env files 34 | .env 35 | .env*.local 36 | 37 | # vercel 38 | .vercel 39 | 40 | # typescript 41 | *.tsbuildinfo 42 | next-env.d.ts 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Markdown to Slides 2 | 3 | A modern web application that converts Markdown content into beautiful presentations, with support for mathematical expressions, dark/light themes, and multiple export options. 4 | 5 | ## Features 6 | 7 | - 📝 Real-time Markdown preview with GitHub Flavored Markdown 8 | - 🎨 Dark/Light theme support with system preference detection 9 | - 📊 Mathematical expressions support via KaTeX 10 | - 🔄 Live slide preview with navigation controls 11 | - 📱 Responsive design for all screen sizes 12 | - 💾 Export options: 13 | - PDF export with high-quality rendering 14 | - PPTX export with customizable layouts 15 | - ⌨️ Code syntax highlighting 16 | - 🎯 Fullscreen presentation mode 17 | - 🔗 External link support 18 | - 📋 Table support 19 | - ✅ Task list support 20 | 21 | ## Getting Started 22 | 23 | First, install the dependencies: 24 | 25 | ```bash 26 | npm install 27 | # or 28 | yarn install 29 | # or 30 | pnpm install 31 | # or 32 | bun install 33 | ``` 34 | 35 | Then, run the development server: 36 | 37 | ```bash 38 | npm run dev 39 | # or 40 | yarn dev 41 | # or 42 | pnpm dev 43 | # or 44 | bun dev 45 | ``` 46 | 47 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the application. 48 | 49 | ## Usage 50 | 51 | 1. Write your presentation content in Markdown format in the editor 52 | 2. Use `#` and `##` for slide titles and subtitles 53 | 3. Use `---` to create new slides 54 | 4. Preview your slides in real-time 55 | 5. Export to PDF or PPTX when ready 56 | 57 | ## Markdown Features 58 | 59 | ### Basic Syntax 60 | - Headers (H1-H6) 61 | - Lists (ordered and unordered) 62 | - Code blocks with syntax highlighting 63 | - Tables 64 | - Images 65 | - Links 66 | - Blockquotes 67 | 68 | ### Extended Features 69 | - Task lists `- [ ]` and `- [x]` 70 | - Mathematical expressions (KaTeX) 71 | - Inline: `$E = mc^2$` 72 | - Block: `$$\int_0^\infty e^{-x^2} dx = \frac{\sqrt{\pi}}{2}$$` 73 | - GitHub Flavored Markdown (GFM) 74 | - External links with custom styling 75 | 76 | ## Tech Stack 77 | 78 | ### Core 79 | - [Next.js 15](https://nextjs.org/) - React framework with App Router 80 | - [React 19](https://react.dev/) - UI framework 81 | - [TypeScript 5](https://www.typescriptlang.org/) - Type safety 82 | 83 | ### UI & Styling 84 | - [Tailwind CSS](https://tailwindcss.com/) - Utility-first CSS 85 | - [shadcn/ui](https://ui.shadcn.com/) - Accessible UI components 86 | - [Lucide React](https://lucide.dev/) - Modern icon set 87 | - [next-themes](https://github.com/pacocoursey/next-themes) - Theme management 88 | 89 | ### Markdown Processing 90 | - [React Markdown](https://github.com/remarkjs/react-markdown) - Markdown rendering 91 | - [remark-gfm](https://github.com/remarkjs/remark-gfm) - GitHub Flavored Markdown 92 | - [remark-math](https://github.com/remarkjs/remark-math) - Math expressions 93 | - [rehype-katex](https://github.com/remarkjs/rehype-katex) - KaTeX rendering 94 | - [rehype-raw](https://github.com/rehypejs/rehype-raw) - Raw HTML support 95 | - [rehype-sanitize](https://github.com/rehypejs/rehype-sanitize) - Security 96 | - [KaTeX](https://katex.org/) - Math typesetting 97 | 98 | ### Export Capabilities 99 | - [html2pdf.js](https://github.com/eKoopmans/html2pdf.js) - PDF generation 100 | - [pptxgenjs](https://github.com/gitbrent/PptxGenJS) - PowerPoint export 101 | 102 | ### Development Tools 103 | - [ESLint 9](https://eslint.org/) - Code linting 104 | - [Turbopack](https://turbo.build/pack) - Development server 105 | - [PostCSS](https://postcss.org/) - CSS processing 106 | 107 | ## Development 108 | 109 | ### Project Structure 110 | ``` 111 | src/ 112 | ├── app/ # Next.js app router 113 | ├── components/ # React components 114 | ├── lib/ # Utilities and constants 115 | ``` 116 | 117 | ### Scripts 118 | - `dev` - Start development server with Turbopack 119 | - `build` - Create production build 120 | - `start` - Start production server 121 | - `lint` - Run ESLint 122 | 123 | ## Contributing 124 | 125 | Contributions are welcome! Please feel free to submit issues and pull requests. 126 | 127 | ## License 128 | 129 | This project is open-source and available under the MIT License. 130 | 131 | ## Acknowledgments 132 | 133 | Developed with ❤️ by ZTABS 134 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript"), 14 | ]; 15 | 16 | export default eslintConfig; 17 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const nextJest = require('next/jest') 2 | 3 | const createJestConfig = nextJest({ 4 | dir: './', 5 | }) 6 | 7 | const customJestConfig = { 8 | setupFilesAfterEnv: ['/jest.setup.js'], 9 | testEnvironment: 'jest-environment-jsdom', 10 | moduleNameMapper: { 11 | '^@/(.*)$': '/src/$1', 12 | }, 13 | transformIgnorePatterns: [ 14 | '/node_modules/(?!(react-markdown|vfile|vfile-message|unist-.*|unified|bail|is-plain-obj|trough|remark-.*|mdast-util-.*|micromark.*|decode-named-character-reference|character-entities|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-.*)/)', 15 | ], 16 | } 17 | 18 | module.exports = createJestConfig(customJestConfig) -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom' 2 | 3 | // Extend expect matchers 4 | expect.extend({ 5 | ...require('@testing-library/jest-dom/matchers') 6 | }) -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const config: NextConfig = { 4 | env: { 5 | DATABASE_URL: process.env.DATABASE_URL, 6 | NEXTAUTH_URL: process.env.NEXTAUTH_URL, 7 | NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET, 8 | OPENAI_API_KEY: process.env.OPENAI_API_KEY, 9 | STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY, 10 | STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET, 11 | STRIPE_PRICE_ID: process.env.STRIPE_PRICE_ID, 12 | NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, 13 | }, 14 | }; 15 | 16 | export default config; 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "markdown-to-slides", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev --turbopack", 7 | "build": "prisma generate && next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "test": "jest", 11 | "test:watch": "jest --watch", 12 | "postinstall": "prisma generate" 13 | }, 14 | "dependencies": { 15 | "@auth/prisma-adapter": "^2.7.4", 16 | "@prisma/client": "^6.1.0", 17 | "@radix-ui/react-dialog": "^1.1.4", 18 | "@radix-ui/react-dropdown-menu": "^2.1.4", 19 | "@radix-ui/react-label": "^2.1.1", 20 | "@radix-ui/react-select": "^2.1.4", 21 | "@radix-ui/react-slot": "^1.1.1", 22 | "@stripe/stripe-js": "^5.4.0", 23 | "@types/bcryptjs": "^2.4.6", 24 | "bcryptjs": "^2.4.3", 25 | "class-variance-authority": "^0.7.1", 26 | "clsx": "^2.1.1", 27 | "html2pdf.js": "^0.10.2", 28 | "katex": "^0.16.18", 29 | "lucide-react": "^0.468.0", 30 | "next": "15.1.2", 31 | "next-auth": "^4.24.11", 32 | "next-themes": "^0.4.4", 33 | "openai": "^4.77.0", 34 | "pptxgenjs": "^3.12.0", 35 | "react": "^19.0.0", 36 | "react-dom": "^19.0.0", 37 | "react-markdown": "^9.0.1", 38 | "rehype-external-links": "^3.0.0", 39 | "rehype-katex": "^7.0.1", 40 | "rehype-raw": "^7.0.0", 41 | "rehype-sanitize": "^6.0.0", 42 | "remark-gfm": "^4.0.0", 43 | "remark-math": "^6.0.0", 44 | "sonner": "^1.7.1", 45 | "stripe": "^17.5.0", 46 | "tailwind-merge": "^2.5.5", 47 | "tailwindcss-animate": "^1.0.7", 48 | "zod": "^3.24.1" 49 | }, 50 | "devDependencies": { 51 | "@eslint/eslintrc": "^3", 52 | "@shadcn/ui": "^0.0.4", 53 | "@testing-library/jest-dom": "^6.6.3", 54 | "@testing-library/react": "^16.1.0", 55 | "@testing-library/user-event": "^14.5.2", 56 | "@types/jest": "^29.5.14", 57 | "@types/node": "^20", 58 | "@types/react": "^19", 59 | "@types/react-dom": "^19", 60 | "eslint": "^9", 61 | "eslint-config-next": "15.1.2", 62 | "jest": "^29.7.0", 63 | "jest-environment-jsdom": "^29.7.0", 64 | "postcss": "^8", 65 | "prisma": "^6.1.0", 66 | "tailwindcss": "^3.4.1", 67 | "typescript": "^5" 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "postgresql" 10 | url = env("DATABASE_URL") 11 | directUrl = env("DIRECT_URL") 12 | } 13 | 14 | model Account { 15 | id String @id @default(cuid()) 16 | userId String 17 | type String 18 | provider String 19 | providerAccountId String 20 | refresh_token String? @db.Text 21 | access_token String? @db.Text 22 | expires_at Int? 23 | token_type String? 24 | scope String? 25 | id_token String? @db.Text 26 | session_state String? 27 | 28 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 29 | 30 | @@unique([provider, providerAccountId]) 31 | } 32 | 33 | model Session { 34 | id String @id @default(cuid()) 35 | sessionToken String @unique 36 | userId String 37 | expires DateTime 38 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 39 | } 40 | 41 | model User { 42 | id String @id @default(cuid()) 43 | name String? 44 | email String @unique 45 | password String @db.Text 46 | emailVerified DateTime? 47 | image String? 48 | accounts Account[] 49 | sessions Session[] 50 | slides Slide[] 51 | createdAt DateTime @default(now()) 52 | updatedAt DateTime @updatedAt 53 | 54 | // Subscription fields 55 | stripeCustomerId String? @unique 56 | stripeSubscriptionId String? @unique 57 | stripePriceId String? 58 | stripeCurrentPeriodEnd DateTime? 59 | } 60 | 61 | model VerificationToken { 62 | identifier String 63 | token String @unique 64 | expires DateTime 65 | 66 | @@unique([identifier, token]) 67 | } 68 | 69 | model Slide { 70 | id String @id @default(cuid()) 71 | title String 72 | content String @db.Text 73 | userId String 74 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 75 | createdAt DateTime @default(now()) 76 | updatedAt DateTime @updatedAt 77 | } -------------------------------------------------------------------------------- /public/file.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/globe.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/window.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/app/api/ai/enhance/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server" 2 | import { getServerSession } from "next-auth" 3 | import { authOptions } from "@/lib/auth" 4 | import { prisma } from "@/lib/prisma" 5 | import OpenAI from "openai" 6 | 7 | const openai = new OpenAI({ 8 | apiKey: process.env.OPENAI_API_KEY, 9 | }) 10 | 11 | export async function POST(req: Request) { 12 | try { 13 | const session = await getServerSession(authOptions) 14 | 15 | if (!session?.user?.id) { 16 | return new NextResponse("Unauthorized", { status: 401 }) 17 | } 18 | 19 | // Check subscription status 20 | const user = await prisma.user.findUnique({ 21 | where: { 22 | id: session.user.id, 23 | }, 24 | select: { 25 | stripeSubscriptionId: true, 26 | stripeCurrentPeriodEnd: true, 27 | }, 28 | }) 29 | 30 | if (!user?.stripeSubscriptionId || !user?.stripeCurrentPeriodEnd) { 31 | return new NextResponse( 32 | "You need a Pro subscription to use AI features", 33 | { status: 403 } 34 | ) 35 | } 36 | 37 | const isSubscriptionActive = user.stripeCurrentPeriodEnd.getTime() > Date.now() 38 | 39 | if (!isSubscriptionActive) { 40 | return new NextResponse( 41 | "Your subscription has expired. Please renew to use AI features", 42 | { status: 403 } 43 | ) 44 | } 45 | 46 | const { markdown } = await req.json() 47 | 48 | if (!markdown) { 49 | return new NextResponse("Missing markdown content", { status: 400 }) 50 | } 51 | 52 | const systemPrompt = `You are a presentation expert that enhances markdown slides. 53 | Follow these guidelines: 54 | 1. Improve clarity and conciseness of content 55 | 2. Add engaging examples and analogies 56 | 3. Enhance formatting using markdown features 57 | 4. Ensure consistent styling across slides 58 | 5. Add or improve speaker notes 59 | 6. Keep the same basic structure and number of slides 60 | 7. Maintain all existing code blocks and examples 61 | 8. Use bold (**) and italic (*) for emphasis 62 | 9. Add relevant emojis where appropriate 63 | 10. Keep the content professional and focused` 64 | 65 | const userPrompt = `Enhance the following markdown slides while maintaining their structure:\n\n${markdown}\n\nMake the content more engaging and professional while keeping the same basic format.` 66 | 67 | const response = await openai.chat.completions.create({ 68 | model: "gpt-4", 69 | messages: [ 70 | { role: "system", content: systemPrompt }, 71 | { role: "user", content: userPrompt } 72 | ], 73 | temperature: 0.7, 74 | max_tokens: 2000, 75 | }) 76 | 77 | const enhancedMarkdown = response.choices[0].message.content 78 | 79 | return NextResponse.json({ markdown: enhancedMarkdown }) 80 | } catch (error) { 81 | console.error("[AI_ENHANCE_ERROR]", error) 82 | return new NextResponse("Internal Error", { status: 500 }) 83 | } 84 | } -------------------------------------------------------------------------------- /src/app/api/ai/generate/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server" 2 | import { getServerSession } from "next-auth" 3 | import { authOptions } from "@/lib/auth" 4 | import { prisma } from "@/lib/prisma" 5 | import OpenAI from "openai" 6 | 7 | const openai = new OpenAI({ 8 | apiKey: process.env.OPENAI_API_KEY, 9 | }) 10 | 11 | export async function POST(req: Request) { 12 | try { 13 | const session = await getServerSession(authOptions) 14 | 15 | if (!session?.user?.id) { 16 | return new NextResponse("Unauthorized", { status: 401 }) 17 | } 18 | 19 | // Check subscription status 20 | const user = await prisma.user.findUnique({ 21 | where: { 22 | id: session.user.id, 23 | }, 24 | select: { 25 | stripeSubscriptionId: true, 26 | stripeCurrentPeriodEnd: true, 27 | }, 28 | }) 29 | 30 | if (!user?.stripeSubscriptionId || !user?.stripeCurrentPeriodEnd) { 31 | return new NextResponse( 32 | "You need a Pro subscription to use AI features", 33 | { status: 403 } 34 | ) 35 | } 36 | 37 | const isSubscriptionActive = user.stripeCurrentPeriodEnd.getTime() > Date.now() 38 | 39 | if (!isSubscriptionActive) { 40 | return new NextResponse( 41 | "Your subscription has expired. Please renew to use AI features", 42 | { status: 403 } 43 | ) 44 | } 45 | 46 | const { topic, prompt } = await req.json() 47 | 48 | if (!topic || !prompt) { 49 | return new NextResponse("Missing required fields", { status: 400 }) 50 | } 51 | 52 | const systemPrompt = `You are a presentation expert that creates well-structured markdown content for slides. 53 | Follow these guidelines: 54 | 1. Use # for slide titles (one # per slide) 55 | 2. Keep each slide focused and concise 56 | 3. Use bullet points (- or *) for lists 57 | 4. Include code blocks with proper language tags when showing code 58 | 5. Use --- to separate slides 59 | 6. Include speaker notes after each slide using > for blockquotes 60 | 7. Aim for 5-8 slides total 61 | 8. Start with an introduction slide and end with a summary/conclusion slide 62 | 9. Use bold (**) and italic (*) for emphasis where appropriate 63 | 10. Include examples and practical applications where relevant` 64 | 65 | const userPrompt = `Create a presentation on: ${topic}\n\nAdditional instructions: ${prompt}\n\nGenerate markdown content formatted as slides.` 66 | 67 | const response = await openai.chat.completions.create({ 68 | model: "gpt-4", 69 | messages: [ 70 | { role: "system", content: systemPrompt }, 71 | { role: "user", content: userPrompt } 72 | ], 73 | temperature: 0.7, 74 | max_tokens: 2000, 75 | }) 76 | 77 | const markdown = response.choices[0].message.content 78 | 79 | return NextResponse.json({ markdown }) 80 | } catch (error) { 81 | console.error("[AI_GENERATE_ERROR]", error) 82 | return new NextResponse("Internal Error", { status: 500 }) 83 | } 84 | } -------------------------------------------------------------------------------- /src/app/api/ai/translate/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server" 2 | import { getServerSession } from "next-auth" 3 | import { authOptions } from "@/lib/auth" 4 | import { prisma } from "@/lib/prisma" 5 | import OpenAI from "openai" 6 | 7 | const openai = new OpenAI({ 8 | apiKey: process.env.OPENAI_API_KEY, 9 | }) 10 | 11 | export async function POST(req: Request) { 12 | try { 13 | const session = await getServerSession(authOptions) 14 | 15 | if (!session?.user?.id) { 16 | return NextResponse.json( 17 | { error: "Unauthorized" }, 18 | { status: 401 } 19 | ) 20 | } 21 | 22 | // Check subscription status 23 | const user = await prisma.user.findUnique({ 24 | where: { 25 | id: session.user.id, 26 | }, 27 | select: { 28 | stripeSubscriptionId: true, 29 | stripeCurrentPeriodEnd: true, 30 | }, 31 | }) 32 | 33 | if (!user?.stripeSubscriptionId || !user?.stripeCurrentPeriodEnd) { 34 | return NextResponse.json( 35 | { error: "You need a Pro subscription to use AI features" }, 36 | { status: 403 } 37 | ) 38 | } 39 | 40 | const isSubscriptionActive = user.stripeCurrentPeriodEnd.getTime() > Date.now() 41 | 42 | if (!isSubscriptionActive) { 43 | return NextResponse.json( 44 | { error: "Your subscription has expired. Please renew to use AI features" }, 45 | { status: 403 } 46 | ) 47 | } 48 | 49 | const body = await req.json() 50 | const { markdown, targetLanguage } = body 51 | 52 | if (!markdown || !targetLanguage) { 53 | return NextResponse.json( 54 | { error: "Missing required fields" }, 55 | { status: 400 } 56 | ) 57 | } 58 | 59 | const systemPrompt = `You are a professional translator that specializes in translating markdown presentations. 60 | Follow these guidelines: 61 | 1. Translate the content to ${targetLanguage} 62 | 2. Preserve all markdown formatting 63 | 3. Keep code blocks unchanged 64 | 4. Maintain slide separators (---) 65 | 5. Keep speaker notes format (> note) 66 | 6. Preserve emojis and special characters 67 | 7. Keep any technical terms that shouldn't be translated 68 | 8. Maintain the same structure and formatting 69 | 9. Ensure natural and fluent translation 70 | 10. Keep URLs and references unchanged` 71 | 72 | const userPrompt = `Translate the following markdown presentation to ${targetLanguage}:\n\n${markdown}` 73 | 74 | try { 75 | const response = await openai.chat.completions.create({ 76 | model: "gpt-4", 77 | messages: [ 78 | { role: "system", content: systemPrompt }, 79 | { role: "user", content: userPrompt } 80 | ], 81 | temperature: 0.3, 82 | max_tokens: 2000, 83 | }) 84 | 85 | if (!response.choices[0]?.message?.content) { 86 | return NextResponse.json( 87 | { error: "Failed to generate translation" }, 88 | { status: 500 } 89 | ) 90 | } 91 | 92 | const translatedMarkdown = response.choices[0].message.content 93 | return NextResponse.json({ markdown: translatedMarkdown }) 94 | } catch (openaiError) { 95 | console.error("[OPENAI_ERROR]", openaiError) 96 | return NextResponse.json( 97 | { error: "Failed to process translation" }, 98 | { status: 500 } 99 | ) 100 | } 101 | } catch (error) { 102 | console.error("[API_ERROR]", error) 103 | return NextResponse.json( 104 | { error: "Internal Server Error" }, 105 | { status: 500 } 106 | ) 107 | } 108 | } -------------------------------------------------------------------------------- /src/app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from "next-auth" 2 | import { authOptions } from "@/lib/auth" 3 | 4 | const handler = NextAuth(authOptions) 5 | 6 | export { handler as GET, handler as POST } -------------------------------------------------------------------------------- /src/app/api/auth/register/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server" 2 | import { PrismaClient } from "@prisma/client" 3 | import bcrypt from "bcryptjs" 4 | 5 | const prisma = new PrismaClient() 6 | 7 | export async function POST(req: Request) { 8 | try { 9 | const { name, email, password } = await req.json() 10 | 11 | if (!email || !password) { 12 | return NextResponse.json( 13 | { error: "Email and password are required" }, 14 | { status: 400 } 15 | ) 16 | } 17 | 18 | // Check if user already exists 19 | const existingUser = await prisma.user.findUnique({ 20 | where: { email } 21 | }) 22 | 23 | if (existingUser) { 24 | return NextResponse.json( 25 | { error: "User already exists" }, 26 | { status: 400 } 27 | ) 28 | } 29 | 30 | // Hash password 31 | const hashedPassword = await bcrypt.hash(password, 10) 32 | 33 | // Create user 34 | const user = await prisma.user.create({ 35 | data: { 36 | name, 37 | email, 38 | password: hashedPassword, 39 | } 40 | }) 41 | 42 | return NextResponse.json({ 43 | user: { 44 | id: user.id, 45 | name: user.name, 46 | email: user.email, 47 | } 48 | }) 49 | } catch (error) { 50 | console.error("Registration error:", error) 51 | return NextResponse.json( 52 | { error: "Error creating user" }, 53 | { status: 500 } 54 | ) 55 | } 56 | } -------------------------------------------------------------------------------- /src/app/api/billing/check-subscription/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server" 2 | import { getServerSession } from "next-auth" 3 | import { authOptions } from "@/lib/auth" 4 | import { prisma } from "@/lib/prisma" 5 | 6 | export async function GET() { 7 | try { 8 | const session = await getServerSession(authOptions) 9 | 10 | if (!session?.user?.id) { 11 | return NextResponse.json({ isSubscribed: false }) 12 | } 13 | 14 | const user = await prisma.user.findUnique({ 15 | where: { 16 | id: session.user.id, 17 | }, 18 | select: { 19 | stripeSubscriptionId: true, 20 | stripeCurrentPeriodEnd: true, 21 | }, 22 | }) 23 | 24 | const isSubscribed = !!( 25 | user?.stripeSubscriptionId && 26 | user?.stripeCurrentPeriodEnd && 27 | user.stripeCurrentPeriodEnd.getTime() > Date.now() 28 | ) 29 | 30 | return NextResponse.json({ isSubscribed }) 31 | } catch (error) { 32 | console.error("[CHECK_SUBSCRIPTION_ERROR]", error) 33 | return NextResponse.json({ isSubscribed: false }) 34 | } 35 | } -------------------------------------------------------------------------------- /src/app/api/billing/portal/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server" 2 | import { getServerSession } from "next-auth" 3 | import { authOptions } from "@/lib/auth" 4 | import { prisma } from "@/lib/prisma" 5 | import Stripe from "stripe" 6 | 7 | const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { 8 | apiVersion: "2024-12-18.acacia", 9 | }) 10 | 11 | export async function POST() { 12 | try { 13 | const session = await getServerSession(authOptions) 14 | 15 | if (!session?.user?.id) { 16 | return new NextResponse("Unauthorized", { status: 401 }) 17 | } 18 | 19 | const user = await prisma.user.findUnique({ 20 | where: { 21 | id: session.user.id 22 | } 23 | }) 24 | 25 | if (!user?.stripeCustomerId) { 26 | return new NextResponse("No customer ID found", { status: 400 }) 27 | } 28 | 29 | const portalSession = await stripe.billingPortal.sessions.create({ 30 | customer: user.stripeCustomerId, 31 | return_url: process.env.NEXT_PUBLIC_APP_URL 32 | }) 33 | 34 | return NextResponse.json({ url: portalSession.url }) 35 | } catch (error) { 36 | console.error("[BILLING_PORTAL]", error) 37 | return new NextResponse("Internal error", { status: 500 }) 38 | } 39 | } -------------------------------------------------------------------------------- /src/app/api/billing/subscription/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server" 2 | import { getServerSession } from "next-auth" 3 | import { authOptions } from "@/lib/auth" 4 | import { prisma } from "@/lib/prisma" 5 | import Stripe from "stripe" 6 | 7 | const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { 8 | apiVersion: "2024-12-18.acacia", 9 | }) 10 | 11 | export async function POST() { 12 | try { 13 | const session = await getServerSession(authOptions) 14 | 15 | if (!session?.user?.id) { 16 | return new NextResponse("Unauthorized", { status: 401 }) 17 | } 18 | 19 | const user = await prisma.user.findUnique({ 20 | where: { 21 | id: session.user.id 22 | } 23 | }) 24 | 25 | if (!user) { 26 | return new NextResponse("User not found", { status: 404 }) 27 | } 28 | 29 | let customerId = user.stripeCustomerId 30 | 31 | if (!customerId) { 32 | const customer = await stripe.customers.create({ 33 | email: session.user.email!, 34 | name: session.user.name || undefined, 35 | }) 36 | customerId = customer.id 37 | 38 | await prisma.user.update({ 39 | where: { 40 | id: session.user.id 41 | }, 42 | data: { 43 | stripeCustomerId: customerId 44 | } 45 | }) 46 | } 47 | 48 | const checkoutSession = await stripe.checkout.sessions.create({ 49 | customer: customerId, 50 | line_items: [ 51 | { 52 | price: process.env.STRIPE_PRICE_ID, 53 | quantity: 1, 54 | }, 55 | ], 56 | mode: "subscription", 57 | allow_promotion_codes: true, 58 | success_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing?success=true`, 59 | cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/billing?canceled=true`, 60 | subscription_data: { 61 | metadata: { 62 | userId: session.user.id, 63 | }, 64 | }, 65 | }) 66 | 67 | return NextResponse.json({ url: checkoutSession.url }) 68 | } catch (error) { 69 | console.error("[SUBSCRIPTION]", error) 70 | return new NextResponse("Internal error", { status: 500 }) 71 | } 72 | } -------------------------------------------------------------------------------- /src/app/api/webhooks/stripe/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server" 2 | import { prisma } from "@/lib/prisma" 3 | import Stripe from "stripe" 4 | 5 | const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { 6 | apiVersion: "2024-12-18.acacia", 7 | }) 8 | 9 | const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET! 10 | 11 | export async function POST(req: Request) { 12 | const body = await req.text() 13 | const signature = req.headers.get("Stripe-Signature") 14 | 15 | if (!signature) { 16 | return new NextResponse("No signature", { status: 400 }) 17 | } 18 | 19 | let event: Stripe.Event 20 | 21 | try { 22 | event = stripe.webhooks.constructEvent(body, signature, webhookSecret) 23 | } catch (error) { 24 | console.error("[WEBHOOK_ERROR]", error) 25 | return new NextResponse("Invalid signature", { status: 400 }) 26 | } 27 | 28 | try { 29 | if (event.type === "checkout.session.completed") { 30 | const checkoutSession = event.data.object as Stripe.Checkout.Session 31 | const subscription = await stripe.subscriptions.retrieve(checkoutSession.subscription as string) 32 | 33 | await prisma.user.update({ 34 | where: { 35 | id: subscription.metadata.userId, 36 | }, 37 | data: { 38 | stripeSubscriptionId: subscription.id, 39 | stripePriceId: subscription.items.data[0].price.id, 40 | stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000), 41 | }, 42 | }) 43 | } 44 | 45 | if (event.type === "invoice.payment_succeeded") { 46 | const invoice = event.data.object as Stripe.Invoice 47 | if (invoice.subscription) { 48 | const subscription = await stripe.subscriptions.retrieve(invoice.subscription as string) 49 | 50 | await prisma.user.update({ 51 | where: { 52 | stripeSubscriptionId: subscription.id, 53 | }, 54 | data: { 55 | stripePriceId: subscription.items.data[0].price.id, 56 | stripeCurrentPeriodEnd: new Date(subscription.current_period_end * 1000), 57 | }, 58 | }) 59 | } 60 | } 61 | 62 | return new NextResponse(null, { status: 200 }) 63 | } catch (error) { 64 | console.error("[WEBHOOK_ERROR]", error) 65 | return new NextResponse("Webhook handler failed", { status: 500 }) 66 | } 67 | } -------------------------------------------------------------------------------- /src/app/auth/register/page.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from "react" 2 | import { RegisterForm } from "@/components/auth/register-form" 3 | 4 | export default function RegisterPage() { 5 | return ( 6 |
7 | 8 | 9 | 10 |
11 | ) 12 | } -------------------------------------------------------------------------------- /src/app/auth/signin/page.tsx: -------------------------------------------------------------------------------- 1 | import { Suspense } from "react" 2 | import { SignInForm } from "@/components/auth/signin-form" 3 | 4 | export default function SignInPage() { 5 | return ( 6 |
7 | 8 | 9 | 10 |
11 | ) 12 | } -------------------------------------------------------------------------------- /src/app/billing/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation" 2 | import { getServerSession } from "next-auth" 3 | import { authOptions } from "@/lib/auth" 4 | import { BillingForm } from "@/components/billing/billing-form" 5 | import { prisma } from "@/lib/prisma" 6 | 7 | export default async function BillingPage() { 8 | const session = await getServerSession(authOptions) 9 | 10 | if (!session?.user?.id) { 11 | redirect("/auth/signin") 12 | } 13 | 14 | const user = await prisma.user.findUnique({ 15 | where: { 16 | id: session.user.id 17 | } 18 | }) 19 | 20 | if (!user) { 21 | redirect("/auth/signin") 22 | } 23 | 24 | return ( 25 |
26 | 30 |
31 | ) 32 | } -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ztabs-official/markdown-to-slides/bd49615e32dcb9c60df4c2f039ce3ee6c2afbda6/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, "Helvetica Neue", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 7 | } 8 | 9 | @layer base { 10 | :root { 11 | --background: 0 0% 100%; 12 | --foreground: 0 0% 3.9%; 13 | --card: 0 0% 100%; 14 | --card-foreground: 0 0% 3.9%; 15 | --popover: 0 0% 100%; 16 | --popover-foreground: 0 0% 3.9%; 17 | --primary: 0 0% 9%; 18 | --primary-foreground: 0 0% 98%; 19 | --secondary: 0 0% 96.1%; 20 | --secondary-foreground: 0 0% 9%; 21 | --muted: 0 0% 96.1%; 22 | --muted-foreground: 0 0% 45.1%; 23 | --accent: 0 0% 96.1%; 24 | --accent-foreground: 0 0% 9%; 25 | --destructive: 0 84.2% 60.2%; 26 | --destructive-foreground: 0 0% 98%; 27 | --border: 0 0% 89.8%; 28 | --input: 0 0% 89.8%; 29 | --ring: 0 0% 3.9%; 30 | --chart-1: 12 76% 61%; 31 | --chart-2: 173 58% 39%; 32 | --chart-3: 197 37% 24%; 33 | --chart-4: 43 74% 66%; 34 | --chart-5: 27 87% 67%; 35 | --radius: 0.5rem; 36 | } 37 | .dark { 38 | --background: 0 0% 3.9%; 39 | --foreground: 0 0% 98%; 40 | --card: 0 0% 3.9%; 41 | --card-foreground: 0 0% 98%; 42 | --popover: 0 0% 3.9%; 43 | --popover-foreground: 0 0% 98%; 44 | --primary: 0 0% 98%; 45 | --primary-foreground: 0 0% 9%; 46 | --secondary: 0 0% 14.9%; 47 | --secondary-foreground: 0 0% 98%; 48 | --muted: 0 0% 14.9%; 49 | --muted-foreground: 0 0% 63.9%; 50 | --accent: 0 0% 14.9%; 51 | --accent-foreground: 0 0% 98%; 52 | --destructive: 0 62.8% 30.6%; 53 | --destructive-foreground: 0 0% 98%; 54 | --border: 0 0% 14.9%; 55 | --input: 0 0% 14.9%; 56 | --ring: 0 0% 83.1%; 57 | --chart-1: 220 70% 50%; 58 | --chart-2: 160 60% 45%; 59 | --chart-3: 30 80% 55%; 60 | --chart-4: 280 65% 60%; 61 | --chart-5: 340 75% 55%; 62 | } 63 | } 64 | 65 | @layer base { 66 | * { 67 | @apply border-border; 68 | } 69 | body { 70 | @apply bg-background text-foreground; 71 | } 72 | } 73 | 74 | /* Enhanced Typography for Markdown */ 75 | .prose { 76 | @apply max-w-none; 77 | } 78 | 79 | .prose h1 { 80 | @apply text-4xl font-bold mb-6 bg-gradient-to-r from-primary to-primary/70 bg-clip-text; 81 | } 82 | 83 | .prose h2 { 84 | @apply text-3xl font-semibold mb-4 text-primary/90; 85 | } 86 | 87 | .prose h3 { 88 | @apply text-2xl font-medium mb-3 text-primary/80; 89 | } 90 | 91 | .prose p { 92 | @apply text-lg leading-relaxed mb-4; 93 | } 94 | 95 | .prose ul { 96 | @apply space-y-2 my-4; 97 | } 98 | 99 | .prose li { 100 | @apply text-lg leading-relaxed; 101 | } 102 | 103 | .prose code { 104 | @apply px-2 py-1 bg-secondary text-secondary-foreground rounded-md text-sm font-mono; 105 | } 106 | 107 | .prose pre { 108 | @apply p-4 bg-secondary rounded-lg overflow-x-auto my-4; 109 | } 110 | 111 | .prose pre code { 112 | @apply bg-transparent p-0 text-sm leading-relaxed; 113 | } 114 | 115 | .prose blockquote { 116 | @apply border-l-8 border-primary/80 pl-6 my-6 bg-secondary/50 p-6 rounded-lg shadow-md; 117 | } 118 | 119 | .prose blockquote p { 120 | @apply text-xl font-medium text-foreground/90 not-italic mb-0; 121 | } 122 | 123 | .dark .prose blockquote { 124 | @apply bg-secondary/20 border-primary/60; 125 | } 126 | 127 | .prose table { 128 | @apply w-full border-collapse my-4; 129 | } 130 | 131 | .prose th { 132 | @apply bg-secondary px-4 py-2 text-left font-semibold border border-border; 133 | } 134 | 135 | .prose td { 136 | @apply px-4 py-2 border border-border; 137 | } 138 | 139 | .prose img { 140 | @apply rounded-lg shadow-lg my-4 mx-auto; 141 | } 142 | 143 | /* Dark mode adjustments */ 144 | .dark .prose { 145 | @apply text-foreground; 146 | } 147 | 148 | .dark .prose a { 149 | @apply text-blue-400; 150 | } 151 | 152 | .dark .prose code { 153 | @apply bg-secondary text-secondary-foreground; 154 | } 155 | 156 | /* Math expressions */ 157 | .katex-display { 158 | @apply my-6 overflow-x-auto overflow-y-hidden; 159 | max-width: 100%; 160 | padding: 1rem 0; 161 | display: block !important; 162 | } 163 | 164 | .katex { 165 | @apply text-current; 166 | font-size: 1.1em !important; 167 | text-rendering: auto; 168 | display: inline-block !important; 169 | } 170 | 171 | .dark .katex { 172 | @apply text-white; 173 | } 174 | 175 | .katex-html { 176 | @apply overflow-x-auto overflow-y-hidden; 177 | max-width: 100%; 178 | } 179 | 180 | .prose .math { 181 | @apply my-6 text-center overflow-x-auto; 182 | max-width: 100%; 183 | display: block; 184 | } 185 | 186 | .prose .math-inline { 187 | @apply mx-1 inline-block; 188 | } 189 | 190 | /* KaTeX specific styles */ 191 | .katex-display > .katex { 192 | @apply text-lg; 193 | display: block !important; 194 | text-align: center; 195 | white-space: nowrap; 196 | } 197 | 198 | .katex-display > .katex > .katex-html { 199 | @apply block overflow-x-auto overflow-y-hidden; 200 | padding: 0.5rem 0; 201 | } 202 | 203 | .katex-display > .katex > .katex-html > .tag { 204 | position: relative; 205 | right: 0; 206 | } 207 | 208 | /* Fix overflow issues */ 209 | .katex-display .base { 210 | margin: 0.25em 0; 211 | } 212 | 213 | .katex .mfrac .frac-line { 214 | border-color: currentColor; 215 | } 216 | 217 | /* Dark mode specific adjustments */ 218 | .dark .katex-display, 219 | .dark .katex, 220 | .dark .katex-html { 221 | @apply text-white; 222 | } 223 | 224 | /* Slide-specific styles */ 225 | .slide-content { 226 | @apply flex flex-col justify-center items-center min-h-[calc(70vh-8rem)] p-8; 227 | } 228 | 229 | .slide-content > * { 230 | @apply w-full max-w-4xl mx-auto; 231 | } 232 | 233 | /* Code block syntax highlighting */ 234 | .prose pre { 235 | @apply bg-secondary/50 backdrop-blur-sm; 236 | } 237 | 238 | /* Lists with better spacing */ 239 | .prose ul li { 240 | @apply relative pl-6 before:content-['•'] before:absolute before:left-0 before:text-primary; 241 | } 242 | 243 | .prose ol { 244 | @apply list-decimal pl-6 space-y-2; 245 | } 246 | 247 | /* Better table styles */ 248 | .prose table { 249 | @apply divide-y divide-border; 250 | } 251 | 252 | .prose thead { 253 | @apply bg-secondary; 254 | } 255 | 256 | .prose thead th { 257 | @apply px-6 py-3 text-left text-xs font-medium uppercase tracking-wider; 258 | } 259 | 260 | .prose tbody { 261 | @apply divide-y divide-border bg-card; 262 | } 263 | 264 | .prose tbody td { 265 | @apply px-6 py-4 whitespace-nowrap text-sm; 266 | } 267 | 268 | /* Links with hover effects */ 269 | .prose a { 270 | @apply relative inline-block after:content-[''] after:absolute after:w-full after:h-0.5 after:bg-primary after:bottom-0 after:left-0 after:origin-bottom-right after:scale-x-0 hover:after:scale-x-100 hover:after:origin-bottom-left after:transition-transform; 271 | } 272 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Inter } from "next/font/google"; 3 | import Link from "next/link"; 4 | import "./globals.css"; 5 | import { ThemeProvider } from "@/app/providers"; 6 | import { SignInButton } from "@/components/auth/sign-in-button"; 7 | import { UserMenu } from "@/components/auth/user-menu"; 8 | 9 | const inter = Inter({ subsets: ["latin"] }); 10 | 11 | export const metadata: Metadata = { 12 | title: "Markdown to Slides", 13 | description: "Convert markdown to beautiful slides with AI", 14 | }; 15 | 16 | export default function RootLayout({ 17 | children, 18 | }: { 19 | children: React.ReactNode 20 | }) { 21 | return ( 22 | 23 | 24 | 30 |
31 |
32 | 33 | Markdown to Slides 34 | 35 |
36 | 37 | 38 |
39 |
40 |
41 | {children} 42 |
43 | 44 | 45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from 'next' 2 | import { SlideMaker } from '@/components/slidemaker' 3 | 4 | export const metadata: Metadata = { 5 | title: 'Markdown to Slides', 6 | description: 'Convert your markdown to beautiful slides', 7 | } 8 | 9 | export default function Home() { 10 | return 11 | } 12 | -------------------------------------------------------------------------------- /src/app/providers.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { ThemeProvider as NextThemesProvider } from "next-themes" 4 | import { SessionProvider } from "next-auth/react" 5 | import { Toaster } from "sonner" 6 | 7 | export function ThemeProvider({ 8 | children, 9 | ...props 10 | }: React.ComponentProps) { 11 | return ( 12 | 13 | 18 | {children} 19 | 20 | 21 | 22 | ) 23 | } -------------------------------------------------------------------------------- /src/components/auth/logout-button.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { LogOut } from "lucide-react" 4 | 5 | export function LogoutButton() { 6 | return ( 7 |
8 | 9 | Sign Out 10 |
11 | ) 12 | } -------------------------------------------------------------------------------- /src/components/auth/register-form.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useState } from "react" 4 | import { useRouter, useSearchParams } from "next/navigation" 5 | import Link from "next/link" 6 | import { Button } from "@/components/ui/button" 7 | 8 | export function RegisterForm() { 9 | const router = useRouter() 10 | const searchParams = useSearchParams() 11 | const callbackUrl = searchParams.get("callbackUrl") || "/" 12 | const [error, setError] = useState(null) 13 | const [loading, setLoading] = useState(false) 14 | 15 | async function handleSubmit(e: React.FormEvent) { 16 | e.preventDefault() 17 | setError(null) 18 | setLoading(true) 19 | 20 | const formData = new FormData(e.currentTarget) 21 | const name = formData.get("name") as string 22 | const email = formData.get("email") as string 23 | const password = formData.get("password") as string 24 | 25 | try { 26 | const res = await fetch("/api/auth/register", { 27 | method: "POST", 28 | headers: { 29 | "Content-Type": "application/json", 30 | }, 31 | body: JSON.stringify({ 32 | name, 33 | email, 34 | password, 35 | }), 36 | }) 37 | 38 | const data = await res.json() 39 | 40 | if (!res.ok) { 41 | throw new Error(data.error || "Something went wrong") 42 | } 43 | 44 | router.push(`/auth/signin?callbackUrl=${encodeURIComponent(callbackUrl)}`) 45 | } catch (error) { 46 | setError(error instanceof Error ? error.message : "Something went wrong") 47 | } finally { 48 | setLoading(false) 49 | } 50 | } 51 | 52 | return ( 53 |
54 |
55 |

Create an Account

56 |

57 | Enter your details below to create your account 58 |

59 |
60 |
61 |
62 | 65 | 72 |
73 |
74 | 77 | 84 |
85 |
86 | 89 | 96 |
97 | {error && ( 98 |
99 | {error} 100 |
101 | )} 102 | 109 |
110 |
111 |

112 | Already have an account?{" "} 113 | 114 | Sign in 115 | 116 |

117 |
118 |
119 | ) 120 | } -------------------------------------------------------------------------------- /src/components/auth/sign-in-button.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useSession, signIn } from "next-auth/react" 4 | import { Button } from "@/components/ui/button" 5 | import { LogIn } from "lucide-react" 6 | 7 | export function SignInButton() { 8 | const { data: session } = useSession() 9 | 10 | if (session) { 11 | return null 12 | } 13 | 14 | return ( 15 | 24 | ) 25 | } -------------------------------------------------------------------------------- /src/components/auth/signin-form.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useState } from "react" 4 | import { signIn } from "next-auth/react" 5 | import { useRouter, useSearchParams } from "next/navigation" 6 | import Link from "next/link" 7 | import { Button } from "@/components/ui/button" 8 | 9 | export function SignInForm() { 10 | const router = useRouter() 11 | const searchParams = useSearchParams() 12 | const callbackUrl = searchParams.get("callbackUrl") || "/" 13 | const [loading, setLoading] = useState(false) 14 | const [errorMessage, setErrorMessage] = useState(null) 15 | 16 | async function handleSubmit(e: React.FormEvent) { 17 | e.preventDefault() 18 | setErrorMessage(null) 19 | setLoading(true) 20 | 21 | const formData = new FormData(e.currentTarget) 22 | const email = formData.get("email") as string 23 | const password = formData.get("password") as string 24 | 25 | try { 26 | const result = await signIn("credentials", { 27 | email, 28 | password, 29 | redirect: false, 30 | callbackUrl, 31 | }) 32 | 33 | if (result?.error) { 34 | setErrorMessage("Invalid email or password") 35 | } else { 36 | router.push(callbackUrl) 37 | router.refresh() 38 | } 39 | } catch { 40 | setErrorMessage("An error occurred. Please try again.") 41 | } finally { 42 | setLoading(false) 43 | } 44 | } 45 | 46 | return ( 47 |
48 |
49 |

Sign In

50 |

51 | Enter your email below to sign in to your account 52 |

53 |
54 |
55 |
56 | 59 | 66 |
67 |
68 | 71 | 78 |
79 | {errorMessage && ( 80 |
81 | {errorMessage} 82 |
83 | )} 84 | 91 |
92 |
93 |

94 | Don't have an account?{" "} 95 | 96 | Sign up 97 | 98 |

99 |
100 |
101 | ) 102 | } -------------------------------------------------------------------------------- /src/components/auth/user-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useSession, signOut } from "next-auth/react" 4 | import Link from "next/link" 5 | import Image from "next/image" 6 | import { 7 | DropdownMenu, 8 | DropdownMenuContent, 9 | DropdownMenuItem, 10 | DropdownMenuLabel, 11 | DropdownMenuSeparator, 12 | DropdownMenuTrigger, 13 | } from "@/components/ui/dropdown-menu" 14 | import { Button } from "@/components/ui/button" 15 | import { User, CreditCard } from "lucide-react" 16 | import { LogoutButton } from "./logout-button" 17 | 18 | export function UserMenu() { 19 | const { data: session } = useSession() 20 | 21 | if (!session?.user) { 22 | return null 23 | } 24 | 25 | return ( 26 | 27 | 28 | 41 | 42 | 43 | 44 |
45 |

46 | {session.user.name || "User"} 47 |

48 |

49 | {session.user.email} 50 |

51 |
52 |
53 | 54 | 55 | 56 |
57 | 58 | Billing 59 |
60 | 61 |
62 | signOut({ callbackUrl: "/auth/signin" })}> 63 | 64 | 65 |
66 |
67 | ) 68 | } -------------------------------------------------------------------------------- /src/components/billing/billing-form.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useState } from "react" 4 | import { useRouter } from "next/navigation" 5 | import { Button } from "@/components/ui/button" 6 | import { 7 | Card, 8 | CardContent, 9 | CardDescription, 10 | CardFooter, 11 | CardHeader, 12 | CardTitle, 13 | } from "@/components/ui/card" 14 | import { Sparkles } from "lucide-react" 15 | 16 | interface BillingFormProps { 17 | subscriptionId?: string | null 18 | currentPeriodEnd?: Date | null 19 | } 20 | 21 | export function BillingForm({ 22 | subscriptionId, 23 | currentPeriodEnd, 24 | }: BillingFormProps) { 25 | const router = useRouter() 26 | const [loading, setLoading] = useState(false) 27 | 28 | const isSubscribed = subscriptionId && currentPeriodEnd && currentPeriodEnd > new Date() 29 | 30 | async function handleSubscribe() { 31 | try { 32 | setLoading(true) 33 | const response = await fetch("/api/billing/subscription", { 34 | method: "POST", 35 | }) 36 | 37 | if (!response.ok) { 38 | throw new Error("Failed to create subscription") 39 | } 40 | 41 | const data = await response.json() 42 | router.push(data.url) 43 | } catch (error) { 44 | console.error("Error:", error) 45 | } finally { 46 | setLoading(false) 47 | } 48 | } 49 | 50 | async function handleManageBilling() { 51 | try { 52 | setLoading(true) 53 | const response = await fetch("/api/billing/portal", { 54 | method: "POST", 55 | }) 56 | 57 | if (!response.ok) { 58 | throw new Error("Failed to access billing portal") 59 | } 60 | 61 | const data = await response.json() 62 | router.push(data.url) 63 | } catch (error) { 64 | console.error("Error:", error) 65 | } finally { 66 | setLoading(false) 67 | } 68 | } 69 | 70 | return ( 71 | 72 | 73 | Pro Plan 74 | 75 | Get access to AI features and enhance your presentations 76 | 77 | 78 | 79 |
80 | 81 | AI-powered slide generation 82 |
83 |
84 | 85 | Smart slide enhancement suggestions 86 |
87 |
88 | 89 | Unlimited AI requests 90 |
91 |
92 |
93 | $10 94 | /month 95 |
96 |
97 |

🎉 Limited Time Offer!

98 |

Use code Launch2025 for 49% lifetime discount

99 |
100 |
101 |
102 | 103 | {isSubscribed ? ( 104 | <> 105 |
106 |
107 | Your plan renews on{" "} 108 | {currentPeriodEnd?.toLocaleDateString()} 109 |
110 | 117 |
118 | 119 | ) : ( 120 | 127 | )} 128 |
129 |
130 | ) 131 | } -------------------------------------------------------------------------------- /src/components/editor/__tests__/markdown-editor.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, fireEvent } from '@testing-library/react' 2 | import { MarkdownEditor } from '../markdown-editor' 3 | 4 | describe('MarkdownEditor', () => { 5 | it('renders with the correct title', () => { 6 | render( {}} />) 7 | expect(screen.getByText('Markdown Editor')).toBeInTheDocument() 8 | }) 9 | 10 | it('displays the provided value', () => { 11 | const testValue = '# Test Slide' 12 | render( {}} />) 13 | expect(screen.getByRole('textbox')).toHaveValue(testValue) 14 | }) 15 | 16 | it('calls onChange when text is entered', () => { 17 | const handleChange = jest.fn() 18 | render() 19 | 20 | const textarea = screen.getByRole('textbox') 21 | fireEvent.change(textarea, { target: { value: '# New Slide' } }) 22 | 23 | expect(handleChange).toHaveBeenCalledWith('# New Slide') 24 | }) 25 | 26 | it('has the correct placeholder text', () => { 27 | render( {}} />) 28 | const textarea = screen.getByRole('textbox') 29 | expect(textarea).toHaveAttribute('placeholder', expect.stringContaining('Slide 1')) 30 | }) 31 | }) -------------------------------------------------------------------------------- /src/components/editor/ai-toolbar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { useState } from "react" 4 | import { useSession } from "next-auth/react" 5 | import { Button } from "@/components/ui/button" 6 | import { 7 | Dialog, 8 | DialogContent, 9 | DialogDescription, 10 | DialogFooter, 11 | DialogHeader, 12 | DialogTitle, 13 | } from "@/components/ui/dialog" 14 | import { 15 | DropdownMenu, 16 | DropdownMenuContent, 17 | DropdownMenuItem, 18 | DropdownMenuTrigger, 19 | } from "@/components/ui/dropdown-menu" 20 | import { Sparkles, Code2, Languages, Palette, ChevronDown, Loader2 } from "lucide-react" 21 | import { Textarea } from "@/components/ui/textarea" 22 | import { Input } from "@/components/ui/input" 23 | import { Label } from "@/components/ui/label" 24 | import { SlideTheme } from "@/types/theme" 25 | import { toast } from "sonner" 26 | import { checkSubscription, showUpgradePrompt } from "@/lib/subscription-utils" 27 | 28 | interface AIToolbarProps { 29 | onMarkdownGenerated: (markdown: string) => void 30 | currentContent: string 31 | onThemeChange?: (theme: SlideTheme) => void 32 | } 33 | 34 | const LANGUAGES = [ 35 | { value: "Spanish", label: "Spanish" }, 36 | { value: "French", label: "French" }, 37 | { value: "German", label: "German" }, 38 | { value: "Italian", label: "Italian" }, 39 | { value: "Portuguese", label: "Portuguese" }, 40 | { value: "Chinese", label: "Chinese" }, 41 | { value: "Japanese", label: "Japanese" }, 42 | { value: "Korean", label: "Korean" }, 43 | ] 44 | 45 | export function AIToolbar({ onMarkdownGenerated, currentContent, onThemeChange }: AIToolbarProps) { 46 | const { data: session } = useSession() 47 | const [prompt, setPrompt] = useState("") 48 | const [topic, setTopic] = useState("") 49 | const [loading, setLoading] = useState(false) 50 | const [enhanceLoading, setEnhanceLoading] = useState(false) 51 | const [translateLoading, setTranslateLoading] = useState(false) 52 | const [selectedLanguage, setSelectedLanguage] = useState("") 53 | const [themeDialogOpen, setThemeDialogOpen] = useState(false) 54 | const [generateDialogOpen, setGenerateDialogOpen] = useState(false) 55 | const [enhanceDialogOpen, setEnhanceDialogOpen] = useState(false) 56 | const [translateDialogOpen, setTranslateDialogOpen] = useState(false) 57 | const [customTheme, setCustomTheme] = useState({ 58 | id: "custom", 59 | name: "Custom Theme", 60 | styles: { 61 | background: "#ffffff", 62 | text: "#000000", 63 | heading: "#111111", 64 | code: "#1a1a1a", 65 | accent: "#0066cc", 66 | link: "#0066cc", 67 | blockquote: "#666666", 68 | }, 69 | fonts: { 70 | heading: "system-ui", 71 | body: "system-ui", 72 | code: "monospace", 73 | }, 74 | spacing: { 75 | padding: "2rem", 76 | headingMargin: "1.5rem", 77 | paragraphMargin: "1rem", 78 | listMargin: "1rem", 79 | }, 80 | }) 81 | 82 | function updateCustomTheme( 83 | field: keyof Pick, 84 | subfield: string, 85 | value: string 86 | ) { 87 | setCustomTheme(prev => ({ 88 | ...prev, 89 | [field]: { 90 | ...prev[field], 91 | [subfield]: value 92 | } 93 | })) 94 | } 95 | 96 | if (!session) { 97 | return ( 98 |
99 | 100 |

101 | Sign in to use AI features 102 |

103 |
104 | ) 105 | } 106 | 107 | // Unified function to handle pro feature actions 108 | const handleProAction = async (action: () => Promise) => { 109 | const hasSubscription = await checkSubscription() 110 | if (!hasSubscription) { 111 | showUpgradePrompt() 112 | return 113 | } 114 | await action() 115 | } 116 | 117 | async function generateSlides() { 118 | if (!prompt || !topic) return 119 | 120 | setLoading(true) 121 | try { 122 | const response = await fetch("/api/ai/generate", { 123 | method: "POST", 124 | headers: { 125 | "Content-Type": "application/json", 126 | }, 127 | body: JSON.stringify({ 128 | prompt, 129 | topic, 130 | }), 131 | }) 132 | 133 | if (!response.ok) { 134 | throw new Error("Failed to generate slides") 135 | } 136 | 137 | const data = await response.json() 138 | onMarkdownGenerated(data.markdown) 139 | setGenerateDialogOpen(false) 140 | } catch (error) { 141 | console.error("Error generating slides:", error) 142 | toast.error("Failed to generate slides") 143 | } finally { 144 | setLoading(false) 145 | } 146 | } 147 | 148 | async function enhanceSlides() { 149 | if (!currentContent.trim()) return 150 | await handleProAction(async () => { 151 | setEnhanceLoading(true) 152 | try { 153 | const response = await fetch("/api/ai/enhance", { 154 | method: "POST", 155 | headers: { 156 | "Content-Type": "application/json", 157 | }, 158 | body: JSON.stringify({ 159 | markdown: currentContent, 160 | }), 161 | }) 162 | 163 | if (!response.ok) { 164 | throw new Error("Failed to enhance slides") 165 | } 166 | 167 | const data = await response.json() 168 | onMarkdownGenerated(data.markdown) 169 | setEnhanceDialogOpen(false) 170 | } catch (error) { 171 | console.error("Error enhancing slides:", error) 172 | toast.error("Failed to enhance slides") 173 | } finally { 174 | setEnhanceLoading(false) 175 | } 176 | }) 177 | } 178 | 179 | async function translateSlides() { 180 | if (!currentContent.trim() || !selectedLanguage) return 181 | await handleProAction(async () => { 182 | setTranslateLoading(true) 183 | try { 184 | const response = await fetch("/api/ai/translate", { 185 | method: "POST", 186 | headers: { 187 | "Content-Type": "application/json", 188 | }, 189 | body: JSON.stringify({ 190 | markdown: currentContent, 191 | targetLanguage: selectedLanguage, 192 | }), 193 | }) 194 | 195 | if (!response.ok) { 196 | throw new Error("Failed to translate slides") 197 | } 198 | 199 | const data = await response.json() 200 | onMarkdownGenerated(data.markdown) 201 | setTranslateDialogOpen(false) 202 | } catch (error) { 203 | console.error("Error translating slides:", error) 204 | toast.error("Failed to translate slides") 205 | } finally { 206 | setTranslateLoading(false) 207 | } 208 | }) 209 | } 210 | 211 | async function applyTheme(theme: SlideTheme) { 212 | await handleProAction(async () => { 213 | if (onThemeChange) { 214 | onThemeChange(theme) 215 | setThemeDialogOpen(false) 216 | } 217 | }) 218 | } 219 | 220 | // Handle opening dialogs for pro features 221 | const handleFeatureClick = async (feature: string, setDialogOpen: (open: boolean) => void) => { 222 | const hasSubscription = await checkSubscription() 223 | if (!hasSubscription) { 224 | showUpgradePrompt() 225 | return 226 | } 227 | setDialogOpen(true) 228 | } 229 | 230 | return ( 231 |
232 | 250 | 251 | 252 | 253 | 256 | 257 | 258 | handleFeatureClick("enhance", setEnhanceDialogOpen)} 260 | className="gap-2" 261 | > 262 | 263 | Enhance Slides 264 | 265 | handleFeatureClick("translate", setTranslateDialogOpen)} 267 | className="gap-2" 268 | > 269 | 270 | Translate Slides 271 | 272 | handleFeatureClick("theme", setThemeDialogOpen)} 274 | className="gap-2" 275 | > 276 | 277 | Custom Theme 278 | 279 | 282 | 283 | Access to API (coming soon) 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | Generate Markdown with AI 292 | 293 | Enter a topic and prompt to generate markdown content. 294 | 295 | 296 |
297 |
298 | 299 | setTopic(e.target.value)} 303 | /> 304 |
305 |
306 | 307 |