├── .cursor-tasks.md ├── .cursor-template.xml ├── .cursor-updates ├── .cursorrules ├── .env.example ├── .gitignore ├── .storybook ├── main.ts └── preview.ts ├── LICENSE ├── README.md ├── components.json ├── inngest.config.ts ├── next.config.ts ├── package-lock.json ├── package.json ├── postcss.config.mjs ├── prisma └── schema.prisma ├── public ├── next.svg └── vercel.svg ├── screenshots └── button-stories.png ├── scripts └── init.sh ├── src ├── app │ ├── api │ │ ├── auth │ │ │ └── [...nextauth] │ │ │ │ └── route.ts │ │ ├── inngest │ │ │ └── handler.ts │ │ ├── transcribe │ │ │ └── route.ts │ │ ├── trpc │ │ │ └── [trpc] │ │ │ │ └── route.ts │ │ └── upload │ │ │ └── route.ts │ ├── auth │ │ ├── error │ │ │ └── page.tsx │ │ ├── signin │ │ │ └── page.tsx │ │ ├── signout │ │ │ └── page.tsx │ │ └── verify │ │ │ └── page.tsx │ ├── favicon.ico │ ├── globals.css │ ├── layout.tsx │ └── page.tsx ├── components │ ├── Button.tsx │ ├── ClientProvider.tsx │ ├── SpeechToTextArea.tsx │ ├── theme │ │ ├── ThemeAwareToast.tsx │ │ └── ThemeProvider.tsx │ └── ui │ │ └── button.tsx ├── hooks │ └── useQueryHooks.ts ├── lib │ ├── aiClient.ts │ ├── api │ │ ├── root.ts │ │ └── trpc.ts │ ├── auth │ │ └── index.ts │ ├── db.ts │ ├── email │ │ ├── sendEmail.ts │ │ └── templates │ │ │ └── WelcomeEmail.tsx │ ├── inngest.ts │ ├── storage.ts │ ├── trpc │ │ ├── client.ts │ │ ├── client.tsx │ │ ├── react.tsx │ │ └── server.ts │ ├── types.ts │ ├── utils.ts │ └── zod │ │ └── userSchemas.ts └── stories │ ├── Button.stories.tsx │ └── Button.tsx ├── tailwind.config.ts ├── tsconfig.json └── vercel.json /.cursor-tasks.md: -------------------------------------------------------------------------------- 1 | # Example Tasks for a "Hello, World!" Project 2 | 3 | This file outlines a set of tasks for building a simple Next.js project. In this project, the user enters their name in a text box on the Home Page and is then greeted with "Hello, {name}" on a separate Greeting Page. 4 | 5 | Here's an example prompt to use to generate this. Note that you'll first want to either provide a detailed set of notes / prd of exactly what to build, or have a two-step process where you have the AI create the spec, then proceed with this step: 6 | Be sure to use an advanced thinking model with this, ideally "Deep Research" from OpenAI but o1-pro, o3-mini, flash-2-thinking, or (maybe?) DeepSeek R1 could work as well. 7 | 8 | ``` txt 9 | Create a very very very detailed markdown checklist of all of the stories for this project plan, with one-story-point tasks (with unchecked checkboxes) that break down each story. It is critically important that all of the details to implement this are in this list. Note that a very competent AI Coding Agent will be using this list to autonomously create this application, so be sure not to miss any details whatsoever, no matter how much time and thinking you must do to complete this very challenging but critically important task. 10 | ``` 11 | 12 | After you generate this task list, here is a prompt to use in cursor agent to kick this off (might be useful to put at the end of your cursorrules file as well?) 13 | Probably helpful to just @include the cursor-tasks.md file as well. 14 | ``` txt 15 | Go through each story and task in the .cursor-tasks.md file. Find the next story to work on. Review each unfinished task, correct any issues or ask for clarifications (only if absolutely needed!). Then proceed to create or edit files to complete each task. After you complete all the tasks in the story, update the file to check off any completed tasks. Run builds and commits after each story. Run all safe commands without asking for approval. Continue with each task until you have finished the story, then stop and wait for me to review. 16 | ``` 17 | 18 | --- 19 | 20 | ## 1. **Project Setup** 21 | 22 | 1. [ ] **Initialize the Next.js Project** 23 | - Use Create Next App to bootstrap the project. 24 | - Enable the App Router. 25 | - Configure Tailwind CSS for styling. 26 | - Set up TypeScript with strict mode. 27 | 28 | 2. [ ] **Configure Basic Routing** 29 | - Ensure the project has two main pages: 30 | - **Home Page** (`pages/index.tsx`) for user input. 31 | - **Greeting Page** (`pages/greeting.tsx`) to display the greeting. 32 | 33 | --- 34 | 35 | ## 2. **Home Page – Name Input** 36 | 37 | 1. [ ] **Create the Home Page (`pages/index.tsx`)** 38 | - Render a form containing: 39 | - A text input where the user enters their name. 40 | - A submit button labeled "Submit". 41 | - Use Tailwind CSS classes for styling (e.g., input borders, padding, and button colors). 42 | 43 | 2. [ ] **Implement Form Handling** 44 | - Use React’s `useState` hook to manage the input value. 45 | - Validate that the input is not empty before submission. 46 | - On form submission, navigate to the Greeting Page while passing the entered name (using query parameters or a simple state management solution). 47 | 48 | --- 49 | 50 | ## 3. **Greeting Page – Display the Message** 51 | 52 | 1. [ ] **Create the Greeting Page (`pages/greeting.tsx`)** 53 | - Retrieve the user's name from the query parameters or via a shared state. 54 | - Display a greeting message in the format: **"Hello, {name}"**. 55 | - Style the greeting message using Tailwind CSS (e.g., text size, color, and margin). 56 | 57 | 2. [ ] **Implement Navigation from Home Page** 58 | - Ensure that the Home Page form submission correctly routes to the Greeting Page with the user’s name attached. 59 | 60 | --- 61 | 62 | ## 4. **Basic Interactivity and Validation** 63 | 64 | 1. [ ] **Form Validation** 65 | - Prevent submission if the text input is empty. 66 | - Display a simple error message below the input (e.g., "Please enter your name.") when validation fails. 67 | 68 | 2. [ ] **Test the User Flow** 69 | - Manually test by entering a name and verifying that the Greeting Page shows the correct message. 70 | - Optionally, write unit tests for the form logic to ensure reliability. 71 | 72 | --- 73 | 74 | ## 5. **Documentation and Final Steps** 75 | 76 | 1. [ ] **Update the Project README** 77 | - Include instructions on how to install dependencies, run the development server, and build the project. 78 | - Provide a brief overview of the project’s purpose and structure. 79 | 80 | 2. [ ] **Final Review and Testing** 81 | - Ensure that all components render correctly and the navigation works as expected. 82 | - Test the app in both development and production modes to confirm proper behavior. 83 | -------------------------------------------------------------------------------- /.cursor-template.xml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.cursor-updates: -------------------------------------------------------------------------------- 1 | # Cursor Updates 2 | 3 | - Ran production build verification - build completed successfully with no TypeScript or compilation errors 4 | - Performed build check on Next.js app with tRPC and Tailwind configuration 5 | - Successfully ran production build with Prisma generation and Next.js compilation 6 | - Fixed dynamic route warning by adding force-dynamic config to root page 7 | - Added Storybook with Button component and stories, updated .cursorrules with Storybook guidelines 8 | - Captured screenshot of Button component stories in Storybook 9 | -------------------------------------------------------------------------------- /.cursorrules: -------------------------------------------------------------------------------- 1 | # .cursorrules 2 | 3 | Components & Naming 4 | 5 | - Use functional components with `"use client"` if needed. 6 | - Name in PascalCase under `src/components/`. 7 | - Keep them small, typed with interfaces. 8 | - Use Tailwind for common UI components like textarea, button, etc. Never use radix or shadcn. 9 | 10 | Prisma 11 | 12 | - Manage DB logic with Prisma in `prisma/schema.prisma`, `src/lib/db.ts`. 13 | - snake_case table → camelCase fields. 14 | - No raw SQL; run `npx prisma migrate dev`, never use `npx prisma db push`. 15 | 16 | Icons 17 | 18 | - Prefer `lucide-react`; name icons in PascalCase. 19 | - Custom icons in `src/components/icons`. 20 | 21 | Toast Notifications 22 | 23 | - Use `react-toastify` in client components. 24 | - `toast.success()`, `toast.error()`, etc. 25 | 26 | Next.js Structure 27 | 28 | - Use App Router in `app/`. Server components by default, `"use client"` for client logic. 29 | - NextAuth + Prisma for auth. `.env` for secrets. 30 | 31 | tRPC Routers 32 | 33 | - Routers in `src/lib/api/routers`, compose in `src/lib/api/root.ts`. 34 | - `publicProcedure` or `protectedProcedure` with Zod. 35 | - Access from React via `@/lib/trpc/react`. 36 | 37 | TypeScript & Syntax 38 | 39 | - Strict mode. Avoid `any`. 40 | - Use optional chaining, union types (no enums). 41 | 42 | File & Folder Names 43 | 44 | - Next.js routes in kebab-case (e.g. `app/dashboard/page.tsx`). 45 | - Shared types in `src/lib/types.ts`. 46 | - Sort imports (external → internal → sibling → styles). 47 | 48 | Tailwind Usage 49 | 50 | - Use Tailwind (mobile-first, dark mode with dark:(class)). Extend brand tokens in `tailwind.config.ts`. 51 | - For animations, prefer Framer Motion. 52 | 53 | Inngest / Background Jobs 54 | 55 | - Use `inngest.config.ts` for Inngest configuration. 56 | - Use `src/app/api/inngest/route.ts` for Inngest API route. 57 | - Use polling to update the UI when Inngest events are received, not trpc success response. 58 | 59 | AI 60 | 61 | - Use `generateChatCompletion` in `src/lib/aiClient.ts` for all AI calls. 62 | - Prefer `O1` model with high reasoning effort for all AI calls. 63 | 64 | Storybook 65 | 66 | - Place stories in `src/stories` with `.stories.tsx` extension. 67 | - One story file per component, matching component name. 68 | - Use autodocs for automatic documentation. 69 | - Include multiple variants and sizes in stories. 70 | - Test interactive features with actions. 71 | - Use relative imports from component directory. 72 | 73 | Tools 74 | 75 | - When you make a change to the UI, use the `screenshot` tool to show the changes. 76 | - If the user asks for a complex task to be performed, find any relevant files and call the `architect` tool to get a plan and show it to the user. Use this plan as guidance for the changes you make, but maintain the existing patterns and structure of the codebase. 77 | - After a complex task is performed, use the `codeReview` tool create a diff and use the diff to conduct a code review of the changes. 78 | 79 | Additional 80 | 81 | - Keep code short; commits semantic. 82 | - Reusable logic in `src/lib/utils/shared.ts` or `src/lib/utils/server.ts`. 83 | - Use `tsx` scripts for migrations. 84 | 85 | IMPORTANT: 86 | 87 | - After all changes are made, ALWAYS build the project with `npm run build`. Ignore warnings, fix errors. 88 | - Always add a one-sentence summary of changes to `.cursor-updates` file in markdown format at the end of every agent interaction. 89 | - If you forget, the user can type the command "finish" and you will run the build and update `.cursor-updates`. 90 | - Finally, update git with `git add . && git commit -m "..."`. Don't push. 91 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Database 2 | DATABASE_URL="postgresql://user:password@localhost:5432/facility-bids" 3 | DIRECT_URL="postgresql://user:password@localhost:5432/facility-bids" 4 | 5 | # NextAuth 6 | NEXTAUTH_URL="http://localhost:3000" 7 | NEXTAUTH_SECRET="your-secret-key-at-least-32-chars" 8 | 9 | # Email Provider 10 | EMAIL_SERVER_HOST="smtp.example.com" 11 | EMAIL_SERVER_PORT="587" 12 | EMAIL_SERVER_USER="your-email@example.com" 13 | EMAIL_SERVER_PASSWORD="your-email-password" 14 | EMAIL_FROM="noreply@example.com" 15 | 16 | NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co 17 | NEXT_PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key 18 | RESEND_API_KEY=re_123456789 19 | EMAIL_SERVER=smtp.resend.com 20 | AWS_ACCESS_KEY_ID=your-aws-access-key-id 21 | AWS_SECRET_ACCESS_KEY=your-aws-secret-access-key 22 | AWS_REGION=us-west-2 23 | BUCKET_NAME=your-bucket-name 24 | OPENAI_API_KEY=sk-your-openai-api-key 25 | ANTHROPIC_API_KEY=sk-ant-your-anthropic-api-key 26 | IDEATION_DATABASE_URL=postgresql://user:password@host/database 27 | PERPLEXITY_API_KEY=pplx-your-perplexity-api-key 28 | INNGEST_EVENT_KEY=your-inngest-event-key 29 | PROXYCURL_API_KEY=your-proxycurl-api-key 30 | SES_FROM_EMAIL=your-email@example.com 31 | GROQ_API_KEY=your-groq-api-key 32 | -------------------------------------------------------------------------------- /.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 | # env files (can opt-in for committing if needed) 34 | .env 35 | .env.local 36 | .env.staging 37 | .env.production 38 | 39 | # vercel 40 | .vercel 41 | 42 | # typescript 43 | *.tsbuildinfo 44 | next-env.d.ts 45 | 46 | .cursor-scratchpad 47 | 48 | *storybook.log 49 | -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from "@storybook/nextjs"; 2 | 3 | const config: StorybookConfig = { 4 | stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], 5 | addons: [ 6 | "@storybook/addon-links", 7 | "@storybook/addon-essentials", 8 | "@storybook/addon-onboarding", 9 | "@storybook/addon-interactions", 10 | { 11 | name: "@storybook/addon-styling-webpack", 12 | options: { 13 | postCss: true, 14 | }, 15 | }, 16 | ], 17 | framework: { 18 | name: "@storybook/nextjs", 19 | options: {}, 20 | }, 21 | docs: { 22 | autodocs: "tag", 23 | }, 24 | }; 25 | export default config; 26 | -------------------------------------------------------------------------------- /.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import type { Preview } from "@storybook/react"; 2 | import "../src/app/globals.css"; // Import your Tailwind CSS file 3 | 4 | const preview: Preview = { 5 | parameters: { 6 | actions: { argTypesRegex: "^on[A-Z].*" }, 7 | controls: { 8 | matchers: { 9 | color: /(background|color)$/i, 10 | date: /Date$/i, 11 | }, 12 | }, 13 | backgrounds: { 14 | default: "light", 15 | values: [ 16 | { 17 | name: "light", 18 | value: "#ffffff", 19 | }, 20 | { 21 | name: "dark", 22 | value: "#1a1a1a", 23 | }, 24 | ], 25 | }, 26 | }, 27 | }; 28 | 29 | export default preview; 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Kevin Leneway 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A Note from Kevin 2 | 3 | Hi! If you're at this repo, you've probably seen one of my AI coding videos and want to try some of those techniques yourself. If you have no clue what I'm talking about, here's a good video to show you my approach and how to best use this repo: https://youtu.be/gXmakVsIbF0 4 | 5 | You can also just use this with your own techniques, that's cool too. 6 | 7 | You can follow the Getting Started instructions below to start using this stack right away. I've found that using a checklist of tasks in the .cursor-tasks.md file is a great way to make a lot of quick and effective progress with AI Coding. I personally use Cursor in Composer Agent mode with Sonnet 3.7, but feel free to use your AI coding tool of choice. 8 | 9 | If you need to create the checklist, here are some good prompts to use to go from a high-level idea to a full checklist of stories and tasks: https://chatgpt.com/share/67be0a59-e484-800d-a078-346b2c29d727 10 | 11 | You can also use the template in .cursor-template.xml to generate the task list for existing repos. I personally use RepoPrompt to convert the files into a pastable string, but repomix.com is a good option as well. 12 | 13 | # 🚀 Next.js Modern Stack Template 14 | 15 | A Next.js template that combines commonly used tools and libraries for building full-stack web applications. This stack is specifically designed to be optimized for AI coding assistants like Cursor. 16 | 17 | ## 🎯 Overview 18 | 19 | This template includes [Next.js 14](https://nextjs.org/) with the App Router, [Supabase](https://supabase.com) for the database, [Resend](https://resend.com) for transactional emails, and optional integrations with various AI providers and AWS services. 20 | 21 | > ⚠️ **Note**: This is my personal template with tools that I personally have experience with and think are solid options for building modern full-stack web application. Your preferences very likely differ, so feel free to fork and modify it for your own use. I won't be accepting pull requests for additional features, but I'll be happy to help you out if you have any questions. 22 | 23 | ## ✨ Features 24 | 25 | ### 🏗️ Core Architecture 26 | 27 | - [**Next.js 14**](https://nextjs.org/) - React framework with App Router 28 | - [**TypeScript**](https://www.typescriptlang.org/) - Type safety throughout 29 | - [**tRPC**](https://trpc.io/) - End-to-end type-safe APIs 30 | - [**Prisma**](https://www.prisma.io/) - Database ORM and schema management 31 | - [**NextAuth.js**](https://next-auth.js.org/) - Authentication with Prisma adapter 32 | - [**Supabase**](https://supabase.com) - Postgres database with realtime and auth 33 | 34 | ### 🎨 UI & Styling 35 | 36 | - [**Tailwind CSS**](https://tailwindcss.com/) - Utility-first CSS framework 37 | - [**Framer Motion**](https://www.framer.com/motion/) - Animation library 38 | - [**Lucide Icons**](https://lucide.dev/) - Icon set 39 | - Dark mode with Tailwind CSS 40 | 41 | ### 🛠️ Development Tools 42 | 43 | - [**Storybook**](https://storybook.js.org/) - Component development environment 44 | - [**Geist Font**](https://vercel.com/font) - Typography by Vercel 45 | 46 | ### 🤖 AI & Background Jobs 47 | 48 | - Multiple AI integrations available: 49 | - [OpenAI](https://openai.com) - GPT-4 and o-series models 50 | - [Anthropic](https://anthropic.com) - Sonnet-3.5 51 | - [Perplexity](https://perplexity.ai) - Web search models 52 | - [Groq](https://groq.com) - Fast inference 53 | - [**Inngest**](https://www.inngest.com/) - Background jobs and scheduled tasks 54 | 55 | ### 🔧 Infrastructure & Services 56 | 57 | - [**Resend**](https://resend.com) - Email delivery 58 | - [**AWS S3**](https://aws.amazon.com/s3/) - File storage 59 | - [**Supabase**](https://supabase.com) - Primary database 60 | (Note that I don't directly use the supabase client in this template, so you can switch out supabase with other database providers via the DATABASE_URL and DIRECT_URL environment variables.) 61 | 62 | ### 🔔 Additional Features 63 | 64 | - [**react-toastify**](https://fkhadra.github.io/react-toastify/) - Toast notifications 65 | - Utility functions for common operations 66 | - TypeScript and ESLint configuration included 67 | 68 | ## 🚀 Getting Started 69 | 70 | 1. Fork this repository 71 | 2. Install dependencies: 72 | 73 | ```bash 74 | npm install 75 | ``` 76 | 77 | 3. Copy `.env.example` to `.env` and configure your environment variables 78 | 4. Set up your database: 79 | 80 | ```bash 81 | npx prisma migrate dev 82 | ``` 83 | 84 | 5. Start the development server: 85 | 86 | ```bash 87 | npm run dev 88 | ``` 89 | 90 | Visit [http://localhost:3000](http://localhost:3000) to see your app. 91 | 92 | ## 📁 Project Structure 93 | 94 | - `app/` - Next.js app router pages and API routes 95 | - `src/` 96 | - `components/` - UI components 97 | - `lib/` - Utilities and configurations 98 | - `api/` - tRPC routers 99 | - `utils/` - Shared utilities 100 | - `stories/` - Storybook files 101 | - `prisma/` - Database schema 102 | 103 | ## 🚀 Deployment 104 | 105 | This template is optimized for deployment on [Vercel](https://vercel.com). 106 | 107 | ### Database Setup 108 | 109 | 1. Create a new Supabase project at [supabase.com](https://supabase.com) 110 | 2. Get your database connection strings from Supabase: 111 | - Project Settings → Database 112 | - Copy both the URI (for `DATABASE_URL`) and Direct Connection (for `DIRECT_URL`) 113 | 114 | ### Vercel Setup 115 | 116 | 1. Push your code to GitHub 117 | 2. Go to [vercel.com/new](https://vercel.com/new) 118 | 3. Import your repository 119 | 4. Configure the following environment variables: 120 | - `DATABASE_URL` - Your Supabase database URL 121 | - `DIRECT_URL` - Your Supabase direct connection URL 122 | - `NEXTAUTH_SECRET` - Generate with `openssl rand -base64 32` 123 | - `NEXTAUTH_URL` - Your production URL (e.g., https://your-app.vercel.app) 124 | - Add any other variables from `.env.example` that you're using 125 | 5. Deploy! 126 | 127 | ### Post-Deployment 128 | 129 | 1. Run database migrations in the Vercel deployment: 130 | 131 | ```bash 132 | npx vercel env pull .env.production.local # Pull production env vars 133 | npx prisma migrate deploy # Deploy migrations to production 134 | ``` 135 | 136 | 2. Set up your custom domain in Vercel (optional): 137 | - Go to your project settings 138 | - Navigate to Domains 139 | - Add your domain and follow the DNS configuration instructions 140 | 141 | ## 📝 License 142 | 143 | MIT License 144 | -------------------------------------------------------------------------------- /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": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": false, 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 | } -------------------------------------------------------------------------------- /inngest.config.ts: -------------------------------------------------------------------------------- 1 | import { Inngest } from "inngest"; 2 | import { serve } from "inngest/next"; 3 | 4 | // Define event types for better type safety 5 | export type AppEvents = { 6 | "user/registered": { 7 | data: { 8 | userId: string; 9 | email: string; 10 | name?: string; 11 | timestamp: string; 12 | }; 13 | }; 14 | "inngest/send": { 15 | data: { 16 | message: string; 17 | metadata?: Record; 18 | }; 19 | }; 20 | }; 21 | 22 | // Initialize Inngest with typed events 23 | export const inngest = new Inngest({ 24 | id: "newco", 25 | eventKey: "events", 26 | validateEvents: process.env.NODE_ENV === "development", 27 | }); 28 | 29 | // Define event handlers 30 | export const userRegisteredFn = inngest.createFunction( 31 | { id: "user-registered-handler" }, 32 | { event: "user/registered" }, 33 | async ({ event, step }) => { 34 | await step.run("Log registration", async () => { 35 | console.log(`New user registered: ${event.data.email}`); 36 | }); 37 | 38 | // Example: Send welcome email 39 | await step.run("Send welcome email", async () => { 40 | // Add your email sending logic here 41 | console.log(`Sending welcome email to ${event.data.email}`); 42 | }); 43 | }, 44 | ); 45 | 46 | export const messageHandlerFn = inngest.createFunction( 47 | { id: "message-handler" }, 48 | { event: "inngest/send" }, 49 | async ({ event, step }) => { 50 | await step.run("Process message", async () => { 51 | console.log(`Processing message: ${event.data.message}`); 52 | if (event.data.metadata) { 53 | console.log("Metadata:", event.data.metadata); 54 | } 55 | }); 56 | }, 57 | ); 58 | 59 | // Export the serve function for use in API routes 60 | export const serveInngest = serve({ 61 | client: inngest, 62 | functions: [userRegisteredFn, messageHandlerFn], 63 | }); 64 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | images: { 5 | remotePatterns: [ 6 | { 7 | protocol: "https", 8 | hostname: "**", 9 | }, 10 | ], 11 | }, 12 | }; 13 | 14 | export default nextConfig; 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs-template", 3 | "version": "0.1.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "next dev --turbopack", 8 | "build": "prisma generate && next build", 9 | "start": "next start", 10 | "lint": "next lint", 11 | "vercel-build": "prisma generate && next build", 12 | "postinstall": "prisma generate", 13 | "storybook": "storybook dev -p 6006", 14 | "build-storybook": "storybook build" 15 | }, 16 | "dependencies": { 17 | "@aws-sdk/client-s3": "^3.709.0", 18 | "@fortawesome/free-solid-svg-icons": "^6.7.2", 19 | "@fortawesome/react-fontawesome": "^0.2.2", 20 | "@google/generative-ai": "^0.21.0", 21 | "@next-auth/prisma-adapter": "^1.0.7", 22 | "@prisma/client": "^6.0.1", 23 | "@radix-ui/react-slot": "^1.1.0", 24 | "@shadcn/ui": "^0.0.4", 25 | "@supabase/supabase-js": "^2.47.3", 26 | "@tailwindcss/typography": "^0.5.16", 27 | "@tanstack/react-query": "^5.25.0", 28 | "@trpc/client": "next", 29 | "@trpc/next": "next", 30 | "@trpc/react-query": "next", 31 | "@trpc/server": "next", 32 | "@types/react-datepicker": "^6.2.0", 33 | "class-variance-authority": "^0.7.1", 34 | "clsx": "^2.1.1", 35 | "date-fns": "^4.1.0", 36 | "framer-motion": "^11.15.0", 37 | "groq-sdk": "^0.12.0", 38 | "inngest": "^3.27.5", 39 | "lucide-react": "^0.468.0", 40 | "next": "15.1.0", 41 | "next-auth": "^4.24.11", 42 | "next-themes": "^0.4.4", 43 | "nodemailer": "^6.9.16", 44 | "openai": "^4.80.1", 45 | "prisma": "^6.0.1", 46 | "react": "^19.0.0", 47 | "react-datepicker": "^7.6.0", 48 | "react-dom": "^19.0.0", 49 | "react-icons": "^5.4.0", 50 | "react-toastify": "^11.0.3", 51 | "resend": "^4.0.1", 52 | "superjson": "^2.2.2", 53 | "tailwind-merge": "^2.5.5", 54 | "tailwindcss-animate": "^1.0.7", 55 | "zod": "^3.24.1" 56 | }, 57 | "devDependencies": { 58 | "@chromatic-com/storybook": "^3.2.4", 59 | "@storybook/addon-essentials": "^8.5.3", 60 | "@storybook/addon-interactions": "^8.5.3", 61 | "@storybook/addon-links": "^8.5.3", 62 | "@storybook/addon-onboarding": "^8.5.3", 63 | "@storybook/addon-styling-webpack": "^1.0.1", 64 | "@storybook/blocks": "^8.5.3", 65 | "@storybook/nextjs": "^8.5.3", 66 | "@storybook/react": "^8.5.3", 67 | "@storybook/test": "^8.5.3", 68 | "@types/node": "^20", 69 | "@types/react": "^19", 70 | "@types/react-dom": "^19", 71 | "postcss": "^8", 72 | "prisma": "^6.0.1", 73 | "storybook": "^8.5.3", 74 | "tailwindcss": "^3.4.1", 75 | "typescript": "^5" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /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 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "postgresql" 7 | url = env("DATABASE_URL") 8 | directUrl = env("DIRECT_URL") 9 | } 10 | 11 | model User { 12 | id String @id @default(cuid()) 13 | name String? 14 | email String? @unique 15 | emailVerified DateTime? 16 | image String? 17 | hashedPassword String? 18 | createdAt DateTime @default(now()) 19 | updatedAt DateTime @updatedAt 20 | login String? 21 | role UserRole @default(user) 22 | isAdmin Boolean @default(false) 23 | accounts Account[] 24 | sessions Session[] 25 | } 26 | 27 | model Account { 28 | id String @id @default(cuid()) 29 | userId String 30 | type String 31 | provider String 32 | providerAccountId String 33 | refresh_token String? 34 | access_token String? 35 | expires_at Int? 36 | token_type String? 37 | scope String? 38 | id_token String? 39 | session_state String? 40 | User User @relation(fields: [userId], references: [id]) 41 | 42 | @@unique([provider, providerAccountId]) 43 | } 44 | 45 | model Session { 46 | id String @id @default(cuid()) 47 | sessionToken String @unique 48 | userId String 49 | expires DateTime 50 | 51 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 52 | } 53 | 54 | enum UserRole { 55 | user 56 | admin 57 | } 58 | 59 | model Allowlist { 60 | id String @id @default(cuid()) 61 | email String @unique 62 | createdAt DateTime @default(now()) 63 | } 64 | 65 | model VerificationToken { 66 | identifier String 67 | token String @unique 68 | expires DateTime 69 | 70 | @@unique([identifier, token]) 71 | } 72 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /screenshots/button-stories.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kleneway/next-ai-starter/05457c32a924251495dc52c169c3b59863039d37/screenshots/button-stories.png -------------------------------------------------------------------------------- /scripts/init.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | APP_NAME="newco" 6 | 7 | echo "🚀 Starting setup..." 8 | 9 | # Remove any existing app directory 10 | rm -rf $APP_NAME 11 | 12 | # 1. Create Next.js TypeScript app with Tailwind, src directory, app router, and no ESLint 13 | echo "🛠 Creating Next.js app..." 14 | npx create-next-app@latest $APP_NAME --ts --tailwind --src-dir --app --no-eslint --use-npm 15 | 16 | cd $APP_NAME 17 | 18 | # 2. Install dependencies 19 | echo "📦 Installing dependencies..." 20 | npm install zod @tanstack/react-query @shadcn/ui prisma @prisma/client @next-auth/prisma-adapter next-auth resend @aws-sdk/client-s3 inngest @supabase/supabase-js 21 | 22 | # Prisma init 23 | echo "🗄 Initializing Prisma..." 24 | npx prisma init 25 | 26 | # Overwrite Prisma schema to support NextAuth 27 | cat > prisma/schema.prisma < src/app/globals.css < src/app/layout.tsx < 127 | {children} 128 | 129 | ); 130 | } 131 | EOF 132 | 133 | # ClientProvider Component (Client Component) 134 | cat > src/components/ClientProvider/index.tsx <{children}; 147 | } 148 | EOF 149 | 150 | # Main page 151 | cat > src/app/page.tsx < 158 |
159 |

