├── .env ├── .eslintrc.cjs ├── .gitattributes ├── .github └── workflows │ └── check.yml ├── .gitignore ├── .vscode ├── extensions.json └── settings.json ├── LICENSE ├── README.md ├── app ├── (auth) │ ├── layout.tsx │ ├── register │ │ └── page.tsx │ └── signin │ │ └── page.tsx ├── (marketing) │ ├── features.tsx │ ├── layout.tsx │ └── page.tsx ├── api │ ├── auth │ │ └── [...nextauth] │ │ │ └── route.ts │ └── ping │ │ └── route.ts ├── apple-icon.png ├── favicon.ico ├── globals.css ├── icon1.png ├── icon2.png ├── icon3.png ├── icon4.png ├── layout.tsx ├── legal │ ├── [slug] │ │ └── page.tsx │ └── layout.tsx ├── manifest.ts ├── opengraph-image.png ├── robots.txt └── sitemap.ts ├── auth.config.ts ├── auth.ts ├── bin ├── migrate.ts ├── rename └── setup ├── biome.json ├── bun.lockb ├── components.json ├── components ├── auth │ ├── external-auth-button.tsx │ └── user-auth-form.tsx ├── debug │ └── tailwind-indicator.tsx ├── icons │ ├── brand │ │ └── logo.tsx │ └── social │ │ ├── google-icon.tsx │ │ ├── index.ts │ │ └── x-icon.tsx ├── layout │ ├── analytics.tsx │ ├── footer.tsx │ ├── header.tsx │ └── header │ │ └── user-nav.tsx ├── spinner │ ├── index.ts │ ├── spinner.module.css │ └── spinner.tsx ├── theme-picker │ ├── index.tsx │ ├── theme-picker-provider.tsx │ └── theme-toggle.tsx └── ui │ ├── alert-dialog.tsx │ ├── avatar.tsx │ ├── badge.tsx │ ├── button.tsx │ ├── dropdown-menu.tsx │ ├── form.tsx │ ├── input.tsx │ ├── label.tsx │ └── sonner.tsx ├── config └── site.ts ├── content └── legal │ ├── privacy.mdx │ └── terms.mdx ├── contentlayer.config.ts ├── drizzle.config.ts ├── drizzle ├── 0000_flashy_mantis.sql ├── client.ts ├── custom-types │ └── citext.ts ├── meta │ ├── 0000_snapshot.json │ └── _journal.json └── schema.ts ├── emails └── signin-email.tsx ├── env.ts ├── hooks └── use-mounted.ts ├── lib ├── auth │ ├── http-email-provider.ts │ └── send-verification-request.tsx ├── email │ └── send-email.ts ├── utils │ ├── cls.test.ts │ ├── cls.ts │ ├── string-fns │ │ ├── get-initials.test.ts │ │ └── get-initials.ts │ └── url-fns │ │ ├── app-host.test.ts │ │ ├── app-host.ts │ │ ├── full-url.test.ts │ │ └── full-url.ts └── validations │ └── user-auth.ts ├── next.config.mjs ├── package.json ├── patches └── jsx-email+1.12.1.patch ├── postcss.config.js ├── public └── images │ └── home │ ├── hero-dark.svg │ └── hero-light.svg ├── tailwind.config.js ├── tsconfig.json ├── vercel.json └── vitest.config.ts /.env: -------------------------------------------------------------------------------- 1 | # Default values for environment variables requried to run the app should be 2 | # defined here. 3 | 4 | # These variables will be overwritten by any environment-specific .env files, 5 | # such as .env.development, and by and local .env files, such as .env.local and 6 | # .env.development.local. 7 | # 8 | # This file should NOT contain any secrets. Secrets should be placed in a 9 | # .env.local file, or in an environment-specific local .env file, such as 10 | # .env.development.local or .env.test.local. Local env files should not be 11 | # committed to source control. 12 | # 13 | # See https://nextjs.org/docs/basic-features/environment-variables 14 | 15 | # Default host for the app 16 | NEXT_PUBLIC_HOST="http://localhost:3000" 17 | 18 | # Google Analytics (for `nextjs-google-analytics`) 19 | NEXT_PUBLIC_GA_MEASUREMENT_ID="G-XXXXXXXXXX" 20 | 21 | # Database 22 | DATABASE_URL="postgresql://postgres@localhost:5432/startkit_development" 23 | 24 | # Auth.js 25 | AUTH_SECRET="changeme" 26 | 27 | # Email (https://postmarkapp.com) 28 | EMAIL_FROM="StartKit " 29 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | const { resolve } = require("node:path") 2 | 3 | const project = resolve(process.cwd(), "tsconfig.json") 4 | 5 | /** @type {import("eslint").Linter.Config} */ 6 | module.exports = { 7 | root: true, 8 | extends: [ 9 | require.resolve("@vercel/style-guide/eslint/browser"), 10 | require.resolve("@vercel/style-guide/eslint/react"), 11 | require.resolve("@vercel/style-guide/eslint/next"), 12 | require.resolve("@vercel/style-guide/eslint/node"), 13 | require.resolve("@vercel/style-guide/eslint/typescript"), 14 | "next/core-web-vitals", 15 | "plugin:tailwindcss/recommended" 16 | ], 17 | env: { 18 | node: true, 19 | browser: true 20 | }, 21 | parser: "@typescript-eslint/parser", 22 | parserOptions: { 23 | project: true 24 | }, 25 | settings: { 26 | "import/resolver": { 27 | typescript: { 28 | project 29 | } 30 | }, 31 | tailwindcss: { 32 | callees: ["className", "clsx", "cls", "cva", "cn"] 33 | } 34 | }, 35 | ignorePatterns: [ 36 | // Ignore dotfiles 37 | ".*.js", 38 | "node_modules/", 39 | "dist/" 40 | ], 41 | overrides: [ 42 | /** 43 | * Config files 44 | */ 45 | { 46 | files: ["*.config.{js,ts}"], 47 | env: { 48 | node: true 49 | }, 50 | rules: { 51 | "@typescript-eslint/no-var-requires": "off", 52 | "import/no-default-export": "off" 53 | } 54 | }, 55 | /** 56 | * Test Configuration 57 | */ 58 | { 59 | files: ["**/__tests__/**/*.{ts,tsx}", "**/*.test.{ts,tsx}"], 60 | extends: [require.resolve("@vercel/style-guide/eslint/vitest")], 61 | rules: { 62 | /** 63 | * Allow non-null assertions in tests 64 | */ 65 | "@typescript-eslint/no-non-null-assertion": "off", 66 | /** 67 | * Don't require description for disabling eslint here 68 | */ 69 | "eslint-comments/require-description": "off" 70 | } 71 | }, 72 | /** 73 | * Next.js configuration / exports 74 | */ 75 | { 76 | files: [ 77 | "app/**/page.tsx", 78 | "app/**/layout.tsx", 79 | "app/**/loading.tsx", 80 | "app/**/not-found.tsx", 81 | "app/**/*error.tsx", 82 | "app/sitemap.ts", 83 | "app/robots.ts", 84 | "app/manifest.ts" 85 | ], 86 | rules: { 87 | "import/no-default-export": "off", 88 | "import/prefer-default-export": ["error", { target: "any" }] 89 | } 90 | }, 91 | /** 92 | * JSX/TSX specific config 93 | */ 94 | { 95 | files: ["**/*.{jsx,tsx}"], 96 | rules: { 97 | "no-nested-ternary": "off" 98 | } 99 | } 100 | ], 101 | rules: { 102 | "@typescript-eslint/consistent-type-definitions": ["error", "type"], 103 | "@typescript-eslint/consistent-type-imports": [ 104 | "error", 105 | { prefer: "type-imports", fixStyle: "separate-type-imports" } 106 | ], 107 | "@typescript-eslint/explicit-function-return-type": "off", 108 | "@typescript-eslint/no-misused-promises": [ 109 | "error", 110 | { 111 | checksVoidReturn: { 112 | arguments: false, 113 | attributes: false 114 | } 115 | } 116 | ], 117 | "@typescript-eslint/no-unused-vars": [ 118 | "warn", 119 | { 120 | argsIgnorePattern: "^_", 121 | caughtErrors: "none", 122 | varsIgnorePattern: "^_" 123 | } 124 | ], 125 | "@typescript-eslint/restrict-template-expressions": [ 126 | "warn", 127 | { 128 | allowBoolean: true, 129 | allowNumber: true 130 | } 131 | ], 132 | "import/order": [ 133 | "error", 134 | { 135 | "newlines-between": "never", 136 | groups: [ 137 | ["builtin", "external", "internal"], 138 | ["sibling", "parent"], 139 | "index", 140 | "object" 141 | // "type" 142 | ], 143 | alphabetize: { 144 | order: "asc" 145 | } 146 | } 147 | ], 148 | "no-console": [ 149 | "warn", 150 | { 151 | allow: ["warn", "error"] 152 | } 153 | ], 154 | "sort-imports": [ 155 | "error", 156 | { 157 | ignoreDeclarationSort: true 158 | } 159 | ], 160 | "tailwindcss/no-custom-classname": [ 161 | "error", 162 | { 163 | cssFiles: ["app/globals.css"] 164 | } 165 | ] 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Support bun.lockb files 2 | # Be sure to set: 3 | # 4 | # git config diff.lockb.textconv bun 5 | # git config diff.lockb.binary true 6 | *.lockb binary diff=lockb 7 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: Check 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | check: 13 | runs-on: ubuntu-latest 14 | env: 15 | AUTH_SECRET: secret 16 | # DATABASE_URL: 17 | steps: 18 | - uses: actions/checkout@v4 19 | - uses: oven-sh/setup-bun@v1 20 | with: 21 | bun-version: latest 22 | - uses: actions/setup-node@v4 23 | - run: bun run setup 24 | - run: bun run check 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | # contentlayer 39 | .contentlayer 40 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "biomejs.biome", 5 | "bradlc.vscode-tailwindcss" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Filetype-specific config 3 | "[javascript]": { 4 | "editor.defaultFormatter": "biomejs.biome" 5 | }, 6 | "[javascriptreact]": { 7 | "editor.defaultFormatter": "biomejs.biome" 8 | }, 9 | "[typescript]": { 10 | "editor.defaultFormatter": "biomejs.biome" 11 | }, 12 | "[typescriptreact]": { 13 | "editor.defaultFormatter": "biomejs.biome" 14 | }, 15 | 16 | // Editor Config 17 | "editor.codeActionsOnSave": { 18 | "source.fixAll": "explicit", 19 | "source.organizeImports.biome": "explicit" 20 | }, 21 | "editor.formatOnSave": true, 22 | "editor.tabSize": 2, 23 | 24 | // Files config 25 | "files.insertFinalNewline": true, 26 | "files.trimTrailingWhitespace": true, 27 | 28 | // Typescript config 29 | "typescript.enablePromptUseWorkspaceTsdk": true, 30 | "typescript.tsdk": "node_modules/typescript/lib" 31 | } 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Matt Venables 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 | 2 | 3 | # StartKit 4 | 5 | > A sane starting point for Next.js projects on the edge. 6 | 7 | ## Features 8 | 9 | - **Edge-driven development.** 100% on the [edge](https://nextjs.org/docs/app/building-your-application/rendering/edge-and-nodejs-runtimes) by default, but easy to move off when needed. 10 | - [Next.js 14](https://nextjs.org) with the `/app` directory and API Route Handlers. 11 | - [Bun](https://bun.sh) as a package manager! 12 | - [React Server Components](https://nextjs.org/docs/app/building-your-application/rendering/server-components) by default. 13 | - [Drizzle](https://orm.drizzle.team) database ORM, configured for [PostgreSQL](https://www.postgresql.org/) and [Drizzle Kit](https://orm.drizzle.team/kit-docs/overview) 14 | - Strict, recommended [ESLint](https://eslint.org/) config using the [Vercel Style Guide](https://github.com/vercel/style-guide) for readable, safe code. 15 | - Insanely fast formatting via [Biome](https://bimoejs.dev), with additional linting. 16 | - [Contentlayer](https://contentlayer.dev) for Markdown content (using the [active fork](https://github.com/timlrx/contentlayer2)) 17 | - [Typescript](https://www.typescriptlang.org/) for a rock-solid codebase 18 | - [TailwindCSS](https://tailwindcss.com/) for utility-first CSS. 19 | - Gorgeous UI built with [Radix](https://www.radix-ui.com/) and [shadcn/ui](https://ui.shadcn.com/). 20 | - Authentication via [Next Auth](https://next-auth.js.org/) version 5. 21 | - Email via [Postmark](https://postmarkapp.com) and [jsx-email](https://jsx.email/). 22 | - The beautiful [Geist](https://vercel.com/font) typeface. 23 | - [Next Metadata API](https://beta.nextjs.org/docs/api-reference/metadata) for SEO handling, with file-system handlers. 24 | - [Vitest](https://vitest.dev) testing, optimized for Next.js 25 | - Dark Mode support (without bypassing Server Components). 26 | - Sane VSCode settings and recommended extensions. 27 | 28 | ## Starting a new project with StartKit 29 | 30 | 1. Clone this repo to your desired path: 31 | 32 | ```sh 33 | git clone git@github.com:startkit-dev/next.git my-new-project 34 | ``` 35 | 36 | 2. Initialize the project: 37 | 38 | ```sh 39 | ./bin/rename 40 | ``` 41 | 42 | This will rename `startkit` to your project name throughout the app, 43 | update your git remote to be named `startkit`, install the `.env` file, and 44 | install all of your dependencies. 45 | 46 | In the future, you'll be able to pull in the latest StartKit changes without 47 | missing a beat by running: 48 | 49 | ```sh 50 | git fetch startkit 51 | git merge startkit/main 52 | ``` 53 | 54 | Once you run `./bin/rename`, it will be safe to delete. You can also delete 55 | this section of the README. 56 | 57 | ## Getting Started 58 | 59 | When you first check out a this project, you should run the following command to get your environment all set up: 60 | 61 | ```sh 62 | bun run setup 63 | ``` 64 | 65 | ## Environment variables 66 | 67 | Environment variables are stored in `.env` files. By default the `.env` file is included in source control and contains 68 | settings and defaults to get the app running. Any secrets or local overrides of these values should be placed in a 69 | `.env.local` file, which is ignored from source control. 70 | 71 | For environment-specific environment variables, you can place the defaults in `.env.development`, and overwrite locally 72 | with `.env.development.local`. 73 | 74 | You can [read more about environment variables here](https://nextjs.org/docs/basic-features/environment-variables). 75 | 76 | ## Running the server 77 | 78 | ```bash 79 | bun run dev 80 | ``` 81 | 82 | The app will be running at [http://localhost:3000](http://localhost:3000). 83 | 84 | ## Edge by default 🚀 85 | 86 | The guiding priciple of this app is that everything should run on the [edge](https://nextjs.org/docs/app/building-your-application/rendering/edge-and-nodejs-runtimes) by default. In some scenarios, we may need to run code in a `nodejs` runtime, but those are exceptions, not the rule. 87 | 88 | All routes in the app are currently running on the `edge` runtime. This includes authentication routes which talk to our database, as well as our email-rendering logic. This has one major limitation: currently the database also can not be run locally, as our edge adapter requires it to communicate via HTTP. We feel that this limitation is acceptable to allow for cleaner code, and to allow apps to run smoothly on the edge. 89 | 90 | ## Database 91 | 92 | Drizzle is set up to use serverless PostgreSQL by default (via [Neon](https://neon.tech)), but any database will work. Simply set `DATABASE_URL` in your `.env` (or `.env.local`) file to work. 93 | 94 | You should set `DATABASE_URL` and/or `DATABASE_URL_POOLED` to your [Neon](https://neon.tech) branch URL. 95 | 96 | NOTE: The code is currently set up to connect to the database using Neon's serverless package. If you would like to run a local database, you can find instructions for connecting as if it were serverless [here](https://github.com/neondatabase/serverless/issues/33#issuecomment-1634853042). 97 | 98 | ### `bun run db` 99 | 100 | This project exposes a package.json script for accessing drizzle-kit via `bun run db `. This script handles all environment variable mapping automatically via `dotenv-cli`, so you don't have to think about it. You should always try to use this script when interacting with drizzle-kit locally. 101 | 102 | ### Making changes to the database schema 103 | 104 | Make changes to your database by modifying `lib/db/schema.ts` ([learn more](https://orm.drizzle.team/docs/sql-schema-declaration)). 105 | 106 | When prototyping changes, you can use [`db push`](https://orm.drizzle.team/kit-docs/overview): 107 | 108 | ```sh 109 | bun run db push:pg 110 | ``` 111 | 112 | When you feel comfortable with the changes, you can make a migration file by running: 113 | 114 | ```sh 115 | bun run db generate:pg 116 | ``` 117 | 118 | ### Browsing the database 119 | 120 | Drizzle offers a simple UI for inspecting the database. To launch it, run: 121 | 122 | ```sh 123 | bun run db studio 124 | ``` 125 | 126 | ## Email 127 | 128 | Email is configured to send via the amazing [Postmark](https://postmarkapp.com) email service, and uses the wonderful [jsx-email](https://jsx.email) library. 129 | 130 | Email templates live with your react code and are defined in [`./emails`](./emails). 131 | 132 | To view live previews of your email templates, you can run: 133 | 134 | ```sh 135 | bun run dev:email 136 | ``` 137 | 138 | And you will be able to visit [http://localhost:3001](http://localhost:3001) to edit your emails with live reload. 139 | 140 | ## UI components 141 | 142 | By default, this project includes the following components from [shadcn/ui](https://ui.shadcn.com/): 143 | 144 | - [Button](https://ui.shadcn.com/docs/components/button) 145 | - [Toast](https://ui.shadcn.com/docs/components/toast) 146 | 147 | To add new UI components from [shadcn/ui](https://ui.shadcn.com/), run: 148 | 149 | ```sh 150 | pnpx shadcn-ui@latest add button 151 | ``` 152 | 153 | where `button` can be any UI element from the project. 154 | 155 | ## Linting / Checking the codebase 156 | 157 | To run a full check of the codebase (type-check, lint, format check, test), run: 158 | 159 | ```sh 160 | bun run check 161 | ``` 162 | 163 | ### Linting 164 | 165 | ```sh 166 | bun run lint 167 | ``` 168 | 169 | ### Type Checking 170 | 171 | ```sh 172 | bun run type-check 173 | ``` 174 | 175 | ### Formatting with Biome 176 | 177 | ```sh 178 | bun run format 179 | ``` 180 | 181 | to check for format errors, run: 182 | 183 | ```sh 184 | bun run format:check 185 | ``` 186 | 187 | ### Testing via Vitest 188 | 189 | ```sh 190 | bun run test 191 | ``` 192 | 193 | ## Patching Packages 194 | 195 | Occasionally you need to patch a dependency. In general, you can use [patch-package](https://github.com/ds300/patch-package), but it does not yet have bun support ([See the open PR](https://github.com/ds300/patch-package/pull/490)). To patch a package, you should do he following: 196 | 197 | 1. Run `bun install --yarn` which will create a `yarn.lock` file (this will be temporary for us). 198 | 2. Modify the packages you need to patch within `node_modules`. 199 | 3. Run `bunx patch-package ` 200 | 4. Remove the `yarn.lock` file (`rm yarn.lock`) 201 | 202 | Now your packages will be patched like normal. 203 | 204 | ## ❤️ Open Source 205 | 206 | This project is MIT-licensed and is free to use and modify for your own projects. 207 | 208 | It was created by [Matt Venables](https://venabl.es). 209 | -------------------------------------------------------------------------------- /app/(auth)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from "@/auth" 2 | import { ThemePickerProvider } from "@/components/theme-picker/theme-picker-provider" 3 | import { redirect } from "next/navigation" 4 | import type { PropsWithChildren } from "react" 5 | 6 | export const runtime = "edge" 7 | 8 | async function getData() { 9 | const session = await auth() 10 | 11 | if (session) { 12 | redirect("/") 13 | } 14 | } 15 | 16 | export default async function AuthLayout({ children }: PropsWithChildren) { 17 | await getData() 18 | 19 | return ( 20 | 21 |
22 |
{children}
23 |
24 |
25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /app/(auth)/register/page.tsx: -------------------------------------------------------------------------------- 1 | import { UserAuthForm } from "@/components/auth/user-auth-form" 2 | import { Logo } from "@/components/icons/brand/logo" 3 | import { Button } from "@/components/ui/button" 4 | import { siteConfig } from "@/config/site" 5 | import Link from "next/link" 6 | 7 | export const metadata = { 8 | title: "Create an account", 9 | description: "Create an account to get started." 10 | } 11 | 12 | export default function RegisterPage() { 13 | return ( 14 |
15 |
16 | 25 | 26 | 29 |
30 | 31 |
32 |
33 | 34 |
35 |
36 |

37 | “This library has saved me countless hours of work and 38 | helped me deliver stunning designs to my clients faster than ever 39 | before.” 40 |

41 |
Sofia Davis
42 |
43 |
44 |
45 |
46 |
47 |
48 |

49 | Create an account 50 |

51 |

52 | Enter your email below to create your account 53 |

54 |
55 | 56 | 57 | 58 |

59 | By clicking continue, you agree to our{" "} 60 | {" "} 67 | and{" "} 68 | 75 | . 76 |

77 |
78 |
79 |
80 | ) 81 | } 82 | -------------------------------------------------------------------------------- /app/(auth)/signin/page.tsx: -------------------------------------------------------------------------------- 1 | import { UserAuthForm } from "@/components/auth/user-auth-form" 2 | import { Logo } from "@/components/icons/brand/logo" 3 | import { Button } from "@/components/ui/button" 4 | import { siteConfig } from "@/config/site" 5 | import type { Metadata } from "next" 6 | import Link from "next/link" 7 | 8 | export const metadata: Metadata = { 9 | title: `Sign in to ${siteConfig.name}`, 10 | description: `Sign in to your ${siteConfig.name} account`, 11 | openGraph: { 12 | title: `Sign in to ${siteConfig.name}`, 13 | description: `Sign in to your ${siteConfig.name} account` 14 | } 15 | } 16 | 17 | export default function SigninPage() { 18 | return ( 19 |
20 |
21 | 30 | 31 | 34 |
35 | 36 |
37 |
38 | 39 |
40 |
41 |

42 | “This library has saved me countless hours of work and 43 | helped me deliver stunning designs to my clients faster than ever 44 | before.” 45 |

46 |
Sofia Davis
47 |
48 |
49 |
50 |
51 |
52 |
53 |

54 | Welcome back 55 |

56 |

57 | Enter your email below to sign in to your account 58 |

59 |
60 | 61 | 62 | 63 |

64 | Already have an account?{" "} 65 | 72 | . 73 |

74 |
75 |
76 |
77 | ) 78 | } 79 | -------------------------------------------------------------------------------- /app/(marketing)/features.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Button } from "@/components/ui/button" 4 | import { cls } from "@/lib/utils/cls" 5 | import { CheckIcon } from "lucide-react" 6 | import Link from "next/link" 7 | import { useMemo } from "react" 8 | import { toast } from "sonner" 9 | 10 | export function Features() { 11 | const FEATURES = useMemo( 12 | () => [ 13 | { title: "Next 14", href: "https://nextjs.org" }, 14 | { title: "100% on the Edge 🚀" }, 15 | { title: "Bun", href: "https://bun.sh" }, 16 | { title: "Drizzle ORM", href: "https://orm.drizzle.team" }, 17 | { title: "shadcn/ui", href: "https://ui.shadcn.com" }, 18 | { title: "Biome", href: "https://biomejs.dev" }, 19 | { title: "Contentlayer", href: "https://contentlayer.dev" }, 20 | { title: "App Directory" }, 21 | { title: "API Route Handlers" }, 22 | { title: "Authentication (Email + OAuth)" }, 23 | { title: "Typescript (Strict)" }, 24 | { 25 | title: "Vercel Style Guide", 26 | href: "https://github.com/vercel/style-guide" 27 | }, 28 | { title: "ESLint" }, 29 | { title: "TailwindCSS", href: "https://tailwindcss.com" }, 30 | { title: "Radix UI", href: "https://www.radix-ui.com" }, 31 | { title: "PostgreSQL" }, 32 | { title: "Email via Postmark", href: "https://postmarkapp.com" }, 33 | { title: "Vercel ready", href: "https://vercel.com" }, 34 | { title: "Metadata SEO" }, 35 | { title: "Geist Font", href: "https://vercel.com/font" }, 36 | { title: "Lucide Icons", href: "https://lucide.dev" }, 37 | { title: "Vitest", href: "https://vitest.dev" }, 38 | { title: "Dark Mode" }, 39 | { 40 | title: "Toasts", 41 | onClick: () => 42 | toast("Wait. Toasts, too?", { 43 | description: "Yep! You can use them anywhere in your app." 44 | }) 45 | }, 46 | { title: "and much more..." } 47 | ], 48 | [] 49 | ) 50 | 51 | return ( 52 |
53 | {FEATURES.map(({ title, onClick, href }) => ( 54 | 78 | ))} 79 |
80 | ) 81 | } 82 | -------------------------------------------------------------------------------- /app/(marketing)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Footer } from "@/components/layout/footer" 2 | import { Header } from "@/components/layout/header" 3 | import type { PropsWithChildren } from "react" 4 | 5 | export const runtime = "edge" 6 | 7 | export default function MarketingLayout({ children }: PropsWithChildren) { 8 | return ( 9 |
10 |
11 |
{children}
12 |
13 |
14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /app/(marketing)/page.tsx: -------------------------------------------------------------------------------- 1 | import { Badge } from "@/components/ui/badge" 2 | import { Button } from "@/components/ui/button" 3 | import { siteConfig } from "@/config/site" 4 | import { cls } from "@/lib/utils/cls" 5 | import { GithubIcon } from "lucide-react" 6 | import type { Metadata } from "next" 7 | import { Permanent_Marker as PermanentMarker } from "next/font/google" 8 | import Image from "next/image" 9 | import Link from "next/link" 10 | import { Features } from "./features" 11 | 12 | const handwriting = PermanentMarker({ weight: "400", subsets: ["latin"] }) 13 | 14 | export const metadata: Metadata = { 15 | title: "A sane way to start your next next project (on the edge)", 16 | description: 17 | "Clean, understandable code. The latest best practices. Best-in-class open source libraries. And 100% on the edge.", 18 | openGraph: { 19 | title: "A sane way to start your next next project (on the edge)", 20 | description: 21 | "Clean, understandable code. The latest best practices. Best-in-class open source libraries. And 100% on the edge." 22 | } 23 | } 24 | 25 | export default function Home() { 26 | return ( 27 | <> 28 |
29 |
30 | 31 | {siteConfig.name} 32 | 33 |

34 | A sane way to start your next{" "} 35 | {" "} 44 | project. 45 | 51 | ^ on the edge 52 | 53 |

54 | 55 |

56 | Clean, understandable code. The latest best practices. Best-in-class 57 | open source libraries.{" "} 58 | And 100% on the edge. 59 |

60 | 61 | 75 |
76 | 77 |
78 | A sane way to start your next next project 85 | A sane way to start your next next project 92 |
93 |
94 | 95 |
96 |

What's included

97 | 98 | 99 |
100 | 112 |
113 |
114 | 115 | ) 116 | } 117 | -------------------------------------------------------------------------------- /app/api/auth/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | export { GET, POST } from "@/auth" 2 | 3 | export const runtime = "edge" 4 | -------------------------------------------------------------------------------- /app/api/ping/route.ts: -------------------------------------------------------------------------------- 1 | import { env } from "@/env" 2 | import { NextResponse } from "next/server" 3 | import { handler } from "typed-route-handler" 4 | 5 | type ResponseData = { 6 | pong: string 7 | } 8 | 9 | const gitSha = env.VERCEL_GIT_COMMIT_SHA ?? "local" 10 | export const runtime = "edge" 11 | 12 | /** 13 | * Healthcheck API endpoint which returns with success if the server is healthy, 14 | * and responds with the latest git sha. 15 | */ 16 | export const GET = handler(() => { 17 | return NextResponse.json({ pong: gitSha.substring(0, 7) }) 18 | }) 19 | -------------------------------------------------------------------------------- /app/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/startkit-dev/startkit-next/1d03cd33af2a0fa07b816baf1073a0b5b50002fe/app/apple-icon.png -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/startkit-dev/startkit-next/1d03cd33af2a0fa07b816baf1073a0b5b50002fe/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 240 10% 3.9%; 9 | 10 | --card: 0 0% 100%; 11 | --card-foreground: 240 10% 3.9%; 12 | 13 | --popover: 0 0% 100%; 14 | --popover-foreground: 240 10% 3.9%; 15 | 16 | --primary: 240 5.9% 10%; 17 | --primary-foreground: 0 0% 98%; 18 | 19 | --secondary: 240 4.8% 95.9%; 20 | --secondary-foreground: 240 5.9% 10%; 21 | 22 | --muted: 240 4.8% 95.9%; 23 | --muted-foreground: 240 3.8% 46.1%; 24 | 25 | --accent: 240 4.8% 95.9%; 26 | --accent-foreground: 240 5.9% 10%; 27 | 28 | --destructive: 0 84.2% 60.2%; 29 | --destructive-foreground: 0 0% 98%; 30 | 31 | --border: 240 5.9% 90%; 32 | --input: 240 5.9% 90%; 33 | --ring: 240 10% 3.9%; 34 | 35 | --radius: 0.5rem; 36 | } 37 | 38 | [data-theme="dark"] { 39 | --background: 240 10% 3.9%; 40 | --foreground: 0 0% 98%; 41 | 42 | --card: 240 10% 3.9%; 43 | --card-foreground: 0 0% 98%; 44 | 45 | --popover: 240 10% 3.9%; 46 | --popover-foreground: 0 0% 98%; 47 | 48 | --primary: 0 0% 98%; 49 | --primary-foreground: 240 5.9% 10%; 50 | 51 | --secondary: 240 3.7% 15.9%; 52 | --secondary-foreground: 0 0% 98%; 53 | 54 | --muted: 240 3.7% 15.9%; 55 | --muted-foreground: 240 5% 64.9%; 56 | 57 | --accent: 240 3.7% 15.9%; 58 | --accent-foreground: 0 0% 98%; 59 | 60 | --destructive: 0 62.8% 30.6%; 61 | --destructive-foreground: 0 0% 98%; 62 | 63 | --border: 240 3.7% 15.9%; 64 | --input: 240 3.7% 15.9%; 65 | --ring: 240 4.9% 83.9%; 66 | } 67 | } 68 | 69 | @layer base { 70 | * { 71 | @apply border-border; 72 | } 73 | body { 74 | @apply bg-background text-foreground; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /app/icon1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/startkit-dev/startkit-next/1d03cd33af2a0fa07b816baf1073a0b5b50002fe/app/icon1.png -------------------------------------------------------------------------------- /app/icon2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/startkit-dev/startkit-next/1d03cd33af2a0fa07b816baf1073a0b5b50002fe/app/icon2.png -------------------------------------------------------------------------------- /app/icon3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/startkit-dev/startkit-next/1d03cd33af2a0fa07b816baf1073a0b5b50002fe/app/icon3.png -------------------------------------------------------------------------------- /app/icon4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/startkit-dev/startkit-next/1d03cd33af2a0fa07b816baf1073a0b5b50002fe/app/icon4.png -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "./globals.css" 2 | import { TailwindIndicator } from "@/components/debug/tailwind-indicator" 3 | import { Analytics } from "@/components/layout/analytics" 4 | import { Toaster } from "@/components/ui/sonner" 5 | import { siteConfig } from "@/config/site" 6 | import { cls } from "@/lib/utils/cls" 7 | import { fullURL } from "@/lib/utils/url-fns/full-url" 8 | import { GeistMono } from "geist/font/mono" 9 | import { GeistSans } from "geist/font/sans" 10 | import type { Metadata, Viewport } from "next" 11 | import type { PropsWithChildren } from "react" 12 | 13 | export const metadata: Metadata = { 14 | metadataBase: fullURL(), 15 | applicationName: siteConfig.name, 16 | title: { 17 | default: siteConfig.name, 18 | template: `%s | ${siteConfig.name}` 19 | }, 20 | description: siteConfig.description 21 | } 22 | 23 | export const viewport: Viewport = { 24 | themeColor: [ 25 | { media: "(prefers-color-scheme: light)", color: "#FFFFFF" }, 26 | { media: "(prefers-color-scheme: dark)", color: "#000000" } 27 | ] 28 | } 29 | 30 | export default function RootLayout({ children }: PropsWithChildren) { 31 | return ( 32 | 41 | 42 | 43 | {children} 44 | 45 | 46 | 47 | 48 | 49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /app/legal/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import { type LegalPage, allLegalPages } from "contentlayer/generated" 2 | import { format, parseISO } from "date-fns" 3 | import { useMDXComponent } from "next-contentlayer2/hooks" 4 | 5 | function getSlug(page: LegalPage) { 6 | return page._raw.flattenedPath.replace("legal/", "") 7 | } 8 | 9 | export const generateStaticParams = () => 10 | allLegalPages.map((page) => ({ 11 | slug: getSlug(page) 12 | })) 13 | 14 | export const generateMetadata = ({ params }: { params: { slug: string } }) => { 15 | const page = allLegalPages.find((p) => getSlug(p) === params.slug) 16 | if (!page) throw new Error(`Post not found for slug: ${params.slug}`) 17 | return { title: page.title } 18 | } 19 | 20 | type Props = { params: { slug: string } } 21 | 22 | export default function Page({ params }: Props) { 23 | const page = allLegalPages.find((p) => getSlug(p) === params.slug) 24 | if (!page) throw new Error(`Post not found for slug: ${params.slug}`) 25 | 26 | const MDXContent = useMDXComponent(page.body.code) 27 | 28 | return ( 29 |
30 |
31 |

{page.title}

32 | 35 |
36 |
37 | {/* dangerouslySetInnerHTML={{ __html: post.body.html }} */} 38 | 39 |
40 |
41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /app/legal/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Footer } from "@/components/layout/footer" 2 | import { Button } from "@/components/ui/button" 3 | import { ChevronLeftIcon } from "lucide-react" 4 | import Link from "next/link" 5 | import type { PropsWithChildren } from "react" 6 | 7 | export default function LegalLayout({ children }: PropsWithChildren) { 8 | return ( 9 |
10 |
11 | 17 |
18 | 19 |
20 | {children} 21 |
22 | 23 |
24 |
25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /app/manifest.ts: -------------------------------------------------------------------------------- 1 | import { siteConfig } from "@/config/site" 2 | import type { MetadataRoute } from "next" 3 | 4 | export default function manifest(): MetadataRoute.Manifest { 5 | return { 6 | name: siteConfig.name, 7 | short_name: siteConfig.shortName, 8 | description: siteConfig.description, 9 | start_url: "/", 10 | display: "standalone", 11 | background_color: "#FFFFFF", 12 | theme_color: "#c026d3", 13 | icons: [ 14 | { 15 | src: "/favicon.ico", 16 | sizes: "16x16", 17 | type: "image/x-icon" 18 | }, 19 | { 20 | src: "/icon1.png", 21 | sizes: "16x16", 22 | type: "image/png" 23 | }, 24 | { 25 | src: "/icon2.png", 26 | sizes: "32x32", 27 | type: "image/png" 28 | }, 29 | { 30 | src: "/icon3.png", 31 | sizes: "192x192", 32 | type: "image/png" 33 | }, 34 | { 35 | src: "/icon4.png", 36 | sizes: "512x512", 37 | type: "image/png" 38 | } 39 | ] 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /app/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/startkit-dev/startkit-next/1d03cd33af2a0fa07b816baf1073a0b5b50002fe/app/opengraph-image.png -------------------------------------------------------------------------------- /app/robots.txt: -------------------------------------------------------------------------------- 1 | # See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | 3 | User-agent: * 4 | Disallow: 5 | -------------------------------------------------------------------------------- /app/sitemap.ts: -------------------------------------------------------------------------------- 1 | import { fullURL } from "@/lib/utils/url-fns/full-url" 2 | import type { MetadataRoute } from "next" 3 | 4 | export default function sitemap(): MetadataRoute.Sitemap { 5 | return [ 6 | { 7 | url: fullURL().toString(), 8 | lastModified: new Date(), 9 | changeFrequency: "monthly", 10 | priority: 1 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /auth.config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | accountsTable, 3 | sessionsTable, 4 | usersTable, 5 | verificationTokensTable 6 | } from "@/drizzle/schema" 7 | import { HttpEmailProvider } from "@/lib/auth/http-email-provider" 8 | import { DrizzleAdapter } from "@auth/drizzle-adapter" 9 | import type { NextAuthConfig } from "next-auth" 10 | import GitHub from "next-auth/providers/github" 11 | import Google from "next-auth/providers/google" 12 | import { db } from "./drizzle/client" 13 | 14 | export default { 15 | /** 16 | * @see {@link https://authjs.dev/reference/adapter/drizzle} 17 | */ 18 | adapter: DrizzleAdapter(db, { 19 | // @ts-expect-error custom citext column causes an error 20 | usersTable, 21 | accountsTable, 22 | sessionsTable, 23 | verificationTokensTable 24 | }), 25 | /** 26 | * 27 | */ 28 | providers: [Google, GitHub, HttpEmailProvider], 29 | 30 | /** 31 | * Using JWTs for session tokens, so we can access the user's ID and email 32 | * from edge networks without requiring database access (which may not be 33 | * available in edge environments). 34 | */ 35 | session: { strategy: "jwt" }, 36 | 37 | /** 38 | * 39 | */ 40 | callbacks: { 41 | session({ session, token }) { 42 | if (session.user && token.sub) { 43 | session.user.id = token.sub 44 | } 45 | 46 | return session 47 | } 48 | }, 49 | 50 | /** 51 | * 52 | */ 53 | pages: { 54 | signIn: "/signin" 55 | } 56 | } satisfies NextAuthConfig 57 | -------------------------------------------------------------------------------- /auth.ts: -------------------------------------------------------------------------------- 1 | import NextAuth, { type DefaultSession } from "next-auth" 2 | import authConfig from "./auth.config" 3 | 4 | declare module "next-auth" { 5 | /** 6 | * Add additional attributes to the session object. 7 | */ 8 | interface Session { 9 | user?: { 10 | /** The user's id. */ 11 | id: string 12 | } & DefaultSession["user"] 13 | } 14 | } 15 | 16 | /** 17 | * 18 | * All NextAuth config should be defined in `./auth.config.ts` to allow for us 19 | * to use a non-edge compliant database adapter if necessary. 20 | */ 21 | export const { 22 | handlers: { GET, POST }, 23 | auth 24 | } = NextAuth(authConfig) 25 | -------------------------------------------------------------------------------- /bin/migrate.ts: -------------------------------------------------------------------------------- 1 | import { join } from "node:path" 2 | import { env } from "@/env" 3 | import { drizzle } from "drizzle-orm/postgres-js" 4 | import { migrate } from "drizzle-orm/postgres-js/migrator" 5 | import postgres from "postgres" 6 | 7 | const client = postgres(env.DATABASE_URL, { ssl: "require", max: 1 }) 8 | const db = drizzle(client) 9 | 10 | console.log("Migrating the database ...") 11 | migrate(db, { migrationsFolder: join(__dirname, "..", "drizzle") }) 12 | .then(() => { 13 | console.log("Migration complete.") 14 | process.exit(0) 15 | }) 16 | .catch((error) => { 17 | console.log(error) 18 | process.exit(1) 19 | }) 20 | -------------------------------------------------------------------------------- /bin/rename: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env zsh 2 | 3 | echo -n "What is your new project name (start-kit)?: " 4 | read project_name 5 | 6 | snake_name=${project_name//-/_} 7 | upper_name=${(U)project_name} 8 | dash_name=${snake_name//_/-} 9 | words=(${(s:_:)snake_name}) 10 | class_name=${(j::)${(C)words}} 11 | 12 | mappings=("s/startkit/$snake_name/g" "s/start_kit/$snake_name/g" "s/START_KIT/$upper_name/g" "s/StartKit/$class_name/g" "s/start-kit/$dash_name/g") 13 | 14 | # Rename git origin 15 | git remote rename origin startkit 16 | 17 | for mapping in $mappings 18 | do 19 | echo "Renaming $mapping ..." 20 | sed -i '' $mapping ".env" 21 | sed -i '' $mapping "package.json" 22 | sed -i '' $mapping "README.md" 23 | sed -i '' $mapping ".gitignore" 24 | sed -i '' $mapping "app/(marketing)/page.tsx" 25 | sed -i '' $mapping "bin/setup" 26 | sed -i '' $mapping "config/site.ts" 27 | sed -i '' $mapping "emails/signin-email.tsx" 28 | sed -i '' $mapping "lib/auth/send-verification-request.tsx" 29 | done 30 | 31 | # Done. 32 | echo "\n\n✨ Now you can delete this script:" 33 | echo "\n rm ./bin/rename\n" 34 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | # This function adds a secret to the .env.local file, unless the key is already 4 | # defined. This allows us to add new default values even if a .env.local file 5 | # alrady exists 6 | define_secret() { 7 | local secret_name=$1 8 | local secret_value=$2 9 | 10 | if ! grep -q "^${secret_name}=" .env.local; then 11 | echo " > Setting '${secret_name}' ..." 12 | echo "${secret_name}=\"${secret_value}\"" >> .env.local 13 | else 14 | echo " > '${secret_name}' already defined, skipping ..." 15 | fi 16 | } 17 | 18 | # 19 | # Install dependencies 20 | # 21 | echo "✨ Installing dependencies ..." 22 | bun install --frozen-lockfile 23 | 24 | # 25 | # Create a .env.local file and popupate it with auto-generated secrets 26 | # 27 | echo "\n✨ Creating a .env.local file for local environment variables..." 28 | touch .env.local 29 | 30 | echo "\n✨ Generating a secure value for local AUTH_SECRET ..." 31 | define_secret "AUTH_SECRET" "$(openssl rand -base64 32)" 32 | define_secret "AUTH_URL" "http://localhost:3000/api/auth" 33 | 34 | # 35 | # Migrate the database 36 | # 37 | echo "\n✨ Migrating the database..." 38 | bun run db:migrate 39 | 40 | # 41 | # Done. 42 | # 43 | echo "\n🎉 All set. Happy coding!" 44 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.7.1/schema.json", 3 | "vcs": { 4 | "enabled": true, 5 | "clientKind": "git", 6 | "useIgnoreFile": true 7 | }, 8 | "files": { 9 | "ignore": ["./.vscode/settings.json"] 10 | }, 11 | "formatter": { 12 | "enabled": true, 13 | "indentStyle": "space" 14 | }, 15 | "javascript": { 16 | "formatter": { 17 | "semicolons": "asNeeded", 18 | "trailingComma": "none" 19 | } 20 | }, 21 | "linter": { 22 | "enabled": true, 23 | "rules": { 24 | "recommended": true 25 | } 26 | }, 27 | "organizeImports": { 28 | "enabled": false 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/startkit-dev/startkit-next/1d03cd33af2a0fa07b816baf1073a0b5b50002fe/bun.lockb -------------------------------------------------------------------------------- /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.js", 8 | "css": "app/globals.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /components/auth/external-auth-button.tsx: -------------------------------------------------------------------------------- 1 | import { GoogleSocialIcon } from "@/components/icons/social" 2 | import { Spinner } from "@/components/spinner" 3 | import { Button } from "@/components/ui/button" 4 | import { GithubIcon } from "lucide-react" 5 | import type { LucideIcon } from "lucide-react" 6 | import { signIn } from "next-auth/react" 7 | import { useCallback, useMemo, useState } from "react" 8 | 9 | const PROVIDERS = { 10 | google: { 11 | name: "Google", 12 | icon: GoogleSocialIcon as LucideIcon 13 | }, 14 | github: { 15 | name: "GitHub", 16 | icon: GithubIcon 17 | } 18 | } 19 | 20 | type Props = { 21 | provider: keyof typeof PROVIDERS 22 | isLoading?: boolean 23 | setIsLoading?: (isLoading: boolean) => void 24 | } 25 | 26 | export function ExternalAuthButton({ 27 | provider, 28 | isLoading = false, 29 | setIsLoading 30 | }: Props) { 31 | const [isExternalAuthLoading, setIsExternalAuthLoading] = useState(false) 32 | const ProviderIcon = useMemo( 33 | () => PROVIDERS[provider].icon, 34 | [provider] 35 | ) 36 | 37 | const onSignIn = useCallback(() => { 38 | setIsExternalAuthLoading(true) 39 | setIsLoading?.(true) 40 | 41 | return signIn(provider) 42 | }, [provider, setIsLoading]) 43 | 44 | return ( 45 | 58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /components/auth/user-auth-form.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Spinner } from "@/components/spinner" 4 | import { Button } from "@/components/ui/button" 5 | import { Form, FormControl, FormField, FormItem } from "@/components/ui/form" 6 | import { Input } from "@/components/ui/input" 7 | import { cls } from "@/lib/utils/cls" 8 | import { userAuthSchema } from "@/lib/validations/user-auth" 9 | import { zodResolver } from "@hookform/resolvers/zod" 10 | import { MailIcon } from "lucide-react" 11 | import { useSearchParams } from "next/navigation" 12 | import { signIn } from "next-auth/react" 13 | import type { SignInResponse } from "next-auth/react" 14 | import { useCallback, useEffect, useMemo, useState } from "react" 15 | import type { HTMLAttributes } from "react" 16 | import { useForm } from "react-hook-form" 17 | import { toast } from "sonner" 18 | import type { z } from "zod" 19 | import { ExternalAuthButton } from "./external-auth-button" 20 | 21 | type FormData = z.infer 22 | 23 | /** 24 | * https://github.com/nextauthjs/next-auth/blob/a79774f6e890b492ae30201f24b3f7024d0d7c9d/docs/docs/guides/basics/pages.md?plain=1#L42 25 | */ 26 | function handleError(error?: string | null) { 27 | switch (error) { 28 | case "OAuthAccountNotLinked": 29 | return toast("You already have an account", { 30 | description: 31 | "Please sign in with the other service you used to sign up." 32 | }) 33 | case "EmailSignin": 34 | return toast("Unable to send login e-mail", { 35 | description: "Sending your login e-mail failed. Please try again." 36 | }) 37 | case "CredentialsSignin": 38 | return toast("Invalid username or password", { 39 | description: 40 | "The username and password you entered did not match our records. Please double-check and try again." 41 | }) 42 | case "SessionRequired": 43 | return toast("Login required", { 44 | description: "You must be logged in to view this page" 45 | }) 46 | // case "OAuthCallback": 47 | // case "OAuthCreateAccount": 48 | // case "OAuthSignin": 49 | // case "EmailCreateAccount": 50 | // case "Callback": 51 | // case "Default": 52 | default: 53 | return toast("Something went wrong.", { 54 | description: "Your sign in request failed. Please try again." 55 | }) 56 | } 57 | } 58 | 59 | type Props = HTMLAttributes 60 | 61 | export function UserAuthForm({ className, ...props }: Props) { 62 | const searchParams = useSearchParams() 63 | const form = useForm>({ 64 | resolver: zodResolver(userAuthSchema), 65 | defaultValues: { 66 | email: "" 67 | } 68 | }) 69 | 70 | const [isExternalAuthLoading, setIsExternalAuthLoading] = 71 | useState(false) 72 | 73 | const isLoading = useMemo( 74 | () => isExternalAuthLoading || form.formState.isSubmitting, 75 | [form.formState.isSubmitting, isExternalAuthLoading] 76 | ) 77 | 78 | /** 79 | * If this page loads with an error query parameter, display the error message. 80 | */ 81 | useEffect(() => { 82 | if (searchParams.get("error")) { 83 | handleError(searchParams.get("error")) 84 | } 85 | }, [searchParams]) 86 | 87 | /** 88 | * Handle the form submission. 89 | */ 90 | const onSubmit = useCallback( 91 | async (data: FormData) => { 92 | let signInResult: SignInResponse | undefined 93 | 94 | if (isLoading) { 95 | return 96 | } 97 | 98 | try { 99 | signInResult = await signIn("http-email", { 100 | email: data.email.toLowerCase(), 101 | redirect: false, 102 | callbackUrl: searchParams.get("from") ?? "/" 103 | }) 104 | } catch (err) { 105 | console.error(err) 106 | } 107 | 108 | if (!signInResult?.ok || signInResult.error) { 109 | return handleError(signInResult?.error) 110 | } 111 | 112 | return toast("Check your email", { 113 | description: "We sent you a login link. Be sure to check your spam too." 114 | }) 115 | }, 116 | [isLoading, searchParams] 117 | ) 118 | 119 | return ( 120 |
121 | {/* Email form */} 122 |
123 | void form.handleSubmit(onSubmit)(...args)} 126 | > 127 | ( 131 | 132 | 133 | 134 | 135 | 136 | )} 137 | /> 138 | 139 | 152 | 153 | 154 | 155 |
156 |
157 | 158 |
159 |
160 | Or continue with 161 |
162 |
163 | 164 |
165 | 170 | 175 |
176 |
177 | ) 178 | } 179 | -------------------------------------------------------------------------------- /components/debug/tailwind-indicator.tsx: -------------------------------------------------------------------------------- 1 | import { env } from "@/env" 2 | 3 | /** 4 | * Adds a small indicator to the bottom left of the screen that shows the current 5 | * breakpoint. This is useful for debugging responsive styles. 6 | */ 7 | export function TailwindIndicator() { 8 | if (env.NODE_ENV === "production") { 9 | return null 10 | } 11 | 12 | return ( 13 |
14 |
xs
15 |
16 | sm 17 |
18 |
md
19 |
lg
20 |
xl
21 |
2xl
22 |
23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /components/icons/brand/logo.tsx: -------------------------------------------------------------------------------- 1 | import { ShellIcon } from "lucide-react" 2 | import type { LucideProps } from "lucide-react" 3 | 4 | type LogoProps = LucideProps 5 | 6 | export function Logo(props: LogoProps) { 7 | return 8 | } 9 | -------------------------------------------------------------------------------- /components/icons/social/google-icon.tsx: -------------------------------------------------------------------------------- 1 | import type { SVGProps } from "react" 2 | 3 | export function GoogleSocialIcon(props: SVGProps) { 4 | return ( 5 | 6 | Google 7 | 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /components/icons/social/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./google-icon" 2 | export * from "./x-icon" 3 | -------------------------------------------------------------------------------- /components/icons/social/x-icon.tsx: -------------------------------------------------------------------------------- 1 | import type { SVGProps } from "react" 2 | 3 | export function XSocialIcon(props: SVGProps) { 4 | return ( 5 | 12 | X / Twitter 13 | 14 | 15 | 16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /components/layout/analytics.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { GoogleAnalytics } from "nextjs-google-analytics" 4 | 5 | export function Analytics() { 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /components/layout/footer.tsx: -------------------------------------------------------------------------------- 1 | import { XSocialIcon } from "@/components/icons/social" 2 | import { ThemePicker } from "@/components/theme-picker" 3 | import { Button } from "@/components/ui/button" 4 | import { siteConfig } from "@/config/site" 5 | import { GithubIcon } from "lucide-react" 6 | import Link from "next/link" 7 | 8 | const navigation = [ 9 | { 10 | name: "Github", 11 | href: siteConfig.links.github, 12 | icon: GithubIcon 13 | }, 14 | { 15 | name: "X", 16 | href: siteConfig.links.twitter, 17 | icon: XSocialIcon 18 | } 19 | ] 20 | 21 | export function Footer() { 22 | return ( 23 |
24 |
25 |
26 | {navigation.map((item) => ( 27 | 33 | ))} 34 | 35 | 36 |
37 | 38 |
39 |
40 |
41 | © {new Date().getFullYear()}. 42 | 43 | Built by{" "} 44 | 53 | . 54 | 55 |
56 |
57 | 58 | Illustrations by{" "} 59 | 66 | . 67 | 68 |
69 | 70 |
71 | 78 | . 79 | 86 | . 87 |
88 |
89 |
90 |
91 |
92 | ) 93 | } 94 | -------------------------------------------------------------------------------- /components/layout/header.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from "@/auth" 2 | import { Logo } from "@/components/icons/brand/logo" 3 | import { Button } from "@/components/ui/button" 4 | import { siteConfig } from "@/config/site" 5 | import { GithubIcon } from "lucide-react" 6 | import Link from "next/link" 7 | import { UserNav } from "./header/user-nav" 8 | 9 | export async function Header() { 10 | const session = await auth() 11 | 12 | return ( 13 |
14 |
15 |
16 | 28 |
29 | 30 |
31 | 36 | 37 | {session?.user ? ( 38 | 39 | ) : ( 40 | 43 | )} 44 |
45 |
46 |
47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /components/layout/header/user-nav.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { 4 | AlertDialog, 5 | AlertDialogAction, 6 | AlertDialogCancel, 7 | AlertDialogContent, 8 | AlertDialogDescription, 9 | AlertDialogFooter, 10 | AlertDialogHeader, 11 | AlertDialogTitle, 12 | AlertDialogTrigger 13 | } from "@/components/ui/alert-dialog" 14 | import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" 15 | import { Button } from "@/components/ui/button" 16 | import { 17 | DropdownMenu, 18 | DropdownMenuContent, 19 | DropdownMenuGroup, 20 | DropdownMenuItem, 21 | DropdownMenuLabel, 22 | DropdownMenuSeparator, 23 | DropdownMenuTrigger 24 | } from "@/components/ui/dropdown-menu" 25 | import { getInitials } from "@/lib/utils/string-fns/get-initials" 26 | import type { User } from "next-auth" 27 | import { signOut } from "next-auth/react" 28 | import { useCallback, useState } from "react" 29 | 30 | type UserNavProps = { 31 | user: User 32 | } 33 | 34 | export function UserNav({ user }: UserNavProps) { 35 | const [open, setOpen] = useState(false) 36 | const [isSigningOut, setIsSigningOut] = useState(false) 37 | 38 | const onSignOutClicked = useCallback(async () => { 39 | setIsSigningOut(true) 40 | try { 41 | await signOut() 42 | } finally { 43 | setIsSigningOut(false) 44 | setOpen(false) 45 | } 46 | }, []) 47 | 48 | return ( 49 | 50 | 51 | 52 | 67 | 68 | 69 | 70 |
71 |

{user.name}

72 |

73 | {user.email} 74 |

75 |
76 |
77 | 78 | 79 | Profile 80 | Billing 81 | Settings 82 | 83 | 84 | 85 | Sign out 86 | 87 |
88 |
89 | 90 | 91 | 92 | Are you sure you want to sign out? 93 | 94 | 95 | Just checking. You can always come back whenever you want. 96 | 97 | 98 | 99 | Cancel 100 | { 103 | e.preventDefault() 104 | void onSignOutClicked() 105 | }} 106 | > 107 | Sign out 108 | 109 | 110 | 111 |
112 | ) 113 | } 114 | -------------------------------------------------------------------------------- /components/spinner/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./spinner" 2 | -------------------------------------------------------------------------------- /components/spinner/spinner.module.css: -------------------------------------------------------------------------------- 1 | .svg { 2 | animation: 2s linear infinite svg-animation; 3 | max-width: 100px; 4 | } 5 | 6 | @keyframes svg-animation { 7 | 0% { 8 | transform: rotateZ(0deg); 9 | } 10 | 100% { 11 | transform: rotateZ(360deg); 12 | } 13 | } 14 | 15 | .svg circle { 16 | animation: 1.4s ease-in-out infinite both circle-animation; 17 | display: block; 18 | fill: transparent; 19 | stroke-linecap: round; 20 | stroke-dasharray: 283; 21 | stroke-dashoffset: 280; 22 | stroke-width: 10px; 23 | transform-origin: 50% 50%; 24 | } 25 | 26 | @keyframes circle-animation { 27 | 0%, 28 | 25% { 29 | stroke-dashoffset: 280; 30 | transform: rotate(0); 31 | } 32 | 33 | 50%, 34 | 75% { 35 | stroke-dashoffset: 75; 36 | transform: rotate(45deg); 37 | } 38 | 39 | 100% { 40 | stroke-dashoffset: 280; 41 | transform: rotate(360deg); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /components/spinner/spinner.tsx: -------------------------------------------------------------------------------- 1 | import { cls } from "@/lib/utils/cls" 2 | import styles from "./spinner.module.css" 3 | 4 | type Props = { 5 | className?: string 6 | } 7 | 8 | export function Spinner({ className, ...props }: Props) { 9 | return ( 10 | 15 | Loading 16 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /components/theme-picker/index.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { ThemePickerProvider } from "./theme-picker-provider" 4 | import { ThemeToggle, type ThemeToggleProps } from "./theme-toggle" 5 | 6 | type Props = ThemeToggleProps 7 | 8 | /** 9 | * A Toggle menu for switching between light mode, dark mode, and system 10 | * preference. 11 | * 12 | * NOTE: next-themes can only be used client-side, and must be marked with 'use 13 | * client'. Since wrapping the entire app in a `use client` defeats much of the 14 | * benefits of Server Components and the NextJS app directory, this component 15 | * adds the from next-themes directly to the component instead 16 | * of wrapping the entire app in a ThemeProvider. This means access to theme 17 | * values will be limited to this component. If you would like to access theme 18 | * values from other components, you should add ThemeProvider to the relevant 19 | * `layout.tsx` file in the app directory. 20 | */ 21 | export function ThemePicker(props: Props) { 22 | return ( 23 | 24 | 25 | 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /components/theme-picker/theme-picker-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { ThemeProvider } from "next-themes" 4 | import type { ThemeProviderProps } from "next-themes/dist/types" 5 | 6 | type Props = ThemeProviderProps 7 | 8 | export function ThemePickerProvider(props: Props) { 9 | return 10 | } 11 | -------------------------------------------------------------------------------- /components/theme-picker/theme-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Button, type ButtonProps } from "@/components/ui/button" 4 | import { useMounted } from "@/hooks/use-mounted" 5 | import { cls } from "@/lib/utils/cls" 6 | import { MoonIcon, SunIcon } from "lucide-react" 7 | import { useTheme } from "next-themes" 8 | import { useCallback } from "react" 9 | 10 | export type ThemeToggleProps = ButtonProps & { 11 | iconClassName?: string 12 | } 13 | 14 | export function ThemeToggle({ 15 | iconClassName = "w-6 h-6", 16 | className, 17 | ...props 18 | }: ThemeToggleProps) { 19 | const mounted = useMounted() 20 | const { resolvedTheme, setTheme, systemTheme } = useTheme() 21 | 22 | const toggleTheme = useCallback(() => { 23 | if (resolvedTheme === systemTheme) { 24 | /** 25 | * If we're currently on the same theme as the system preference, we 26 | * should toggle to the opposite theme. 27 | */ 28 | setTheme(resolvedTheme === "dark" ? "light" : "dark") 29 | } else { 30 | /** 31 | * If we are toggling to the same theme as the current system preference, 32 | * we should toggle to `system`. This prevents us from forever being 33 | * stuck away from our system preference. 34 | */ 35 | setTheme("system") 36 | } 37 | }, [resolvedTheme, setTheme, systemTheme]) 38 | 39 | return ( 40 | 65 | ) 66 | } 67 | -------------------------------------------------------------------------------- /components/ui/alert-dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { buttonVariants } from "@/components/ui/button" 4 | import { cn } from "@/lib/utils/cls" 5 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" 6 | import * as React from "react" 7 | 8 | const AlertDialog = AlertDialogPrimitive.Root 9 | 10 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger 11 | 12 | const AlertDialogPortal = AlertDialogPrimitive.Portal 13 | 14 | const AlertDialogOverlay = React.forwardRef< 15 | React.ElementRef, 16 | React.ComponentPropsWithoutRef 17 | >(({ className, ...props }, ref) => ( 18 | 26 | )) 27 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName 28 | 29 | const AlertDialogContent = React.forwardRef< 30 | React.ElementRef, 31 | React.ComponentPropsWithoutRef 32 | >(({ className, ...props }, ref) => ( 33 | 34 | 35 | 43 | 44 | )) 45 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName 46 | 47 | function AlertDialogHeader({ 48 | className, 49 | ...props 50 | }: React.HTMLAttributes) { 51 | return ( 52 |
59 | ) 60 | } 61 | AlertDialogHeader.displayName = "AlertDialogHeader" 62 | 63 | function AlertDialogFooter({ 64 | className, 65 | ...props 66 | }: React.HTMLAttributes) { 67 | return ( 68 |
75 | ) 76 | } 77 | AlertDialogFooter.displayName = "AlertDialogFooter" 78 | 79 | const AlertDialogTitle = React.forwardRef< 80 | React.ElementRef, 81 | React.ComponentPropsWithoutRef 82 | >(({ className, ...props }, ref) => ( 83 | 88 | )) 89 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName 90 | 91 | const AlertDialogDescription = React.forwardRef< 92 | React.ElementRef, 93 | React.ComponentPropsWithoutRef 94 | >(({ className, ...props }, ref) => ( 95 | 100 | )) 101 | AlertDialogDescription.displayName = 102 | AlertDialogPrimitive.Description.displayName 103 | 104 | const AlertDialogAction = React.forwardRef< 105 | React.ElementRef, 106 | React.ComponentPropsWithoutRef 107 | >(({ className, ...props }, ref) => ( 108 | 113 | )) 114 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName 115 | 116 | const AlertDialogCancel = React.forwardRef< 117 | React.ElementRef, 118 | React.ComponentPropsWithoutRef 119 | >(({ className, ...props }, ref) => ( 120 | 129 | )) 130 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName 131 | 132 | export { 133 | AlertDialog, 134 | AlertDialogPortal, 135 | AlertDialogOverlay, 136 | AlertDialogTrigger, 137 | AlertDialogContent, 138 | AlertDialogHeader, 139 | AlertDialogFooter, 140 | AlertDialogTitle, 141 | AlertDialogDescription, 142 | AlertDialogAction, 143 | AlertDialogCancel 144 | } 145 | -------------------------------------------------------------------------------- /components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { cn } from "@/lib/utils/cls" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | import * as React from "react" 6 | 7 | const Avatar = React.forwardRef< 8 | React.ElementRef, 9 | React.ComponentPropsWithoutRef 10 | >(({ className, ...props }, ref) => ( 11 | 19 | )) 20 | Avatar.displayName = AvatarPrimitive.Root.displayName 21 | 22 | const AvatarImage = React.forwardRef< 23 | React.ElementRef, 24 | React.ComponentPropsWithoutRef 25 | >(({ className, ...props }, ref) => ( 26 | 31 | )) 32 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 33 | 34 | const AvatarFallback = React.forwardRef< 35 | React.ElementRef, 36 | React.ComponentPropsWithoutRef 37 | >(({ className, ...props }, ref) => ( 38 | 46 | )) 47 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 48 | 49 | export { Avatar, AvatarImage, AvatarFallback } 50 | -------------------------------------------------------------------------------- /components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils/cls" 2 | import { type VariantProps, cva } from "class-variance-authority" 3 | import type * as React from "react" 4 | 5 | const badgeVariants = cva( 6 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 7 | { 8 | variants: { 9 | variant: { 10 | default: 11 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 12 | secondary: 13 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 14 | destructive: 15 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 16 | outline: "text-foreground" 17 | } 18 | }, 19 | defaultVariants: { 20 | variant: "default" 21 | } 22 | } 23 | ) 24 | 25 | export type BadgeProps = React.HTMLAttributes & 26 | VariantProps 27 | 28 | function Badge({ className, variant, ...props }: BadgeProps) { 29 | return ( 30 |
31 | ) 32 | } 33 | 34 | export { Badge, badgeVariants } 35 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils/cls" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { type VariantProps, cva } from "class-variance-authority" 4 | import * as React from "react" 5 | 6 | const buttonVariants = cva( 7 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 12 | destructive: 13 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 14 | outline: 15 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 16 | secondary: 17 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 18 | ghost: "hover:bg-accent hover:text-accent-foreground", 19 | link: "text-primary underline-offset-4 hover:underline" 20 | }, 21 | size: { 22 | default: "h-10 px-4 py-2", 23 | sm: "h-9 rounded-md px-3", 24 | lg: "h-11 rounded-md px-8", 25 | icon: "size-10" 26 | } 27 | }, 28 | defaultVariants: { 29 | variant: "default", 30 | size: "default" 31 | } 32 | } 33 | ) 34 | 35 | export type ButtonProps = { 36 | asChild?: boolean 37 | } & React.ButtonHTMLAttributes & 38 | VariantProps 39 | 40 | const Button = React.forwardRef( 41 | ({ className, variant, size, asChild = false, ...props }, ref) => { 42 | const Comp = asChild ? Slot : "button" 43 | return ( 44 | 49 | ) 50 | } 51 | ) 52 | Button.displayName = "Button" 53 | 54 | export { Button, buttonVariants } 55 | -------------------------------------------------------------------------------- /components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { cn } from "@/lib/utils/cls" 4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" 5 | import { Check, ChevronRight, Circle } from "lucide-react" 6 | import * as React from "react" 7 | 8 | const DropdownMenu = DropdownMenuPrimitive.Root 9 | 10 | const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger 11 | 12 | const DropdownMenuGroup = DropdownMenuPrimitive.Group 13 | 14 | const DropdownMenuPortal = DropdownMenuPrimitive.Portal 15 | 16 | const DropdownMenuSub = DropdownMenuPrimitive.Sub 17 | 18 | const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup 19 | 20 | const DropdownMenuSubTrigger = React.forwardRef< 21 | React.ElementRef, 22 | React.ComponentPropsWithoutRef & { 23 | inset?: boolean 24 | } 25 | >(({ className, inset, children, ...props }, ref) => ( 26 | 35 | {children} 36 | 37 | 38 | )) 39 | DropdownMenuSubTrigger.displayName = 40 | DropdownMenuPrimitive.SubTrigger.displayName 41 | 42 | const DropdownMenuSubContent = React.forwardRef< 43 | React.ElementRef, 44 | React.ComponentPropsWithoutRef 45 | >(({ className, ...props }, ref) => ( 46 | 54 | )) 55 | DropdownMenuSubContent.displayName = 56 | DropdownMenuPrimitive.SubContent.displayName 57 | 58 | const DropdownMenuContent = React.forwardRef< 59 | React.ElementRef, 60 | React.ComponentPropsWithoutRef 61 | >(({ className, sideOffset = 4, ...props }, ref) => ( 62 | 63 | 72 | 73 | )) 74 | DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName 75 | 76 | const DropdownMenuItem = React.forwardRef< 77 | React.ElementRef, 78 | React.ComponentPropsWithoutRef & { 79 | inset?: boolean 80 | } 81 | >(({ className, inset, ...props }, ref) => ( 82 | 91 | )) 92 | DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName 93 | 94 | const DropdownMenuCheckboxItem = React.forwardRef< 95 | React.ElementRef, 96 | React.ComponentPropsWithoutRef 97 | >(({ className, children, checked, ...props }, ref) => ( 98 | 107 | 108 | 109 | 110 | 111 | 112 | {children} 113 | 114 | )) 115 | DropdownMenuCheckboxItem.displayName = 116 | DropdownMenuPrimitive.CheckboxItem.displayName 117 | 118 | const DropdownMenuRadioItem = React.forwardRef< 119 | React.ElementRef, 120 | React.ComponentPropsWithoutRef 121 | >(({ className, children, ...props }, ref) => ( 122 | 130 | 131 | 132 | 133 | 134 | 135 | {children} 136 | 137 | )) 138 | DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName 139 | 140 | const DropdownMenuLabel = React.forwardRef< 141 | React.ElementRef, 142 | React.ComponentPropsWithoutRef & { 143 | inset?: boolean 144 | } 145 | >(({ className, inset, ...props }, ref) => ( 146 | 155 | )) 156 | DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName 157 | 158 | const DropdownMenuSeparator = React.forwardRef< 159 | React.ElementRef, 160 | React.ComponentPropsWithoutRef 161 | >(({ className, ...props }, ref) => ( 162 | 167 | )) 168 | DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName 169 | 170 | function DropdownMenuShortcut({ 171 | className, 172 | ...props 173 | }: React.HTMLAttributes) { 174 | return ( 175 | 179 | ) 180 | } 181 | DropdownMenuShortcut.displayName = "DropdownMenuShortcut" 182 | 183 | export { 184 | DropdownMenu, 185 | DropdownMenuTrigger, 186 | DropdownMenuContent, 187 | DropdownMenuItem, 188 | DropdownMenuCheckboxItem, 189 | DropdownMenuRadioItem, 190 | DropdownMenuLabel, 191 | DropdownMenuSeparator, 192 | DropdownMenuShortcut, 193 | DropdownMenuGroup, 194 | DropdownMenuPortal, 195 | DropdownMenuSub, 196 | DropdownMenuSubContent, 197 | DropdownMenuSubTrigger, 198 | DropdownMenuRadioGroup 199 | } 200 | -------------------------------------------------------------------------------- /components/ui/form.tsx: -------------------------------------------------------------------------------- 1 | import { Label } from "@/components/ui/label" 2 | import { cn } from "@/lib/utils/cls" 3 | import type * as LabelPrimitive from "@radix-ui/react-label" 4 | import { Slot } from "@radix-ui/react-slot" 5 | import * as React from "react" 6 | import { 7 | Controller, 8 | type ControllerProps, 9 | type FieldPath, 10 | type FieldValues, 11 | FormProvider, 12 | useFormContext 13 | } from "react-hook-form" 14 | 15 | const Form = FormProvider 16 | 17 | type FormFieldContextValue< 18 | TFieldValues extends FieldValues = FieldValues, 19 | TName extends FieldPath = FieldPath 20 | > = { 21 | name: TName 22 | } 23 | 24 | const FormFieldContext = React.createContext( 25 | {} as FormFieldContextValue 26 | ) 27 | 28 | function FormField< 29 | TFieldValues extends FieldValues = FieldValues, 30 | TName extends FieldPath = FieldPath 31 | >({ ...props }: ControllerProps) { 32 | return ( 33 | 34 | 35 | 36 | ) 37 | } 38 | 39 | const useFormField = () => { 40 | const fieldContext = React.useContext(FormFieldContext) 41 | const itemContext = React.useContext(FormItemContext) 42 | const { getFieldState, formState } = useFormContext() 43 | 44 | const fieldState = getFieldState(fieldContext.name, formState) 45 | 46 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- from shadcn/ui - leave it 47 | if (!fieldContext) { 48 | throw new Error("useFormField should be used within ") 49 | } 50 | 51 | const { id } = itemContext 52 | 53 | return { 54 | id, 55 | name: fieldContext.name, 56 | formItemId: `${id}-form-item`, 57 | formDescriptionId: `${id}-form-item-description`, 58 | formMessageId: `${id}-form-item-message`, 59 | ...fieldState 60 | } 61 | } 62 | 63 | type FormItemContextValue = { 64 | id: string 65 | } 66 | 67 | const FormItemContext = React.createContext( 68 | {} as FormItemContextValue 69 | ) 70 | 71 | const FormItem = React.forwardRef< 72 | HTMLDivElement, 73 | React.HTMLAttributes 74 | >(({ className, ...props }, ref) => { 75 | const id = React.useId() 76 | 77 | return ( 78 | 79 |
80 | 81 | ) 82 | }) 83 | FormItem.displayName = "FormItem" 84 | 85 | const FormLabel = React.forwardRef< 86 | React.ElementRef, 87 | React.ComponentPropsWithoutRef 88 | >(({ className, ...props }, ref) => { 89 | const { error, formItemId } = useFormField() 90 | 91 | return ( 92 |