├── .commitlintrc.json ├── .editorconfig ├── .env.example ├── .eslintrc.json ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── .nvmrc ├── .prettierignore ├── LICENSE.md ├── README.md ├── app ├── (auth) │ ├── layout.tsx │ └── login │ │ └── page.tsx ├── (dashboard) │ └── dashboard │ │ ├── billing │ │ ├── loading.tsx │ │ └── page.tsx │ │ ├── layout.tsx │ │ ├── loading.tsx │ │ ├── page.tsx │ │ └── settings │ │ ├── loading.tsx │ │ └── page.tsx ├── (docs) │ ├── docs │ │ ├── [[...slug]] │ │ │ └── page.tsx │ │ └── layout.tsx │ ├── guides │ │ ├── [...slug] │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ └── page.tsx │ └── layout.tsx ├── (editor) │ └── editor │ │ ├── [postId] │ │ ├── loading.tsx │ │ ├── not-found.tsx │ │ └── page.tsx │ │ └── layout.tsx ├── (marketing) │ ├── [...slug] │ │ └── page.tsx │ ├── blog │ │ ├── [...slug] │ │ │ └── page.tsx │ │ └── page.tsx │ ├── layout.tsx │ ├── page.tsx │ └── pricing │ │ └── page.tsx ├── api │ ├── og │ │ └── route.tsx │ ├── posts │ │ ├── [postId] │ │ │ └── route.ts │ │ └── route.ts │ ├── users │ │ ├── [userId] │ │ │ └── route.ts │ │ └── stripe │ │ │ └── route.ts │ └── webhooks │ │ └── stripe │ │ └── route.ts ├── auth │ └── callback │ │ └── route.ts ├── layout.tsx ├── opengraph-image.jpg ├── robots.ts └── supabase-server.ts ├── assets └── fonts │ ├── CalSans-SemiBold.ttf │ ├── CalSans-SemiBold.woff │ ├── CalSans-SemiBold.woff2 │ ├── Inter-Bold.ttf │ └── Inter-Regular.ttf ├── components ├── analytics.tsx ├── billing-form.tsx ├── callout.tsx ├── card-skeleton.tsx ├── editor.tsx ├── empty-placeholder.tsx ├── header.tsx ├── icons.tsx ├── main-nav.tsx ├── mdx-card.tsx ├── mdx-components.tsx ├── mobile-nav.tsx ├── mode-toggle.tsx ├── nav.tsx ├── page-header.tsx ├── pager.tsx ├── post-create-button.tsx ├── post-item.tsx ├── post-operations.tsx ├── search.tsx ├── shell.tsx ├── sidebar-nav.tsx ├── site-footer.tsx ├── tailwind-indicator.tsx ├── theme-provider.tsx ├── toc.tsx ├── ui │ ├── accordion.tsx │ ├── alert-dialog.tsx │ ├── alert.tsx │ ├── aspect-ratio.tsx │ ├── avatar.tsx │ ├── badge.tsx │ ├── button.tsx │ ├── calendar.tsx │ ├── card.tsx │ ├── checkbox.tsx │ ├── collapsible.tsx │ ├── command.tsx │ ├── context-menu.tsx │ ├── dialog.tsx │ ├── dropdown-menu.tsx │ ├── hover-card.tsx │ ├── input.tsx │ ├── label.tsx │ ├── menubar.tsx │ ├── navigation-menu.tsx │ ├── popover.tsx │ ├── progress.tsx │ ├── radio-group.tsx │ ├── scroll-area.tsx │ ├── select.tsx │ ├── separator.tsx │ ├── sheet.tsx │ ├── skeleton.tsx │ ├── slider.tsx │ ├── switch.tsx │ ├── tabs.tsx │ ├── textarea.tsx │ ├── toast.tsx │ ├── toaster.tsx │ ├── toggle.tsx │ ├── tooltip.tsx │ └── use-toast.ts ├── user-account-nav.tsx ├── user-auth-form.tsx ├── user-avatar.tsx └── user-name-form.tsx ├── config ├── dashboard.ts ├── docs.ts ├── marketing.ts ├── site.ts └── subscriptions.ts ├── content ├── authors │ └── shadcn.mdx ├── blog │ ├── deploying-next-apps.mdx │ ├── dynamic-routing-static-regeneration.mdx │ ├── preview-mode-headless-cms.mdx │ └── server-client-components.mdx ├── docs │ ├── documentation │ │ ├── code-blocks.mdx │ │ ├── components.mdx │ │ ├── index.mdx │ │ └── style-guide.mdx │ ├── in-progress.mdx │ └── index.mdx ├── guides │ ├── build-blog-using-contentlayer-mdx.mdx │ └── using-next-auth-next-13.mdx └── pages │ ├── privacy.mdx │ └── terms.mdx ├── contentlayer.config.js ├── env.mjs ├── hooks ├── use-lock-body.ts └── use-mounted.ts ├── lib ├── exceptions.ts ├── helpers.ts ├── stripe.ts ├── subscription.ts ├── toc.ts ├── utils.ts └── validations │ ├── auth.ts │ ├── og.ts │ ├── post.ts │ └── user.ts ├── middleware.ts ├── next.config.mjs ├── package.json ├── postcss.config.js ├── prettier.config.js ├── public ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── apple-touch-icon.png ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── images │ ├── avatars │ │ └── shadcn.png │ ├── blog │ │ ├── blog-post-1.jpg │ │ ├── blog-post-2.jpg │ │ ├── blog-post-3.jpg │ │ └── blog-post-4.jpg │ └── hero.png ├── og.jpg ├── site.webmanifest └── vercel.svg ├── schema.sql ├── styles ├── editor.css ├── globals.css └── mdx.css ├── tailwind.config.js ├── tsconfig.json └── types ├── db.ts ├── index.d.ts └── main.ts /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"] 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # App 3 | # ----------------------------------------------------------------------------- 4 | NEXT_PUBLIC_APP_URL=http://localhost:3000 5 | 6 | # ----------------------------------------------------------------------------- 7 | # Database (PostgreSQL - Supabase) 8 | # ----------------------------------------------------------------------------- 9 | NEXT_PUBLIC_SUPABASE_ANON= 10 | NEXT_PUBLIC_SUPABASE_URL= 11 | 12 | # ----------------------------------------------------------------------------- 13 | # Subscriptions (Stripe) 14 | # ----------------------------------------------------------------------------- 15 | STRIPE_API_KEY= 16 | STRIPE_WEBHOOK_SECRET= 17 | STRIPE_PRO_MONTHLY_PLAN_ID= 18 | 19 | # ----------------------------------------------------------------------------- 20 | # Misc (GitHub) 21 | # ----------------------------------------------------------------------------- 22 | GITHUB_ACCESS_TOKEN= -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/eslintrc", 3 | "root": true, 4 | "extends": [ 5 | "next/core-web-vitals", 6 | "prettier", 7 | "plugin:tailwindcss/recommended" 8 | ], 9 | "plugins": ["tailwindcss"], 10 | "rules": { 11 | "@next/next/no-html-link-for-pages": "off", 12 | "react/jsx-key": "off", 13 | "tailwindcss/no-custom-classname": "off", 14 | "tailwindcss/classnames-order": "error" 15 | }, 16 | "settings": { 17 | "tailwindcss": { 18 | "callees": ["cn"], 19 | "config": "tailwind.config.js" 20 | }, 21 | "next": { 22 | "rootDir": true 23 | } 24 | }, 25 | "overrides": [ 26 | { 27 | "files": ["*.ts", "*.tsx"], 28 | "parser": "@typescript-eslint/parser" 29 | } 30 | ] 31 | } 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.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 | .env 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | 39 | .vscode 40 | .contentlayer -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx pretty-quick --staged 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v16.18.0 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .next 4 | build 5 | .contentlayer -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 shadcn 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 | # Taxonomy + Supabase 2 | 3 | A clone of @shadcn's open source application built using the new router, server components, and everything new in Next.js 13, but with Supabase as the backend/auth solution. This project will be kept up to date with the main project as an alternative option to PlanetScale/Next Auth. The below is the README from the main project. 4 | 5 | > **Warning** 6 | > This app is a work in progress. I'm building this in public. You can follow the progress on Twitter [@shadcn](https://twitter.com/shadcn). 7 | > See the roadmap below. 8 | 9 | ## About this project 10 | 11 | This project as an experiment to see how a modern app (with features like authentication, subscriptions, API routes, static pages for docs ...etc) would work in Next.js 13 and server components. 12 | 13 | **This is not a starter template.** 14 | 15 | A few people have asked me to turn this into a starter. I think we could do that once the new features are out of beta. 16 | 17 | ## Note on Performance 18 | 19 | > **Warning** 20 | > This app is using the unstable releases for Next.js 13 and React 18. The new router and app dir is still in beta and not production-ready. 21 | > **Expect some performance hits when testing the dashboard**. 22 | > If you see something broken, you can ping me [@shadcn](https://twitter.com/shadcn). 23 | 24 | ## Features 25 | 26 | - New `/app` dir, 27 | - Routing, Layouts, Nested Layouts and Layout Groups 28 | - Data Fetching, Caching and Mutation 29 | - Loading UI 30 | - Route handlers 31 | - Metadata files 32 | - Server and Client Components 33 | - API Routes and Middlewares 34 | - Authentication using **Supabase Auth** 35 | - Database on **Supabase** 36 | - UI Components built using **Radix UI** 37 | - Documentation and blog using **MDX** and **Contentlayer** 38 | - Subscriptions using **Stripe** 39 | - Styled using **Tailwind CSS** 40 | - Validations using **Zod** 41 | - Written in **TypeScript** 42 | 43 | ## Roadmap 44 | 45 | - [x] ~Add MDX support for basic pages~ 46 | - [x] ~Build marketing pages~ 47 | - [x] ~Subscriptions using Stripe~ 48 | - [x] ~Responsive styles~ 49 | - [x] ~Add OG image for blog using @vercel/og~ 50 | - [x] Dark mode 51 | 52 | ## Known Issues 53 | 54 | A list of things not working right now: 55 | 56 | 1. ~GitHub authentication (use email)~ 57 | 2. ~[Prisma: Error: ENOENT: no such file or directory, open '/var/task/.next/server/chunks/schema.prisma'](https://github.com/prisma/prisma/issues/16117)~ 58 | 3. ~[Next.js 13: Client side navigation does not update head](https://github.com/vercel/next.js/issues/42414)~ 59 | 4. [Cannot use opengraph-image.tsx inside catch-all routes](https://github.com/vercel/next.js/issues/48162) 60 | 61 | ## Why not tRPC, Turborepo or X? 62 | 63 | I might add this later. For now, I want to see how far we can get using Next.js only. 64 | 65 | If you have some suggestions, feel free to create an issue. 66 | 67 | ## Running Locally 68 | 69 | 1. Install dependencies using pnpm: 70 | 71 | ```sh 72 | pnpm install 73 | ``` 74 | 75 | 2. Copy `.env.example` to `.env.local` and update the variables. 76 | ``` 77 | cp .env.example .env.local 78 | ``` 79 | 80 | 3. Create a Supabase project and copy the environmental variables into `.env.local`. You can follow the [official docs](https://supabase.io/docs/guides/with-nextjs) to get started. 81 | 82 | 4. Copy the `schema.sql` file from the root of this project into your Supabase project's SQL editor and run it to create the tables. 83 | 84 | 5. Start the development server: 85 | 86 | ```sh 87 | pnpm dev 88 | ``` 89 | 90 | ## License 91 | 92 | Licensed under the [MIT license](https://github.com/shadcn/taxonomy/blob/main/LICENSE.md). 93 | -------------------------------------------------------------------------------- /app/(auth)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation" 2 | 3 | import { getUser } from "@/app/supabase-server" 4 | 5 | interface AuthLayoutProps { 6 | children: React.ReactNode 7 | } 8 | 9 | export default async function AuthLayout({ children }: AuthLayoutProps) { 10 | const user = await getUser() 11 | 12 | if (user) { 13 | redirect("/dashboard") 14 | } 15 | return
{children}
16 | } 17 | -------------------------------------------------------------------------------- /app/(auth)/login/page.tsx: -------------------------------------------------------------------------------- 1 | import { Metadata } from "next" 2 | import Link from "next/link" 3 | import { redirect } from "next/navigation" 4 | 5 | import { cn } from "@/lib/utils" 6 | import { buttonVariants } from "@/components/ui/button" 7 | import { Icons } from "@/components/icons" 8 | import UserAuthForm from "@/components/user-auth-form" 9 | import { getAuthUser } from "@/app/supabase-server" 10 | 11 | export const metadata: Metadata = { 12 | title: "Login", 13 | description: "Login to your account", 14 | } 15 | 16 | export default async function LoginPage() { 17 | const user = await getAuthUser() 18 | 19 | if (user) { 20 | redirect("/dashboard") 21 | } 22 | 23 | return ( 24 |
25 | 32 | <> 33 | 34 | Back 35 | 36 | 37 |
38 |
39 | 40 |

41 | Welcome back 42 |

43 |
44 | 45 |
46 |
47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /app/(dashboard)/dashboard/billing/loading.tsx: -------------------------------------------------------------------------------- 1 | import { CardSkeleton } from "@/components/card-skeleton" 2 | import { DashboardHeader } from "@/components/header" 3 | import { DashboardShell } from "@/components/shell" 4 | 5 | export default function DashboardBillingLoading() { 6 | return ( 7 | 8 | 12 |
13 | 14 |
15 |
16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /app/(dashboard)/dashboard/billing/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation" 2 | 3 | import { stripe } from "@/lib/stripe" 4 | import { getUserSubscriptionPlan } from "@/lib/subscription" 5 | import { getUser } from "@/app/supabase-server" 6 | import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert" 7 | import { 8 | Card, 9 | CardContent, 10 | CardDescription, 11 | CardHeader, 12 | CardTitle, 13 | } from "@/components/ui/card" 14 | import { BillingForm } from "@/components/billing-form" 15 | import { DashboardHeader } from "@/components/header" 16 | import { Icons } from "@/components/icons" 17 | import { DashboardShell } from "@/components/shell" 18 | 19 | export const metadata = { 20 | title: "Billing", 21 | description: "Manage billing and your subscription plan.", 22 | } 23 | 24 | export default async function BillingPage() { 25 | const user = await getUser() 26 | 27 | if (!user) { 28 | redirect("/login") 29 | } 30 | 31 | const subscriptionPlan = await getUserSubscriptionPlan(user.id) 32 | 33 | // If user has a pro plan, check cancel status on Stripe. 34 | let isCanceled = false 35 | if (subscriptionPlan.isPro && subscriptionPlan.stripe_subscription_id) { 36 | const stripePlan = await stripe.subscriptions.retrieve( 37 | subscriptionPlan.stripe_subscription_id 38 | ) 39 | isCanceled = stripePlan.cancel_at_period_end 40 | } 41 | 42 | return ( 43 | 44 | 48 |
49 | 50 | 51 | This is a demo app. 52 | 53 | Taxonomy app is a demo app using a Stripe test environment. You can 54 | find a list of test card numbers on the{" "} 55 | 61 | Stripe docs 62 | 63 | . 64 | 65 | 66 | 72 |
73 |
74 | ) 75 | } 76 | -------------------------------------------------------------------------------- /app/(dashboard)/dashboard/layout.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation" 2 | 3 | import { dashboardConfig } from "@/config/dashboard" 4 | import { MainNav } from "@/components/main-nav" 5 | import { DashboardNav } from "@/components/nav" 6 | import { SiteFooter } from "@/components/site-footer" 7 | import { UserAccountNav } from "@/components/user-account-nav" 8 | import { getAuthUser } from "@/app/supabase-server" 9 | 10 | interface DashboardLayoutProps { 11 | children?: React.ReactNode 12 | } 13 | 14 | export default async function DashboardLayout({ 15 | children, 16 | }: DashboardLayoutProps) { 17 | const user = await getAuthUser() 18 | 19 | if (!user) { 20 | redirect("/login") 21 | } 22 | 23 | return ( 24 |
25 |
26 |
27 | 28 | 35 |
36 |
37 |
38 | 41 |
42 | {children} 43 |
44 |
45 | 46 |
47 | ) 48 | } 49 | -------------------------------------------------------------------------------- /app/(dashboard)/dashboard/loading.tsx: -------------------------------------------------------------------------------- 1 | import { DashboardHeader } from "@/components/header" 2 | import { PostCreateButton } from "@/components/post-create-button" 3 | import { PostItem } from "@/components/post-item" 4 | import { DashboardShell } from "@/components/shell" 5 | 6 | export default function DashboardLoading() { 7 | return ( 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 |
19 |
20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /app/(dashboard)/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | import { EmptyPlaceholder } from "@/components/empty-placeholder" 2 | import { DashboardHeader } from "@/components/header" 3 | import { PostCreateButton } from "@/components/post-create-button" 4 | import { PostItem } from "@/components/post-item" 5 | import { DashboardShell } from "@/components/shell" 6 | import { createServerSupabaseClient } from "@/app/supabase-server" 7 | 8 | export const metadata = { 9 | title: "Dashboard", 10 | } 11 | 12 | export default async function DashboardPage() { 13 | const supabase = createServerSupabaseClient() 14 | 15 | const { data: posts } = await supabase 16 | .from("posts") 17 | .select("id, title, published, created_at") 18 | .order("updated_at", { ascending: false }) 19 | 20 | return ( 21 | 22 | 23 | 24 | 25 |
26 | {posts?.length ? ( 27 |
28 | {posts.map((post) => ( 29 | 30 | ))} 31 |
32 | ) : ( 33 | 34 | 35 | No posts created 36 | 37 | You don't have any posts yet. Start creating content. 38 | 39 | 40 | 41 | )} 42 |
43 |
44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /app/(dashboard)/dashboard/settings/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Card } from "@/components/ui/card" 2 | import { CardSkeleton } from "@/components/card-skeleton" 3 | import { DashboardHeader } from "@/components/header" 4 | import { DashboardShell } from "@/components/shell" 5 | 6 | export default function DashboardSettingsLoading() { 7 | return ( 8 | 9 | 13 |
14 | 15 |
16 |
17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /app/(dashboard)/dashboard/settings/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation" 2 | 3 | import { getUser } from "@/app/supabase-server" 4 | import { DashboardHeader } from "@/components/header" 5 | import { DashboardShell } from "@/components/shell" 6 | import { UserNameForm } from "@/components/user-name-form" 7 | 8 | export const metadata = { 9 | title: "Settings", 10 | description: "Manage account and website settings.", 11 | } 12 | 13 | export default async function SettingsPage() { 14 | const user = await getUser() 15 | 16 | if (!user) { 17 | redirect( "/login") 18 | } 19 | 20 | return ( 21 | 22 | 26 |
27 | 28 |
29 |
30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /app/(docs)/docs/[[...slug]]/page.tsx: -------------------------------------------------------------------------------- 1 | import { notFound } from "next/navigation" 2 | import { allDocs } from "contentlayer/generated" 3 | 4 | import { getTableOfContents } from "@/lib/toc" 5 | import { Mdx } from "@/components/mdx-components" 6 | import { DocsPageHeader } from "@/components/page-header" 7 | import { DocsPager } from "@/components/pager" 8 | import { DashboardTableOfContents } from "@/components/toc" 9 | 10 | import "@/styles/mdx.css" 11 | import { Metadata } from "next" 12 | 13 | import { env } from "@/env.mjs" 14 | import { absoluteUrl } from "@/lib/utils" 15 | 16 | interface DocPageProps { 17 | params: { 18 | slug: string[] 19 | } 20 | } 21 | 22 | async function getDocFromParams(params) { 23 | const slug = params.slug?.join("/") || "" 24 | const doc = allDocs.find((doc) => doc.slugAsParams === slug) 25 | 26 | if (!doc) { 27 | null 28 | } 29 | 30 | return doc 31 | } 32 | 33 | export async function generateMetadata({ 34 | params, 35 | }: DocPageProps): Promise { 36 | const doc = await getDocFromParams(params) 37 | 38 | if (!doc) { 39 | return {} 40 | } 41 | 42 | const url = env.NEXT_PUBLIC_APP_URL 43 | 44 | const ogUrl = new URL(`${url}/api/og`) 45 | ogUrl.searchParams.set("heading", doc.description ?? doc.title) 46 | ogUrl.searchParams.set("type", "Documentation") 47 | ogUrl.searchParams.set("mode", "dark") 48 | 49 | return { 50 | title: doc.title, 51 | description: doc.description, 52 | openGraph: { 53 | title: doc.title, 54 | description: doc.description, 55 | type: "article", 56 | url: absoluteUrl(doc.slug), 57 | images: [ 58 | { 59 | url: ogUrl.toString(), 60 | width: 1200, 61 | height: 630, 62 | alt: doc.title, 63 | }, 64 | ], 65 | }, 66 | twitter: { 67 | card: "summary_large_image", 68 | title: doc.title, 69 | description: doc.description, 70 | images: [ogUrl.toString()], 71 | }, 72 | } 73 | } 74 | 75 | export async function generateStaticParams(): Promise< 76 | DocPageProps["params"][] 77 | > { 78 | return allDocs.map((doc) => ({ 79 | slug: doc.slugAsParams.split("/"), 80 | })) 81 | } 82 | 83 | export default async function DocPage({ params }: DocPageProps) { 84 | const doc = await getDocFromParams(params) 85 | 86 | if (!doc) { 87 | notFound() 88 | } 89 | 90 | const toc = await getTableOfContents(doc.body.raw) 91 | 92 | return ( 93 |
94 |
95 | 96 | 97 |
98 | 99 |
100 |
101 |
102 | 103 |
104 |
105 |
106 | ) 107 | } 108 | -------------------------------------------------------------------------------- /app/(docs)/docs/layout.tsx: -------------------------------------------------------------------------------- 1 | import { docsConfig } from "@/config/docs" 2 | import { DocsSidebarNav } from "@/components/sidebar-nav" 3 | 4 | interface DocsLayoutProps { 5 | children: React.ReactNode 6 | } 7 | 8 | export default function DocsLayout({ children }: DocsLayoutProps) { 9 | return ( 10 |
11 | 14 | {children} 15 |
16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /app/(docs)/guides/[...slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | import { notFound } from "next/navigation" 3 | import { allGuides } from "contentlayer/generated" 4 | 5 | import { getTableOfContents } from "@/lib/toc" 6 | import { Icons } from "@/components/icons" 7 | import { Mdx } from "@/components/mdx-components" 8 | import { DocsPageHeader } from "@/components/page-header" 9 | import { DashboardTableOfContents } from "@/components/toc" 10 | 11 | import "@/styles/mdx.css" 12 | import { Metadata } from "next" 13 | 14 | import { env } from "@/env.mjs" 15 | import { absoluteUrl, cn } from "@/lib/utils" 16 | import { buttonVariants } from "@/components/ui/button" 17 | 18 | interface GuidePageProps { 19 | params: { 20 | slug: string[] 21 | } 22 | } 23 | 24 | async function getGuideFromParams(params) { 25 | const slug = params?.slug?.join("/") 26 | const guide = allGuides.find((guide) => guide.slugAsParams === slug) 27 | 28 | if (!guide) { 29 | null 30 | } 31 | 32 | return guide 33 | } 34 | 35 | export async function generateMetadata({ 36 | params, 37 | }: GuidePageProps): Promise { 38 | const guide = await getGuideFromParams(params) 39 | 40 | if (!guide) { 41 | return {} 42 | } 43 | 44 | const url = env.NEXT_PUBLIC_APP_URL 45 | 46 | const ogUrl = new URL(`${url}/api/og`) 47 | ogUrl.searchParams.set("heading", guide.title) 48 | ogUrl.searchParams.set("type", "Guide") 49 | ogUrl.searchParams.set("mode", "dark") 50 | 51 | return { 52 | title: guide.title, 53 | description: guide.description, 54 | openGraph: { 55 | title: guide.title, 56 | description: guide.description, 57 | type: "article", 58 | url: absoluteUrl(guide.slug), 59 | images: [ 60 | { 61 | url: ogUrl.toString(), 62 | width: 1200, 63 | height: 630, 64 | alt: guide.title, 65 | }, 66 | ], 67 | }, 68 | twitter: { 69 | card: "summary_large_image", 70 | title: guide.title, 71 | description: guide.description, 72 | images: [ogUrl.toString()], 73 | }, 74 | } 75 | } 76 | 77 | export async function generateStaticParams(): Promise< 78 | GuidePageProps["params"][] 79 | > { 80 | return allGuides.map((guide) => ({ 81 | slug: guide.slugAsParams.split("/"), 82 | })) 83 | } 84 | 85 | export default async function GuidePage({ params }: GuidePageProps) { 86 | const guide = await getGuideFromParams(params) 87 | 88 | if (!guide) { 89 | notFound() 90 | } 91 | 92 | const toc = await getTableOfContents(guide.body.raw) 93 | 94 | return ( 95 |
96 |
97 | 98 | 99 |
100 |
101 | 105 | 106 | See all guides 107 | 108 |
109 |
110 |
111 |
112 | 113 |
114 |
115 |
116 | ) 117 | } 118 | -------------------------------------------------------------------------------- /app/(docs)/guides/layout.tsx: -------------------------------------------------------------------------------- 1 | interface GuidesLayoutProps { 2 | children: React.ReactNode 3 | } 4 | 5 | export default function GuidesLayout({ children }: GuidesLayoutProps) { 6 | return
{children}
7 | } 8 | -------------------------------------------------------------------------------- /app/(docs)/guides/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | import { allGuides } from "contentlayer/generated" 3 | import { compareDesc } from "date-fns" 4 | 5 | import { formatDate } from "@/lib/utils" 6 | import { DocsPageHeader } from "@/components/page-header" 7 | 8 | export const metadata = { 9 | title: "Guides", 10 | description: 11 | "This section includes end-to-end guides for developing Next.js 13 apps.", 12 | } 13 | 14 | export default function GuidesPage() { 15 | const guides = allGuides 16 | .filter((guide) => guide.published) 17 | .sort((a, b) => { 18 | return compareDesc(new Date(a.date), new Date(b.date)) 19 | }) 20 | 21 | return ( 22 |
23 | 27 | {guides?.length ? ( 28 |
29 | {guides.map((guide) => ( 30 |
34 | {guide.featured && ( 35 | 36 | Featured 37 | 38 | )} 39 |
40 |
41 |

42 | {guide.title} 43 |

44 | {guide.description && ( 45 |

{guide.description}

46 | )} 47 |
48 | {guide.date && ( 49 |

50 | {formatDate(guide.date)} 51 |

52 | )} 53 |
54 | 55 | View 56 | 57 |
58 | ))} 59 |
60 | ) : ( 61 |

No guides published.

62 | )} 63 |
64 | ) 65 | } 66 | -------------------------------------------------------------------------------- /app/(docs)/layout.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | import { docsConfig } from "@/config/docs" 4 | import { siteConfig } from "@/config/site" 5 | import { Icons } from "@/components/icons" 6 | import { MainNav } from "@/components/main-nav" 7 | import { DocsSearch } from "@/components/search" 8 | import { DocsSidebarNav } from "@/components/sidebar-nav" 9 | import { SiteFooter } from "@/components/site-footer" 10 | 11 | interface DocsLayoutProps { 12 | children: React.ReactNode 13 | } 14 | 15 | export default function DocsLayout({ children }: DocsLayoutProps) { 16 | return ( 17 |
18 |
19 |
20 | 21 | 22 | 23 |
24 |
25 | 26 |
27 | 37 |
38 |
39 |
40 |
{children}
41 | 42 |
43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /app/(editor)/editor/[postId]/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@/components/ui/skeleton" 2 | 3 | export default function Loading() { 4 | return ( 5 |
6 |
7 | 8 | 9 |
10 |
11 | 12 | 13 | 14 | 15 |
16 |
17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /app/(editor)/editor/[postId]/not-found.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | import { buttonVariants } from "@/components/ui/button" 4 | import { EmptyPlaceholder } from "@/components/empty-placeholder" 5 | 6 | export default function NotFound() { 7 | return ( 8 | 9 | 10 | Uh oh! Not Found 11 | 12 | This post could not be found. Please try again. 13 | 14 | 15 | Go to Dashboard 16 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /app/(editor)/editor/[postId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { notFound, redirect } from "next/navigation" 2 | 3 | import { Editor } from "@/components/editor" 4 | import { getPostForUser, getUser } from "@/app/supabase-server" 5 | 6 | interface EditorPageProps { 7 | params: { postId: string } 8 | } 9 | 10 | export default async function EditorPage({ params }: EditorPageProps) { 11 | const user = await getUser() 12 | 13 | if (!user) { 14 | redirect("/login") 15 | } 16 | 17 | const post = await getPostForUser(params.postId, user.id) 18 | 19 | if (!post) { 20 | notFound() 21 | } 22 | 23 | return ( 24 | 32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /app/(editor)/editor/layout.tsx: -------------------------------------------------------------------------------- 1 | interface EditorProps { 2 | children?: React.ReactNode 3 | } 4 | 5 | export default function EditorLayout({ children }: EditorProps) { 6 | return ( 7 |
8 | {children} 9 |
10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /app/(marketing)/[...slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import { notFound } from "next/navigation" 2 | import { allPages } from "contentlayer/generated" 3 | 4 | import { Mdx } from "@/components/mdx-components" 5 | 6 | import "@/styles/mdx.css" 7 | import { Metadata } from "next" 8 | 9 | import { env } from "@/env.mjs" 10 | import { siteConfig } from "@/config/site" 11 | import { absoluteUrl } from "@/lib/utils" 12 | 13 | interface PageProps { 14 | params: { 15 | slug: string[] 16 | } 17 | } 18 | 19 | async function getPageFromParams(params) { 20 | const slug = params?.slug?.join("/") 21 | const page = allPages.find((page) => page.slugAsParams === slug) 22 | 23 | if (!page) { 24 | null 25 | } 26 | 27 | return page 28 | } 29 | 30 | export async function generateMetadata({ 31 | params, 32 | }: PageProps): Promise { 33 | const page = await getPageFromParams(params) 34 | 35 | if (!page) { 36 | return {} 37 | } 38 | 39 | const url = env.NEXT_PUBLIC_APP_URL 40 | 41 | const ogUrl = new URL(`${url}/api/og`) 42 | ogUrl.searchParams.set("heading", page.title) 43 | ogUrl.searchParams.set("type", siteConfig.name) 44 | ogUrl.searchParams.set("mode", "light") 45 | 46 | return { 47 | title: page.title, 48 | description: page.description, 49 | openGraph: { 50 | title: page.title, 51 | description: page.description, 52 | type: "article", 53 | url: absoluteUrl(page.slug), 54 | images: [ 55 | { 56 | url: ogUrl.toString(), 57 | width: 1200, 58 | height: 630, 59 | alt: page.title, 60 | }, 61 | ], 62 | }, 63 | twitter: { 64 | card: "summary_large_image", 65 | title: page.title, 66 | description: page.description, 67 | images: [ogUrl.toString()], 68 | }, 69 | } 70 | } 71 | 72 | export async function generateStaticParams(): Promise { 73 | return allPages.map((page) => ({ 74 | slug: page.slugAsParams.split("/"), 75 | })) 76 | } 77 | 78 | export default async function PagePage({ params }: PageProps) { 79 | const page = await getPageFromParams(params) 80 | 81 | if (!page) { 82 | notFound() 83 | } 84 | 85 | return ( 86 |
87 |
88 |

89 | {page.title} 90 |

91 | {page.description && ( 92 |

{page.description}

93 | )} 94 |
95 |
96 | 97 |
98 | ) 99 | } 100 | -------------------------------------------------------------------------------- /app/(marketing)/blog/page.tsx: -------------------------------------------------------------------------------- 1 | import Image from "next/image" 2 | import Link from "next/link" 3 | import { allPosts } from "contentlayer/generated" 4 | import { compareDesc } from "date-fns" 5 | 6 | import { formatDate } from "@/lib/utils" 7 | 8 | export const metadata = { 9 | title: "Blog", 10 | } 11 | 12 | export default async function BlogPage() { 13 | const posts = allPosts 14 | .filter((post) => post.published) 15 | .sort((a, b) => { 16 | return compareDesc(new Date(a.date), new Date(b.date)) 17 | }) 18 | 19 | return ( 20 |
21 |
22 |
23 |

24 | Blog 25 |

26 |

27 | A blog built using Contentlayer. Posts are written in MDX. 28 |

29 |
30 |
31 |
32 | {posts?.length ? ( 33 |
34 | {posts.map((post, index) => ( 35 |
39 | {post.image && ( 40 | {post.title} 48 | )} 49 |

{post.title}

50 | {post.description && ( 51 |

{post.description}

52 | )} 53 | {post.date && ( 54 |

55 | {formatDate(post.date)} 56 |

57 | )} 58 | 59 | View Article 60 | 61 |
62 | ))} 63 |
64 | ) : ( 65 |

No posts published.

66 | )} 67 |
68 | ) 69 | } 70 | -------------------------------------------------------------------------------- /app/(marketing)/layout.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | import { marketingConfig } from "@/config/marketing" 4 | import { cn } from "@/lib/utils" 5 | import { buttonVariants } from "@/components/ui/button" 6 | import { MainNav } from "@/components/main-nav" 7 | import { SiteFooter } from "@/components/site-footer" 8 | 9 | import { getUser } from "../supabase-server" 10 | 11 | interface MarketingLayoutProps { 12 | children: React.ReactNode 13 | } 14 | 15 | export default async function MarketingLayout({ 16 | children, 17 | }: MarketingLayoutProps) { 18 | const user = await getUser() 19 | return ( 20 |
21 |
22 |
23 | 24 | 35 |
36 |
37 |
{children}
38 | 39 |
40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /app/(marketing)/pricing/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | import { cn } from "@/lib/utils" 4 | import { buttonVariants } from "@/components/ui/button" 5 | import { Icons } from "@/components/icons" 6 | 7 | export const metadata = { 8 | title: "Pricing", 9 | } 10 | 11 | export default function PricingPage() { 12 | return ( 13 |
14 |
15 |

16 | Simple, transparent pricing 17 |

18 |

19 | Unlock all features including unlimited posts for your blog. 20 |

21 |
22 |
23 |
24 |

25 | What's included in the PRO plan 26 |

27 |
    28 |
  • 29 | Unlimited Posts 30 |
  • 31 |
  • 32 | Unlimited Users 33 |
  • 34 | 35 |
  • 36 | Custom domain 37 |
  • 38 |
  • 39 | Dashboard Analytics 40 |
  • 41 |
  • 42 | Access to Discord 43 |
  • 44 |
  • 45 | Premium Support 46 |
  • 47 |
48 |
49 |
50 |
51 |

$19

52 |

53 | Billed Monthly 54 |

55 |
56 | 57 | Get Started 58 | 59 |
60 |
61 |
62 |

63 | Taxonomy is a demo app.{" "} 64 | You can test the upgrade and won't be charged. 65 |

66 |
67 |
68 | ) 69 | } 70 | -------------------------------------------------------------------------------- /app/api/posts/[postId]/route.ts: -------------------------------------------------------------------------------- 1 | import { cookies } from "next/headers" 2 | import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs" 3 | import * as z from "zod" 4 | 5 | import { Database } from "@/types/db" 6 | import { postPatchSchema } from "@/lib/validations/post" 7 | 8 | const routeContextSchema = z.object({ 9 | params: z.object({ 10 | postId: z.string(), 11 | }), 12 | }) 13 | 14 | export async function DELETE( 15 | req: Request, 16 | context: z.infer 17 | ) { 18 | const supabase = createRouteHandlerClient ({ 19 | cookies, 20 | }) 21 | try { 22 | // Validate the route params. 23 | const { params } = routeContextSchema.parse(context) 24 | 25 | // Check if the user has access to this post. 26 | if (!(await verifyCurrentUserHasAccessToPost(params.postId))) { 27 | return new Response(null, { status: 403 }) 28 | } 29 | // Delete the post. 30 | await supabase.from("posts").delete().eq("id", params.postId) 31 | 32 | return new Response(null, { status: 204 }) 33 | } catch (error) { 34 | if (error instanceof z.ZodError) { 35 | return new Response(JSON.stringify(error.issues), { status: 422 }) 36 | } 37 | 38 | return new Response(null, { status: 500 }) 39 | } 40 | } 41 | 42 | export async function PATCH( 43 | req: Request, 44 | context: z.infer 45 | ) { 46 | const supabase = createRouteHandlerClient ({ 47 | cookies, 48 | }) 49 | try { 50 | // Validate route params. 51 | const { params } = routeContextSchema.parse(context) 52 | 53 | // Check if the user has access to this post. 54 | if (!(await verifyCurrentUserHasAccessToPost(params.postId))) { 55 | return new Response(null, { status: 403 }) 56 | } 57 | 58 | // Get the request body and validate it. 59 | const json = await req.json() 60 | const body = postPatchSchema.parse(json) 61 | 62 | // Update the post. 63 | // TODO: Implement sanitization for content. 64 | await supabase 65 | .from("posts") 66 | .update({ 67 | title: body.title, 68 | content: body.content, 69 | }) 70 | .eq("id", params.postId) 71 | .select() 72 | 73 | return new Response(null, { status: 200 }) 74 | } catch (error) { 75 | if (error instanceof z.ZodError) { 76 | return new Response(JSON.stringify(error.issues), { status: 422 }) 77 | } 78 | 79 | return new Response(null, { status: 500 }) 80 | } 81 | } 82 | 83 | async function verifyCurrentUserHasAccessToPost(postId: string) { 84 | const supabase = createRouteHandlerClient ({ 85 | cookies, 86 | }) 87 | const { 88 | data: { session }, 89 | } = await supabase.auth.getSession() 90 | 91 | const { count } = await supabase 92 | .from("posts") 93 | .select("*", { count: "exact", head: true }) 94 | .eq("id", postId) 95 | .eq("author_id", session?.user.id) 96 | 97 | return count ? count > 0 : false 98 | } 99 | -------------------------------------------------------------------------------- /app/api/posts/route.ts: -------------------------------------------------------------------------------- 1 | import { cookies } from "next/headers" 2 | import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs" 3 | import * as z from "zod" 4 | 5 | import { Database } from "@/types/db" 6 | import { RequiresProPlanError } from "@/lib/exceptions" 7 | import { getUserSubscriptionPlan } from "@/lib/subscription" 8 | 9 | const postCreateSchema = z.object({ 10 | title: z.string(), 11 | content: z.string().optional(), 12 | }) 13 | 14 | export async function GET() { 15 | const supabase = createRouteHandlerClient ({ 16 | cookies, 17 | }) 18 | try { 19 | const { 20 | data: { session }, 21 | } = await supabase.auth.getSession() 22 | 23 | if (!session) { 24 | return new Response("Unauthorized", { status: 403 }) 25 | } 26 | 27 | const { data: posts } = await supabase 28 | .from("posts") 29 | .select("id, title, published, created_at") 30 | .eq("author_id", session.user.id) 31 | .order("updated_at", { ascending: false }) 32 | 33 | return new Response(JSON.stringify(posts)) 34 | } catch (error) { 35 | return new Response(null, { status: 500 }) 36 | } 37 | } 38 | 39 | export async function POST(req: Request) { 40 | const supabase = createRouteHandlerClient ({ 41 | cookies, 42 | }) 43 | try { 44 | const { 45 | data: { session }, 46 | } = await supabase.auth.getSession() 47 | 48 | if (!session) { 49 | return new Response("Unauthorized", { status: 403 }) 50 | } 51 | 52 | const { user } = session 53 | const subscriptionPlan = await getUserSubscriptionPlan(user.id) 54 | 55 | // If user is on a free plan. 56 | // Check if user has reached limit of 3 posts. 57 | if (!subscriptionPlan?.isPro) { 58 | const { count } = await supabase 59 | .from("posts") 60 | .select("*", { count: "exact", head: true }) 61 | .eq("author_id)", user.id) 62 | 63 | if (count && count >= 3) { 64 | throw new RequiresProPlanError() 65 | } 66 | } 67 | 68 | const json = await req.json() 69 | const body = postCreateSchema.parse(json) 70 | 71 | const { data: post } = await supabase 72 | .from("posts") 73 | .insert({ 74 | title: body.title, 75 | content: body.content, 76 | author_id: session.user.id, 77 | }) 78 | .select() 79 | 80 | return new Response(JSON.stringify(post)) 81 | } catch (error) { 82 | if (error instanceof z.ZodError) { 83 | return new Response(JSON.stringify(error.issues), { status: 422 }) 84 | } 85 | 86 | if (error instanceof RequiresProPlanError) { 87 | return new Response("Requires Pro Plan", { status: 402 }) 88 | } 89 | 90 | return new Response(null, { status: 500 }) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /app/api/users/[userId]/route.ts: -------------------------------------------------------------------------------- 1 | import { cookies } from "next/headers" 2 | import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs" 3 | import { z } from "zod" 4 | 5 | import { Database } from "@/types/db" 6 | import { userNameSchema } from "@/lib/validations/user" 7 | 8 | const routeContextSchema = z.object({ 9 | params: z.object({ 10 | userId: z.string(), 11 | }), 12 | }) 13 | 14 | export async function PATCH( 15 | req: Request, 16 | context: z.infer 17 | ) { 18 | const supabase = createRouteHandlerClient ({ 19 | cookies, 20 | }) 21 | try { 22 | // Validate the route context. 23 | const { params } = routeContextSchema.parse(context) 24 | 25 | // Ensure user is authentication and has access to this user. 26 | const { 27 | data: { session }, 28 | } = await supabase.auth.getSession() 29 | if (!session?.user || params.userId !== session?.user.id) { 30 | return new Response(null, { status: 403 }) 31 | } 32 | 33 | // Get the request body and validate it. 34 | const body = await req.json() 35 | const payload = userNameSchema.parse(body) 36 | 37 | // Update the user. 38 | await supabase.auth.updateUser({ 39 | data: { full_name: payload.name }, 40 | }) 41 | 42 | return new Response(null, { status: 200 }) 43 | } catch (error) { 44 | if (error instanceof z.ZodError) { 45 | return new Response(JSON.stringify(error.issues), { status: 422 }) 46 | } 47 | 48 | return new Response(null, { status: 500 }) 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/api/users/stripe/route.ts: -------------------------------------------------------------------------------- 1 | import { cookies } from "next/headers" 2 | import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs" 3 | import { z } from "zod" 4 | 5 | import { Database } from "@/types/db" 6 | import { proPlan } from "@/config/subscriptions" 7 | import { stripe } from "@/lib/stripe" 8 | import { getUserSubscriptionPlan } from "@/lib/subscription" 9 | import { absoluteUrl } from "@/lib/utils" 10 | 11 | const billingUrl = absoluteUrl("/dashboard/billing") 12 | 13 | export async function GET(req: Request) { 14 | const supabase = createRouteHandlerClient ({ 15 | cookies, 16 | }) 17 | 18 | try { 19 | const { 20 | data: { session }, 21 | } = await supabase.auth.getSession() 22 | 23 | if (!session?.user || !session?.user.email) { 24 | return new Response(null, { status: 403 }) 25 | } 26 | 27 | const subscriptionPlan = await getUserSubscriptionPlan(session.user.id) 28 | 29 | // The user is on the pro plan. 30 | // Create a portal session to manage subscription. 31 | if (subscriptionPlan.isPro && subscriptionPlan.stripe_customer_id) { 32 | const stripeSession = await stripe.billingPortal.sessions.create({ 33 | customer: subscriptionPlan.stripe_customer_id, 34 | return_url: billingUrl, 35 | }) 36 | 37 | return new Response(JSON.stringify({ url: stripeSession.url })) 38 | } 39 | 40 | // The user is on the free plan. 41 | // Create a checkout session to upgrade. 42 | const stripeSession = await stripe.checkout.sessions.create({ 43 | success_url: billingUrl, 44 | cancel_url: billingUrl, 45 | payment_method_types: ["card"], 46 | mode: "subscription", 47 | billing_address_collection: "auto", 48 | customer_email: session.user.email, 49 | line_items: [ 50 | { 51 | price: proPlan.stripe_price_id, 52 | quantity: 1, 53 | }, 54 | ], 55 | metadata: { 56 | userId: session.user.id, 57 | }, 58 | }) 59 | 60 | return new Response(JSON.stringify({ url: stripeSession.url })) 61 | } catch (error) { 62 | if (error instanceof z.ZodError) { 63 | return new Response(JSON.stringify(error.issues), { status: 422 }) 64 | } 65 | 66 | return new Response(error, { status: 500 }) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /app/api/webhooks/stripe/route.ts: -------------------------------------------------------------------------------- 1 | import { cookies, headers } from "next/headers" 2 | import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs" 3 | import Stripe from "stripe" 4 | 5 | import { env } from "@/env.mjs" 6 | import { Database } from "@/types/db" 7 | import { stripe } from "@/lib/stripe" 8 | 9 | export async function POST(req: Request) { 10 | const body = await req.text() 11 | const signature = headers().get("Stripe-Signature") as string 12 | const supabase = createRouteHandlerClient ({ 13 | cookies, 14 | }) 15 | 16 | let event: Stripe.Event 17 | 18 | try { 19 | event = stripe.webhooks.constructEvent( 20 | body, 21 | signature, 22 | env.STRIPE_WEBHOOK_SECRET 23 | ) 24 | } catch (error) { 25 | return new Response(`Webhook Error: ${error.message}`, { status: 400 }) 26 | } 27 | 28 | const session = event.data.object as Stripe.Checkout.Session 29 | 30 | if (event.type === "checkout.session.completed") { 31 | // Retrieve the subscription details from Stripe. 32 | const subscription = await stripe.subscriptions.retrieve( 33 | session.subscription as string 34 | ) 35 | 36 | // Update the user stripe into in our database. 37 | // Since this is the initial subscription, we need to update 38 | // the subscription id and customer id. 39 | 40 | await supabase 41 | .from("users") 42 | .update({ 43 | stripe_customer_id: subscription.id, 44 | stripe_subscription_id: subscription.customer as string, 45 | stripe_price_id: subscription.items.data[0].price.id, 46 | stripe_current_period_end: new Date( 47 | subscription.current_period_end * 1000 48 | ).toISOString(), 49 | }) 50 | .eq("id", session?.metadata?.userId) 51 | } 52 | 53 | if (event.type === "invoice.payment_succeeded") { 54 | // Retrieve the subscription details from Stripe. 55 | const subscription = await stripe.subscriptions.retrieve( 56 | session.subscription as string 57 | ) 58 | 59 | // Update the price id and set the new period end. 60 | await supabase 61 | .from("users") 62 | .update({ 63 | stripe_price_id: subscription.items.data[0].price.id, 64 | stripe_current_period_end: new Date( 65 | subscription.current_period_end * 1000 66 | ).toISOString(), 67 | }) 68 | .eq("stripe_subscription_id", subscription.id) 69 | } 70 | 71 | return new Response(null, { status: 200 }) 72 | } 73 | -------------------------------------------------------------------------------- /app/auth/callback/route.ts: -------------------------------------------------------------------------------- 1 | import { cookies } from "next/headers" 2 | import { NextResponse } from "next/server" 3 | import type { NextRequest } from "next/server" 4 | import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs" 5 | 6 | import type { Database } from "@/types/db" 7 | 8 | export async function GET(request: NextRequest) { 9 | const requestUrl = new URL(request.url) 10 | const code = requestUrl.searchParams.get("code") 11 | 12 | if (code) { 13 | const supabase = createRouteHandlerClient({ cookies }) 14 | await supabase.auth.exchangeCodeForSession(code) 15 | } 16 | 17 | // URL to redirect to after sign in process completes 18 | return NextResponse.redirect(requestUrl.origin.concat("/dashboard")) 19 | } 20 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Inter as FontSans } from "next/font/google" 2 | import localFont from "next/font/local" 3 | 4 | import "@/styles/globals.css" 5 | 6 | import { siteConfig } from "@/config/site" 7 | import { cn } from "@/lib/utils" 8 | import { Toaster } from "@/components/ui/toaster" 9 | import { Analytics } from "@/components/analytics" 10 | import { TailwindIndicator } from "@/components/tailwind-indicator" 11 | import { ThemeProvider } from "@/components/theme-provider" 12 | 13 | const fontSans = FontSans({ 14 | subsets: ["latin"], 15 | variable: "--font-sans", 16 | }) 17 | 18 | // Font files can be colocated inside of `pages` 19 | const fontHeading = localFont({ 20 | src: "../assets/fonts/CalSans-SemiBold.woff2", 21 | variable: "--font-heading", 22 | }) 23 | 24 | interface RootLayoutProps { 25 | children: React.ReactNode 26 | } 27 | 28 | export const metadata = { 29 | title: { 30 | default: siteConfig.name, 31 | template: `%s | ${siteConfig.name}`, 32 | }, 33 | description: siteConfig.description, 34 | keywords: [ 35 | "Next.js", 36 | "React", 37 | "Tailwind CSS", 38 | "Server Components", 39 | "Radix UI", 40 | ], 41 | authors: [ 42 | { 43 | name: "shadcn", 44 | url: "https://shadcn.com", 45 | }, 46 | ], 47 | creator: "shadcn", 48 | themeColor: [ 49 | { media: "(prefers-color-scheme: light)", color: "white" }, 50 | { media: "(prefers-color-scheme: dark)", color: "black" }, 51 | ], 52 | openGraph: { 53 | type: "website", 54 | locale: "en_US", 55 | url: siteConfig.url, 56 | title: siteConfig.name, 57 | description: siteConfig.description, 58 | siteName: siteConfig.name, 59 | }, 60 | twitter: { 61 | card: "summary_large_image", 62 | title: siteConfig.name, 63 | description: siteConfig.description, 64 | images: [`${siteConfig.url}/og.jpg`], 65 | creator: "@shadcn", 66 | }, 67 | icons: { 68 | icon: "/favicon.ico", 69 | shortcut: "/favicon-16x16.png", 70 | apple: "/apple-touch-icon.png", 71 | }, 72 | manifest: `${siteConfig.url}/site.webmanifest`, 73 | } 74 | 75 | export default function RootLayout({ children }: RootLayoutProps) { 76 | return ( 77 | 78 | 79 | 86 | 87 | {children} 88 | 89 | 90 | 91 | 92 | 93 | 94 | ) 95 | } 96 | -------------------------------------------------------------------------------- /app/opengraph-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalkommatt/taxonomy-supabase/bfe4dd5de46e250c7c7f1d8cf45c57a7a583bc97/app/opengraph-image.jpg -------------------------------------------------------------------------------- /app/robots.ts: -------------------------------------------------------------------------------- 1 | import { MetadataRoute } from "next" 2 | 3 | export default function robots(): MetadataRoute.Robots { 4 | return { 5 | rules: { 6 | userAgent: "*", 7 | allow: "/", 8 | }, 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /app/supabase-server.ts: -------------------------------------------------------------------------------- 1 | import { cache } from "react" 2 | import { cookies } from "next/headers" 3 | import { createServerComponentClient } from "@supabase/auth-helpers-nextjs" 4 | 5 | import { Database } from "@/types/db" 6 | import { Post, User } from "@/types/main" 7 | 8 | export const createServerSupabaseClient = cache(() => 9 | createServerComponentClient({ cookies }) 10 | ) 11 | 12 | export async function getSupabaseSession() { 13 | const supabase = createServerSupabaseClient() 14 | try { 15 | const { 16 | data: { session }, 17 | } = await supabase.auth.getSession() 18 | return session 19 | } catch (error) { 20 | console.error("Error:", error) 21 | return null 22 | } 23 | } 24 | 25 | export async function getAuthUser() { 26 | const supabase = createServerSupabaseClient() 27 | try { 28 | const { 29 | data: { user }, 30 | } = await supabase.auth.getUser() 31 | return user 32 | } catch (error) { 33 | console.error("Error:", error) 34 | return null 35 | } 36 | } 37 | 38 | export async function getUser() { 39 | const supabase = createServerSupabaseClient() 40 | try { 41 | const { data } = await supabase.from("users").select("*").single() 42 | return data 43 | } catch (error) { 44 | console.error("Error:", error) 45 | return null 46 | } 47 | } 48 | 49 | export async function getPostForUser(postId: Post["id"], userId: User["id"]) { 50 | const supabase = createServerSupabaseClient() 51 | const { data } = await supabase 52 | .from("posts") 53 | .select("*") 54 | .eq("id", postId) 55 | .eq("author_id", userId) 56 | .single() 57 | return data ? { ...data, content: data.content as unknown as JSON } : null 58 | } 59 | -------------------------------------------------------------------------------- /assets/fonts/CalSans-SemiBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalkommatt/taxonomy-supabase/bfe4dd5de46e250c7c7f1d8cf45c57a7a583bc97/assets/fonts/CalSans-SemiBold.ttf -------------------------------------------------------------------------------- /assets/fonts/CalSans-SemiBold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalkommatt/taxonomy-supabase/bfe4dd5de46e250c7c7f1d8cf45c57a7a583bc97/assets/fonts/CalSans-SemiBold.woff -------------------------------------------------------------------------------- /assets/fonts/CalSans-SemiBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalkommatt/taxonomy-supabase/bfe4dd5de46e250c7c7f1d8cf45c57a7a583bc97/assets/fonts/CalSans-SemiBold.woff2 -------------------------------------------------------------------------------- /assets/fonts/Inter-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalkommatt/taxonomy-supabase/bfe4dd5de46e250c7c7f1d8cf45c57a7a583bc97/assets/fonts/Inter-Bold.ttf -------------------------------------------------------------------------------- /assets/fonts/Inter-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dalkommatt/taxonomy-supabase/bfe4dd5de46e250c7c7f1d8cf45c57a7a583bc97/assets/fonts/Inter-Regular.ttf -------------------------------------------------------------------------------- /components/analytics.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { Analytics as VercelAnalytics } from "@vercel/analytics/react" 4 | 5 | export function Analytics() { 6 | return 7 | } 8 | -------------------------------------------------------------------------------- /components/billing-form.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | 5 | import { UserSubscriptionPlan } from "types" 6 | import { cn, formatDate } from "@/lib/utils" 7 | import { buttonVariants } from "@/components/ui/button" 8 | import { 9 | Card, 10 | CardContent, 11 | CardDescription, 12 | CardFooter, 13 | CardHeader, 14 | CardTitle, 15 | } from "@/components/ui/card" 16 | import { toast } from "@/components/ui/use-toast" 17 | import { Icons } from "@/components/icons" 18 | 19 | interface BillingFormProps extends React.HTMLAttributes { 20 | subscriptionPlan: UserSubscriptionPlan & { 21 | isCanceled: boolean 22 | } 23 | } 24 | 25 | export function BillingForm({ 26 | subscriptionPlan, 27 | className, 28 | ...props 29 | }: BillingFormProps) { 30 | const [isLoading, setIsLoading] = React.useState(false) 31 | 32 | async function onSubmit(event) { 33 | event.preventDefault() 34 | setIsLoading(!isLoading) 35 | 36 | // Get a Stripe session URL. 37 | const response = await fetch("/api/users/stripe") 38 | 39 | if (!response?.ok) { 40 | return toast({ 41 | title: "Something went wrong.", 42 | description: "Please refresh the page and try again.", 43 | variant: "destructive", 44 | }) 45 | } 46 | 47 | // Redirect to the Stripe session. 48 | // This could be a checkout page for initial upgrade. 49 | // Or portal to manage existing subscription. 50 | const session = await response.json() 51 | if (session) { 52 | window.location.href = session.url 53 | } 54 | } 55 | 56 | return ( 57 |
58 | 59 | 60 | Subscription Plan 61 | 62 | You are currently on the {subscriptionPlan.name}{" "} 63 | plan. 64 | 65 | 66 | {subscriptionPlan.description} 67 | 68 | 78 | {subscriptionPlan.isPro ? ( 79 |

80 | {subscriptionPlan.isCanceled 81 | ? "Your plan will be canceled on " 82 | : "Your plan renews on "} 83 | {formatDate(subscriptionPlan.stripe_current_period_end)}. 84 |

85 | ) : null} 86 |
87 |
88 |
89 | ) 90 | } 91 | -------------------------------------------------------------------------------- /components/callout.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | interface CalloutProps { 4 | icon?: string 5 | children?: React.ReactNode 6 | type?: "default" | "warning" | "danger" 7 | } 8 | 9 | export function Callout({ 10 | children, 11 | icon, 12 | type = "default", 13 | ...props 14 | }: CalloutProps) { 15 | return ( 16 |
23 | {icon && {icon}} 24 |
{children}
25 |
26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /components/card-skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card" 2 | import { Skeleton } from "@/components/ui/skeleton" 3 | 4 | export function CardSkeleton() { 5 | return ( 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /components/empty-placeholder.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | import { Icons } from "@/components/icons" 5 | 6 | interface EmptyPlaceholderProps extends React.HTMLAttributes {} 7 | 8 | export function EmptyPlaceholder({ 9 | className, 10 | children, 11 | ...props 12 | }: EmptyPlaceholderProps) { 13 | return ( 14 |
21 |
22 | {children} 23 |
24 |
25 | ) 26 | } 27 | 28 | interface EmptyPlaceholderIconProps 29 | extends Partial> { 30 | name: keyof typeof Icons 31 | } 32 | 33 | EmptyPlaceholder.Icon = function EmptyPlaceHolderIcon({ 34 | name, 35 | className, 36 | ...props 37 | }: EmptyPlaceholderIconProps) { 38 | const Icon = Icons[name] 39 | 40 | if (!Icon) { 41 | return null 42 | } 43 | 44 | return ( 45 |
46 | 47 |
48 | ) 49 | } 50 | 51 | interface EmptyPlacholderTitleProps 52 | extends React.HTMLAttributes {} 53 | 54 | EmptyPlaceholder.Title = function EmptyPlaceholderTitle({ 55 | className, 56 | ...props 57 | }: EmptyPlacholderTitleProps) { 58 | return ( 59 |

60 | ) 61 | } 62 | 63 | interface EmptyPlacholderDescriptionProps 64 | extends React.HTMLAttributes {} 65 | 66 | EmptyPlaceholder.Description = function EmptyPlaceholderDescription({ 67 | className, 68 | ...props 69 | }: EmptyPlacholderDescriptionProps) { 70 | return ( 71 |

78 | ) 79 | } 80 | -------------------------------------------------------------------------------- /components/header.tsx: -------------------------------------------------------------------------------- 1 | interface DashboardHeaderProps { 2 | heading: string 3 | text?: string 4 | children?: React.ReactNode 5 | } 6 | 7 | export function DashboardHeader({ 8 | heading, 9 | text, 10 | children, 11 | }: DashboardHeaderProps) { 12 | return ( 13 |

14 |
15 |

{heading}

16 | {text &&

{text}

} 17 |
18 | {children} 19 |
20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /components/icons.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | AlertTriangle, 3 | ArrowRight, 4 | Check, 5 | ChevronLeft, 6 | ChevronRight, 7 | Command, 8 | CreditCard, 9 | File, 10 | FileText, 11 | HelpCircle, 12 | Image, 13 | Laptop, 14 | Loader2, 15 | LucideProps, 16 | Moon, 17 | MoreVertical, 18 | Pizza, 19 | Plus, 20 | Settings, 21 | SunMedium, 22 | Trash, 23 | Twitter, 24 | User, 25 | X, 26 | type Icon as LucideIcon, 27 | } from "lucide-react" 28 | 29 | export type Icon = LucideIcon 30 | 31 | export const Icons = { 32 | logo: Command, 33 | close: X, 34 | spinner: Loader2, 35 | chevronLeft: ChevronLeft, 36 | chevronRight: ChevronRight, 37 | trash: Trash, 38 | post: FileText, 39 | page: File, 40 | media: Image, 41 | settings: Settings, 42 | billing: CreditCard, 43 | ellipsis: MoreVertical, 44 | add: Plus, 45 | warning: AlertTriangle, 46 | user: User, 47 | arrowRight: ArrowRight, 48 | help: HelpCircle, 49 | pizza: Pizza, 50 | sun: SunMedium, 51 | moon: Moon, 52 | laptop: Laptop, 53 | gitHub: ({ ...props }: LucideProps) => ( 54 | 69 | ), 70 | twitter: Twitter, 71 | check: Check, 72 | } 73 | -------------------------------------------------------------------------------- /components/main-nav.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import Link from "next/link" 5 | import { useSelectedLayoutSegment } from "next/navigation" 6 | 7 | import { MainNavItem } from "types" 8 | import { siteConfig } from "@/config/site" 9 | import { cn } from "@/lib/utils" 10 | import { Icons } from "@/components/icons" 11 | import { MobileNav } from "@/components/mobile-nav" 12 | 13 | interface MainNavProps { 14 | items?: MainNavItem[] 15 | children?: React.ReactNode 16 | } 17 | 18 | export function MainNav({ items, children }: MainNavProps) { 19 | const segment = useSelectedLayoutSegment() 20 | const [showMobileMenu, setShowMobileMenu] = React.useState(false) 21 | 22 | return ( 23 |
24 | 25 | 26 | 27 | {siteConfig.name} 28 | 29 | 30 | {items?.length ? ( 31 | 48 | ) : null} 49 | 56 | {showMobileMenu && items && ( 57 | {children} 58 | )} 59 |
60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /components/mdx-card.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | interface CardProps extends React.HTMLAttributes { 6 | href?: string 7 | disabled?: boolean 8 | } 9 | 10 | export function MdxCard({ 11 | href, 12 | className, 13 | children, 14 | disabled, 15 | ...props 16 | }: CardProps) { 17 | return ( 18 |
26 |
27 |
28 | {children} 29 |
30 |
31 | {href && ( 32 | 33 | View 34 | 35 | )} 36 |
37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /components/mdx-components.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import Image from "next/image" 3 | import { useMDXComponent } from "next-contentlayer/hooks" 4 | 5 | import { cn } from "@/lib/utils" 6 | import { Callout } from "@/components/callout" 7 | import { MdxCard } from "@/components/mdx-card" 8 | 9 | const components = { 10 | h1: ({ className, ...props }) => ( 11 |

18 | ), 19 | h2: ({ className, ...props }) => ( 20 |

27 | ), 28 | h3: ({ className, ...props }) => ( 29 |

36 | ), 37 | h4: ({ className, ...props }) => ( 38 |

45 | ), 46 | h5: ({ className, ...props }) => ( 47 |

54 | ), 55 | h6: ({ className, ...props }) => ( 56 |
63 | ), 64 | a: ({ className, ...props }) => ( 65 | 69 | ), 70 | p: ({ className, ...props }) => ( 71 |

75 | ), 76 | ul: ({ className, ...props }) => ( 77 |

    78 | ), 79 | ol: ({ className, ...props }) => ( 80 |
      81 | ), 82 | li: ({ className, ...props }) => ( 83 |
    1. 84 | ), 85 | blockquote: ({ className, ...props }) => ( 86 |
      *]:text-muted-foreground", 89 | className 90 | )} 91 | {...props} 92 | /> 93 | ), 94 | img: ({ 95 | className, 96 | alt, 97 | ...props 98 | }: React.ImgHTMLAttributes) => ( 99 | // eslint-disable-next-line @next/next/no-img-element 100 | {alt} 101 | ), 102 | hr: ({ ...props }) =>
      , 103 | table: ({ className, ...props }: React.HTMLAttributes) => ( 104 |
      105 | 106 | 107 | ), 108 | tr: ({ className, ...props }: React.HTMLAttributes) => ( 109 | 113 | ), 114 | th: ({ className, ...props }) => ( 115 |
      122 | ), 123 | td: ({ className, ...props }) => ( 124 | 131 | ), 132 | pre: ({ className, ...props }) => ( 133 |
      140 |   ),
      141 |   code: ({ className, ...props }) => (
      142 |     
      149 |   ),
      150 |   Image,
      151 |   Callout,
      152 |   Card: MdxCard,
      153 | }
      154 | 
      155 | interface MdxProps {
      156 |   code: string
      157 | }
      158 | 
      159 | export function Mdx({ code }: MdxProps) {
      160 |   const Component = useMDXComponent(code)
      161 | 
      162 |   return (
      163 |     
      164 | 165 |
      166 | ) 167 | } 168 | -------------------------------------------------------------------------------- /components/mobile-nav.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import Link from "next/link" 3 | 4 | import { MainNavItem } from "types" 5 | import { siteConfig } from "@/config/site" 6 | import { cn } from "@/lib/utils" 7 | import { useLockBody } from "@/hooks/use-lock-body" 8 | import { Icons } from "@/components/icons" 9 | 10 | interface MobileNavProps { 11 | items: MainNavItem[] 12 | children?: React.ReactNode 13 | } 14 | 15 | export function MobileNav({ items, children }: MobileNavProps) { 16 | useLockBody() 17 | 18 | return ( 19 |
      24 |
      25 | 26 | 27 | {siteConfig.name} 28 | 29 | 43 | {children} 44 |
      45 |
      46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /components/mode-toggle.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { useTheme } from "next-themes" 5 | 6 | import { Button } from "@/components/ui/button" 7 | import { 8 | DropdownMenu, 9 | DropdownMenuContent, 10 | DropdownMenuItem, 11 | DropdownMenuTrigger, 12 | } from "@/components/ui/dropdown-menu" 13 | import { Icons } from "@/components/icons" 14 | 15 | export function ModeToggle() { 16 | const { setTheme } = useTheme() 17 | 18 | return ( 19 | 20 | 21 | 26 | 27 | 28 | setTheme("light")}> 29 | 30 | Light 31 | 32 | setTheme("dark")}> 33 | 34 | Dark 35 | 36 | setTheme("system")}> 37 | 38 | System 39 | 40 | 41 | 42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /components/nav.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import Link from "next/link" 4 | import { usePathname } from "next/navigation" 5 | 6 | import { SidebarNavItem } from "types" 7 | import { cn } from "@/lib/utils" 8 | import { Icons } from "@/components/icons" 9 | 10 | interface DashboardNavProps { 11 | items: SidebarNavItem[] 12 | } 13 | 14 | export function DashboardNav({ items }: DashboardNavProps) { 15 | const path = usePathname() 16 | 17 | if (!items?.length) { 18 | return null 19 | } 20 | 21 | return ( 22 | 43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /components/page-header.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | interface DocsPageHeaderProps extends React.HTMLAttributes { 4 | heading: string 5 | text?: string 6 | } 7 | 8 | export function DocsPageHeader({ 9 | heading, 10 | text, 11 | className, 12 | ...props 13 | }: DocsPageHeaderProps) { 14 | return ( 15 | <> 16 |
      17 |

      18 | {heading} 19 |

      20 | {text &&

      {text}

      } 21 |
      22 |
      23 | 24 | ) 25 | } 26 | -------------------------------------------------------------------------------- /components/pager.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | import { Doc } from "contentlayer/generated" 3 | 4 | import { docsConfig } from "@/config/docs" 5 | import { cn } from "@/lib/utils" 6 | import { buttonVariants } from "@/components/ui/button" 7 | import { Icons } from "@/components/icons" 8 | 9 | interface DocsPagerProps { 10 | doc: Doc 11 | } 12 | 13 | export function DocsPager({ doc }: DocsPagerProps) { 14 | const pager = getPagerForDoc(doc) 15 | 16 | if (!pager) { 17 | return null 18 | } 19 | 20 | return ( 21 |
      22 | {pager?.prev && ( 23 | 27 | 28 | {pager.prev.title} 29 | 30 | )} 31 | {pager?.next && ( 32 | 36 | {pager.next.title} 37 | 38 | 39 | )} 40 |
      41 | ) 42 | } 43 | 44 | export function getPagerForDoc(doc: Doc) { 45 | const flattenedLinks = [null, ...flatten(docsConfig.sidebarNav), null] 46 | const activeIndex = flattenedLinks.findIndex( 47 | (link) => doc.slug === link?.href 48 | ) 49 | const prev = activeIndex !== 0 ? flattenedLinks[activeIndex - 1] : null 50 | const next = 51 | activeIndex !== flattenedLinks.length - 1 52 | ? flattenedLinks[activeIndex + 1] 53 | : null 54 | return { 55 | prev, 56 | next, 57 | } 58 | } 59 | 60 | export function flatten(links: { items? }[]) { 61 | return links.reduce((flat, link) => { 62 | return flat.concat(link.items ? flatten(link.items) : link) 63 | }, []) 64 | } 65 | -------------------------------------------------------------------------------- /components/post-create-button.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { useRouter } from "next/navigation" 5 | 6 | import { cn } from "@/lib/utils" 7 | import { ButtonProps, buttonVariants } from "@/components/ui/button" 8 | import { toast } from "@/components/ui/use-toast" 9 | import { Icons } from "@/components/icons" 10 | 11 | interface PostCreateButtonProps extends ButtonProps {} 12 | 13 | export function PostCreateButton({ 14 | className, 15 | variant, 16 | ...props 17 | }: PostCreateButtonProps) { 18 | const router = useRouter() 19 | const [isLoading, setIsLoading] = React.useState(false) 20 | 21 | async function onClick() { 22 | setIsLoading(true) 23 | 24 | const response = await fetch("/api/posts", { 25 | method: "POST", 26 | headers: { 27 | "Content-Type": "application/json", 28 | }, 29 | body: JSON.stringify({ 30 | title: "Untitled Post", 31 | }), 32 | }) 33 | 34 | setIsLoading(false) 35 | 36 | if (!response?.ok) { 37 | if (response.status === 402) { 38 | return toast({ 39 | title: "Limit of 3 posts reached.", 40 | description: "Please upgrade to the PRO plan.", 41 | variant: "destructive", 42 | }) 43 | } 44 | 45 | return toast({ 46 | title: "Something went wrong.", 47 | description: "Your post was not created. Please try again.", 48 | variant: "destructive", 49 | }) 50 | } 51 | 52 | const post = await response.json() 53 | 54 | // This forces a cache invalidation. 55 | router.refresh() 56 | 57 | router.push(`/editor/${post[0].id}`) 58 | } 59 | 60 | return ( 61 | 80 | ) 81 | } 82 | -------------------------------------------------------------------------------- /components/post-item.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | import { Post } from "@/types/main" 4 | import { formatDate } from "@/lib/utils" 5 | import { Skeleton } from "@/components/ui/skeleton" 6 | import { PostOperations } from "@/components/post-operations" 7 | 8 | interface PostItemProps { 9 | post: Pick 10 | } 11 | 12 | export function PostItem({ post }: PostItemProps) { 13 | return ( 14 |
      15 |
      16 | 20 | {post.title} 21 | 22 |
      23 |

      24 | {formatDate(new Date(post.created_at).toDateString())} 25 |

      26 |
      27 |
      28 | 29 |
      30 | ) 31 | } 32 | 33 | PostItem.Skeleton = function PostItemSkeleton() { 34 | return ( 35 |
      36 |
      37 | 38 | 39 |
      40 |
      41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /components/post-operations.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import Link from "next/link" 5 | import { useRouter } from "next/navigation" 6 | import { Post } from "@/types/main" 7 | 8 | import { 9 | AlertDialog, 10 | AlertDialogAction, 11 | AlertDialogCancel, 12 | AlertDialogContent, 13 | AlertDialogDescription, 14 | AlertDialogFooter, 15 | AlertDialogHeader, 16 | AlertDialogTitle, 17 | } from "@/components/ui/alert-dialog" 18 | import { 19 | DropdownMenu, 20 | DropdownMenuContent, 21 | DropdownMenuItem, 22 | DropdownMenuSeparator, 23 | DropdownMenuTrigger, 24 | } from "@/components/ui/dropdown-menu" 25 | import { toast } from "@/components/ui/use-toast" 26 | import { Icons } from "@/components/icons" 27 | 28 | async function deletePost(postId: string) { 29 | const response = await fetch(`/api/posts/${postId}`, { 30 | method: "DELETE", 31 | }) 32 | 33 | if (!response?.ok) { 34 | toast({ 35 | title: "Something went wrong.", 36 | description: "Your post was not deleted. Please try again.", 37 | variant: "destructive", 38 | }) 39 | } 40 | 41 | return true 42 | } 43 | 44 | interface PostOperationsProps { 45 | post: Pick 46 | } 47 | 48 | export function PostOperations({ post }: PostOperationsProps) { 49 | const router = useRouter() 50 | const [showDeleteAlert, setShowDeleteAlert] = React.useState(false) 51 | const [isDeleteLoading, setIsDeleteLoading] = React.useState(false) 52 | 53 | return ( 54 | <> 55 | 56 | 57 | 58 | Open 59 | 60 | 61 | 62 | 63 | Edit 64 | 65 | 66 | 67 | setShowDeleteAlert(true)} 70 | > 71 | Delete 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | Are you sure you want to delete this post? 80 | 81 | 82 | This action cannot be undone. 83 | 84 | 85 | 86 | Cancel 87 | { 89 | event.preventDefault() 90 | setIsDeleteLoading(true) 91 | 92 | const deleted = await deletePost(post.id) 93 | 94 | if (deleted) { 95 | setIsDeleteLoading(false) 96 | setShowDeleteAlert(false) 97 | router.refresh() 98 | } 99 | }} 100 | className="bg-red-600 focus:ring-red-600" 101 | > 102 | {isDeleteLoading ? ( 103 | 104 | ) : ( 105 | 106 | )} 107 | Delete 108 | 109 | 110 | 111 | 112 | 113 | ) 114 | } 115 | -------------------------------------------------------------------------------- /components/search.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | 5 | import { cn } from "@/lib/utils" 6 | import { Input } from "@/components/ui/input" 7 | import { toast } from "@/components/ui/use-toast" 8 | 9 | interface DocsSearchProps extends React.HTMLAttributes {} 10 | 11 | export function DocsSearch({ className, ...props }: DocsSearchProps) { 12 | function onSubmit(event: React.SyntheticEvent) { 13 | event.preventDefault() 14 | 15 | return toast({ 16 | title: "Not implemented", 17 | description: "We're still working on the search.", 18 | }) 19 | } 20 | 21 | return ( 22 |
      27 | 32 | 33 | K 34 | 35 |
      36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /components/shell.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | interface DashboardShellProps extends React.HTMLAttributes {} 6 | 7 | export function DashboardShell({ 8 | children, 9 | className, 10 | ...props 11 | }: DashboardShellProps) { 12 | return ( 13 |
      14 | {children} 15 |
      16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /components/sidebar-nav.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import Link from "next/link" 4 | import { usePathname } from "next/navigation" 5 | 6 | import { SidebarNavItem } from "types" 7 | import { cn } from "@/lib/utils" 8 | 9 | export interface DocsSidebarNavProps { 10 | items: SidebarNavItem[] 11 | } 12 | 13 | export function DocsSidebarNav({ items }: DocsSidebarNavProps) { 14 | const pathname = usePathname() 15 | 16 | return items.length ? ( 17 |
      18 | {items.map((item, index) => ( 19 |
      20 |

      21 | {item.title} 22 |

      23 | {item.items ? ( 24 | 25 | ) : null} 26 |
      27 | ))} 28 |
      29 | ) : null 30 | } 31 | 32 | interface DocsSidebarNavItemsProps { 33 | items: SidebarNavItem[] 34 | pathname: string | null 35 | } 36 | 37 | export function DocsSidebarNavItems({ 38 | items, 39 | pathname, 40 | }: DocsSidebarNavItemsProps) { 41 | return items?.length ? ( 42 |
      43 | {items.map((item, index) => 44 | !item.disabled && item.href ? ( 45 | 57 | {item.title} 58 | 59 | ) : ( 60 | 61 | {item.title} 62 | 63 | ) 64 | )} 65 |
      66 | ) : null 67 | } 68 | -------------------------------------------------------------------------------- /components/site-footer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { siteConfig } from "@/config/site" 4 | import { cn } from "@/lib/utils" 5 | import { Icons } from "@/components/icons" 6 | import { ModeToggle } from "@/components/mode-toggle" 7 | 8 | export function SiteFooter({ className }: React.HTMLAttributes) { 9 | return ( 10 | 57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /components/tailwind-indicator.tsx: -------------------------------------------------------------------------------- 1 | export function TailwindIndicator() { 2 | if (process.env.NODE_ENV === "production") return null 3 | 4 | return ( 5 |
      6 |
      xs
      7 |
      8 | sm 9 |
      10 |
      md
      11 |
      lg
      12 |
      xl
      13 |
      2xl
      14 |
      15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { ThemeProvider as NextThemesProvider } from "next-themes" 5 | import { ThemeProviderProps } from "next-themes/dist/types" 6 | 7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) { 8 | return {children} 9 | } 10 | -------------------------------------------------------------------------------- /components/toc.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | 5 | import { TableOfContents } from "@/lib/toc" 6 | import { cn } from "@/lib/utils" 7 | import { useMounted } from "@/hooks/use-mounted" 8 | 9 | interface TocProps { 10 | toc: TableOfContents 11 | } 12 | 13 | export function DashboardTableOfContents({ toc }: TocProps) { 14 | const itemIds = React.useMemo( 15 | () => 16 | toc.items 17 | ? toc.items 18 | .flatMap((item) => [item.url, item?.items?.map((item) => item.url)]) 19 | .flat() 20 | .filter(Boolean) 21 | .map((id) => id?.split("#")[1]) 22 | : [], 23 | [toc] 24 | ) 25 | const activeHeading = useActiveItem(itemIds) 26 | const mounted = useMounted() 27 | 28 | if (!toc?.items) { 29 | return null 30 | } 31 | 32 | return mounted ? ( 33 |
      34 |

      On This Page

      35 | 36 |
      37 | ) : null 38 | } 39 | 40 | function useActiveItem(itemIds: (string | undefined)[]) { 41 | const [activeId, setActiveId] = React.useState("") 42 | 43 | React.useEffect(() => { 44 | const observer = new IntersectionObserver( 45 | (entries) => { 46 | entries.forEach((entry) => { 47 | if (entry.isIntersecting) { 48 | setActiveId(entry.target.id) 49 | } 50 | }) 51 | }, 52 | { rootMargin: `0% 0% -80% 0%` } 53 | ) 54 | 55 | itemIds?.forEach((id) => { 56 | if (!id) { 57 | return 58 | } 59 | 60 | const element = document.getElementById(id) 61 | if (element) { 62 | observer.observe(element) 63 | } 64 | }) 65 | 66 | return () => { 67 | itemIds?.forEach((id) => { 68 | if (!id) { 69 | return 70 | } 71 | 72 | const element = document.getElementById(id) 73 | if (element) { 74 | observer.unobserve(element) 75 | } 76 | }) 77 | } 78 | }, [itemIds]) 79 | 80 | return activeId 81 | } 82 | 83 | interface TreeProps { 84 | tree: TableOfContents 85 | level?: number 86 | activeItem?: string | null 87 | } 88 | 89 | function Tree({ tree, level = 1, activeItem }: TreeProps) { 90 | return tree?.items?.length && level < 3 ? ( 91 |
        92 | {tree.items.map((item, index) => { 93 | return ( 94 |
      • 95 | 104 | {item.title} 105 | 106 | {item.items?.length ? ( 107 | 108 | ) : null} 109 |
      • 110 | ) 111 | })} 112 |
      113 | ) : null 114 | } 115 | -------------------------------------------------------------------------------- /components/ui/accordion.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AccordionPrimitive from "@radix-ui/react-accordion" 5 | import { ChevronDown } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Accordion = AccordionPrimitive.Root 10 | 11 | const AccordionItem = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef 14 | >(({ className, ...props }, ref) => ( 15 | 20 | )) 21 | AccordionItem.displayName = "AccordionItem" 22 | 23 | const AccordionTrigger = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, children, ...props }, ref) => ( 27 | 28 | svg]:rotate-180", 32 | className 33 | )} 34 | {...props} 35 | > 36 | {children} 37 | 38 | 39 | 40 | )) 41 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName 42 | 43 | const AccordionContent = React.forwardRef< 44 | React.ElementRef, 45 | React.ComponentPropsWithoutRef 46 | >(({ className, children, ...props }, ref) => ( 47 | 55 |
      {children}
      56 |
      57 | )) 58 | AccordionContent.displayName = AccordionPrimitive.Content.displayName 59 | 60 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } 61 | -------------------------------------------------------------------------------- /components/ui/alert.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { VariantProps, cva } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const alertVariants = cva( 7 | "relative w-full rounded-lg border p-4 [&>svg]:absolute [&>svg]:text-foreground [&>svg]:left-4 [&>svg]:top-4 [&>svg+div]:translate-y-[-3px] [&:has(svg)]:pl-11", 8 | { 9 | variants: { 10 | variant: { 11 | default: "bg-background text-foreground", 12 | destructive: 13 | "text-destructive border-destructive/50 dark:border-destructive [&>svg]:text-destructive text-destructive", 14 | }, 15 | }, 16 | defaultVariants: { 17 | variant: "default", 18 | }, 19 | } 20 | ) 21 | 22 | const Alert = React.forwardRef< 23 | HTMLDivElement, 24 | React.HTMLAttributes & VariantProps 25 | >(({ className, variant, ...props }, ref) => ( 26 |
      32 | )) 33 | Alert.displayName = "Alert" 34 | 35 | const AlertTitle = React.forwardRef< 36 | HTMLParagraphElement, 37 | React.HTMLAttributes 38 | >(({ className, ...props }, ref) => ( 39 |
      44 | )) 45 | AlertTitle.displayName = "AlertTitle" 46 | 47 | const AlertDescription = React.forwardRef< 48 | HTMLParagraphElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |
      56 | )) 57 | AlertDescription.displayName = "AlertDescription" 58 | 59 | export { Alert, AlertTitle, AlertDescription } 60 | -------------------------------------------------------------------------------- /components/ui/aspect-ratio.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" 4 | 5 | const AspectRatio = AspectRatioPrimitive.Root 6 | 7 | export { AspectRatio } 8 | -------------------------------------------------------------------------------- /components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Avatar = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | Avatar.displayName = AvatarPrimitive.Root.displayName 22 | 23 | const AvatarImage = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 32 | )) 33 | AvatarImage.displayName = AvatarPrimitive.Image.displayName 34 | 35 | const AvatarFallback = React.forwardRef< 36 | React.ElementRef, 37 | React.ComponentPropsWithoutRef 38 | >(({ className, ...props }, ref) => ( 39 | 47 | )) 48 | AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName 49 | 50 | export { Avatar, AvatarImage, AvatarFallback } 51 | -------------------------------------------------------------------------------- /components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { VariantProps, cva } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center border rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "bg-primary hover:bg-primary/80 border-transparent text-primary-foreground", 13 | secondary: 14 | "bg-secondary hover:bg-secondary/80 border-transparent text-secondary-foreground", 15 | destructive: 16 | "bg-destructive hover:bg-destructive/80 border-transparent text-destructive-foreground", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
      33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { VariantProps, cva } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const buttonVariants = cva( 7 | "inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:pointer-events-none ring-offset-background", 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 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: "underline-offset-4 hover:underline text-primary", 20 | }, 21 | size: { 22 | default: "h-10 py-2 px-4", 23 | sm: "h-9 px-3 rounded-md", 24 | lg: "h-11 px-8 rounded-md", 25 | }, 26 | }, 27 | defaultVariants: { 28 | variant: "default", 29 | size: "default", 30 | }, 31 | } 32 | ) 33 | 34 | export interface ButtonProps 35 | extends React.ButtonHTMLAttributes, 36 | VariantProps {} 37 | 38 | const Button = React.forwardRef( 39 | ({ className, variant, size, ...props }, ref) => { 40 | return ( 41 |