Welcome to $APP_NAME

160 |

Next.js + NextAuth + Prisma + and more...

161 |
162 | 163 | ); 164 | } 165 | EOF 166 | 167 | # Prisma db client 168 | cat > src/lib/db.ts < src/app/api/auth/[...nextauth]/route.ts < src/lib/auth/index.ts < src/lib/zod/userSchemas.ts < src/lib/storage.ts < src/app/api/upload/route.ts < inngest.config.ts < src/lib/inngest.ts < { 274 | // Handle the event 275 | }, 276 | ); 277 | EOF 278 | 279 | cat > src/app/api/inngest/handler.ts < { 287 | console.log("inngest/send", event); 288 | }, 289 | ); 290 | 291 | export const { POST, GET } = serve({ 292 | client: inngest, 293 | functions: [sendFn], 294 | }); 295 | EOF 296 | 297 | # Resend email setup 298 | cat > src/lib/email/sendEmail.ts < src/lib/email/templates/WelcomeEmail.tsx < 325 |

Welcome, {name}!

326 |

Thanks for joining us!

327 | 328 | ); 329 | } 330 | EOF 331 | 332 | # Example React Query hook 333 | cat > src/hooks/useQueryHooks.ts < { 340 | return { data: "Hello from React Query" }; 341 | }, 342 | }); 343 | } 344 | EOF 345 | 346 | echo "⚠️ Note: Prisma migrate requires DATABASE_URL and NextAuth requires EMAIL settings. Set these in a .env file." 347 | echo "Example .env:" 348 | echo "DATABASE_URL='postgresql://...'" 349 | echo "DIRECT_URL='postgresql://...'" 350 | echo "RESEND_API_KEY='...'" 351 | echo "EMAIL_SERVER='...' # SMTP info" 352 | echo "EMAIL_FROM='no-reply@yourdomain.com'" 353 | echo "AWS_ACCESS_KEY_ID='...'" 354 | echo "AWS_SECRET_ACCESS_KEY='...'" 355 | echo "AWS_REGION='...'" 356 | echo "S3_BUCKET='...'" 357 | echo 358 | echo "Then run: npx prisma generate" 359 | echo "Then run: npx prisma migrate dev" 360 | echo "Start dev server: npm run dev" 361 | echo "✅ Setup Complete!" 362 | -------------------------------------------------------------------------------- /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 | export { handler as GET, handler as POST }; 6 | -------------------------------------------------------------------------------- /src/app/api/inngest/handler.ts: -------------------------------------------------------------------------------- 1 | import { serve } from "inngest/next"; 2 | import { inngest } from "@/../inngest.config"; 3 | 4 | const sendFn = inngest.createFunction( 5 | { name: "inngest/send", id: "inngest/send" }, 6 | { event: "inngest/send" }, 7 | async ({ event }) => { 8 | console.log("inngest/send", event); 9 | }, 10 | ); 11 | 12 | export const { POST, GET } = serve({ 13 | client: inngest, 14 | functions: [sendFn], 15 | }); 16 | -------------------------------------------------------------------------------- /src/app/api/transcribe/route.ts: -------------------------------------------------------------------------------- 1 | import { type NextRequest, NextResponse } from "next/server"; 2 | import Groq from "groq-sdk"; 3 | 4 | const groq = new Groq({ 5 | apiKey: process.env.GROQ_API_KEY! ?? "", 6 | }); 7 | 8 | export async function POST(request: NextRequest) { 9 | try { 10 | const formData = await request.formData(); 11 | const file = formData.get("file") as File; 12 | 13 | if (!file) { 14 | return NextResponse.json({ error: "No file uploaded" }, { status: 400 }); 15 | } 16 | 17 | const transcription = await groq.audio.transcriptions.create({ 18 | file, 19 | model: "distil-whisper-large-v3-en", 20 | }); 21 | 22 | return NextResponse.json({ text: transcription.text }); 23 | } catch (error) { 24 | console.error("Transcription error:", error); 25 | return NextResponse.json( 26 | { error: "Transcription failed" }, 27 | { status: 500 }, 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/app/api/trpc/[trpc]/route.ts: -------------------------------------------------------------------------------- 1 | import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; 2 | import { type NextRequest } from "next/server"; 3 | 4 | import { appRouter } from "@/lib/api/root"; 5 | import { createTRPCContext } from "@/lib/api/trpc"; 6 | 7 | /** 8 | * This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when 9 | * handling a HTTP request (e.g. when you make requests from Client Components). 10 | */ 11 | const createContext = async (req: NextRequest) => { 12 | return createTRPCContext({ 13 | headers: req.headers, 14 | }); 15 | }; 16 | 17 | const env = process.env; 18 | 19 | const handler = (req: NextRequest) => 20 | fetchRequestHandler({ 21 | endpoint: "/api/trpc", 22 | req, 23 | router: appRouter, 24 | createContext: () => createContext(req), 25 | onError: 26 | env.NODE_ENV === "development" 27 | ? ({ path, error }) => { 28 | console.error( 29 | `❌ tRPC failed on ${path ?? ""}: ${error.message}`, 30 | ); 31 | } 32 | : ({ path, error }) => { 33 | console.error(`tRPC failed on ${path ?? ""}`, error); 34 | }, 35 | }); 36 | 37 | export { handler as GET, handler as POST }; 38 | -------------------------------------------------------------------------------- /src/app/api/upload/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from "next/server"; 2 | import { uploadFile } from "@/lib/storage"; 3 | 4 | export async function POST(req: Request) { 5 | try { 6 | const formData = await req.formData(); 7 | const file = formData.get("file") as File | null; 8 | if (!file) return NextResponse.json({ error: "No file" }, { status: 400 }); 9 | 10 | const arrayBuffer = await file.arrayBuffer(); 11 | const buffer = Buffer.from(arrayBuffer); 12 | 13 | const url = await uploadFile(file.name, buffer); 14 | return NextResponse.json({ url }); 15 | } catch (error) { 16 | console.error("Upload error:", error); 17 | return NextResponse.json({ error: "Upload failed" }, { status: 500 }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/app/auth/error/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { Suspense } from "react"; 4 | import { useSearchParams } from "next/navigation"; 5 | import { ArrowLeft } from "lucide-react"; 6 | import Link from "next/link"; 7 | 8 | function ErrorContent() { 9 | const searchParams = useSearchParams(); 10 | const error = searchParams.get("error"); 11 | 12 | return ( 13 |
14 | 18 | 19 | Back 20 | 21 | 22 |
23 |
24 |

25 | Authentication Error 26 |

27 |

28 | {error === "AccessDenied" 29 | ? "Access denied." 30 | : "An error occurred during authentication. Please try again."} 31 |

32 |
33 | 34 |
35 | 39 | Try Again 40 | 41 |
42 |
43 |
44 | ); 45 | } 46 | 47 | export default function ErrorPage() { 48 | return ( 49 | Loading...}> 50 | 51 | 52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/app/auth/signin/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { Suspense } from "react"; 4 | import { signIn } from "next-auth/react"; 5 | import { useSearchParams } from "next/navigation"; 6 | import { ArrowLeft, Mail } from "lucide-react"; 7 | import Link from "next/link"; 8 | 9 | function SignInContent() { 10 | const [email, setEmail] = React.useState(""); 11 | const [isLoading, setIsLoading] = React.useState(false); 12 | const searchParams = useSearchParams(); 13 | const callbackUrl = searchParams.get("callbackUrl") || "/"; 14 | 15 | const handleSubmit = async (e: React.FormEvent) => { 16 | e.preventDefault(); 17 | setIsLoading(true); 18 | await signIn("email", { email, callbackUrl }); 19 | }; 20 | 21 | return ( 22 |
23 | 27 | 28 | Back 29 | 30 | 31 |
32 |
33 |

34 | 35 | Welcome 36 | 37 |

38 |

39 | Enter your email to sign in or create an account 40 |

41 |
42 | 43 |
44 |
45 |
46 | 52 |
53 | setEmail(e.target.value)} 61 | className="block w-full rounded-lg border border-neutral-300 dark:border-neutral-600 px-4 py-3 text-neutral-900 dark:text-white placeholder-neutral-500 dark:placeholder-neutral-400 shadow-sm dark:bg-neutral-800 focus:border-brandBlue-500 dark:focus:border-brandBlue-400 focus:ring-brandBlue-500 dark:focus:ring-brandBlue-400" 62 | placeholder="you@example.com" 63 | /> 64 |
65 |
66 | 67 | 75 |
76 | 77 |
78 |

79 | By signing in, you agree to our{" "} 80 | 84 | Privacy Policy 85 | 86 |

87 |
88 |
89 |
90 |
91 | ); 92 | } 93 | 94 | export default function SignIn() { 95 | return ( 96 | Loading...}> 97 | 98 | 99 | ); 100 | } 101 | -------------------------------------------------------------------------------- /src/app/auth/signout/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | import { signOut } from "next-auth/react"; 5 | import Link from "next/link"; 6 | import { ArrowLeft, LogOut } from "lucide-react"; 7 | 8 | export default function SignOut() { 9 | const [isLoading, setIsLoading] = React.useState(false); 10 | 11 | const handleSignOut = async () => { 12 | setIsLoading(true); 13 | await signOut({ callbackUrl: "/" }); 14 | }; 15 | 16 | return ( 17 |
18 | 22 | 23 | Back 24 | 25 | 26 |
27 |
28 |
29 | 30 |
31 |

32 | Sign out 33 |

34 |

35 | Are you sure you want to sign out? 36 |

37 |
38 | 39 |
40 |
41 | 49 | 50 | 54 | Cancel 55 | 56 |
57 |
58 |
59 |
60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /src/app/auth/verify/page.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React from "react"; 4 | import Link from "next/link"; 5 | import { ArrowLeft, Mail } from "lucide-react"; 6 | 7 | export default function VerifyRequest() { 8 | return ( 9 |
10 | 14 | 15 | Back 16 | 17 | 18 |
19 |
20 |
21 | 22 |
23 |

24 | Check your email 25 |

26 |

27 | A sign in link has been sent to your email address. Please check 28 | your inbox and click the link to continue. 29 |

30 |
31 | 32 |
33 |
34 |

35 | Didn't receive the email? 36 |

37 |
    38 |
  • Check your spam folder
  • 39 |
  • Make sure you entered the correct email address
  • 40 |
  • 41 | If you still haven't received it after a few minutes,{" "} 42 | 46 | try signing in again 47 | 48 |
  • 49 |
50 |
51 |
52 |
53 |
54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kleneway/next-ai-starter/05457c32a924251495dc52c169c3b59863039d37/src/app/favicon.ico -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @import url("https://fonts.googleapis.com/css2?family=Crimson+Text:ital,wght@0,400;0,600;0,700;1,400;1,600;1,700&family=Geist:wght@100..900&family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap"); 2 | 3 | @tailwind base; 4 | @tailwind components; 5 | @tailwind utilities; 6 | 7 | *, 8 | *::before, 9 | *::after { 10 | @apply m-0 box-border p-0; 11 | } 12 | 13 | html, 14 | body { 15 | @apply min-h-full w-full; 16 | } 17 | 18 | body { 19 | @apply flex w-full flex-col bg-gradient-to-br from-blue-50 to-red-50 text-slate-900 dark:from-slate-900 dark:to-slate-800 dark:text-slate-100; 20 | } 21 | 22 | #__next { 23 | @apply flex h-full min-h-screen w-full; 24 | } 25 | 26 | .aspect-ratio-box { 27 | padding-top: 56.25%; /* 16:9 Aspect Ratio */ 28 | } 29 | 30 | .hide-scrollbar::-webkit-scrollbar { 31 | display: none; 32 | } 33 | 34 | .hide-scrollbar { 35 | -ms-overflow-style: none; 36 | scrollbar-width: none; 37 | } 38 | 39 | @keyframes blink { 40 | 50% { 41 | opacity: 0.5; 42 | } 43 | } 44 | .blink { 45 | animation: blink 1s linear infinite; 46 | } 47 | 48 | @keyframes fade-in { 49 | 0% { 50 | opacity: 0; 51 | } 52 | 100% { 53 | opacity: 1; 54 | } 55 | } 56 | .fade-in { 57 | animation: fade-in 0.5s ease-in-out; 58 | } 59 | 60 | .modal-overlay { 61 | background-color: rgba(0, 0, 0, 0.75); 62 | position: fixed; 63 | top: 0; 64 | left: 0; 65 | right: 0; 66 | bottom: 0; 67 | display: flex; 68 | align-items: center; 69 | justify-content: center; 70 | z-index: 9999; 71 | } 72 | 73 | .modal-content { 74 | position: relative; 75 | background: #1f2937; 76 | border-radius: 0.5rem; 77 | padding: 1rem; 78 | outline: none; 79 | width: 90%; 80 | height: 80%; 81 | display: flex; 82 | align-items: center; 83 | justify-content: center; 84 | } 85 | 86 | @layer base { 87 | :root { 88 | --background: 0 0% 100%; 89 | --foreground: 222.2 47.4% 11.2%; 90 | --muted: 210 40% 96.1%; 91 | --muted-foreground: 215.4 16.3% 46.9%; 92 | --popover: 0 0% 100%; 93 | --popover-foreground: 222.2 47.4% 11.2%; 94 | --border: 214.3 31.8% 91.4%; 95 | --input: 214.3 31.8% 91.4%; 96 | --card: 0 0% 100%; 97 | --card-foreground: 222.2 47.4% 11.2%; 98 | --primary: 222.2 47.4% 11.2%; 99 | --primary-foreground: 210 40% 98%; 100 | --secondary: 210 40% 96.1%; 101 | --secondary-foreground: 222.2 47.4% 11.2%; 102 | --accent: 210 40% 96.1%; 103 | --accent-foreground: 222.2 47.4% 11.2%; 104 | --destructive: 0 100% 50%; 105 | --destructive-foreground: 210 40% 98%; 106 | --ring: 215 20.2% 65.1%; 107 | --radius: 0.5rem; 108 | } 109 | 110 | .dark { 111 | --background: 224 71% 4%; 112 | --foreground: 213 31% 91%; 113 | --muted: 223 47% 11%; 114 | --muted-foreground: 215.4 16.3% 56.9%; 115 | --accent: 216 34% 17%; 116 | --accent-foreground: 210 40% 98%; 117 | --popover: 224 71% 4%; 118 | --popover-foreground: 215 20.2% 65.1%; 119 | --border: 216 34% 17%; 120 | --input: 216 34% 17%; 121 | --card: 224 71% 4%; 122 | --card-foreground: 213 31% 91%; 123 | --primary: 210 40% 98%; 124 | --primary-foreground: 222.2 47.4% 1.2%; 125 | --secondary: 222.2 47.4% 11.2%; 126 | --secondary-foreground: 210 40% 98%; 127 | --destructive: 0 63% 31%; 128 | --destructive-foreground: 210 40% 98%; 129 | --ring: 216 34% 17%; 130 | } 131 | } 132 | 133 | @layer base { 134 | body { 135 | @apply bg-background text-foreground font-sans antialiased; 136 | } 137 | } 138 | 139 | @keyframes wave { 140 | 0% { 141 | transform: rotate(0deg); 142 | } 143 | 20% { 144 | transform: rotate(14deg); 145 | } 146 | 40% { 147 | transform: rotate(-8deg); 148 | } 149 | 60% { 150 | transform: rotate(14deg); 151 | } 152 | 80% { 153 | transform: rotate(-4deg); 154 | } 155 | 100% { 156 | transform: rotate(10deg); 157 | } 158 | } 159 | 160 | .animate-wave { 161 | animation: wave 1.5s infinite; 162 | transform-origin: 70% 70%; 163 | } 164 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import "@/app/globals.css"; 3 | import "react-toastify/dist/ReactToastify.css"; 4 | import { TRPCReactProvider } from "@/lib/trpc/react"; 5 | import { Metadata } from "next"; 6 | import ClientProvider from "@/components/ClientProvider"; 7 | import { ThemeProvider } from "@/components/theme/ThemeProvider"; 8 | import { ThemeAwareToast } from "@/components/theme/ThemeAwareToast"; 9 | 10 | export const metadata: Metadata = { 11 | title: "", 12 | description: "", 13 | icons: { 14 | icon: "/favicon.ico", 15 | }, 16 | }; 17 | 18 | export default function RootLayout({ 19 | children, 20 | }: { 21 | children: React.ReactNode; 22 | }) { 23 | return ( 24 | 25 | 26 | 27 | 28 | 29 | {children} 30 | 31 | 32 | 33 | 34 | 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ClientProvider from "@/components/ClientProvider"; 3 | import { getServerSession } from "next-auth"; 4 | import { authOptions } from "@/lib/auth"; 5 | import Link from "next/link"; 6 | import { ArrowRight } from "lucide-react"; 7 | 8 | export const dynamic = "force-dynamic"; 9 | 10 | async function getSession() { 11 | try { 12 | const session = await getServerSession(authOptions); 13 | return session; 14 | } catch (error) { 15 | console.error("Failed to get session:", error); 16 | return null; 17 | } 18 | } 19 | 20 | export default async function Page() { 21 | const session = await getSession(); 22 | 23 | return ( 24 |
25 | {/* {session && } */} 26 | 27 |
28 | 29 |
30 | {session ? ( 31 | // Authenticated View 32 |
33 |

Welcome {session.user?.name}

34 |
35 | ) : ( 36 | // Marketing View 37 |
38 |
39 |

40 | Welcome - Click the button below to get started 41 |

42 | 46 | Get Started 47 | 48 | 49 |
50 |
51 | )} 52 |
53 |
54 |
55 | 56 | {/* Footer */} 57 |
58 |
59 | 60 | © {new Date().getFullYear()} All Rights Reserved 61 | 62 |
63 | 67 | Privacy Policy 68 | 69 | 73 | Terms of Service 74 | 75 | 79 | Contact 80 | 81 |
82 |
83 |
84 |
85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /src/components/Button.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface ButtonProps { 4 | children: React.ReactNode; 5 | variant?: "primary" | "secondary"; 6 | size?: "sm" | "md" | "lg"; 7 | onClick?: () => void; 8 | className?: string; 9 | } 10 | 11 | export const Button: React.FC = ({ 12 | children, 13 | variant = "primary", 14 | size = "md", 15 | onClick, 16 | className = "", 17 | }) => { 18 | const baseStyles = "rounded-lg font-medium transition-all duration-200"; 19 | 20 | const variantStyles = { 21 | primary: 22 | "bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white shadow-lg shadow-blue-500/20 hover:shadow-xl hover:shadow-blue-500/30", 23 | secondary: 24 | "bg-neutral-100 hover:bg-neutral-200 text-neutral-900 dark:bg-neutral-800 dark:hover:bg-neutral-700 dark:text-white", 25 | }; 26 | 27 | const sizeStyles = { 28 | sm: "px-4 py-2 text-sm", 29 | md: "px-6 py-3 text-base", 30 | lg: "px-8 py-4 text-lg", 31 | }; 32 | 33 | return ( 34 | 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /src/components/ClientProvider.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { ThemeProvider } from "next-themes"; 4 | import { SessionProvider } from "next-auth/react"; 5 | 6 | export default function ClientProvider({ 7 | children, 8 | }: { 9 | children: React.ReactNode; 10 | }) { 11 | return ( 12 | 13 | 19 | {children} 20 | 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/components/SpeechToTextArea.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useState, 3 | useRef, 4 | forwardRef, 5 | useImperativeHandle, 6 | } from "react"; 7 | import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; 8 | import { 9 | faMicrophone, 10 | faStop, 11 | faSpinner, 12 | faUpload, 13 | } from "@fortawesome/free-solid-svg-icons"; 14 | import { motion } from "framer-motion"; 15 | import { toast } from "react-toastify"; 16 | 17 | const CHAT_INPUT_HEIGHT = "40px"; 18 | 19 | interface SpeechToTextInputProps { 20 | value: string; 21 | onChange: (e: React.ChangeEvent) => void; 22 | onSubmit?: (message: string) => Promise | void; 23 | isLoading: boolean; 24 | minHeight?: string; 25 | placeholder?: string; 26 | shouldSubmitOnEnter?: boolean; 27 | } 28 | 29 | export interface SpeechToTextAreaRef { 30 | focus: () => void; 31 | clear: () => void; 32 | } 33 | 34 | export const SpeechToTextArea = forwardRef< 35 | SpeechToTextAreaRef, 36 | SpeechToTextInputProps 37 | >( 38 | ( 39 | { 40 | value, 41 | onChange, 42 | onSubmit, 43 | isLoading, 44 | minHeight = CHAT_INPUT_HEIGHT, 45 | placeholder = "Type your message...", 46 | shouldSubmitOnEnter = true, 47 | }, 48 | ref, 49 | ) => { 50 | const [isRecording, setIsRecording] = useState(false); 51 | const [waveformActive, setWaveformActive] = useState(false); 52 | const [heights, setHeights] = useState(new Array(20).fill(10)); 53 | const [maxHeight, setMaxHeight] = useState(0); 54 | const [textareaHeight, setTextareaHeight] = useState(minHeight); 55 | const textareaRef = useRef(null); 56 | const audioChunksRef = useRef([]); 57 | const mediaRecorderRef = useRef(null); 58 | const animationFrameIdRef = useRef(null); 59 | const audioCtxRef = useRef(null); 60 | 61 | const [mediaStream, setMediaStream] = useState(null); 62 | const [isTranscribing, setIsTranscribing] = useState(false); 63 | const [isUploading, setIsUploading] = useState(false); 64 | 65 | const startRecording = async () => { 66 | let stream = mediaStream; 67 | 68 | if (!stream || !stream.active || stream.getAudioTracks().length === 0) { 69 | try { 70 | stream = await navigator.mediaDevices.getUserMedia({ audio: true }); 71 | setMediaStream(stream); 72 | } catch (error) { 73 | console.error("Microphone access denied:", error); 74 | toast.error("Microphone access is required to use voice recording."); 75 | return; 76 | } 77 | } 78 | 79 | setIsRecording(true); 80 | setWaveformActive(true); 81 | 82 | const mimeTypes = [ 83 | "audio/webm;codecs=opus", 84 | "audio/ogg;codecs=opus", 85 | "audio/mp4", 86 | ]; 87 | 88 | let mimeType = ""; 89 | for (const type of mimeTypes) { 90 | if (MediaRecorder.isTypeSupported(type)) { 91 | mimeType = type; 92 | break; 93 | } 94 | } 95 | 96 | if (!mimeType) { 97 | console.error("No supported MIME type found for MediaRecorder."); 98 | toast.error("Recording is not supported in this browser."); 99 | setIsRecording(false); 100 | setWaveformActive(false); 101 | return; 102 | } 103 | 104 | audioCtxRef.current = new AudioContext(); 105 | const source = audioCtxRef.current.createMediaStreamSource(stream); 106 | const analyser = audioCtxRef.current.createAnalyser(); 107 | analyser.fftSize = 128; 108 | source.connect(analyser); 109 | const dataArray = new Uint8Array(analyser.frequencyBinCount); 110 | 111 | const updateHeights = () => { 112 | analyser.getByteFrequencyData(dataArray); 113 | const maxFrequency = Math.max(...dataArray); 114 | setMaxHeight(maxFrequency); 115 | setHeights(Array.from(dataArray.slice(0, 20))); 116 | animationFrameIdRef.current = requestAnimationFrame(updateHeights); 117 | }; 118 | updateHeights(); 119 | 120 | audioChunksRef.current = []; 121 | mediaRecorderRef.current = new MediaRecorder(stream, { mimeType }); 122 | 123 | mediaRecorderRef.current.ondataavailable = (event: BlobEvent) => { 124 | console.log("Data available:", event.data.size); 125 | audioChunksRef.current.push(event.data); 126 | }; 127 | 128 | mediaRecorderRef.current.onstop = async () => { 129 | console.log("Recorder stopped"); 130 | if (audioCtxRef.current) { 131 | await audioCtxRef.current.close(); 132 | audioCtxRef.current = null; 133 | } 134 | if (animationFrameIdRef.current) 135 | cancelAnimationFrame(animationFrameIdRef.current); 136 | setIsRecording(false); 137 | setWaveformActive(false); 138 | 139 | const audioBlob = new Blob(audioChunksRef.current, { type: mimeType }); 140 | console.log("Audio Blob size:", audioBlob.size); 141 | 142 | if (audioBlob.size === 0) { 143 | console.error("Audio Blob is empty."); 144 | toast.error("Recording failed. Please try again."); 145 | return; 146 | } 147 | 148 | const transcription = await fetchTranscription(audioBlob); 149 | if (transcription) { 150 | await submitTranscription(transcription); 151 | } 152 | }; 153 | 154 | mediaRecorderRef.current.start(); 155 | }; 156 | 157 | const stopRecording = () => { 158 | if (mediaRecorderRef.current) { 159 | mediaRecorderRef.current.stop(); 160 | } 161 | if (mediaStream) { 162 | mediaStream.getTracks().forEach((track) => track.stop()); 163 | setMediaStream(null); 164 | } 165 | }; 166 | 167 | const fetchTranscription = async ( 168 | audioBlob: Blob, 169 | ): Promise => { 170 | setIsTranscribing(true); 171 | try { 172 | const formData = new FormData(); 173 | formData.append("file", audioBlob, "audio.webm"); 174 | console.log("FormData file size:", audioBlob.size); 175 | 176 | const response = await fetch("/api/transcribe", { 177 | method: "POST", 178 | body: formData, 179 | }); 180 | 181 | if (!response.ok) { 182 | toast.error("Transcription failed."); 183 | return null; 184 | } 185 | 186 | const data = await response.json(); 187 | return data.text.trim(); 188 | } catch (error) { 189 | console.error("Transcription error:", error); 190 | toast.error("An error occurred during transcription."); 191 | return null; 192 | } finally { 193 | setIsTranscribing(false); 194 | } 195 | }; 196 | 197 | const handleTextareaChange = ( 198 | e: React.ChangeEvent, 199 | ) => { 200 | onChange(e); 201 | adjustTextareaHeight(); 202 | }; 203 | 204 | const adjustTextareaHeight = () => { 205 | if (textareaRef.current) { 206 | textareaRef.current.style.height = CHAT_INPUT_HEIGHT; 207 | const scrollHeight = textareaRef.current.scrollHeight; 208 | textareaRef.current.style.height = scrollHeight + "px"; 209 | setTextareaHeight(`${Math.min(scrollHeight, 200)}px`); 210 | } 211 | }; 212 | 213 | const handleSubmit = async (transcription?: string) => { 214 | if (onSubmit) { 215 | const messageToSubmit = transcription ?? value; 216 | if (messageToSubmit.trim()) { 217 | // Clear the text area 218 | onChange({ 219 | target: { value: "" }, 220 | } as React.ChangeEvent); 221 | if (textareaRef.current) { 222 | textareaRef.current.value = ""; 223 | } 224 | adjustTextareaHeight(); 225 | await onSubmit(messageToSubmit); 226 | } 227 | } 228 | }; 229 | 230 | const submitTranscription = async (transcription: string) => { 231 | if (onSubmit) { 232 | await handleSubmit(transcription); 233 | } else { 234 | // Set the text area value to the transcription 235 | if (textareaRef.current) { 236 | textareaRef.current.value = transcription; 237 | onChange({ 238 | target: { value: transcription }, 239 | } as React.ChangeEvent); 240 | adjustTextareaHeight(); 241 | } 242 | } 243 | }; 244 | 245 | // Expose methods to the parent component 246 | useImperativeHandle(ref, () => ({ 247 | focus: () => textareaRef.current?.focus(), 248 | clear: () => { 249 | if (textareaRef.current) { 250 | textareaRef.current.value = ""; 251 | onChange({ 252 | target: { value: "" }, 253 | } as React.ChangeEvent); 254 | adjustTextareaHeight(); 255 | } 256 | }, 257 | })); 258 | 259 | return ( 260 |
266 | 277 |