├── pages ├── .gitkeep └── api │ ├── auth │ └── [...nextauth].ts │ ├── users │ ├── [userId].ts │ └── stripe.ts │ ├── posts │ ├── [postId].ts │ └── index.ts │ ├── webhooks │ └── stripe.ts │ └── og.tsx ├── .nvmrc ├── public ├── robots.txt ├── og.jpg ├── favicon.ico ├── images │ ├── hero.png │ ├── avatars │ │ └── shadcn.png │ └── blog │ │ ├── blog-post-1.jpg │ │ ├── blog-post-2.jpg │ │ ├── blog-post-3.jpg │ │ └── blog-post-4.jpg └── vercel.svg ├── .commitlintrc.json ├── styles ├── globals.css └── mdx.css ├── .husky ├── commit-msg └── pre-commit ├── assets └── fonts │ ├── Inter-Bold.ttf │ └── Inter-Regular.ttf ├── content ├── authors │ └── shadcn.mdx ├── docs │ ├── in-progress.mdx │ ├── index.mdx │ └── documentation │ │ ├── index.mdx │ │ ├── code-blocks.mdx │ │ └── components.mdx ├── pages │ ├── privacy.mdx │ └── terms.mdx └── blog │ └── deploying-next-apps.mdx ├── postcss.config.js ├── .eslintrc.json ├── lib ├── validations │ ├── auth.ts │ ├── user.ts │ ├── og.ts │ └── post.ts ├── exceptions.ts ├── stripe.ts ├── session.ts ├── api-middlewares │ ├── with-methods.ts │ ├── with-authentication.ts │ ├── with-validation.ts │ ├── with-current-user.ts │ └── with-post.ts ├── db.ts ├── utils.ts ├── subscription.ts ├── toc.ts └── auth.ts ├── .prettierrc.json ├── prisma ├── migrations │ ├── migration_lock.toml │ ├── 20221118173244_add_stripe_columns │ │ └── migration.sql │ └── 20221021182747_init │ │ └── migration.sql └── schema.prisma ├── app ├── (marketing) │ ├── [...slug] │ │ ├── head.tsx │ │ └── page.tsx │ ├── blog │ │ ├── [...slug] │ │ │ ├── head.tsx │ │ │ └── page.tsx │ │ └── page.tsx │ ├── layout.tsx │ └── pricing │ │ └── page.tsx ├── (docs) │ ├── guides │ │ ├── [...slug] │ │ │ ├── head.tsx │ │ │ └── page.tsx │ │ ├── layout.tsx │ │ └── page.tsx │ ├── docs │ │ ├── [[...slug]] │ │ │ ├── head.tsx │ │ │ └── page.tsx │ │ └── layout.tsx │ └── layout.tsx ├── (auth) │ ├── layout.tsx │ ├── login │ │ └── page.tsx │ └── register │ │ └── page.tsx ├── (editor) │ └── editor │ │ ├── layout.tsx │ │ └── [postId] │ │ ├── loading.tsx │ │ ├── not-found.tsx │ │ └── page.tsx ├── (dashboard) │ └── dashboard │ │ ├── settings │ │ ├── loading.tsx │ │ └── page.tsx │ │ ├── billing │ │ ├── loading.tsx │ │ └── page.tsx │ │ ├── loading.tsx │ │ ├── layout.tsx │ │ └── page.tsx ├── head.tsx └── layout.tsx ├── components ├── analytics.tsx ├── dashboard │ ├── shell.tsx │ ├── header.tsx │ ├── user-avatar.tsx │ ├── nav.tsx │ ├── post-item.tsx │ ├── empty-placeholder.tsx │ ├── post-create-button.tsx │ ├── user-account-nav.tsx │ ├── billing-form.tsx │ ├── post-operations.tsx │ ├── user-name-form.tsx │ ├── editor.tsx │ └── user-auth-form.tsx ├── docs │ ├── page-header.tsx │ ├── callout.tsx │ ├── card.tsx │ ├── search.tsx │ ├── sidebar-nav.tsx │ ├── mdx-head.tsx │ ├── pager.tsx │ ├── toc.tsx │ └── mdx.tsx ├── tailwind-indicator.tsx ├── icons.tsx ├── mobile-nav.tsx ├── help.tsx ├── site-footer.tsx └── main-nav.tsx ├── .editorconfig ├── config ├── site.ts ├── subscriptions.ts ├── marketing.ts ├── dashboard.ts └── docs.ts ├── hooks ├── use-mounted.ts └── use-lock-body.ts ├── types ├── next-auth.d.ts └── index.d.ts ├── ui ├── skeleton.tsx ├── popover.tsx ├── avatar.tsx ├── dropdown.tsx ├── card.tsx ├── toast.tsx └── alert.tsx ├── next.config.mjs ├── .gitignore ├── LICENSE.md ├── tailwind.config.js ├── tsconfig.json ├── middleware.ts ├── .env.example ├── README.md ├── package.json └── contentlayer.config.js /pages/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v16.18.0 2 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # * 2 | User-agent: * 3 | Allow: / 4 | -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"] 3 | } 4 | -------------------------------------------------------------------------------- /public/og.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KiranRevanna1/taxonomy/HEAD/public/og.jpg -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KiranRevanna1/taxonomy/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/images/hero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KiranRevanna1/taxonomy/HEAD/public/images/hero.png -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /assets/fonts/Inter-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KiranRevanna1/taxonomy/HEAD/assets/fonts/Inter-Bold.ttf -------------------------------------------------------------------------------- /assets/fonts/Inter-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KiranRevanna1/taxonomy/HEAD/assets/fonts/Inter-Regular.ttf -------------------------------------------------------------------------------- /content/authors/shadcn.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: shadcn 3 | avatar: /images/avatars/shadcn.png 4 | twitter: shadcn 5 | --- 6 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/images/avatars/shadcn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KiranRevanna1/taxonomy/HEAD/public/images/avatars/shadcn.png -------------------------------------------------------------------------------- /public/images/blog/blog-post-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KiranRevanna1/taxonomy/HEAD/public/images/blog/blog-post-1.jpg -------------------------------------------------------------------------------- /public/images/blog/blog-post-2.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KiranRevanna1/taxonomy/HEAD/public/images/blog/blog-post-2.jpg -------------------------------------------------------------------------------- /public/images/blog/blog-post-3.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KiranRevanna1/taxonomy/HEAD/public/images/blog/blog-post-3.jpg -------------------------------------------------------------------------------- /public/images/blog/blog-post-4.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/KiranRevanna1/taxonomy/HEAD/public/images/blog/blog-post-4.jpg -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals", 3 | "rules": { 4 | "@next/next/no-head-element": "off" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /lib/validations/auth.ts: -------------------------------------------------------------------------------- 1 | import * as z from "zod" 2 | 3 | export const userAuthSchema = z.object({ 4 | email: z.string().email(), 5 | }) 6 | -------------------------------------------------------------------------------- /lib/validations/user.ts: -------------------------------------------------------------------------------- 1 | import * as z from "zod" 2 | 3 | export const userNameSchema = z.object({ 4 | name: z.string().min(3).max(32), 5 | }) 6 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "semi": false, 4 | "singleQuote": false, 5 | "tabWidth": 2, 6 | "trailingComma": "es5" 7 | } 8 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "mysql" -------------------------------------------------------------------------------- /lib/exceptions.ts: -------------------------------------------------------------------------------- 1 | export class RequiresProPlanError extends Error { 2 | constructor(message = "This action requires a pro plan") { 3 | super(message) 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /lib/stripe.ts: -------------------------------------------------------------------------------- 1 | import Stripe from "stripe" 2 | 3 | export const stripe = new Stripe(process.env.STRIPE_API_KEY, { 4 | apiVersion: "2022-11-15", 5 | typescript: true, 6 | }) 7 | -------------------------------------------------------------------------------- /pages/api/auth/[...nextauth].ts: -------------------------------------------------------------------------------- 1 | import NextAuth from "next-auth" 2 | import { authOptions } from "@/lib/auth" 3 | 4 | // @see ./lib/auth 5 | export default NextAuth(authOptions) 6 | -------------------------------------------------------------------------------- /app/(marketing)/[...slug]/head.tsx: -------------------------------------------------------------------------------- 1 | import MdxHead from "@/components/docs/mdx-head" 2 | 3 | export default function Head({ params }) { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/(docs)/guides/[...slug]/head.tsx: -------------------------------------------------------------------------------- 1 | import MdxHead from "@/components/docs/mdx-head" 2 | 3 | export default function Head({ params }) { 4 | return 5 | } 6 | -------------------------------------------------------------------------------- /lib/validations/og.ts: -------------------------------------------------------------------------------- 1 | import * as z from "zod" 2 | 3 | export const ogImageSchema = z.object({ 4 | heading: z.string(), 5 | type: z.string(), 6 | mode: z.enum(["light", "dark"]).default("dark"), 7 | }) 8 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /app/(auth)/layout.tsx: -------------------------------------------------------------------------------- 1 | interface AuthLayoutProps { 2 | children: React.ReactNode 3 | } 4 | 5 | export default function AuthLayout({ children }: AuthLayoutProps) { 6 | return
{children}
7 | } 8 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /lib/validations/post.ts: -------------------------------------------------------------------------------- 1 | import * as z from "zod" 2 | 3 | export const postPatchSchema = z.object({ 4 | title: z.string().min(3).max(128).optional(), 5 | 6 | // TODO: Type this properly from editorjs block types? 7 | content: z.any().optional(), 8 | }) 9 | -------------------------------------------------------------------------------- /config/site.ts: -------------------------------------------------------------------------------- 1 | import { SiteConfig } from "types" 2 | 3 | export const siteConfig: SiteConfig = { 4 | name: "Taxonomy", 5 | links: { 6 | twitter: "https://twitter.com/shadcn", 7 | github: "https://github.com/shadcn/taxonomy", 8 | }, 9 | } 10 | -------------------------------------------------------------------------------- /hooks/use-mounted.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | export function useMounted() { 4 | const [mounted, setMounted] = React.useState(false) 5 | 6 | React.useEffect(() => { 7 | setMounted(true) 8 | }, []) 9 | 10 | return mounted 11 | } 12 | -------------------------------------------------------------------------------- /app/(marketing)/blog/[...slug]/head.tsx: -------------------------------------------------------------------------------- 1 | import MdxHead from "@/components/docs/mdx-head" 2 | 3 | export default function Head({ params }) { 4 | return ( 5 | 11 | ) 12 | } 13 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /content/docs/in-progress.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Not Implemented 3 | description: This page is in progress. 4 | --- 5 | 6 | 7 | 8 | This site is a work in progress. If you see dummy text on a page, it means I'm still working on it. You can follow updates on Twitter [@shadcn](https://twitter.com/shadcn). 9 | 10 | 11 | -------------------------------------------------------------------------------- /types/next-auth.d.ts: -------------------------------------------------------------------------------- 1 | import { User } from "next-auth" 2 | import { JWT } from "next-auth/jwt" 3 | 4 | type UserId = string 5 | 6 | declare module "next-auth/jwt" { 7 | interface JWT { 8 | id: UserId 9 | } 10 | } 11 | 12 | declare module "next-auth" { 13 | interface Session { 14 | user: User & { 15 | id: UserId 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /lib/session.ts: -------------------------------------------------------------------------------- 1 | import { unstable_getServerSession } from "next-auth/next" 2 | 3 | import { authOptions } from "@/lib/auth" 4 | 5 | export async function getSession() { 6 | return await unstable_getServerSession(authOptions) 7 | } 8 | 9 | export async function getCurrentUser() { 10 | const session = await getSession() 11 | 12 | return session?.user 13 | } 14 | -------------------------------------------------------------------------------- /ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | interface SkeletonProps extends React.HTMLAttributes {} 4 | 5 | export function Skeleton({ className, ...props }: SkeletonProps) { 6 | return ( 7 |
14 | ) 15 | } 16 | -------------------------------------------------------------------------------- /lib/api-middlewares/with-methods.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiHandler, NextApiRequest, NextApiResponse } from "next" 2 | 3 | export function withMethods(methods: string[], handler: NextApiHandler) { 4 | return async function (req: NextApiRequest, res: NextApiResponse) { 5 | if (!methods.includes(req.method)) { 6 | return res.status(405).end() 7 | } 8 | 9 | return handler(req, res) 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | import { withContentlayer } from "next-contentlayer" 2 | 3 | /** @type {import('next').NextConfig} */ 4 | const nextConfig = { 5 | reactStrictMode: true, 6 | images: { 7 | domains: ["avatars.githubusercontent.com"], 8 | }, 9 | experimental: { 10 | appDir: true, 11 | serverComponentsExternalPackages: ["@prisma/client"], 12 | }, 13 | } 14 | 15 | export default withContentlayer(nextConfig) 16 | -------------------------------------------------------------------------------- /hooks/use-lock-body.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | // @see https://usehooks.com/useLockBodyScroll. 4 | export function useLockBody() { 5 | React.useLayoutEffect((): (() => void) => { 6 | const originalStyle: string = window.getComputedStyle( 7 | document.body 8 | ).overflow 9 | document.body.style.overflow = "hidden" 10 | return () => (document.body.style.overflow = originalStyle) 11 | }, []) 12 | } 13 | -------------------------------------------------------------------------------- /components/dashboard/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 | -------------------------------------------------------------------------------- /lib/db.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client" 2 | 3 | declare global { 4 | // eslint-disable-next-line no-var 5 | var cachedPrisma: PrismaClient 6 | } 7 | 8 | let prisma: PrismaClient 9 | if (process.env.NODE_ENV === "production") { 10 | prisma = new PrismaClient() 11 | } else { 12 | if (!global.cachedPrisma) { 13 | global.cachedPrisma = new PrismaClient() 14 | } 15 | prisma = global.cachedPrisma 16 | } 17 | 18 | export const db = prisma 19 | -------------------------------------------------------------------------------- /app/(docs)/docs/[[...slug]]/head.tsx: -------------------------------------------------------------------------------- 1 | import { allDocs } from "contentlayer/generated" 2 | 3 | import MdxHead from "@/components/docs/mdx-head" 4 | 5 | export default function Head({ params }) { 6 | const slug = params?.slug?.join("/") || "" 7 | const doc = allDocs.find((doc) => doc.slugAsParams === slug) 8 | return ( 9 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /config/subscriptions.ts: -------------------------------------------------------------------------------- 1 | import { SubscriptionPlan } from "types" 2 | 3 | export const freePlan: SubscriptionPlan = { 4 | name: "Free", 5 | description: 6 | "The free plan is limited to 3 posts. Upgrade to the PRO plan for unlimited posts.", 7 | stripePriceId: null, 8 | } 9 | 10 | export const proPlan: SubscriptionPlan = { 11 | name: "PRO", 12 | description: "The PRO plan has unlimited posts.", 13 | stripePriceId: process.env.STRIPE_PRO_MONTHLY_PLAN_ID, 14 | } 15 | -------------------------------------------------------------------------------- /lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { ClassValue, clsx } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | 8 | export function formatDate(input: string | number): string { 9 | const date = new Date(input) 10 | return date.toLocaleDateString("en-US", { 11 | month: "long", 12 | day: "numeric", 13 | year: "numeric", 14 | }) 15 | } 16 | 17 | export function absoluteUrl(path: string) { 18 | return `${process.env.NEXT_PUBLIC_APP_URL}${path}` 19 | } 20 | -------------------------------------------------------------------------------- /lib/api-middlewares/with-authentication.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiHandler, NextApiRequest, NextApiResponse } from "next" 2 | import { unstable_getServerSession } from "next-auth/next" 3 | 4 | import { authOptions } from "@/lib/auth" 5 | 6 | export function withAuthentication(handler: NextApiHandler) { 7 | return async function (req: NextApiRequest, res: NextApiResponse) { 8 | const session = await unstable_getServerSession(req, res, authOptions) 9 | 10 | if (!session) { 11 | return res.status(403).end() 12 | } 13 | 14 | return handler(req, res) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /app/(dashboard)/dashboard/settings/loading.tsx: -------------------------------------------------------------------------------- 1 | import { DashboardHeader } from "@/components/dashboard/header" 2 | import { DashboardShell } from "@/components/dashboard/shell" 3 | import { Card } from "@/ui/card" 4 | 5 | export default function DashboardSettingsLoading() { 6 | return ( 7 | 8 | 12 |
13 | 14 | 15 |
16 |
17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /config/marketing.ts: -------------------------------------------------------------------------------- 1 | import { MarketingConfig } from "types" 2 | 3 | export const marketingConfig: MarketingConfig = { 4 | mainNav: [ 5 | { 6 | title: "Features", 7 | href: "/features", 8 | disabled: true, 9 | }, 10 | { 11 | title: "Pricing", 12 | href: "/pricing", 13 | }, 14 | { 15 | title: "Blog", 16 | href: "/blog", 17 | }, 18 | { 19 | title: "Documentation", 20 | href: "/docs", 21 | }, 22 | { 23 | title: "Contact", 24 | href: "/contact", 25 | disabled: true, 26 | }, 27 | ], 28 | } 29 | -------------------------------------------------------------------------------- /app/(dashboard)/dashboard/billing/loading.tsx: -------------------------------------------------------------------------------- 1 | import { DashboardHeader } from "@/components/dashboard/header" 2 | import { DashboardShell } from "@/components/dashboard/shell" 3 | import { Card } from "@/ui/card" 4 | 5 | export default function DashboardBillingLoading() { 6 | return ( 7 | 8 | 12 |
13 | 14 | 15 |
16 |
17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /components/dashboard/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 |

16 | {heading} 17 |

18 | {text &&

{text}

} 19 |
20 | {children} 21 |
22 | ) 23 | } 24 | -------------------------------------------------------------------------------- /app/(editor)/editor/[postId]/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "@/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/(docs)/docs/layout.tsx: -------------------------------------------------------------------------------- 1 | import { docsConfig } from "@/config/docs" 2 | import { DocsSidebarNav } from "@/components/docs/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 | -------------------------------------------------------------------------------- /lib/api-middlewares/with-validation.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiHandler, NextApiRequest, NextApiResponse } from "next" 2 | import * as z from "zod" 3 | import type { ZodSchema } from "zod" 4 | 5 | export function withValidation( 6 | schema: T, 7 | handler: NextApiHandler 8 | ) { 9 | return async function (req: NextApiRequest, res: NextApiResponse) { 10 | try { 11 | const body = req.body ? req.body : {} 12 | 13 | await schema.parse(body) 14 | 15 | return handler(req, res) 16 | } catch (error) { 17 | if (error instanceof z.ZodError) { 18 | return res.status(422).json(error.issues) 19 | } 20 | 21 | return res.status(422).end() 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /components/docs/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/dashboard/user-avatar.tsx: -------------------------------------------------------------------------------- 1 | import { User } from "@prisma/client" 2 | import { AvatarProps } from "@radix-ui/react-avatar" 3 | 4 | import { Icons } from "@/components/icons" 5 | import { Avatar } from "@/ui/avatar" 6 | 7 | interface UserAvatarProps extends AvatarProps { 8 | user: Pick 9 | } 10 | 11 | export function UserAvatar({ user, ...props }: UserAvatarProps) { 12 | return ( 13 | 14 | {user.image ? ( 15 | 16 | ) : ( 17 | 18 | {user.name} 19 | 20 | 21 | )} 22 | 23 | ) 24 | } 25 | -------------------------------------------------------------------------------- /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/docs/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 |
24 | {icon && {icon}} 25 |
{children}
26 |
27 | ) 28 | } 29 | -------------------------------------------------------------------------------- /content/pages/privacy.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Privacy 3 | description: The Privacy Policy for Taxonomy App. 4 | --- 5 | 6 | ## Consent 7 | 8 | Blandit libero volutpat sed cras ornare arcu. Cursus sit amet dictum sit amet. 9 | 10 | Nunc vel risus commodo viverra maecenas accumsan. Libero id faucibus nisl tincidunt eget nullam non nisi est. Varius quam quisque id diam vel quam. Id donec ultrices tincidunt arcu non. 11 | 12 | ## Information we collect 13 | 14 | Amet justo donec enim diam. In hendrerit gravida rutrum quisque non. Hac habitasse platea dictumst quisque sagittis purus sit. 15 | 16 | ## How we use your Information 17 | 18 | Ut sem nulla pharetra diam sit amet nisl suscipit adipiscing. Consectetur adipiscing elit pellentesque habitant. Ut tristique et egestas quis ipsum suspendisse ultrices gravida. 19 | -------------------------------------------------------------------------------- /app/(dashboard)/dashboard/loading.tsx: -------------------------------------------------------------------------------- 1 | import { DashboardHeader } from "@/components/dashboard/header" 2 | import { DashboardShell } from "@/components/dashboard/shell" 3 | import { PostCreateButton } from "@/components/dashboard/post-create-button" 4 | import { PostItem } from "@/components/dashboard/post-item" 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/settings/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation" 2 | 3 | import { getCurrentUser } from "@/lib/session" 4 | import { authOptions } from "@/lib/auth" 5 | import { DashboardHeader } from "@/components/dashboard/header" 6 | import { DashboardShell } from "@/components/dashboard/shell" 7 | import { UserNameForm } from "@/components/dashboard/user-name-form" 8 | 9 | export default async function SettingsPage() { 10 | const user = await getCurrentUser() 11 | 12 | if (!user) { 13 | redirect(authOptions.pages.signIn) 14 | } 15 | 16 | return ( 17 | 18 | 22 |
23 | 24 |
25 |
26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /app/(editor)/editor/[postId]/not-found.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | import { EmptyPlaceholder } from "@/components/dashboard/empty-placeholder" 4 | 5 | export default function NotFound() { 6 | return ( 7 | 8 | 9 | Uh oh! Not Found 10 | 11 | This post cound not be found. Please try again. 12 | 13 | 17 | Go to Dashboard 18 | 19 | 20 | ) 21 | } 22 | -------------------------------------------------------------------------------- /lib/subscription.ts: -------------------------------------------------------------------------------- 1 | import { UserSubscriptionPlan } from "types" 2 | import { freePlan, proPlan } from "@/config/subscriptions" 3 | import { db } from "@/lib/db" 4 | 5 | export async function getUserSubscriptionPlan( 6 | userId: string 7 | ): Promise { 8 | const user = await db.user.findFirst({ 9 | where: { 10 | id: userId, 11 | }, 12 | select: { 13 | stripeSubscriptionId: true, 14 | stripeCurrentPeriodEnd: true, 15 | stripeCustomerId: true, 16 | stripePriceId: true, 17 | }, 18 | }) 19 | 20 | // Check if user is on a pro plan. 21 | const isPro = 22 | user.stripePriceId && 23 | user.stripeCurrentPeriodEnd?.getTime() + 86_400_000 > Date.now() 24 | 25 | const plan = isPro ? proPlan : freePlan 26 | 27 | return { 28 | ...plan, 29 | ...user, 30 | stripeCurrentPeriodEnd: user.stripeCurrentPeriodEnd?.getTime(), 31 | isPro, 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /config/dashboard.ts: -------------------------------------------------------------------------------- 1 | import { DashboardConfig } from "types" 2 | 3 | export const dashboardConfig: DashboardConfig = { 4 | mainNav: [ 5 | { 6 | title: "Documentation", 7 | href: "/docs", 8 | }, 9 | { 10 | title: "Support", 11 | href: "/support", 12 | disabled: true, 13 | }, 14 | ], 15 | sidebarNav: [ 16 | { 17 | title: "Posts", 18 | href: "/dashboard", 19 | icon: "post", 20 | }, 21 | { 22 | title: "Pages", 23 | href: "/", 24 | icon: "page", 25 | disabled: true, 26 | }, 27 | { 28 | title: "Media", 29 | href: "/", 30 | icon: "media", 31 | disabled: true, 32 | }, 33 | { 34 | title: "Billing", 35 | href: "/dashboard/billing", 36 | icon: "billing", 37 | }, 38 | { 39 | title: "Settings", 40 | href: "/dashboard/settings", 41 | icon: "settings", 42 | }, 43 | ], 44 | } 45 | -------------------------------------------------------------------------------- /app/head.tsx: -------------------------------------------------------------------------------- 1 | export default function Head() { 2 | return ( 3 | <> 4 | Taxonomy 5 | 6 | 7 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /components/icons.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | AlertTriangle, 3 | ArrowRight, 4 | Check, 5 | ChevronLeft, 6 | ChevronRight, 7 | Command, 8 | CreditCard, 9 | File, 10 | FileText, 11 | Github, 12 | HelpCircle, 13 | Image, 14 | Loader2, 15 | MoreVertical, 16 | Pizza, 17 | Plus, 18 | Settings, 19 | Trash, 20 | Twitter, 21 | User, 22 | X, 23 | } from "lucide-react" 24 | import type { Icon as LucideIcon } from "lucide-react" 25 | 26 | export type Icon = LucideIcon 27 | 28 | export const Icons = { 29 | logo: Command, 30 | close: X, 31 | spinner: Loader2, 32 | chevronLeft: ChevronLeft, 33 | chevronRight: ChevronRight, 34 | trash: Trash, 35 | post: FileText, 36 | page: File, 37 | media: Image, 38 | settings: Settings, 39 | billing: CreditCard, 40 | ellipsis: MoreVertical, 41 | add: Plus, 42 | warning: AlertTriangle, 43 | user: User, 44 | arrowRight: ArrowRight, 45 | help: HelpCircle, 46 | pizza: Pizza, 47 | gitHub: Github, 48 | twitter: Twitter, 49 | check: Check, 50 | } 51 | -------------------------------------------------------------------------------- /lib/api-middlewares/with-current-user.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiHandler, NextApiRequest, NextApiResponse } from "next" 2 | import { unstable_getServerSession } from "next-auth/next" 3 | import * as z from "zod" 4 | 5 | import { authOptions } from "@/lib/auth" 6 | 7 | export const schema = z.object({ 8 | userId: z.string(), 9 | }) 10 | 11 | export function withCurrentUser(handler: NextApiHandler) { 12 | return async function (req: NextApiRequest, res: NextApiResponse) { 13 | try { 14 | const query = await schema.parse(req.query) 15 | 16 | // Check if the user has access to this user. 17 | const session = await unstable_getServerSession(req, res, authOptions) 18 | 19 | if (query.userId !== session?.user.id) { 20 | return res.status(403).end() 21 | } 22 | 23 | return handler(req, res) 24 | } catch (error) { 25 | if (error instanceof z.ZodError) { 26 | return res.status(422).json(error.issues) 27 | } 28 | 29 | return res.status(500).end() 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /styles/mdx.css: -------------------------------------------------------------------------------- 1 | [data-rehype-pretty-code-fragment] code { 2 | @apply grid min-w-full break-words rounded-none border-0 bg-transparent p-0 text-sm text-black; 3 | counter-reset: line; 4 | box-decoration-break: clone; 5 | } 6 | [data-rehype-pretty-code-fragment] .line { 7 | @apply px-4 py-1; 8 | } 9 | [data-rehype-pretty-code-fragment] [data-line-numbers] > .line::before { 10 | counter-increment: line; 11 | content: counter(line); 12 | display: inline-block; 13 | width: 1rem; 14 | margin-right: 1rem; 15 | text-align: right; 16 | color: gray; 17 | } 18 | [data-rehype-pretty-code-fragment] .line--highlighted { 19 | @apply bg-slate-300 bg-opacity-10; 20 | } 21 | [data-rehype-pretty-code-fragment] .line-highlighted span { 22 | @apply relative; 23 | } 24 | [data-rehype-pretty-code-fragment] .word--highlighted { 25 | @apply rounded-md bg-slate-300 bg-opacity-10 p-1; 26 | } 27 | [data-rehype-pretty-code-title] { 28 | @apply mt-4 py-2 px-4 text-sm font-medium; 29 | } 30 | [data-rehype-pretty-code-title] + pre { 31 | @apply mt-0; 32 | } 33 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { Inter as FontSans } from "@next/font/google" 2 | 3 | import "@/styles/globals.css" 4 | 5 | import { cn } from "@/lib/utils" 6 | import { Toaster } from "@/ui/toast" 7 | import { Help } from "@/components/help" 8 | import { Analytics } from "@/components/analytics" 9 | import { TailwindIndicator } from "@/components/tailwind-indicator" 10 | 11 | const fontSans = FontSans({ 12 | subsets: ["latin"], 13 | variable: "--font-inter", 14 | }) 15 | 16 | interface RootLayoutProps { 17 | children: React.ReactNode 18 | } 19 | 20 | export default function RootLayout({ children }: RootLayoutProps) { 21 | return ( 22 | 29 | 30 | 31 | {children} 32 | 33 | 34 | 35 | 36 | 37 | 38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /components/docs/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 Card({ 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 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as PopoverPrimitive from "@radix-ui/react-popover" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | type PopoverProps = PopoverPrimitive.PopoverProps 9 | 10 | export function Popover({ ...props }: PopoverProps) { 11 | return 12 | } 13 | 14 | Popover.Trigger = React.forwardRef< 15 | HTMLButtonElement, 16 | PopoverPrimitive.PopoverTriggerProps 17 | >(function PopoverTrigger({ ...props }, ref) { 18 | return 19 | }) 20 | 21 | Popover.Portal = PopoverPrimitive.Portal 22 | 23 | Popover.Content = React.forwardRef< 24 | HTMLDivElement, 25 | PopoverPrimitive.PopoverContentProps 26 | >(function PopoverContent({ className, ...props }, ref) { 27 | return ( 28 | 37 | ) 38 | }) 39 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /app/(editor)/editor/[postId]/page.tsx: -------------------------------------------------------------------------------- 1 | import { notFound, redirect } from "next/navigation" 2 | 3 | import { Post, User } from "@prisma/client" 4 | import { db } from "@/lib/db" 5 | import { getCurrentUser } from "@/lib/session" 6 | import { authOptions } from "@/lib/auth" 7 | import { Editor } from "@/components/dashboard/editor" 8 | 9 | async function getPostForUser(postId: Post["id"], userId: User["id"]) { 10 | return await db.post.findFirst({ 11 | where: { 12 | id: postId, 13 | authorId: userId, 14 | }, 15 | }) 16 | } 17 | 18 | interface EditorPageProps { 19 | params: { postId: string } 20 | } 21 | 22 | export default async function EditorPage({ params }: EditorPageProps) { 23 | const user = await getCurrentUser() 24 | 25 | if (!user) { 26 | redirect(authOptions.pages.signIn) 27 | } 28 | 29 | const post = await getPostForUser(params.postId, user.id) 30 | 31 | if (!post) { 32 | notFound() 33 | } 34 | 35 | return ( 36 | 44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | const { colors } = require("tailwindcss/colors") 2 | const { fontFamily } = require("tailwindcss/defaultTheme") 3 | 4 | /** @type {import('tailwindcss').Config} */ 5 | module.exports = { 6 | content: [ 7 | "./app/**/*.{ts,tsx}", 8 | "./components/**/*.{ts,tsx}", 9 | "./ui/**/*.{ts,tsx}", 10 | "./content/**/*.{md,mdx}", 11 | ], 12 | theme: { 13 | container: { 14 | center: true, 15 | padding: "1.5rem", 16 | screens: { 17 | "2xl": "1440px", 18 | }, 19 | }, 20 | extend: { 21 | fontFamily: { 22 | sans: ["var(--font-inter)", ...fontFamily.sans], 23 | }, 24 | colors: { 25 | ...colors, 26 | brand: { 27 | 50: "#f3f3f3", 28 | 100: "#e7e7e7", 29 | 200: "#c4c4c4", 30 | 300: "#a0a0a0", 31 | 400: "#585858", 32 | 500: "#111111", 33 | 600: "#0f0f0f", 34 | 700: "#0d0d0d", 35 | 800: "#0a0a0a", 36 | 900: "#080808", 37 | DEFAULT: "#111111", 38 | }, 39 | }, 40 | }, 41 | }, 42 | plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")], 43 | } 44 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": false, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "baseUrl": ".", 18 | "paths": { 19 | "@/components/*": ["components/*"], 20 | "@/ui/*": ["ui/*"], 21 | "@/hooks/*": ["hooks/*"], 22 | "@/lib/*": ["lib/*"], 23 | "@/config/*": ["config/*"], 24 | "@/styles/*": ["styles/*"], 25 | "@/prisma/*": ["prisma/*"], 26 | "contentlayer/generated": ["./.contentlayer/generated"] 27 | }, 28 | "plugins": [ 29 | { 30 | "name": "next" 31 | } 32 | ] 33 | }, 34 | "include": [ 35 | "next-env.d.ts", 36 | "**/*.ts", 37 | "**/*.tsx", 38 | ".next/types/**/*.ts", 39 | ".contentlayer/generated", 40 | "content/pages/about.mdx" 41 | ], 42 | "exclude": ["node_modules"] 43 | } 44 | -------------------------------------------------------------------------------- /lib/api-middlewares/with-post.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiHandler, NextApiRequest, NextApiResponse } from "next" 2 | import { unstable_getServerSession } from "next-auth/next" 3 | import * as z from "zod" 4 | 5 | import { authOptions } from "@/lib/auth" 6 | import { db } from "@/lib/db" 7 | 8 | export const schema = z.object({ 9 | postId: z.string(), 10 | }) 11 | 12 | export function withPost(handler: NextApiHandler) { 13 | return async function (req: NextApiRequest, res: NextApiResponse) { 14 | try { 15 | const query = await schema.parse(req.query) 16 | 17 | // Check if the user has access to this post. 18 | const session = await unstable_getServerSession(req, res, authOptions) 19 | const count = await db.post.count({ 20 | where: { 21 | id: query.postId, 22 | authorId: session.user.id, 23 | }, 24 | }) 25 | 26 | if (count < 1) { 27 | return res.status(403).end() 28 | } 29 | 30 | return handler(req, res) 31 | } catch (error) { 32 | if (error instanceof z.ZodError) { 33 | return res.status(422).json(error.issues) 34 | } 35 | 36 | return res.status(500).end() 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /prisma/migrations/20221118173244_add_stripe_columns/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - A unique constraint covering the columns `[stripe_customer_id]` on the table `users` will be added. If there are existing duplicate values, this will fail. 5 | - A unique constraint covering the columns `[stripe_subscription_id]` on the table `users` will be added. If there are existing duplicate values, this will fail. 6 | 7 | */ 8 | -- DropForeignKey 9 | ALTER TABLE `accounts` DROP FOREIGN KEY `accounts_userId_fkey`; 10 | 11 | -- DropForeignKey 12 | ALTER TABLE `posts` DROP FOREIGN KEY `posts_authorId_fkey`; 13 | 14 | -- DropForeignKey 15 | ALTER TABLE `sessions` DROP FOREIGN KEY `sessions_userId_fkey`; 16 | 17 | -- AlterTable 18 | ALTER TABLE `users` ADD COLUMN `stripe_current_period_end` DATETIME(3) NULL, 19 | ADD COLUMN `stripe_customer_id` VARCHAR(191) NULL, 20 | ADD COLUMN `stripe_price_id` VARCHAR(191) NULL, 21 | ADD COLUMN `stripe_subscription_id` VARCHAR(191) NULL; 22 | 23 | -- CreateIndex 24 | CREATE UNIQUE INDEX `users_stripe_customer_id_key` ON `users`(`stripe_customer_id`); 25 | 26 | -- CreateIndex 27 | CREATE UNIQUE INDEX `users_stripe_subscription_id_key` ON `users`(`stripe_subscription_id`); 28 | -------------------------------------------------------------------------------- /app/(marketing)/layout.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | import { marketingConfig } from "@/config/marketing" 4 | import { MainNav } from "@/components/main-nav" 5 | import { SiteFooter } from "@/components/site-footer" 6 | 7 | interface MarketingLayoutProps { 8 | children: React.ReactNode 9 | } 10 | 11 | export default async function MarketingLayout({ 12 | children, 13 | }: MarketingLayoutProps) { 14 | return ( 15 |
16 |
17 |
18 | 19 | 27 |
28 |
29 |
{children}
30 | 31 |
32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /app/(marketing)/[...slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import { notFound } from "next/navigation" 2 | import { allPages } from "contentlayer/generated" 3 | 4 | import { Mdx } from "@/components/docs/mdx" 5 | import "@/styles/mdx.css" 6 | 7 | interface PageProps { 8 | params: { 9 | slug: string[] 10 | } 11 | } 12 | 13 | export async function generateStaticParams(): Promise { 14 | return allPages.map((page) => ({ 15 | slug: page.slugAsParams.split("/"), 16 | })) 17 | } 18 | 19 | export default async function PagePage({ params }: PageProps) { 20 | const slug = params?.slug?.join("/") 21 | const page = allPages.find((page) => page.slugAsParams === slug) 22 | 23 | if (!page) { 24 | notFound() 25 | } 26 | 27 | return ( 28 |
29 |
30 |

31 | {page.title} 32 |

33 | {page.description && ( 34 |

{page.description}

35 | )} 36 |
37 |
38 | 39 |
40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /content/docs/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Documentation 3 | description: Welcome to the Taxonomy documentation. 4 | --- 5 | 6 | This is the documentation for the Taxonomy site. 7 | 8 | I'm going to use this to document all the features of Taxonomy and how they are built. The documentation site is built using [ContentLayer](/docs/documentation/contentlayer) and MDX. 9 | 10 | 11 | 12 | This site is a work in progress. If you see dummy text on a page, it means I'm still working on it. You can follow updates on Twitter [@shadcn](https://twitter.com/shadcn). 13 | 14 | 15 | 16 | ## Features 17 | 18 | Select a feature below to learn more about it. 19 | 20 |
21 | 22 | 23 | 24 | ### Documentation 25 | 26 | This documentation site built using Contentlayer. 27 | 28 | 29 | 30 | 31 | 32 | ### Marketing 33 | 34 | The marketing site with landing pages. 35 | 36 | 37 | 38 | 39 | 40 | ### App 41 | 42 | The dashboard with auth and subscriptions. 43 | 44 | 45 | 46 | 47 | 48 | ### Blog 49 | 50 | The blog built using Contentlayer and MDX. 51 | 52 | 53 | 54 |
55 | -------------------------------------------------------------------------------- /components/dashboard/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 | 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { getToken } from "next-auth/jwt" 2 | import { withAuth } from "next-auth/middleware" 3 | import { NextResponse } from "next/server" 4 | 5 | export default withAuth( 6 | async function middleware(req) { 7 | const token = await getToken({ req }) 8 | const isAuth = !!token 9 | const isAuthPage = 10 | req.nextUrl.pathname.startsWith("/login") || 11 | req.nextUrl.pathname.startsWith("/register") 12 | 13 | if (isAuthPage) { 14 | if (isAuth) { 15 | return NextResponse.redirect(new URL("/dashboard", req.url)) 16 | } 17 | 18 | return null 19 | } 20 | 21 | if (!isAuth) { 22 | let from = req.nextUrl.pathname; 23 | if (req.nextUrl.search) { 24 | from += req.nextUrl.search; 25 | } 26 | 27 | return NextResponse.redirect( 28 | new URL(`/login?from=${encodeURIComponent(from)}`, req.url) 29 | ); 30 | } 31 | }, 32 | { 33 | callbacks: { 34 | async authorized() { 35 | // This is a work-around for handling redirect on auth pages. 36 | // We return true here so that the middleware function above 37 | // is always called. 38 | return true 39 | }, 40 | }, 41 | } 42 | ) 43 | 44 | export const config = { 45 | matcher: ["/dashboard/:path*", "/editor/:path*", "/login", "/register"], 46 | } 47 | -------------------------------------------------------------------------------- /types/index.d.ts: -------------------------------------------------------------------------------- 1 | import { Icons } from "@/components/icons" 2 | import { User } from "@prisma/client" 3 | import type { Icon } from "lucide-react" 4 | 5 | export type NavItem = { 6 | title: string 7 | href: string 8 | disabled?: boolean 9 | } 10 | 11 | export type MainNavItem = NavItem 12 | 13 | export type SidebarNavItem = { 14 | title: string 15 | disabled?: boolean 16 | external?: boolean 17 | icon?: keyof typeof Icons 18 | } & ( 19 | | { 20 | href: string 21 | items?: never 22 | } 23 | | { 24 | href?: string 25 | items: NavLink[] 26 | } 27 | ) 28 | 29 | export type SiteConfig = { 30 | name: string 31 | links: { 32 | twitter: string 33 | github: string 34 | } 35 | } 36 | 37 | export type DocsConfig = { 38 | mainNav: MainNavItem[] 39 | sidebarNav: SidebarNavItem[] 40 | } 41 | 42 | export type MarketingConfig = { 43 | mainNav: MainNavItem[] 44 | } 45 | 46 | export type DashboardConfig = { 47 | mainNav: MainNavItem[] 48 | sidebarNav: SidebarNavItem[] 49 | } 50 | 51 | export type SubscriptionPlan = { 52 | name: string 53 | description: string 54 | stripePriceId: string 55 | } 56 | 57 | export type UserSubscriptionPlan = SubscriptionPlan & 58 | Pick & { 59 | stripeCurrentPeriodEnd: number 60 | isPro: boolean 61 | } 62 | -------------------------------------------------------------------------------- /pages/api/users/[userId].ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next" 2 | import * as z from "zod" 3 | import { unstable_getServerSession } from "next-auth/next" 4 | 5 | import { db } from "@/lib/db" 6 | import { withMethods } from "@/lib/api-middlewares/with-methods" 7 | import { withCurrentUser } from "@/lib/api-middlewares/with-current-user" 8 | import { userNameSchema } from "@/lib/validations/user" 9 | import { authOptions } from "@/lib/auth" 10 | 11 | async function handler(req: NextApiRequest, res: NextApiResponse) { 12 | if (req.method === "PATCH") { 13 | try { 14 | const session = await unstable_getServerSession(req, res, authOptions) 15 | const user = session?.user 16 | 17 | const body = req.body 18 | 19 | if (body?.name) { 20 | const payload = userNameSchema.parse(body) 21 | 22 | await db.user.update({ 23 | where: { 24 | id: user.id, 25 | }, 26 | data: { 27 | name: payload.name, 28 | }, 29 | }) 30 | } 31 | 32 | return res.end() 33 | } catch (error) { 34 | if (error instanceof z.ZodError) { 35 | return res.status(422).json(error.issues) 36 | } 37 | 38 | return res.status(422).end() 39 | } 40 | } 41 | } 42 | 43 | export default withMethods(["PATCH"], withCurrentUser(handler)) 44 | -------------------------------------------------------------------------------- /ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | import Image, { ImageProps } from "next/image" 2 | import * as AvatarPrimitive from "@radix-ui/react-avatar" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | type AvatarProps = AvatarPrimitive.AvatarProps 7 | 8 | export function Avatar({ className, ...props }: AvatarProps) { 9 | return ( 10 | 17 | ) 18 | } 19 | 20 | Avatar.Image = function AvatarImage({ 21 | src, 22 | className, 23 | alt, 24 | width = 32, 25 | height = 32, 26 | ...props 27 | }: ImageProps) { 28 | if (!src) { 29 | return 30 | } 31 | 32 | return ( 33 | {alt} 41 | ) 42 | } 43 | 44 | Avatar.Fallback = function AvatarFallback({ 45 | className, 46 | children, 47 | ...props 48 | }: AvatarPrimitive.AvatarFallbackProps) { 49 | return ( 50 | 55 | {children} 56 | 57 | ) 58 | } 59 | -------------------------------------------------------------------------------- /components/dashboard/post-item.tsx: -------------------------------------------------------------------------------- 1 | import { Post } from "@prisma/client" 2 | import Link from "next/link" 3 | 4 | import { formatDate } from "@/lib/utils" 5 | import { PostOperations } from "@/components/dashboard/post-operations" 6 | import { Skeleton } from "@/ui/skeleton" 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(post.createdAt?.toDateString())} 25 |

26 |
27 |
28 | 29 | {/* */} 30 |
31 | ) 32 | } 33 | 34 | PostItem.Skeleton = function PostItemSkeleton() { 35 | return ( 36 |
37 |
38 | 39 | 40 |
41 |
42 | ) 43 | } 44 | -------------------------------------------------------------------------------- /components/docs/search.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | 5 | import { cn } from "@/lib/utils" 6 | import { toast } from "@/ui/toast" 7 | 8 | interface DocsSearchProps extends React.HTMLAttributes {} 9 | 10 | export function DocsSearch({ className, ...props }: DocsSearchProps) { 11 | function onSubmit(event: React.SyntheticEvent) { 12 | event.preventDefault() 13 | 14 | return toast({ 15 | title: "Not implemented", 16 | message: "We're still working on the search.", 17 | type: "error", 18 | }) 19 | } 20 | 21 | return ( 22 |
27 | 32 | 33 | K 34 | 35 |
36 | ) 37 | } 38 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # ----------------------------------------------------------------------------- 2 | # App 3 | # ----------------------------------------------------------------------------- 4 | NEXT_PUBLIC_APP_URL=http://localhost:3000 5 | 6 | # ----------------------------------------------------------------------------- 7 | # Authentication (NextAuth.js) 8 | # ----------------------------------------------------------------------------- 9 | NEXTAUTH_URL=http://localhost:3000 10 | NEXTAUTH_SECRET= 11 | 12 | GITHUB_CLIENT_ID= 13 | GITHUB_CLIENT_SECRET= 14 | GITHUB_ACCESS_TOKEN= 15 | 16 | # ----------------------------------------------------------------------------- 17 | # Database (MySQL - PlanetScale) 18 | # ----------------------------------------------------------------------------- 19 | DATABASE_URL="mysql://root:root@localhost:3306/taxonomy?schema=public" 20 | 21 | # ----------------------------------------------------------------------------- 22 | # Email (Postmark) 23 | # ----------------------------------------------------------------------------- 24 | SMTP_FROM= 25 | POSTMARK_API_TOKEN= 26 | POSTMARK_SIGN_IN_TEMPLATE= 27 | POSTMARK_ACTIVATION_TEMPLATE= 28 | 29 | # ----------------------------------------------------------------------------- 30 | # Subscriptions (Stripe) 31 | # ----------------------------------------------------------------------------- 32 | STRIPE_API_KEY= 33 | STRIPE_WEBHOOK_SECRET= 34 | STRIPE_PRO_MONTHLY_PLAN_ID= -------------------------------------------------------------------------------- /content/docs/documentation/index.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Documentation 3 | description: Build your documentation site using Contentlayer and MDX. 4 | --- 5 | 6 | Taxonomy includes a documentation site built using [Contentlayer](https://contentlayer.dev) and [MDX](http://mdxjs.com). 7 | 8 | ## Features 9 | 10 | It comes with the following features out of the box: 11 | 12 | 1. Write content using MDX. 13 | 2. Transform and validate content using Contentlayer. 14 | 3. MDX components such as `` and ``. 15 | 4. Support for table of contents. 16 | 5. Custom navigation with prev and next pager. 17 | 6. Beautiful code blocks using `rehype-pretty-code`. 18 | 7. Syntax highlighting using `shiki`. 19 | 8. Built-in search (_in progress_). 20 | 9. Dark mode (_in progress_). 21 | 22 | ## How is it built 23 | 24 | Click on a section below to learn how the documentation site built. 25 | 26 |
27 | 28 | 29 | 30 | ### Contentlayer 31 | 32 | Learn how to use MDX with Contentlayer. 33 | 34 | 35 | 36 | 37 | 38 | ### Components 39 | 40 | Using React components in Mardown. 41 | 42 | 43 | 44 | 45 | 46 | ### Code Blocks 47 | 48 | Beautiful code blocks with syntax highlighting. 49 | 50 | 51 | 52 | 53 | 54 | ### Style Guide 55 | 56 | View a sample page with all the styles. 57 | 58 | 59 | 60 |
61 | -------------------------------------------------------------------------------- /app/(auth)/login/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | import { Icons } from "@/components/icons" 4 | import { UserAuthForm } from "@/components/dashboard/user-auth-form" 5 | 6 | export default function LoginPage() { 7 | return ( 8 |
9 | 13 | <> 14 | 15 | Back 16 | 17 | 18 |
19 |
20 | 21 |

Welcome back

22 |

23 | Enter your email to sign in to your account 24 |

25 |
26 | 27 |

28 | 29 | Don't have an account? Sign Up 30 | 31 |

32 |
33 |
34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /app/(dashboard)/dashboard/layout.tsx: -------------------------------------------------------------------------------- 1 | import { notFound } from "next/navigation" 2 | 3 | import { dashboardConfig } from "@/config/dashboard" 4 | import { getCurrentUser } from "@/lib/session" 5 | import { DashboardNav } from "@/components/dashboard/nav" 6 | import { UserAccountNav } from "@/components/dashboard/user-account-nav" 7 | import { MainNav } from "@/components/main-nav" 8 | 9 | interface DashboardLayoutProps { 10 | children?: React.ReactNode 11 | } 12 | 13 | export default async function DashboardLayout({ 14 | children, 15 | }: DashboardLayoutProps) { 16 | const user = await getCurrentUser() 17 | 18 | if (!user) { 19 | return notFound() 20 | } 21 | 22 | return ( 23 |
24 |
25 |
26 | 27 | 34 |
35 |
36 |
37 | 40 |
41 | {children} 42 |
43 |
44 |
45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /pages/api/posts/[postId].ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next" 2 | import * as z from "zod" 3 | 4 | import { withMethods } from "@/lib/api-middlewares/with-methods" 5 | import { withPost } from "@/lib/api-middlewares/with-post" 6 | import { db } from "@/lib/db" 7 | import { postPatchSchema } from "@/lib/validations/post" 8 | 9 | async function handler(req: NextApiRequest, res: NextApiResponse) { 10 | if (req.method === "DELETE") { 11 | try { 12 | await db.post.delete({ 13 | where: { 14 | id: req.query.postId as string, 15 | }, 16 | }) 17 | 18 | return res.status(204).end() 19 | } catch (error) { 20 | return res.status(500).end() 21 | } 22 | } 23 | 24 | if (req.method === "PATCH") { 25 | try { 26 | const postId = req.query.postId as string 27 | const post = await db.post.findUnique({ 28 | where: { 29 | id: postId, 30 | }, 31 | }) 32 | 33 | const body = postPatchSchema.parse(req.body) 34 | 35 | // TODO: Implement sanitization for content. 36 | 37 | await db.post.update({ 38 | where: { 39 | id: post.id, 40 | }, 41 | data: { 42 | title: body.title || post.title, 43 | content: body.content, 44 | }, 45 | }) 46 | 47 | return res.end() 48 | } catch (error) { 49 | if (error instanceof z.ZodError) { 50 | return res.status(422).json(error.issues) 51 | } 52 | 53 | return res.status(422).end() 54 | } 55 | } 56 | } 57 | 58 | export default withMethods(["DELETE", "PATCH"], withPost(handler)) 59 | -------------------------------------------------------------------------------- /components/mobile-nav.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import Link from "next/link" 3 | 4 | import { MainNavItem } from "types" 5 | import { cn } from "@/lib/utils" 6 | import { useLockBody } from "@/hooks/use-lock-body" 7 | import { Icons } from "./icons" 8 | import { siteConfig } from "@/config/site" 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 | -------------------------------------------------------------------------------- /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/docs/mdx" 6 | import { DashboardTableOfContents } from "@/components/docs/toc" 7 | import { DocsPageHeader } from "@/components/docs/page-header" 8 | import { DocsPager } from "@/components/docs/pager" 9 | import "@/styles/mdx.css" 10 | 11 | interface DocPageProps { 12 | params: { 13 | slug: string[] 14 | } 15 | } 16 | 17 | export async function generateStaticParams(): Promise< 18 | DocPageProps["params"][] 19 | > { 20 | return allDocs.map((doc) => ({ 21 | slug: doc.slugAsParams.split("/"), 22 | })) 23 | } 24 | 25 | export default async function DocPage({ params }: DocPageProps) { 26 | const slug = params?.slug?.join("/") || "" 27 | const doc = allDocs.find((doc) => doc.slugAsParams === slug) 28 | 29 | if (!doc) { 30 | notFound() 31 | } 32 | 33 | const toc = await getTableOfContents(doc.body.raw) 34 | 35 | return ( 36 |
37 |
38 | 39 | 40 |
41 | 42 |
43 |
44 |
45 | 46 |
47 |
48 |
49 | ) 50 | } 51 | -------------------------------------------------------------------------------- /components/help.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import Image from "next/image" 4 | 5 | import { Popover } from "@/ui/popover" 6 | import { Icons } from "@/components/icons" 7 | import { siteConfig } from "@/config/site" 8 | import OgImage from "public/og.jpg" 9 | 10 | export function Help() { 11 | return ( 12 | 13 | 14 | 15 | Toggle 16 | 17 | 18 |
19 | Screenshot 24 |

25 | This app is a work in progress. I'm building this in public. 26 |

27 |

28 | You can follow the progress on Twitter{" "} 29 | 35 | @shadcn 36 | {" "} 37 | or on{" "} 38 | 44 | GitHub 45 | 46 | . 47 |

48 |
49 |
50 |
51 | ) 52 | } 53 | -------------------------------------------------------------------------------- /lib/toc.ts: -------------------------------------------------------------------------------- 1 | import { remark } from "remark" 2 | import { toc } from "mdast-util-toc" 3 | import { visit } from "unist-util-visit" 4 | 5 | const textTypes = ["text", "emphasis", "strong", "inlineCode"] 6 | 7 | function flattenNode(node) { 8 | const p = [] 9 | visit(node, (node) => { 10 | if (!textTypes.includes(node.type)) return 11 | p.push(node.value) 12 | }) 13 | return p.join(``) 14 | } 15 | 16 | interface Item { 17 | title: string 18 | url: string 19 | items?: Item[] 20 | } 21 | 22 | interface Items { 23 | items?: Item[] 24 | } 25 | 26 | function getItems(node, current): Items { 27 | if (!node) { 28 | return {} 29 | } 30 | 31 | if (node.type === "paragraph") { 32 | visit(node, (item) => { 33 | if (item.type === "link") { 34 | current.url = item.url 35 | current.title = flattenNode(node) 36 | } 37 | 38 | if (item.type === "text") { 39 | current.title = flattenNode(node) 40 | } 41 | }) 42 | 43 | return current 44 | } 45 | 46 | if (node.type === "list") { 47 | current.items = node.children.map((i) => getItems(i, {})) 48 | 49 | return current 50 | } else if (node.type === "listItem") { 51 | const heading = getItems(node.children[0], {}) 52 | 53 | if (node.children.length > 1) { 54 | getItems(node.children[1], heading) 55 | } 56 | 57 | return heading 58 | } 59 | 60 | return {} 61 | } 62 | 63 | const getToc = () => (node, file) => { 64 | const table = toc(node) 65 | file.data = getItems(table.map, {}) 66 | } 67 | 68 | export type TableOfContents = Items 69 | 70 | export async function getTableOfContents( 71 | content: string 72 | ): Promise { 73 | const result = await remark().use(getToc).process(content) 74 | 75 | return result.data 76 | } 77 | -------------------------------------------------------------------------------- /components/docs/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 | 24 |
25 | ))} 26 |
27 | ) : null 28 | } 29 | 30 | interface DocsSidebarNavItemsProps { 31 | items: SidebarNavItem[] 32 | pathname: string 33 | } 34 | 35 | export function DocsSidebarNavItems({ 36 | items, 37 | pathname, 38 | }: DocsSidebarNavItemsProps) { 39 | return items?.length ? ( 40 |
41 | {items.map((item, index) => ( 42 | 55 | {item.title} 56 | 57 | ))} 58 |
59 | ) : null 60 | } 61 | -------------------------------------------------------------------------------- /content/docs/documentation/code-blocks.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Code Blocks 3 | description: Advanced code blocks with highlighting, file names and more. 4 | --- 5 | 6 | The code blocks on the documentation site and the blog are powered by [rehype-pretty-code](https://github.com/atomiks/rehype-pretty-code). The syntax highlighting is done using [shiki](https://github.com/shikijs/shiki). 7 | 8 | It has the following features: 9 | 10 | 1. Beautiful code blocks with syntax highlighting. 11 | 2. Support for VS Code themes. 12 | 3. Works with hundreds of languages. 13 | 4. Line and word highlighting. 14 | 5. Support for line numbers. 15 | 6. Show code block titles using meta strings. 16 | 17 | 18 | 19 | Thanks to Shiki, highlighting is done at build time. No JavaScript is sent to the client for runtime highlighting. 20 | 21 | 22 | 23 | ## Example 24 | 25 | ```ts showLineNumbers title="next.config.js" {3} /appDir: true/ 26 | import { withContentlayer } from "next-contentlayer" 27 | 28 | /** @type {import('next').NextConfig} */ 29 | const nextConfig = { 30 | reactStrictMode: true, 31 | images: { 32 | domains: ["avatars.githubusercontent.com"], 33 | }, 34 | experimental: { 35 | appDir: true, 36 | serverComponentsExternalPackages: ["@prisma/client"], 37 | }, 38 | } 39 | 40 | export default withContentlayer(nextConfig) 41 | ``` 42 | 43 | ## Title 44 | 45 | ````mdx 46 | ```ts title="path/to/file.ts" 47 | // Code here 48 | ``` 49 | ```` 50 | 51 | ## Line Highlight 52 | 53 | ````mdx 54 | ```ts {1,3-6} 55 | // Highlight line 1 and line 3 to 6 56 | ``` 57 | ```` 58 | 59 | ## Word Highlight 60 | 61 | ````mdx 62 | ```ts /shadcn/ 63 | // Highlight the word shadcn. 64 | ``` 65 | ```` 66 | 67 | ## Line Numbers 68 | 69 | ````mdx 70 | ```ts showLineNumbers 71 | // This will show line numbers. 72 | ``` 73 | ```` 74 | -------------------------------------------------------------------------------- /app/(auth)/register/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | import { Icons } from "@/components/icons" 4 | import { UserAuthForm } from "@/components/dashboard/user-auth-form" 5 | 6 | export default function RegisterPage() { 7 | return ( 8 |
9 | 13 | Login 14 | 15 |
16 |
17 |
18 |
19 | 20 |

Create an account

21 |

22 | Enter your email below to create your account 23 |

24 |
25 | 26 |

27 | By clicking continue, you agree to our{" "} 28 | 29 | Terms of Service 30 | {" "} 31 | and{" "} 32 | 33 | Privacy Policy 34 | 35 | . 36 |

37 |
38 |
39 |
40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /app/(docs)/layout.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | import { siteConfig } from "@/config/site" 4 | import { docsConfig } from "@/config/docs" 5 | import { Icons } from "@/components/icons" 6 | import { MainNav } from "@/components/main-nav" 7 | import { DocsSearch } from "@/components/docs/search" 8 | import { SiteFooter } from "@/components/site-footer" 9 | import { DocsSidebarNav } from "@/components/docs/sidebar-nav" 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 | 39 |
40 |
41 |
42 |
{children}
43 | 44 |
45 | ) 46 | } 47 | -------------------------------------------------------------------------------- /components/site-footer.tsx: -------------------------------------------------------------------------------- 1 | import { siteConfig } from "@/config/site" 2 | import { Icons } from "@/components/icons" 3 | 4 | export function SiteFooter() { 5 | return ( 6 | 55 | ) 56 | } 57 | -------------------------------------------------------------------------------- /components/docs/mdx-head.tsx: -------------------------------------------------------------------------------- 1 | import * as z from "zod" 2 | import { allDocuments } from "contentlayer/generated" 3 | 4 | import { ogImageSchema } from "@/lib/validations/og" 5 | import { absoluteUrl } from "@/lib/utils" 6 | 7 | interface MdxHeadProps { 8 | params: { 9 | slug?: string[] 10 | } 11 | og?: z.infer 12 | } 13 | 14 | export default function MdxHead({ params, og }: MdxHeadProps) { 15 | const slug = params?.slug?.join("/") || "" 16 | const mdxDoc = allDocuments.find((doc) => doc.slugAsParams === slug) 17 | 18 | if (!mdxDoc) { 19 | return null 20 | } 21 | 22 | const title = `${mdxDoc.title} - Taxonomy` 23 | const url = process.env.NEXT_PUBLIC_APP_URL 24 | let ogUrl = new URL(`${url}/og.jpg`) 25 | 26 | const ogTitle = og?.heading || mdxDoc.title 27 | const ogDescription = mdxDoc.description 28 | 29 | if (og?.type) { 30 | ogUrl = new URL(`${url}/api/og`) 31 | ogUrl.searchParams.set("heading", ogTitle) 32 | ogUrl.searchParams.set("type", og.type) 33 | ogUrl.searchParams.set("mode", og.mode || "dark") 34 | } 35 | 36 | return ( 37 | <> 38 | {title} 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | ) 55 | } 56 | -------------------------------------------------------------------------------- /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 { cn } from "@/lib/utils" 9 | import { siteConfig } from "@/config/site" 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 | 46 | ) : null} 47 | 54 | {showMobileMenu && {children}} 55 |
56 | ) 57 | } 58 | -------------------------------------------------------------------------------- /app/(docs)/guides/[...slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | import { notFound } from "next/navigation" 3 | 4 | import { allGuides } from "contentlayer/generated" 5 | 6 | import { getTableOfContents } from "@/lib/toc" 7 | import { Mdx } from "@/components/docs/mdx" 8 | import { DashboardTableOfContents } from "@/components/docs/toc" 9 | import { DocsPageHeader } from "@/components/docs/page-header" 10 | import { Icons } from "@/components/icons" 11 | import "@/styles/mdx.css" 12 | 13 | interface GuidePageProps { 14 | params: { 15 | slug: string[] 16 | } 17 | } 18 | 19 | export async function generateStaticParams(): Promise< 20 | GuidePageProps["params"][] 21 | > { 22 | return allGuides.map((guide) => ({ 23 | slug: guide.slugAsParams.split("/"), 24 | })) 25 | } 26 | 27 | export default async function GuidePage({ params }: GuidePageProps) { 28 | const slug = params?.slug?.join("/") 29 | const guide = allGuides.find((guide) => guide.slugAsParams === slug) 30 | 31 | if (!guide) { 32 | notFound() 33 | } 34 | 35 | const toc = await getTableOfContents(guide.body.raw) 36 | 37 | return ( 38 |
39 |
40 | 41 | 42 |
43 |
44 | 48 | 49 | See all guides 50 | 51 |
52 |
53 |
54 |
55 | 56 |
57 |
58 |
59 | ) 60 | } 61 | -------------------------------------------------------------------------------- /ui/dropdown.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | type DropdownMenuProps = DropdownMenuPrimitive.DropdownMenuProps 9 | 10 | export function DropdownMenu({ ...props }: DropdownMenuProps) { 11 | return 12 | } 13 | 14 | DropdownMenu.Trigger = React.forwardRef< 15 | HTMLButtonElement, 16 | DropdownMenuPrimitive.DropdownMenuTriggerProps 17 | >(function DropdownMenuTrigger({ ...props }, ref) { 18 | return 19 | }) 20 | 21 | DropdownMenu.Portal = DropdownMenuPrimitive.Portal 22 | 23 | DropdownMenu.Content = React.forwardRef< 24 | HTMLDivElement, 25 | DropdownMenuPrimitive.MenuContentProps 26 | >(function DropdownMenuContent({ className, ...props }, ref) { 27 | return ( 28 | 37 | ) 38 | }) 39 | 40 | DropdownMenu.Item = React.forwardRef< 41 | HTMLDivElement, 42 | DropdownMenuPrimitive.DropdownMenuItemProps 43 | >(function DropdownMenuItem({ className, ...props }, ref) { 44 | return ( 45 | 53 | ) 54 | }) 55 | 56 | DropdownMenu.Separator = React.forwardRef< 57 | HTMLDivElement, 58 | DropdownMenuPrimitive.DropdownMenuSeparatorProps 59 | >(function DropdownMenuItem({ className, ...props }, ref) { 60 | return ( 61 | 66 | ) 67 | }) 68 | -------------------------------------------------------------------------------- /content/pages/terms.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Terms & Conditions 3 | description: Read our terms and conditions. 4 | --- 5 | 6 | ## Legal Notices 7 | 8 | Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Volutpat sed cras ornare arcu. Nibh ipsum consequat nisl vel pretium lectus quam id leo. A arcu cursus vitae congue. Amet justo donec enim diam. Vel pharetra vel turpis nunc eget lorem. Gravida quis blandit turpis cursus in. Semper auctor neque vitae tempus. Elementum facilisis leo vel fringilla est ullamcorper eget nulla. Imperdiet nulla malesuada pellentesque elit eget. 9 | 10 | Felis donec et odio pellentesque diam volutpat commodo sed. 11 | 12 | Tortor consequat id porta nibh. Fames ac turpis egestas maecenas pharetra convallis posuere morbi leo. Scelerisque fermentum dui faucibus in. Tortor posuere ac ut consequat semper viverra. 13 | 14 | ## Warranty Disclaimer 15 | 16 | Tellus in hac habitasse platea dictumst vestibulum. Faucibus in ornare quam viverra. Viverra aliquet eget sit amet tellus cras adipiscing. Erat nam at lectus urna duis convallis convallis tellus. Bibendum est ultricies integer quis auctor elit sed vulputate. 17 | 18 | Nisl condimentum id venenatis a condimentum vitae. Ac auctor augue mauris augue neque gravida in fermentum. Arcu felis bibendum ut tristique. Tempor commodo ullamcorper a lacus vestibulum sed arcu non. 19 | 20 | ## General 21 | 22 | Magna fermentum iaculis eu non diam. Vitae purus faucibus ornare suspendisse sed nisi lacus sed. In nibh mauris cursus mattis molestie a iaculis at. Enim sit amet venenatis urna. Eget sit amet tellus cras adipiscing. 23 | 24 | Sed lectus vestibulum mattis ullamcorper velit. Id diam vel quam elementum pulvinar. In iaculis nunc sed augue lacus viverra. In hendrerit gravida rutrum quisque non tellus. Nisl purus in mollis nunc. 25 | 26 | ## Disclaimer 27 | 28 | Amet justo donec enim diam. In hendrerit gravida rutrum quisque non. Hac habitasse platea dictumst quisque sagittis purus sit. Faucibus ornare suspendisse sed nisi lacus. Nulla porttitor massa id neque aliquam vestibulum. Ante in nibh mauris cursus mattis molestie a. Mi tempus imperdiet nulla malesuada. 29 | -------------------------------------------------------------------------------- /ui/card.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | import { Skeleton } from "@/ui/skeleton" 3 | 4 | interface CardProps extends React.HTMLAttributes {} 5 | 6 | export function Card({ className, ...props }: CardProps) { 7 | return ( 8 |
12 | ) 13 | } 14 | 15 | interface CardHeaderProps extends React.HTMLAttributes {} 16 | 17 | Card.Header = function CardHeader({ className, ...props }: CardHeaderProps) { 18 | return
19 | } 20 | 21 | interface CardContentProps extends React.HTMLAttributes {} 22 | 23 | Card.Content = function CardContent({ className, ...props }: CardContentProps) { 24 | return
25 | } 26 | 27 | interface CardFooterProps extends React.HTMLAttributes {} 28 | 29 | Card.Footer = function CardFooter({ className, ...props }: CardFooterProps) { 30 | return ( 31 |
35 | ) 36 | } 37 | 38 | interface CardTitleProps extends React.HTMLAttributes {} 39 | 40 | Card.Title = function CardTitle({ className, ...props }: CardTitleProps) { 41 | return

42 | } 43 | 44 | interface CardDescriptionProps 45 | extends React.HTMLAttributes {} 46 | 47 | Card.Description = function CardDescription({ 48 | className, 49 | ...props 50 | }: CardDescriptionProps) { 51 | return

52 | } 53 | 54 | Card.Skeleton = function CardSeleton() { 55 | return ( 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /pages/api/users/stripe.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next" 2 | import { unstable_getServerSession } from "next-auth/next" 3 | 4 | import { proPlan } from "@/config/subscriptions" 5 | import { withMethods } from "@/lib/api-middlewares/with-methods" 6 | import { getUserSubscriptionPlan } from "@/lib/subscription" 7 | import { stripe } from "@/lib/stripe" 8 | import { withAuthentication } from "@/lib/api-middlewares/with-authentication" 9 | import { absoluteUrl } from "@/lib/utils" 10 | import { authOptions } from "@/lib/auth" 11 | 12 | const billingUrl = absoluteUrl("/dashboard/billing") 13 | 14 | async function handler(req: NextApiRequest, res: NextApiResponse) { 15 | if (req.method === "GET") { 16 | try { 17 | const session = await unstable_getServerSession(req, res, authOptions) 18 | const user = session.user 19 | const subscriptionPlan = await getUserSubscriptionPlan(user.id) 20 | 21 | // The user is on the pro plan. 22 | // Create a portal session to manage subscription. 23 | if (subscriptionPlan.isPro) { 24 | const stripeSession = await stripe.billingPortal.sessions.create({ 25 | customer: subscriptionPlan.stripeCustomerId, 26 | return_url: billingUrl, 27 | }) 28 | 29 | return res.json({ url: stripeSession.url }) 30 | } 31 | 32 | // The user is on the free plan. 33 | // Create a checkout session to upgrade. 34 | const stripeSession = await stripe.checkout.sessions.create({ 35 | success_url: billingUrl, 36 | cancel_url: billingUrl, 37 | payment_method_types: ["card"], 38 | mode: "subscription", 39 | billing_address_collection: "auto", 40 | customer_email: user.email, 41 | line_items: [ 42 | { 43 | price: proPlan.stripePriceId, 44 | quantity: 1, 45 | }, 46 | ], 47 | metadata: { 48 | userId: user.id, 49 | }, 50 | }) 51 | 52 | return res.json({ url: stripeSession.url }) 53 | } catch (error) { 54 | return res.status(500).end() 55 | } 56 | } 57 | } 58 | 59 | export default withMethods(["GET"], withAuthentication(handler)) 60 | -------------------------------------------------------------------------------- /components/dashboard/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/docs/pager.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | import { Doc } from "contentlayer/generated" 3 | 4 | import { docsConfig } from "@/config/docs" 5 | import { Icons } from "@/components/icons" 6 | 7 | interface DocsPagerProps { 8 | doc: Doc 9 | } 10 | 11 | export function DocsPager({ doc }: DocsPagerProps) { 12 | const pager = getPagerForDoc(doc) 13 | 14 | if (!pager) { 15 | return null 16 | } 17 | 18 | return ( 19 |

20 | {pager?.prev && ( 21 | 25 | 26 | {pager.prev.title} 27 | 28 | )} 29 | {pager?.next && ( 30 | 34 | {pager.next.title} 35 | 36 | 37 | )} 38 |
39 | ) 40 | } 41 | 42 | export function getPagerForDoc(doc: Doc) { 43 | const flattenedLinks = [null, ...flatten(docsConfig.sidebarNav), null] 44 | const activeIndex = flattenedLinks.findIndex( 45 | (link) => doc.slug === link?.href 46 | ) 47 | const prev = activeIndex !== 0 ? flattenedLinks[activeIndex - 1] : null 48 | const next = 49 | activeIndex !== flattenedLinks.length - 1 50 | ? flattenedLinks[activeIndex + 1] 51 | : null 52 | return { 53 | prev, 54 | next, 55 | } 56 | } 57 | 58 | export function flatten(links: { items? }[]) { 59 | return links.reduce((flat, link) => { 60 | return flat.concat(link.items ? flatten(link.items) : link) 61 | }, []) 62 | } 63 | -------------------------------------------------------------------------------- /app/(docs)/guides/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | import { compareDesc } from "date-fns" 3 | import { allGuides } from "contentlayer/generated" 4 | 5 | import { DocsPageHeader } from "@/components/docs/page-header" 6 | import { formatDate } from "@/lib/utils" 7 | 8 | export default function GuidesPage() { 9 | const guides = allGuides 10 | .filter((guide) => guide.published) 11 | .sort((a, b) => { 12 | return compareDesc(new Date(a.date), new Date(b.date)) 13 | }) 14 | 15 | return ( 16 |
17 | 21 | {guides?.length ? ( 22 |
23 | {guides.map((guide) => ( 24 |
28 | {guide.featured && ( 29 | 30 | Featured 31 | 32 | )} 33 |
34 |
35 |

36 | {guide.title} 37 |

38 | {guide.description && ( 39 |

{guide.description}

40 | )} 41 |
42 | {guide.date && ( 43 |

44 | {formatDate(guide.date)} 45 |

46 | )} 47 |
48 | 49 | View 50 | 51 |
52 | ))} 53 |
54 | ) : ( 55 |

No guides published.

56 | )} 57 |
58 | ) 59 | } 60 | -------------------------------------------------------------------------------- /components/dashboard/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 { Icons } from "@/components/icons" 8 | import { toast } from "@/ui/toast" 9 | 10 | interface PostCreateButtonProps 11 | extends React.HTMLAttributes {} 12 | 13 | export function PostCreateButton({ 14 | className, 15 | ...props 16 | }: PostCreateButtonProps) { 17 | const router = useRouter() 18 | const [isLoading, setIsLoading] = React.useState(false) 19 | 20 | async function onClick() { 21 | setIsLoading(true) 22 | 23 | const response = await fetch("/api/posts", { 24 | method: "POST", 25 | headers: { 26 | "Content-Type": "application/json", 27 | }, 28 | body: JSON.stringify({ 29 | title: "Untitled Post", 30 | }), 31 | }) 32 | 33 | setIsLoading(false) 34 | 35 | if (!response?.ok) { 36 | if (response.status === 402) { 37 | return toast({ 38 | title: "Limit of 3 posts reached.", 39 | message: "Please upgrade to the PRO plan.", 40 | type: "error", 41 | }) 42 | } 43 | 44 | return toast({ 45 | title: "Something went wrong.", 46 | message: "Your post was not created. Please try again.", 47 | type: "error", 48 | }) 49 | } 50 | 51 | const post = await response.json() 52 | 53 | // This forces a cache invalidation. 54 | router.refresh() 55 | 56 | router.push(`/editor/${post.id}`) 57 | } 58 | 59 | return ( 60 | 79 | ) 80 | } 81 | -------------------------------------------------------------------------------- /app/(dashboard)/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation" 2 | import { cache } from "react" 3 | 4 | import { db } from "@/lib/db" 5 | import { getCurrentUser } from "@/lib/session" 6 | import { User } from "@prisma/client" 7 | import { authOptions } from "@/lib/auth" 8 | import { DashboardHeader } from "@/components/dashboard/header" 9 | import { PostCreateButton } from "@/components/dashboard/post-create-button" 10 | import { DashboardShell } from "@/components/dashboard/shell" 11 | import { PostItem } from "@/components/dashboard/post-item" 12 | import { EmptyPlaceholder } from "@/components/dashboard/empty-placeholder" 13 | 14 | const getPostsForUser = cache(async (userId: User["id"]) => { 15 | return await db.post.findMany({ 16 | where: { 17 | authorId: userId, 18 | }, 19 | select: { 20 | id: true, 21 | title: true, 22 | published: true, 23 | createdAt: true, 24 | }, 25 | orderBy: { 26 | updatedAt: "desc", 27 | }, 28 | }) 29 | }) 30 | 31 | export default async function DashboardPage() { 32 | const user = await getCurrentUser() 33 | 34 | if (!user) { 35 | redirect(authOptions.pages.signIn) 36 | } 37 | 38 | const posts = await getPostsForUser(user.id) 39 | 40 | return ( 41 | 42 | 43 | 44 | 45 |
46 | {posts?.length ? ( 47 |
48 | {posts.map((post) => ( 49 | 50 | ))} 51 |
52 | ) : ( 53 | 54 | 55 | No posts created 56 | 57 | You don't have any posts yet. Start creating content. 58 | 59 | 60 | 61 | )} 62 |
63 |
64 | ) 65 | } 66 | -------------------------------------------------------------------------------- /app/(dashboard)/dashboard/billing/page.tsx: -------------------------------------------------------------------------------- 1 | import { redirect } from "next/navigation" 2 | 3 | import { getCurrentUser } from "@/lib/session" 4 | import { authOptions } from "@/lib/auth" 5 | import { stripe } from "@/lib/stripe" 6 | import { getUserSubscriptionPlan as getUserSubscriptionPlan } from "@/lib/subscription" 7 | import { Card } from "@/ui/card" 8 | import { DashboardHeader } from "@/components/dashboard/header" 9 | import { DashboardShell } from "@/components/dashboard/shell" 10 | import { BillingForm } from "@/components/dashboard/billing-form" 11 | 12 | export default async function BillingPage() { 13 | const user = await getCurrentUser() 14 | 15 | if (!user) { 16 | redirect(authOptions.pages.signIn) 17 | } 18 | 19 | const subscriptionPlan = await getUserSubscriptionPlan(user.id) 20 | 21 | // If user has a pro plan, check cancel status on Stripe. 22 | let isCanceled = false 23 | if (subscriptionPlan.isPro) { 24 | const stripePlan = await stripe.subscriptions.retrieve( 25 | subscriptionPlan.stripeSubscriptionId 26 | ) 27 | isCanceled = stripePlan.cancel_at_period_end 28 | } 29 | 30 | return ( 31 | 32 | 36 |
37 | 43 | 44 | 45 | Note 46 | 47 | 48 |

49 | Taxonomy app is a demo app using a Stripe test environment.{" "} 50 | 51 | You can test the upgrade and won't be charged. 52 | 53 |

54 |

55 | You can find a list of test card numbers on the{" "} 56 | 62 | Stripe docs 63 | 64 | . 65 |

66 |
67 |
68 |
69 |
70 | ) 71 | } 72 | -------------------------------------------------------------------------------- /pages/api/webhooks/stripe.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next" 2 | import Stripe from "stripe" 3 | import rawBody from "raw-body" 4 | 5 | import { stripe } from "@/lib/stripe" 6 | import { db } from "@/lib/db" 7 | 8 | export const config = { 9 | api: { 10 | // Turn off the body parser so we can access raw body for verification. 11 | bodyParser: false, 12 | }, 13 | } 14 | 15 | export default async function handler( 16 | req: NextApiRequest, 17 | res: NextApiResponse 18 | ) { 19 | const body = await rawBody(req) 20 | const signature = req.headers["stripe-signature"] 21 | 22 | let event: Stripe.Event 23 | 24 | try { 25 | event = stripe.webhooks.constructEvent( 26 | body, 27 | signature, 28 | process.env.STRIPE_WEBHOOK_SECRET 29 | ) 30 | } catch (error) { 31 | return res.status(400).send(`Webhook Error: ${error.message}`) 32 | } 33 | 34 | const session = event.data.object as Stripe.Checkout.Session 35 | 36 | if (event.type === "checkout.session.completed") { 37 | // Retrieve the subscription details from Stripe. 38 | const subscription = await stripe.subscriptions.retrieve( 39 | session.subscription as string 40 | ) 41 | 42 | // Update the user stripe into in our database. 43 | // Since this is the initial subscription, we need to update 44 | // the subscription id and customer id. 45 | await db.user.update({ 46 | where: { 47 | id: session.metadata.userId, 48 | }, 49 | data: { 50 | stripeSubscriptionId: subscription.id, 51 | stripeCustomerId: subscription.customer as string, 52 | stripePriceId: subscription.items.data[0].price.id, 53 | stripeCurrentPeriodEnd: new Date( 54 | subscription.current_period_end * 1000 55 | ), 56 | }, 57 | }) 58 | } 59 | 60 | if (event.type === "invoice.payment_succeeded") { 61 | // Retrieve the subscription details from Stripe. 62 | const subscription = await stripe.subscriptions.retrieve( 63 | session.subscription as string 64 | ) 65 | 66 | // Update the price id and set the new period end. 67 | await db.user.update({ 68 | where: { 69 | stripeSubscriptionId: subscription.id, 70 | }, 71 | data: { 72 | stripePriceId: subscription.items.data[0].price.id, 73 | stripeCurrentPeriodEnd: new Date( 74 | subscription.current_period_end * 1000 75 | ), 76 | }, 77 | }) 78 | } 79 | 80 | return res.json({}) 81 | } 82 | -------------------------------------------------------------------------------- /pages/api/posts/index.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next" 2 | import * as z from "zod" 3 | import { unstable_getServerSession } from "next-auth/next" 4 | 5 | import { db } from "@/lib/db" 6 | import { withMethods } from "@/lib/api-middlewares/with-methods" 7 | import { getUserSubscriptionPlan } from "@/lib/subscription" 8 | import { RequiresProPlanError } from "@/lib/exceptions" 9 | import { authOptions } from "@/lib/auth" 10 | 11 | const postCreateSchema = z.object({ 12 | title: z.string().optional(), 13 | content: z.string().optional(), 14 | }) 15 | 16 | async function handler(req: NextApiRequest, res: NextApiResponse) { 17 | const session = await unstable_getServerSession(req, res, authOptions) 18 | 19 | if (!session) { 20 | return res.status(403).end() 21 | } 22 | 23 | const { user } = session 24 | 25 | if (req.method === "GET") { 26 | try { 27 | const posts = await db.post.findMany({ 28 | select: { 29 | id: true, 30 | title: true, 31 | published: true, 32 | createdAt: true, 33 | }, 34 | where: { 35 | authorId: user.id, 36 | }, 37 | }) 38 | 39 | return res.json(posts) 40 | } catch (error) { 41 | return res.status(500).end() 42 | } 43 | } 44 | 45 | if (req.method === "POST") { 46 | try { 47 | const subscriptionPlan = await getUserSubscriptionPlan(user.id) 48 | 49 | // If user is on a free plan. 50 | // Check if user has reached limit of 3 posts. 51 | if (!subscriptionPlan?.isPro) { 52 | const count = await db.post.count({ 53 | where: { 54 | authorId: user.id, 55 | }, 56 | }) 57 | 58 | if (count >= 3) { 59 | throw new RequiresProPlanError() 60 | } 61 | } 62 | 63 | const body = postCreateSchema.parse(req.body) 64 | 65 | const post = await db.post.create({ 66 | data: { 67 | title: body.title, 68 | content: body.content, 69 | authorId: session.user.id, 70 | }, 71 | select: { 72 | id: true, 73 | }, 74 | }) 75 | 76 | return res.json(post) 77 | } catch (error) { 78 | if (error instanceof z.ZodError) { 79 | return res.status(422).json(error.issues) 80 | } 81 | 82 | if (error instanceof RequiresProPlanError) { 83 | return res.status(402).end() 84 | } 85 | 86 | return res.status(500).end() 87 | } 88 | } 89 | } 90 | 91 | export default withMethods(["GET", "POST"], handler) 92 | -------------------------------------------------------------------------------- /ui/toast.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import hotToast, { Toaster as HotToaster } from "react-hot-toast" 5 | 6 | import { cn } from "@/lib/utils" 7 | import { Icons } from "@/components/icons" 8 | 9 | export const Toaster = HotToaster 10 | 11 | interface ToastProps extends React.HTMLAttributes { 12 | visible: boolean 13 | } 14 | 15 | export function Toast({ visible, className, ...props }: ToastProps) { 16 | return ( 17 |
25 | ) 26 | } 27 | 28 | interface ToastIconProps extends Partial> { 29 | name: keyof typeof Icons 30 | } 31 | 32 | Toast.Icon = function ToastIcon({ name, className, ...props }: ToastIconProps) { 33 | const Icon = Icons[name] 34 | 35 | if (!Icon) { 36 | return null 37 | } 38 | 39 | return ( 40 |
41 | 42 |
43 | ) 44 | } 45 | 46 | interface ToastTitleProps extends React.HTMLAttributes {} 47 | 48 | Toast.Title = function ToastTitle({ className, ...props }: ToastTitleProps) { 49 | return

50 | } 51 | 52 | interface ToastDescriptionProps 53 | extends React.HTMLAttributes {} 54 | 55 | Toast.Description = function ToastDescription({ 56 | className, 57 | ...props 58 | }: ToastDescriptionProps) { 59 | return

60 | } 61 | 62 | interface ToastOpts { 63 | title?: string 64 | message: string 65 | type?: "success" | "error" | "default" 66 | duration?: number 67 | } 68 | 69 | export function toast(opts: ToastOpts) { 70 | const { title, message, type = "default", duration = 3000 } = opts 71 | 72 | return hotToast.custom( 73 | ({ visible }) => ( 74 | 81 | {title} 82 | {message && {message}} 83 | 84 | ), 85 | { duration } 86 | ) 87 | } 88 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Taxonomy 2 | 3 | An open source application built using the new router, server components and everything new in Next.js 13. 4 | 5 | > **Warning** 6 | > This app is a work in progress. I'm building this in public. You can follow the progress 7 | > See the roadmap below. 8 | 9 | ## Demo 10 | 11 | ![screenshot-2](https://user-images.githubusercontent.com/124599/198038921-2b16b18b-cb4d-44b1-bd1d-6419d4a8d92c.png) 12 | 13 | ## About this project 14 | 15 | Right now, I'm using 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. 16 | 17 | I'll be posting updates and issues here. 18 | 19 | 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. 20 | 21 | ## Note on Performance 22 | 23 | > **Warning** 24 | > This app is using the canary releases for Next.js 13 and React 18. The new router and app dir is still in beta and not production-ready. 25 | > NextAuth.js, which is used for authentication, is also not fully supported in Next.js 13 and RSC. 26 | > **Expect some performance hits when testing the dashboard**. 27 | > If you see something broken, you can ping me 28 | 29 | ## Features 30 | 31 | - New `/app` dir, 32 | - Routing, Layouts, Nested Layouts and Layout Groups 33 | - Data Fetching, Caching and Mutation 34 | - Loading UI 35 | - Server and Client Components 36 | - API Routes and Middlewares 37 | - Authentication using **NextAuth.js** 38 | - ORM using **Prisma** 39 | - Database on **PlanetScale** 40 | - UI Components built using **Radix UI** 41 | - Documentation and blog using **MDX** and **Contentlayer** 42 | - Subscriptions using **Stripe** 43 | - Styled using **Tailwind CSS** 44 | - Validations using **Zod** 45 | - Written in **TypeScript** 46 | 47 | ## Roadmap 48 | 49 | - [x] ~Add MDX support for basic pages~ 50 | - [x] ~Build marketing pages~ 51 | - [x] ~Subscriptions using Stripe~ 52 | - [x] ~Responsive styles~ 53 | - [x] ~Add OG image for blog using @vercel/og~ 54 | - [ ] Add tests 55 | - [ ] Dark mode 56 | 57 | 58 | 59 | ## Why not tRPC, Turborepo or X? 60 | 61 | I might add this later. For now, I want to see how far we can get using Next.js only. 62 | 63 | If you have some suggestions, feel free to create an issue. 64 | 65 | ## Running Locally 66 | 67 | 1. Install dependencies using pnpm: 68 | 69 | ```sh 70 | pnpm install 71 | ``` 72 | 73 | 2. Copy `.env.example` to `.env.local` and update the variables. 74 | 75 | ```sh 76 | cp .env.example .env.local 77 | ``` 78 | 79 | 3. Start the development server: 80 | 81 | ```sh 82 | pnpm dev 83 | ``` 84 | 85 | ## License 86 | 87 | Licensed under the [MIT license](https://github.com/shadcn/taxonomy/blob/main/LICENSE.md). 88 | -------------------------------------------------------------------------------- /app/(marketing)/blog/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | import { compareDesc } from "date-fns" 3 | import { allPosts } from "contentlayer/generated" 4 | 5 | import { formatDate } from "@/lib/utils" 6 | import Image from "next/image" 7 | 8 | export default async function BlogPage() { 9 | const posts = allPosts 10 | .filter((post) => post.published) 11 | .sort((a, b) => { 12 | return compareDesc(new Date(a.date), new Date(b.date)) 13 | }) 14 | 15 | return ( 16 |

17 |
18 |
19 |

20 | Blog 21 |

22 |

23 | A blog built using Contentlayer. Posts are written in MDX. 24 |

25 |
26 | 30 | Build your own 31 | 32 |
33 |
34 | {posts?.length ? ( 35 |
36 | {posts.map((post, index) => ( 37 |
41 | {post.image && ( 42 | {post.title} 50 | )} 51 |

{post.title}

52 | {post.description && ( 53 |

{post.description}

54 | )} 55 | {post.date && ( 56 |

57 | {formatDate(post.date)} 58 |

59 | )} 60 | 61 | View Article 62 | 63 |
64 | ))} 65 |
66 | ) : ( 67 |

No posts published.

68 | )} 69 |
70 | ) 71 | } 72 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | previewFeatures = ["referentialIntegrity"] 7 | } 8 | 9 | datasource db { 10 | provider = "mysql" 11 | url = env("DATABASE_URL") 12 | referentialIntegrity = "prisma" 13 | } 14 | 15 | model Account { 16 | id String @id @default(cuid()) 17 | userId String 18 | type String 19 | provider String 20 | providerAccountId String 21 | refresh_token String? @db.Text 22 | access_token String? @db.Text 23 | expires_at Int? 24 | token_type String? 25 | scope String? 26 | id_token String? @db.Text 27 | session_state String? 28 | createdAt DateTime @default(now()) @map(name: "created_at") 29 | updatedAt DateTime @default(now()) @map(name: "updated_at") 30 | 31 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 32 | 33 | @@unique([provider, providerAccountId]) 34 | @@map(name: "accounts") 35 | } 36 | 37 | model Session { 38 | id String @id @default(cuid()) 39 | sessionToken String @unique 40 | userId String 41 | expires DateTime 42 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 43 | 44 | @@map(name: "sessions") 45 | } 46 | 47 | model User { 48 | id String @id @default(cuid()) 49 | name String? 50 | email String? @unique 51 | emailVerified DateTime? 52 | image String? 53 | createdAt DateTime @default(now()) @map(name: "created_at") 54 | updatedAt DateTime @default(now()) @map(name: "updated_at") 55 | 56 | accounts Account[] 57 | sessions Session[] 58 | Post Post[] 59 | 60 | stripeCustomerId String? @unique @map(name: "stripe_customer_id") 61 | stripeSubscriptionId String? @unique @map(name: "stripe_subscription_id") 62 | stripePriceId String? @map(name: "stripe_price_id") 63 | stripeCurrentPeriodEnd DateTime? @map(name: "stripe_current_period_end") 64 | 65 | @@map(name: "users") 66 | } 67 | 68 | model VerificationToken { 69 | identifier String 70 | token String @unique 71 | expires DateTime 72 | 73 | @@unique([identifier, token]) 74 | @@map(name: "verification_tokens") 75 | } 76 | 77 | model Post { 78 | id String @id @default(cuid()) 79 | title String 80 | content Json? 81 | published Boolean @default(false) 82 | createdAt DateTime @default(now()) @map(name: "created_at") 83 | updatedAt DateTime @default(now()) @map(name: "updated_at") 84 | authorId String 85 | 86 | author User @relation(fields: [authorId], references: [id]) 87 | 88 | @@map(name: "posts") 89 | } 90 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "taxonomy", 3 | "version": "0.1.0", 4 | "private": true, 5 | "author": { 6 | "name": "shadcn", 7 | "url": "https://twitter.com/shadcn" 8 | }, 9 | "scripts": { 10 | "dev": "next dev", 11 | "turbo": "next dev --turbo", 12 | "build": "next build", 13 | "start": "next start", 14 | "lint": "next lint", 15 | "preview": "next build && next start", 16 | "postinstall": "prisma generate" 17 | }, 18 | "dependencies": { 19 | "@editorjs/code": "^2.7.0", 20 | "@editorjs/editorjs": "^2.25.0", 21 | "@editorjs/embed": "^2.5.3", 22 | "@editorjs/header": "^2.6.2", 23 | "@editorjs/inline-code": "^1.3.1", 24 | "@editorjs/link": "^2.4.1", 25 | "@editorjs/list": "^1.7.0", 26 | "@editorjs/paragraph": "^2.8.0", 27 | "@editorjs/table": "^2.0.4", 28 | "@hookform/resolvers": "^2.9.10", 29 | "@next-auth/prisma-adapter": "^1.0.4", 30 | "@next/font": "^13.0.3", 31 | "@prisma/client": "^4.5.0", 32 | "@radix-ui/react-alert-dialog": "^1.0.2", 33 | "@radix-ui/react-avatar": "^1.0.1", 34 | "@radix-ui/react-dropdown-menu": "^2.0.1", 35 | "@radix-ui/react-popover": "^1.0.2", 36 | "@radix-ui/react-toggle": "^1.0.0", 37 | "@vercel/analytics": "^0.1.3", 38 | "@vercel/og": "^0.0.21", 39 | "clsx": "^1.2.1", 40 | "contentlayer": "^0.2.9", 41 | "date-fns": "^2.29.3", 42 | "lucide-react": "^0.92.0", 43 | "next": "^13.1.0", 44 | "next-auth": "^4.18.7", 45 | "next-contentlayer": "^0.2.9", 46 | "nodemailer": "^6.8.0", 47 | "postmark": "^3.0.14", 48 | "prop-types": "^15.8.1", 49 | "raw-body": "^2.5.1", 50 | "react": "^18.2.0", 51 | "react-dom": "^18.2.0", 52 | "react-editor-js": "^2.1.0", 53 | "react-hook-form": "^7.38.0", 54 | "react-hot-toast": "^2.4.0", 55 | "react-textarea-autosize": "^8.3.4", 56 | "sharp": "^0.31.1", 57 | "shiki": "^0.11.1", 58 | "stripe": "^11.1.0", 59 | "tailwind-merge": "^1.6.2", 60 | "tailwindcss-animate": "^1.0.5", 61 | "zod": "^3.19.1" 62 | }, 63 | "devDependencies": { 64 | "@commitlint/cli": "^17.3.0", 65 | "@commitlint/config-conventional": "^17.3.0", 66 | "@tailwindcss/typography": "^0.5.7", 67 | "@types/node": "^18.11.9", 68 | "@types/react": "18.0.15", 69 | "@types/react-dom": "18.0.6", 70 | "autoprefixer": "^10.4.8", 71 | "eslint": "8.21.0", 72 | "eslint-config-next": "^13.0.0", 73 | "husky": "^8.0.2", 74 | "mdast-util-toc": "^6.1.0", 75 | "postcss": "^8.4.14", 76 | "prettier": "^2.7.1", 77 | "prettier-plugin-tailwindcss": "^0.1.13", 78 | "pretty-quick": "^3.1.3", 79 | "prisma": "^4.5.0", 80 | "rehype": "^12.0.1", 81 | "rehype-autolink-headings": "^6.1.1", 82 | "rehype-pretty-code": "^0.5.0", 83 | "rehype-slug": "^5.1.0", 84 | "remark": "^14.0.2", 85 | "remark-gfm": "^3.0.1", 86 | "tailwindcss": "^3.1.7", 87 | "typescript": "4.7.4", 88 | "unist-util-visit": "^4.1.1" 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /app/(marketing)/pricing/page.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link" 2 | 3 | import { Icons } from "@/components/icons" 4 | 5 | export default function PricingPage() { 6 | return ( 7 |
8 |
9 |

10 | Simple, transparent pricing 11 |

12 |

13 | Unlock all features including unlimited posts for your blog. 14 |

15 |
16 |
17 |
18 |

19 | What's included in the PRO plan 20 |

21 |
    22 |
  • 23 | Unlimited Posts 24 |
  • 25 |
  • 26 | Unlimited Users 27 |
  • 28 | 29 |
  • 30 | Custom domain 31 |
  • 32 |
  • 33 | Dashboard Analytics 34 |
  • 35 |
  • 36 | Access to Discord 37 |
  • 38 |
  • 39 | Premium Support 40 |
  • 41 |
42 |
43 |
44 |
45 |

$19

46 |

Billed Monthly

47 |
48 | 52 | Get Started 53 | 54 |
55 |
56 |
57 |

58 | Taxonomy is a demo app.{" "} 59 | You can test the upgrade and won't be charged. 60 |

61 |
62 |
63 | ) 64 | } 65 | -------------------------------------------------------------------------------- /components/dashboard/user-account-nav.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import { User } from "next-auth" 4 | import { signOut } from "next-auth/react" 5 | import Link from "next/link" 6 | 7 | import { siteConfig } from "@/config/site" 8 | import { DropdownMenu } from "@/ui/dropdown" 9 | import { UserAvatar } from "@/components/dashboard/user-avatar" 10 | 11 | interface UserAccountNavProps extends React.HTMLAttributes { 12 | user: Pick 13 | } 14 | 15 | export function UserAccountNav({ user }: UserAccountNavProps) { 16 | return ( 17 | 18 | 19 | 20 | 21 | 22 | 23 |
24 |
25 | {user.name &&

{user.name}

} 26 | {user.email && ( 27 |

28 | {user.email} 29 |

30 | )} 31 |
32 |
33 | 34 | 35 | 36 | Dashboard 37 | 38 | 39 | 40 | 41 | Billing 42 | 43 | 44 | 45 | 46 | Settings 47 | 48 | 49 | 50 | 51 | 52 | Documentation 53 | 54 | 55 | 56 | 61 | GitHub 62 | 63 | 64 | 65 | { 68 | event.preventDefault() 69 | signOut({ 70 | callbackUrl: `${window.location.origin}/login`, 71 | }) 72 | }} 73 | > 74 | Sign out 75 | 76 |
77 |
78 |
79 | ) 80 | } 81 | -------------------------------------------------------------------------------- /lib/auth.ts: -------------------------------------------------------------------------------- 1 | import { NextAuthOptions } from "next-auth" 2 | import GitHubProvider from "next-auth/providers/github" 3 | import EmailProvider from "next-auth/providers/email" 4 | import { PrismaAdapter } from "@next-auth/prisma-adapter" 5 | import { Client } from "postmark" 6 | 7 | import { db } from "@/lib/db" 8 | import { siteConfig } from "@/config/site" 9 | 10 | const postmarkClient = new Client(process.env.POSTMARK_API_TOKEN) 11 | 12 | export const authOptions: NextAuthOptions = { 13 | // huh any! I know. 14 | // This is a temporary fix for prisma client. 15 | // @see https://github.com/prisma/prisma/issues/16117 16 | adapter: PrismaAdapter(db as any), 17 | session: { 18 | strategy: "jwt", 19 | }, 20 | pages: { 21 | signIn: "/login", 22 | }, 23 | providers: [ 24 | GitHubProvider({ 25 | clientId: process.env.GITHUB_CLIENT_ID, 26 | clientSecret: process.env.GITHUB_CLIENT_SECRET, 27 | }), 28 | EmailProvider({ 29 | from: process.env.SMTP_FROM, 30 | sendVerificationRequest: async ({ identifier, url, provider }) => { 31 | const user = await db.user.findUnique({ 32 | where: { 33 | email: identifier, 34 | }, 35 | select: { 36 | emailVerified: true, 37 | }, 38 | }) 39 | 40 | const templateId = user?.emailVerified 41 | ? process.env.POSTMARK_SIGN_IN_TEMPLATE 42 | : process.env.POSTMARK_ACTIVATION_TEMPLATE 43 | const result = await postmarkClient.sendEmailWithTemplate({ 44 | TemplateId: parseInt(templateId), 45 | To: identifier, 46 | From: provider.from, 47 | TemplateModel: { 48 | action_url: url, 49 | product_name: siteConfig.name, 50 | }, 51 | Headers: [ 52 | { 53 | // Set this to prevent Gmail from threading emails. 54 | // See https://stackoverflow.com/questions/23434110/force-emails-not-to-be-grouped-into-conversations/25435722. 55 | Name: "X-Entity-Ref-ID", 56 | Value: new Date().getTime() + "", 57 | }, 58 | ], 59 | }) 60 | 61 | if (result.ErrorCode) { 62 | throw new Error(result.Message) 63 | } 64 | }, 65 | }), 66 | ], 67 | callbacks: { 68 | async session({ token, session }) { 69 | if (token) { 70 | session.user.id = token.id 71 | session.user.name = token.name 72 | session.user.email = token.email 73 | session.user.image = token.picture 74 | } 75 | 76 | return session 77 | }, 78 | async jwt({ token, user }) { 79 | const dbUser = await db.user.findFirst({ 80 | where: { 81 | email: token.email, 82 | }, 83 | }) 84 | 85 | if (!dbUser) { 86 | token.id = user.id 87 | return token 88 | } 89 | 90 | return { 91 | id: dbUser.id, 92 | name: dbUser.name, 93 | email: dbUser.email, 94 | picture: dbUser.image, 95 | } 96 | }, 97 | }, 98 | } 99 | -------------------------------------------------------------------------------- /components/docs/toc.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | 5 | import { cn } from "@/lib/utils" 6 | import { TableOfContents } from "@/lib/toc" 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 ( 33 | mounted && ( 34 |
35 |

On This Page

36 | 37 |
38 | ) 39 | ) 40 | } 41 | 42 | function useActiveItem(itemIds: string[]) { 43 | const [activeId, setActiveId] = React.useState(null) 44 | 45 | React.useEffect(() => { 46 | const observer = new IntersectionObserver( 47 | (entries) => { 48 | entries.forEach((entry) => { 49 | if (entry.isIntersecting) { 50 | setActiveId(entry.target.id) 51 | } 52 | }) 53 | }, 54 | { rootMargin: `0% 0% -80% 0%` } 55 | ) 56 | 57 | itemIds?.forEach((id) => { 58 | const element = document.getElementById(id) 59 | if (element) { 60 | observer.observe(element) 61 | } 62 | }) 63 | 64 | return () => { 65 | itemIds?.forEach((id) => { 66 | const element = document.getElementById(id) 67 | if (element) { 68 | observer.unobserve(element) 69 | } 70 | }) 71 | } 72 | }, [itemIds]) 73 | 74 | return activeId 75 | } 76 | 77 | interface TreeProps { 78 | tree: TableOfContents 79 | level?: number 80 | activeItem?: string 81 | } 82 | 83 | function Tree({ tree, level = 1, activeItem }: TreeProps) { 84 | return tree?.items?.length && level < 3 ? ( 85 |
    86 | {tree.items.map((item, index) => { 87 | return ( 88 |
  • 89 | 98 | {item.title} 99 | 100 | {item.items?.length ? ( 101 | 102 | ) : null} 103 |
  • 104 | ) 105 | })} 106 |
107 | ) : null 108 | } 109 | -------------------------------------------------------------------------------- /components/dashboard/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 { Card } from "@/ui/card" 8 | import { toast } from "@/ui/toast" 9 | import { Icons } from "@/components/icons" 10 | 11 | interface BillingFormProps extends React.HTMLAttributes { 12 | subscriptionPlan: UserSubscriptionPlan & { 13 | isCanceled: boolean 14 | } 15 | } 16 | 17 | export function BillingForm({ 18 | subscriptionPlan, 19 | className, 20 | ...props 21 | }: BillingFormProps) { 22 | const [isLoading, setIsLoading] = React.useState(false) 23 | 24 | async function onSubmit(event) { 25 | event.preventDefault() 26 | setIsLoading(!isLoading) 27 | 28 | // Get a Stripe session URL. 29 | const response = await fetch("/api/users/stripe") 30 | 31 | if (!response?.ok) { 32 | return toast({ 33 | title: "Something went wrong.", 34 | message: "Please refresh the page and try again.", 35 | type: "error", 36 | }) 37 | } 38 | 39 | // Redirect to the Stripe session. 40 | // This could be a checkout page for initial upgrade. 41 | // Or portal to manage existing subscription. 42 | const session = await response.json() 43 | if (session) { 44 | window.location.href = session.url 45 | } 46 | } 47 | 48 | return ( 49 |
50 | 51 | 52 | Plan 53 | 54 | You are currently on the {subscriptionPlan.name}{" "} 55 | plan. 56 | 57 | 58 | {subscriptionPlan.description} 59 | 60 | 75 | {subscriptionPlan.isPro ? ( 76 |

77 | {subscriptionPlan.isCanceled 78 | ? "Your plan will be canceled on " 79 | : "Your plan renews on "} 80 | {formatDate(subscriptionPlan.stripeCurrentPeriodEnd)}. 81 |

82 | ) : null} 83 |
84 |
85 |
86 | ) 87 | } 88 | -------------------------------------------------------------------------------- /prisma/migrations/20221021182747_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE `accounts` ( 3 | `id` VARCHAR(191) NOT NULL, 4 | `userId` VARCHAR(191) NOT NULL, 5 | `type` VARCHAR(191) NOT NULL, 6 | `provider` VARCHAR(191) NOT NULL, 7 | `providerAccountId` VARCHAR(191) NOT NULL, 8 | `refresh_token` TEXT NULL, 9 | `access_token` TEXT NULL, 10 | `expires_at` INTEGER NULL, 11 | `token_type` VARCHAR(191) NULL, 12 | `scope` VARCHAR(191) NULL, 13 | `id_token` TEXT NULL, 14 | `session_state` VARCHAR(191) NULL, 15 | `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 16 | `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 17 | 18 | UNIQUE INDEX `accounts_provider_providerAccountId_key`(`provider`, `providerAccountId`), 19 | PRIMARY KEY (`id`) 20 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 21 | 22 | -- CreateTable 23 | CREATE TABLE `sessions` ( 24 | `id` VARCHAR(191) NOT NULL, 25 | `sessionToken` VARCHAR(191) NOT NULL, 26 | `userId` VARCHAR(191) NOT NULL, 27 | `expires` DATETIME(3) NOT NULL, 28 | 29 | UNIQUE INDEX `sessions_sessionToken_key`(`sessionToken`), 30 | PRIMARY KEY (`id`) 31 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 32 | 33 | -- CreateTable 34 | CREATE TABLE `users` ( 35 | `id` VARCHAR(191) NOT NULL, 36 | `name` VARCHAR(191) NULL, 37 | `email` VARCHAR(191) NULL, 38 | `emailVerified` DATETIME(3) NULL, 39 | `image` VARCHAR(191) NULL, 40 | `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 41 | `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 42 | 43 | UNIQUE INDEX `users_email_key`(`email`), 44 | PRIMARY KEY (`id`) 45 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 46 | 47 | -- CreateTable 48 | CREATE TABLE `verification_tokens` ( 49 | `identifier` VARCHAR(191) NOT NULL, 50 | `token` VARCHAR(191) NOT NULL, 51 | `expires` DATETIME(3) NOT NULL, 52 | 53 | UNIQUE INDEX `verification_tokens_token_key`(`token`), 54 | UNIQUE INDEX `verification_tokens_identifier_token_key`(`identifier`, `token`) 55 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 56 | 57 | -- CreateTable 58 | CREATE TABLE `posts` ( 59 | `id` VARCHAR(191) NOT NULL, 60 | `title` VARCHAR(191) NOT NULL, 61 | `content` JSON NULL, 62 | `published` BOOLEAN NOT NULL DEFAULT false, 63 | `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 64 | `updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), 65 | `authorId` VARCHAR(191) NOT NULL, 66 | 67 | PRIMARY KEY (`id`) 68 | ) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; 69 | 70 | -- AddForeignKey 71 | ALTER TABLE `accounts` ADD CONSTRAINT `accounts_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; 72 | 73 | -- AddForeignKey 74 | ALTER TABLE `sessions` ADD CONSTRAINT `sessions_userId_fkey` FOREIGN KEY (`userId`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; 75 | 76 | -- AddForeignKey 77 | ALTER TABLE `posts` ADD CONSTRAINT `posts_authorId_fkey` FOREIGN KEY (`authorId`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE; 78 | -------------------------------------------------------------------------------- /config/docs.ts: -------------------------------------------------------------------------------- 1 | import { DocsConfig } from "types" 2 | 3 | export const docsConfig: DocsConfig = { 4 | mainNav: [ 5 | { 6 | title: "Documentation", 7 | href: "/docs", 8 | }, 9 | { 10 | title: "Guides", 11 | href: "/guides", 12 | }, 13 | ], 14 | sidebarNav: [ 15 | { 16 | title: "Getting Started", 17 | items: [ 18 | { 19 | title: "Introduction", 20 | href: "/docs", 21 | }, 22 | ], 23 | }, 24 | { 25 | title: "Documentation", 26 | items: [ 27 | { 28 | title: "Introduction", 29 | href: "/docs/documentation", 30 | }, 31 | { 32 | title: "Contentlayer", 33 | href: "/docs/in-progress", 34 | disabled: true, 35 | }, 36 | { 37 | title: "Components", 38 | href: "/docs/documentation/components", 39 | }, 40 | { 41 | title: "Code Blocks", 42 | href: "/docs/documentation/code-blocks", 43 | }, 44 | { 45 | title: "Style Guide", 46 | href: "/docs/documentation/style-guide", 47 | }, 48 | { 49 | title: "Search", 50 | href: "/docs/in-progress", 51 | disabled: true, 52 | }, 53 | ], 54 | }, 55 | { 56 | title: "Blog", 57 | items: [ 58 | { 59 | title: "Introduction", 60 | href: "/docs/in-progress", 61 | disabled: true, 62 | }, 63 | { 64 | title: "Build your own", 65 | href: "/docs/in-progress", 66 | disabled: true, 67 | }, 68 | { 69 | title: "Writing Posts", 70 | href: "/docs/in-progress", 71 | disabled: true, 72 | }, 73 | ], 74 | }, 75 | { 76 | title: "Dashboard", 77 | items: [ 78 | { 79 | title: "Introduction", 80 | href: "/docs/in-progress", 81 | disabled: true, 82 | }, 83 | { 84 | title: "Layouts", 85 | href: "/docs/in-progress", 86 | disabled: true, 87 | }, 88 | { 89 | title: "Server Components", 90 | href: "/docs/in-progress", 91 | disabled: true, 92 | }, 93 | { 94 | title: "Authentication", 95 | href: "/docs/in-progress", 96 | disabled: true, 97 | }, 98 | { 99 | title: "Database with Prisma", 100 | href: "/docs/in-progress", 101 | disabled: true, 102 | }, 103 | { 104 | title: "API Routes", 105 | href: "/docs/in-progress", 106 | disabled: true, 107 | }, 108 | ], 109 | }, 110 | { 111 | title: "Marketing Site", 112 | items: [ 113 | { 114 | title: "Introduction", 115 | href: "/docs/in-progress", 116 | disabled: true, 117 | }, 118 | { 119 | title: "File Structure", 120 | href: "/docs/in-progress", 121 | disabled: true, 122 | }, 123 | { 124 | title: "Tailwind CSS", 125 | href: "/docs/in-progress", 126 | disabled: true, 127 | }, 128 | { 129 | title: "Typography", 130 | href: "/docs/in-progress", 131 | disabled: true, 132 | }, 133 | ], 134 | }, 135 | ], 136 | } 137 | -------------------------------------------------------------------------------- /components/dashboard/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 "@prisma/client" 7 | 8 | import { DropdownMenu } from "@/ui/dropdown" 9 | import { Icons } from "@/components/icons" 10 | import { Alert } from "@/ui/alert" 11 | import { toast } from "@/ui/toast" 12 | 13 | async function deletePost(postId: string) { 14 | const response = await fetch(`/api/posts/${postId}`, { 15 | method: "DELETE", 16 | }) 17 | 18 | if (!response?.ok) { 19 | toast({ 20 | title: "Something went wrong.", 21 | message: "Your post was not deleted. Please try again.", 22 | type: "error", 23 | }) 24 | } 25 | 26 | return true 27 | } 28 | 29 | interface PostOperationsProps { 30 | post: Pick 31 | } 32 | 33 | export function PostOperations({ post }: PostOperationsProps) { 34 | const router = useRouter() 35 | const [showDeleteAlert, setShowDeleteAlert] = React.useState(false) 36 | const [isDeleteLoading, setIsDeleteLoading] = React.useState(false) 37 | 38 | return ( 39 | <> 40 | 41 | 42 | 43 | Open 44 | 45 | 46 | 47 | 48 | 49 | Edit 50 | 51 | 52 | 53 | setShowDeleteAlert(true)} 56 | > 57 | Delete 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | Are you sure you want to delete this post? 67 | 68 | This action cannot be undone. 69 | 70 | 71 | Cancel 72 | { 74 | event.preventDefault() 75 | setIsDeleteLoading(true) 76 | 77 | const deleted = await deletePost(post.id) 78 | 79 | if (deleted) { 80 | setIsDeleteLoading(false) 81 | setShowDeleteAlert(false) 82 | router.refresh() 83 | } 84 | }} 85 | className="bg-red-600 focus:ring-red-600" 86 | > 87 | {isDeleteLoading ? ( 88 | 89 | ) : ( 90 | 91 | )} 92 | Delete 93 | 94 | 95 | 96 | 97 | 98 | ) 99 | } 100 | -------------------------------------------------------------------------------- /content/docs/documentation/components.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Components 3 | description: Use React components in Markdown using MDX. 4 | --- 5 | 6 | The following components are available out of the box for use in Markdown. 7 | 8 | If you'd like to build and add your own custom components, see the [Custom Components](#custom-components) section below. 9 | 10 | ## Built-in Components 11 | 12 | ### 1. Callout 13 | 14 | ```mdx 15 | 16 | 17 | This is a default callout. You can embed **Markdown** inside a `callout`. 18 | 19 | 20 | ``` 21 | 22 | 23 | 24 | This is a default callout. You can embed **Markdown** inside a `callout`. 25 | 26 | 27 | 28 | 29 | 30 | This is a warning callout. It uses the props `type="warning"`. 31 | 32 | 33 | 34 | 35 | 36 | This is a danger callout. It uses the props `type="danger"`. 37 | 38 | 39 | 40 | ### 2. Card 41 | 42 | ```mdx 43 | 44 | 45 | #### Heading 46 | 47 | You can use **markdown** inside cards. 48 | 49 | 50 | ``` 51 | 52 | 53 | 54 | #### Heading 55 | 56 | You can use **markdown** inside cards. 57 | 58 | 59 | 60 | You can also use HTML to embed cards in a grid. 61 | 62 | ```mdx 63 |
64 | 65 | #### Card One 66 | You can use **markdown** inside cards. 67 | 68 | 69 | 70 | #### Card Two 71 | You can also use `inline code` and code blocks. 72 | 73 |
74 | ``` 75 | 76 |
77 | 78 | #### Card One 79 | You can use **markdown** inside cards. 80 | 81 | 82 | 83 | #### Card Two 84 | You can also use `inline code` and code blocks. 85 | 86 |
87 | 88 | --- 89 | 90 | ## Custom Components 91 | 92 | You can add your own custom components using the `components` props from `useMDXComponent`. 93 | 94 | ```ts title="components/mdx.tsx" {2,6} 95 | import { Callout } from "@/components/callout" 96 | import { CustomComponent } from "@/components/custom" 97 | 98 | const components = { 99 | Callout, 100 | CustomComponent, 101 | } 102 | 103 | export function Mdx({ code }) { 104 | const Component = useMDXComponent(code) 105 | 106 | return ( 107 |
108 | 109 |
110 | ) 111 | } 112 | ``` 113 | 114 | Once you've added your custom component, you can use it in MDX as follows: 115 | 116 | ```js 117 | 118 | ``` 119 | 120 | --- 121 | 122 | ## HTML Elements 123 | 124 | You can overwrite HTML elements using the same technique above. 125 | 126 | ```ts {4} 127 | const components = { 128 | Callout, 129 | CustomComponent, 130 | hr: ({ ...props }) =>
, 131 | } 132 | ``` 133 | 134 | This will overwrite the `
` tag or `---` in Mardown with the HTML output above. 135 | 136 | --- 137 | 138 | ## Styling 139 | 140 | Tailwind CSS classes can be used inside MDX for styling. 141 | 142 | ```mdx 143 |

This text will be red.

144 | ``` 145 | 146 | Make sure you have configured the path to your content in your `tailwind.config.js` file: 147 | 148 | ```js title="tailwind.config.js" {6} 149 | /** @type {import('tailwindcss').Config} */ 150 | module.exports = { 151 | content: [ 152 | "./app/**/*.{ts,tsx}", 153 | "./components/**/*.{ts,tsx}", 154 | "./content/**/*.{md,mdx}", 155 | ], 156 | } 157 | ``` 158 | -------------------------------------------------------------------------------- /app/(marketing)/blog/[...slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import { notFound } from "next/navigation" 2 | import { allAuthors, allPosts } from "contentlayer/generated" 3 | 4 | import { Mdx } from "@/components/docs/mdx" 5 | import "@/styles/mdx.css" 6 | import { formatDate } from "@/lib/utils" 7 | import Link from "next/link" 8 | import { Icons } from "@/components/icons" 9 | import Image from "next/image" 10 | 11 | interface PostPageProps { 12 | params: { 13 | slug: string[] 14 | } 15 | } 16 | 17 | export async function generateStaticParams(): Promise< 18 | PostPageProps["params"][] 19 | > { 20 | return allPosts.map((post) => ({ 21 | slug: post.slugAsParams.split("/"), 22 | })) 23 | } 24 | 25 | export default async function PostPage({ params }: PostPageProps) { 26 | const slug = params?.slug?.join("/") 27 | const post = allPosts.find((post) => post.slugAsParams === slug) 28 | 29 | if (!post) { 30 | notFound() 31 | } 32 | 33 | const authors = post.authors.map((author) => 34 | allAuthors.find(({ slug }) => slug === `/authors/${author}`) 35 | ) 36 | 37 | return ( 38 |
39 | 43 | 44 | See all posts 45 | 46 |
47 | {post.date && ( 48 | 51 | )} 52 |

53 | {post.title} 54 |

55 | {authors?.length ? ( 56 |
57 | {authors.map((author) => ( 58 | 63 | {author.title} 70 |
71 |

{author.title}

72 |

73 | @{author.twitter} 74 |

75 |
76 | 77 | ))} 78 |
79 | ) : null} 80 |
81 | {post.image && ( 82 | {post.title} 90 | )} 91 | 92 |
93 |
94 | 98 | 99 | See all posts 100 | 101 |
102 |
103 | ) 104 | } 105 | -------------------------------------------------------------------------------- /components/dashboard/user-name-form.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { User } from "@prisma/client" 5 | import { useForm } from "react-hook-form" 6 | import * as z from "zod" 7 | import { zodResolver } from "@hookform/resolvers/zod" 8 | import { useRouter } from "next/navigation" 9 | 10 | import { cn } from "@/lib/utils" 11 | import { userNameSchema } from "@/lib/validations/user" 12 | import { Card } from "@/ui/card" 13 | import { toast } from "@/ui/toast" 14 | import { Icons } from "@/components/icons" 15 | 16 | interface UserNameFormProps extends React.HTMLAttributes { 17 | user: Pick 18 | } 19 | 20 | type FormData = z.infer 21 | 22 | export function UserNameForm({ user, className, ...props }: UserNameFormProps) { 23 | const router = useRouter() 24 | const { 25 | handleSubmit, 26 | register, 27 | formState: { errors }, 28 | } = useForm({ 29 | resolver: zodResolver(userNameSchema), 30 | defaultValues: { 31 | name: user.name, 32 | }, 33 | }) 34 | const [isSaving, setIsSaving] = React.useState(false) 35 | 36 | async function onSubmit(data: FormData) { 37 | setIsSaving(true) 38 | 39 | const response = await fetch(`/api/users/${user.id}`, { 40 | method: "PATCH", 41 | headers: { 42 | "Content-Type": "application/json", 43 | }, 44 | body: JSON.stringify({ 45 | name: data.name, 46 | }), 47 | }) 48 | 49 | setIsSaving(false) 50 | 51 | if (!response?.ok) { 52 | return toast({ 53 | title: "Something went wrong.", 54 | message: "Your name was not updated. Please try again.", 55 | type: "error", 56 | }) 57 | } 58 | 59 | toast({ 60 | message: "Your name has been updated.", 61 | type: "success", 62 | }) 63 | 64 | router.refresh() 65 | } 66 | 67 | return ( 68 |
73 | 74 | 75 | Your Name 76 | 77 | Please enter your full name or a display name you are comfortable 78 | with. 79 | 80 | 81 | 82 |
83 | 86 | 93 | {errors?.name && ( 94 |

{errors.name.message}

95 | )} 96 |
97 |
98 | 99 | 115 | 116 |
117 |
118 | ) 119 | } 120 | -------------------------------------------------------------------------------- /ui/alert.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as AlertDialogPrimitives from "@radix-ui/react-alert-dialog" 5 | import { cn } from "@/lib/utils" 6 | 7 | type AlertProps = AlertDialogPrimitives.AlertDialogProps 8 | 9 | export function Alert({ ...props }: AlertProps) { 10 | return 11 | } 12 | 13 | Alert.Trigger = React.forwardRef< 14 | HTMLButtonElement, 15 | AlertDialogPrimitives.AlertDialogTriggerProps 16 | >(function AlertTrigger({ ...props }, ref) { 17 | return 18 | }) 19 | 20 | Alert.Portal = AlertDialogPrimitives.Portal 21 | 22 | Alert.Content = React.forwardRef< 23 | HTMLDivElement, 24 | AlertDialogPrimitives.AlertDialogContentProps 25 | >(function AlertContent({ className, ...props }, ref) { 26 | return ( 27 | 28 | 29 |
30 | 38 |
39 |
40 |
41 | ) 42 | }) 43 | 44 | type AlertHeaderProps = React.HTMLAttributes 45 | 46 | Alert.Header = function AlertHeader({ className, ...props }: AlertHeaderProps) { 47 | return
48 | } 49 | 50 | Alert.Title = React.forwardRef< 51 | HTMLHeadingElement, 52 | AlertDialogPrimitives.AlertDialogTitleProps 53 | >(function AlertTitle({ className, ...props }, ref) { 54 | return ( 55 | 60 | ) 61 | }) 62 | 63 | Alert.Description = React.forwardRef< 64 | HTMLParagraphElement, 65 | AlertDialogPrimitives.AlertDialogDescriptionProps 66 | >(function AlertDescription({ className, ...props }, ref) { 67 | return ( 68 | 73 | ) 74 | }) 75 | 76 | Alert.Footer = function AlertFooter({ className, ...props }: AlertHeaderProps) { 77 | return ( 78 |
79 | ) 80 | } 81 | 82 | Alert.Cancel = React.forwardRef< 83 | HTMLButtonElement, 84 | AlertDialogPrimitives.AlertDialogCancelProps 85 | >(function AlertCancel({ className, ...props }, ref) { 86 | return ( 87 | 95 | ) 96 | }) 97 | 98 | Alert.Action = React.forwardRef< 99 | HTMLButtonElement, 100 | AlertDialogPrimitives.AlertDialogActionProps 101 | >(function AlertAction({ className, ...props }, ref) { 102 | return ( 103 | 111 | ) 112 | }) 113 | -------------------------------------------------------------------------------- /contentlayer.config.js: -------------------------------------------------------------------------------- 1 | import { defineDocumentType, makeSource } from "contentlayer/source-files" 2 | import remarkGfm from "remark-gfm" 3 | import rehypePrettyCode from "rehype-pretty-code" 4 | import rehypeSlug from "rehype-slug" 5 | import rehypeAutolinkHeadings from "rehype-autolink-headings" 6 | 7 | /** @type {import('contentlayer/source-files').ComputedFields} */ 8 | const computedFields = { 9 | slug: { 10 | type: "string", 11 | resolve: (doc) => `/${doc._raw.flattenedPath}`, 12 | }, 13 | slugAsParams: { 14 | type: "string", 15 | resolve: (doc) => doc._raw.flattenedPath.split("/").slice(1).join("/"), 16 | }, 17 | } 18 | 19 | export const Doc = defineDocumentType(() => ({ 20 | name: "Doc", 21 | filePathPattern: `docs/**/*.mdx`, 22 | contentType: "mdx", 23 | fields: { 24 | title: { 25 | type: "string", 26 | required: true, 27 | }, 28 | description: { 29 | type: "string", 30 | }, 31 | published: { 32 | type: "boolean", 33 | default: true, 34 | }, 35 | }, 36 | computedFields, 37 | })) 38 | 39 | export const Guide = defineDocumentType(() => ({ 40 | name: "Guide", 41 | filePathPattern: `guides/**/*.mdx`, 42 | contentType: "mdx", 43 | fields: { 44 | title: { 45 | type: "string", 46 | required: true, 47 | }, 48 | description: { 49 | type: "string", 50 | }, 51 | date: { 52 | type: "date", 53 | required: true, 54 | }, 55 | published: { 56 | type: "boolean", 57 | default: true, 58 | }, 59 | featured: { 60 | type: "boolean", 61 | default: false, 62 | }, 63 | }, 64 | computedFields, 65 | })) 66 | 67 | export const Post = defineDocumentType(() => ({ 68 | name: "Post", 69 | filePathPattern: `blog/**/*.mdx`, 70 | contentType: "mdx", 71 | fields: { 72 | title: { 73 | type: "string", 74 | required: true, 75 | }, 76 | description: { 77 | type: "string", 78 | }, 79 | date: { 80 | type: "date", 81 | required: true, 82 | }, 83 | published: { 84 | type: "boolean", 85 | default: true, 86 | }, 87 | image: { 88 | type: "string", 89 | required: true, 90 | }, 91 | authors: { 92 | // Reference types are not embedded. 93 | // Until this is fixed, we can use a simple list. 94 | // type: "reference", 95 | // of: Author, 96 | type: "list", 97 | of: { type: "string" }, 98 | required: true, 99 | }, 100 | }, 101 | computedFields, 102 | })) 103 | 104 | export const Author = defineDocumentType(() => ({ 105 | name: "Author", 106 | filePathPattern: `authors/**/*.mdx`, 107 | contentType: "mdx", 108 | fields: { 109 | title: { 110 | type: "string", 111 | required: true, 112 | }, 113 | description: { 114 | type: "string", 115 | }, 116 | avatar: { 117 | type: "string", 118 | required: true, 119 | }, 120 | twitter: { 121 | type: "string", 122 | required: true, 123 | }, 124 | }, 125 | computedFields, 126 | })) 127 | 128 | export const Page = defineDocumentType(() => ({ 129 | name: "Page", 130 | filePathPattern: `pages/**/*.mdx`, 131 | contentType: "mdx", 132 | fields: { 133 | title: { 134 | type: "string", 135 | required: true, 136 | }, 137 | description: { 138 | type: "string", 139 | }, 140 | }, 141 | computedFields, 142 | })) 143 | 144 | export default makeSource({ 145 | contentDirPath: "./content", 146 | documentTypes: [Page, Doc, Guide, Post, Author], 147 | mdx: { 148 | remarkPlugins: [remarkGfm], 149 | rehypePlugins: [ 150 | rehypeSlug, 151 | [ 152 | rehypePrettyCode, 153 | { 154 | theme: "github-dark", 155 | onVisitLine(node) { 156 | // Prevent lines from collapsing in `display: grid` mode, and allow empty 157 | // lines to be copy/pasted 158 | if (node.children.length === 0) { 159 | node.children = [{ type: "text", value: " " }] 160 | } 161 | }, 162 | onVisitHighlightedLine(node) { 163 | node.properties.className.push("line--highlighted") 164 | }, 165 | onVisitHighlightedWord(node) { 166 | node.properties.className = ["word--highlighted"] 167 | }, 168 | }, 169 | ], 170 | [ 171 | rehypeAutolinkHeadings, 172 | { 173 | properties: { 174 | className: ["subheading-anchor"], 175 | ariaLabel: "Link to section", 176 | }, 177 | }, 178 | ], 179 | ], 180 | }, 181 | }) 182 | -------------------------------------------------------------------------------- /components/docs/mdx.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/docs/callout" 7 | import { Card } from "@/components/docs/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 | 72 | ), 73 | p: ({ className, ...props }) => ( 74 |

78 | ), 79 | ul: ({ className, ...props }) => ( 80 |

    81 | ), 82 | ol: ({ className, ...props }) => ( 83 |
      84 | ), 85 | li: ({ className, ...props }) => ( 86 |
    1. 87 | ), 88 | blockquote: ({ className, ...props }) => ( 89 |
      *]:text-slate-600", 92 | className 93 | )} 94 | {...props} 95 | /> 96 | ), 97 | img: ({ 98 | className, 99 | alt, 100 | ...props 101 | }: React.ImgHTMLAttributes) => ( 102 | // eslint-disable-next-line @next/next/no-img-element 103 | {alt} 108 | ), 109 | hr: ({ ...props }) => ( 110 |
      111 | ), 112 | table: ({ className, ...props }: React.HTMLAttributes) => ( 113 |
      114 | 115 | 116 | ), 117 | tr: ({ className, ...props }: React.HTMLAttributes) => ( 118 | 125 | ), 126 | th: ({ className, ...props }) => ( 127 |
      134 | ), 135 | td: ({ className, ...props }) => ( 136 | 143 | ), 144 | pre: ({ className, ...props }) => ( 145 |
      152 |   ),
      153 |   code: ({ className, ...props }) => (
      154 |     
      161 |   ),
      162 |   Image,
      163 |   Callout,
      164 |   Card,
      165 | }
      166 | 
      167 | interface MdxProps {
      168 |   code: string
      169 | }
      170 | 
      171 | export function Mdx({ code }: MdxProps) {
      172 |   const Component = useMDXComponent(code)
      173 | 
      174 |   return (
      175 |     
      176 | 177 |
      178 | ) 179 | } 180 | -------------------------------------------------------------------------------- /components/dashboard/editor.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import EditorJS from "@editorjs/editorjs" 5 | import { Post } from "@prisma/client" 6 | import { useForm } from "react-hook-form" 7 | import Link from "next/link" 8 | import TextareaAutosize from "react-textarea-autosize" 9 | import * as z from "zod" 10 | import { zodResolver } from "@hookform/resolvers/zod" 11 | import { useRouter } from "next/navigation" 12 | 13 | import { postPatchSchema } from "@/lib/validations/post" 14 | import { toast } from "@/ui/toast" 15 | import { Icons } from "@/components/icons" 16 | 17 | interface EditorProps { 18 | post: Pick 19 | } 20 | 21 | type FormData = z.infer 22 | 23 | export function Editor({ post }: EditorProps) { 24 | const { register, handleSubmit } = useForm({ 25 | resolver: zodResolver(postPatchSchema), 26 | }) 27 | const ref = React.useRef() 28 | const router = useRouter() 29 | const [isSaving, setIsSaving] = React.useState(false) 30 | const [isMounted, setIsMounted] = React.useState(false) 31 | 32 | async function initializeEditor() { 33 | const EditorJS = (await import("@editorjs/editorjs")).default 34 | const Header = (await import("@editorjs/header")).default 35 | const Embed = (await import("@editorjs/embed")).default 36 | const Table = (await import("@editorjs/table")).default 37 | const List = (await import("@editorjs/list")).default 38 | const Code = (await import("@editorjs/code")).default 39 | const LinkTool = (await import("@editorjs/link")).default 40 | const InlineCode = (await import("@editorjs/inline-code")).default 41 | 42 | const body = postPatchSchema.parse(post) 43 | 44 | if (!ref.current) { 45 | const editor = new EditorJS({ 46 | holder: "editor", 47 | onReady() { 48 | ref.current = editor 49 | }, 50 | placeholder: "Type here to write your post...", 51 | inlineToolbar: true, 52 | data: body.content, 53 | tools: { 54 | header: Header, 55 | linkTool: LinkTool, 56 | list: List, 57 | code: Code, 58 | inlineCode: InlineCode, 59 | table: Table, 60 | embed: Embed, 61 | }, 62 | }) 63 | } 64 | } 65 | 66 | React.useEffect(() => { 67 | if (typeof window !== "undefined") { 68 | setIsMounted(true) 69 | } 70 | }, []) 71 | 72 | React.useEffect(() => { 73 | if (isMounted) { 74 | initializeEditor() 75 | 76 | return () => { 77 | ref.current?.destroy() 78 | ref.current = null 79 | } 80 | } 81 | }, [isMounted]) 82 | 83 | async function onSubmit(data: FormData) { 84 | setIsSaving(true) 85 | 86 | const blocks = await ref.current.save() 87 | 88 | const response = await fetch(`/api/posts/${post.id}`, { 89 | method: "PATCH", 90 | headers: { 91 | "Content-Type": "application/json", 92 | }, 93 | body: JSON.stringify({ 94 | title: data.title, 95 | content: blocks, 96 | }), 97 | }) 98 | 99 | setIsSaving(false) 100 | 101 | if (!response?.ok) { 102 | return toast({ 103 | title: "Something went wrong.", 104 | message: "Your post was not saved. Please try again.", 105 | type: "error", 106 | }) 107 | } 108 | 109 | router.refresh() 110 | 111 | return toast({ 112 | message: "Your post has been saved.", 113 | type: "success", 114 | }) 115 | } 116 | 117 | if (!isMounted) { 118 | return null 119 | } 120 | 121 | return ( 122 |
      123 |
      124 |
      125 |
      126 | 130 | <> 131 | 132 | Back 133 | 134 | 135 |

      136 | {post.published ? "Published" : "Draft"} 137 |

      138 |
      139 | 148 |
      149 |
      150 | 159 |
      160 |

      161 | Use{" "} 162 | 163 | Tab 164 | {" "} 165 | to open the command menu. 166 |

      167 |
      168 |
      169 | 170 | ) 171 | } 172 | -------------------------------------------------------------------------------- /components/dashboard/user-auth-form.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { useSearchParams } from "next/navigation" 5 | import { signIn } from "next-auth/react" 6 | import * as z from "zod" 7 | import { useForm } from "react-hook-form" 8 | import { zodResolver } from "@hookform/resolvers/zod" 9 | 10 | import { cn } from "@/lib/utils" 11 | import { userAuthSchema } from "@/lib/validations/auth" 12 | import { toast } from "@/ui/toast" 13 | import { Icons } from "@/components/icons" 14 | 15 | interface UserAuthFormProps extends React.HTMLAttributes {} 16 | 17 | type FormData = z.infer 18 | 19 | export function UserAuthForm({ className, ...props }: UserAuthFormProps) { 20 | const { 21 | register, 22 | handleSubmit, 23 | formState: { errors }, 24 | } = useForm({ 25 | resolver: zodResolver(userAuthSchema), 26 | }) 27 | const [isLoading, setIsLoading] = React.useState(false) 28 | const searchParams = useSearchParams() 29 | 30 | async function onSubmit(data: FormData) { 31 | setIsLoading(true) 32 | 33 | const signInResult = await signIn("email", { 34 | email: data.email.toLowerCase(), 35 | redirect: false, 36 | callbackUrl: searchParams.get("from") || "/dashboard", 37 | }) 38 | 39 | setIsLoading(false) 40 | 41 | if (!signInResult?.ok) { 42 | return toast({ 43 | title: "Something went wrong.", 44 | message: "Your sign in request failed. Please try again.", 45 | type: "error", 46 | }) 47 | } 48 | 49 | return toast({ 50 | title: "Check your email", 51 | message: "We sent you a login link. Be sure to check your spam too.", 52 | type: "success", 53 | }) 54 | } 55 | 56 | return ( 57 |
      58 |
      59 |
      60 |
      61 | 64 | 76 | {errors?.email && ( 77 |

      78 | {errors.email.message} 79 |

      80 | )} 81 |
      82 | 91 |
      92 |
      93 |
      94 |
      95 |
      96 |
      97 |
      98 | Or continue with 99 |
      100 |
      101 | 124 |
      125 | ) 126 | } 127 | -------------------------------------------------------------------------------- /pages/api/og.tsx: -------------------------------------------------------------------------------- 1 | import { ogImageSchema } from "@/lib/validations/og" 2 | import { ImageResponse } from "@vercel/og" 3 | import { NextRequest } from "next/server" 4 | 5 | export const config = { 6 | runtime: "experimental-edge", 7 | } 8 | 9 | const interRegular = fetch( 10 | new URL("../../assets/fonts/Inter-Regular.ttf", import.meta.url) 11 | ).then((res) => res.arrayBuffer()) 12 | 13 | const interBold = fetch( 14 | new URL("../../assets/fonts/Inter-Bold.ttf", import.meta.url) 15 | ).then((res) => res.arrayBuffer()) 16 | 17 | export default async function handler(req: NextRequest) { 18 | try { 19 | const fontRegular = await interRegular 20 | const fontBold = await interBold 21 | 22 | const url = new URL(req.url) 23 | const values = ogImageSchema.parse(Object.fromEntries(url.searchParams)) 24 | const heading = 25 | values.heading.length > 140 26 | ? `${values.heading.substring(0, 140)}...` 27 | : values.heading 28 | 29 | const { mode } = values 30 | const paint = mode === "dark" ? "#fff" : "#000" 31 | 32 | const fontSize = heading.length > 100 ? "70px" : "100px" 33 | 34 | return new ImageResponse( 35 | ( 36 |
      46 | 47 | 48 | 49 | 54 | 55 | 56 | 60 | 69 | 70 | 71 | 72 | 73 | 74 | 75 |
      76 |
      80 | {values.type} 81 |
      82 |
      91 | {heading} 92 |
      93 |
      94 |
      95 |
      99 | tx.shadcn.com 100 |
      101 |
      105 | 106 | 113 | 120 | 121 |
      github.com/shadcn/taxonomy
      122 |
      123 |
      124 |
      125 | ), 126 | { 127 | width: 1200, 128 | height: 630, 129 | fonts: [ 130 | { 131 | name: "Inter", 132 | data: fontRegular, 133 | weight: 400, 134 | style: "normal", 135 | }, 136 | { 137 | name: "Inter", 138 | data: fontBold, 139 | weight: 700, 140 | style: "normal", 141 | }, 142 | ], 143 | } 144 | ) 145 | } catch (error) { 146 | return new Response(`Failed to generate image`, { 147 | status: 500, 148 | }) 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /content/blog/deploying-next-apps.mdx: -------------------------------------------------------------------------------- 1 | --- 2 | title: Deploying Next.js Apps 3 | description: How to deploy your Next.js apps on Vercel. 4 | image: /images/blog/blog-post-3.jpg 5 | date: "2023-01-02" 6 | authors: 7 | - shadcn 8 | --- 9 | 10 | 11 | The text below is from the [Tailwind 12 | CSS](https://play.tailwindcss.com/uj1vGACRJA?layout=preview) docs. I copied it 13 | here to test the markdown styles. **Tailwind is awesome. You should use it.** 14 | 15 | 16 | Until now, trying to style an article, document, or blog post with Tailwind has been a tedious task that required a keen eye for typography and a lot of complex custom CSS. 17 | 18 | By default, Tailwind removes all of the default browser styling from paragraphs, headings, lists and more. This ends up being really useful for building application UIs because you spend less time undoing user-agent styles, but when you _really are_ just trying to style some content that came from a rich-text editor in a CMS or a markdown file, it can be surprising and unintuitive. 19 | 20 | We get lots of complaints about it actually, with people regularly asking us things like: 21 | 22 | > Why is Tailwind removing the default styles on my `h1` elements? How do I disable this? What do you mean I lose all the other base styles too? 23 | > We hear you, but we're not convinced that simply disabling our base styles is what you really want. You don't want to have to remove annoying margins every time you use a `p` element in a piece of your dashboard UI. And I doubt you really want your blog posts to use the user-agent styles either — you want them to look _awesome_, not awful. 24 | 25 | The `@tailwindcss/typography` plugin is our attempt to give you what you _actually_ want, without any of the downsides of doing something stupid like disabling our base styles. 26 | 27 | It adds a new `prose` class that you can slap on any block of vanilla HTML content and turn it into a beautiful, well-formatted document: 28 | 29 | ```html 30 |
      31 |

      Garlic bread with cheese: What the science tells us

      32 |

      33 | For years parents have espoused the health benefits of eating garlic bread 34 | with cheese to their children, with the food earning such an iconic status 35 | in our culture that kids will often dress up as warm, cheesy loaf for 36 | Halloween. 37 |

      38 |

      39 | But a recent study shows that the celebrated appetizer may be linked to a 40 | series of rabies cases springing up around the country. 41 |

      42 |
      43 | ``` 44 | 45 | For more information about how to use the plugin and the features it includes, [read the documentation](https://github.com/tailwindcss/typography/blob/master/README.md). 46 | 47 | --- 48 | 49 | ## What to expect from here on out 50 | 51 | What follows from here is just a bunch of absolute nonsense I've written to dogfood the plugin itself. It includes every sensible typographic element I could think of, like **bold text**, unordered lists, ordered lists, code blocks, block quotes, _and even italics_. 52 | 53 | It's important to cover all of these use cases for a few reasons: 54 | 55 | 1. We want everything to look good out of the box. 56 | 2. Really just the first reason, that's the whole point of the plugin. 57 | 3. Here's a third pretend reason though a list with three items looks more realistic than a list with two items. 58 | 59 | Now we're going to try out another header style. 60 | 61 | ### Typography should be easy 62 | 63 | So that's a header for you — with any luck if we've done our job correctly that will look pretty reasonable. 64 | 65 | Something a wise person once told me about typography is: 66 | 67 | > Typography is pretty important if you don't want your stuff to look like trash. Make it good then it won't be bad. 68 | 69 | It's probably important that images look okay here by default as well: 70 | 71 | Image 77 | 78 | Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. 79 | 80 | Now I'm going to show you an example of an unordered list to make sure that looks good, too: 81 | 82 | - So here is the first item in this list. 83 | - In this example we're keeping the items short. 84 | - Later, we'll use longer, more complex list items. 85 | 86 | And that's the end of this section. 87 | 88 | ## What if we stack headings? 89 | 90 | ### We should make sure that looks good, too. 91 | 92 | Sometimes you have headings directly underneath each other. In those cases you often have to undo the top margin on the second heading because it usually looks better for the headings to be closer together than a paragraph followed by a heading should be. 93 | 94 | ### When a heading comes after a paragraph … 95 | 96 | When a heading comes after a paragraph, we need a bit more space, like I already mentioned above. Now let's see what a more complex list would look like. 97 | 98 | - **I often do this thing where list items have headings.** 99 | 100 | For some reason I think this looks cool which is unfortunate because it's pretty annoying to get the styles right. 101 | 102 | I often have two or three paragraphs in these list items, too, so the hard part is getting the spacing between the paragraphs, list item heading, and separate list items to all make sense. Pretty tough honestly, you could make a strong argument that you just shouldn't write this way. 103 | 104 | - **Since this is a list, I need at least two items.** 105 | 106 | I explained what I'm doing already in the previous list item, but a list wouldn't be a list if it only had one item, and we really want this to look realistic. That's why I've added this second list item so I actually have something to look at when writing the styles. 107 | 108 | - **It's not a bad idea to add a third item either.** 109 | 110 | I think it probably would've been fine to just use two items but three is definitely not worse, and since I seem to be having no trouble making up arbitrary things to type, I might as well include it. 111 | 112 | After this sort of list I usually have a closing statement or paragraph, because it kinda looks weird jumping right to a heading. 113 | 114 | ## Code should look okay by default. 115 | 116 | I think most people are going to use [highlight.js](https://highlightjs.org/) or [Prism](https://prismjs.com/) or something if they want to style their code blocks but it wouldn't hurt to make them look _okay_ out of the box, even with no syntax highlighting. 117 | 118 | Here's what a default `tailwind.config.js` file looks like at the time of writing: 119 | 120 | ```js 121 | module.exports = { 122 | purge: [], 123 | theme: { 124 | extend: {}, 125 | }, 126 | variants: {}, 127 | plugins: [], 128 | } 129 | ``` 130 | 131 | Hopefully that looks good enough to you. 132 | 133 | ### What about nested lists? 134 | 135 | Nested lists basically always look bad which is why editors like Medium don't even let you do it, but I guess since some of you goofballs are going to do it we have to carry the burden of at least making it work. 136 | 137 | 1. **Nested lists are rarely a good idea.** 138 | - You might feel like you are being really "organized" or something but you are just creating a gross shape on the screen that is hard to read. 139 | - Nested navigation in UIs is a bad idea too, keep things as flat as possible. 140 | - Nesting tons of folders in your source code is also not helpful. 141 | 2. **Since we need to have more items, here's another one.** 142 | - I'm not sure if we'll bother styling more than two levels deep. 143 | - Two is already too much, three is guaranteed to be a bad idea. 144 | - If you nest four levels deep you belong in prison. 145 | 3. **Two items isn't really a list, three is good though.** 146 | - Again please don't nest lists if you want people to actually read your content. 147 | - Nobody wants to look at this. 148 | - I'm upset that we even have to bother styling this. 149 | 150 | The most annoying thing about lists in Markdown is that `
    2. ` elements aren't given a child `

      ` tag unless there are multiple paragraphs in the list item. That means I have to worry about styling that annoying situation too. 151 | 152 | - **For example, here's another nested list.** 153 | 154 | But this time with a second paragraph. 155 | 156 | - These list items won't have `

      ` tags 157 | - Because they are only one line each 158 | 159 | - **But in this second top-level list item, they will.** 160 | 161 | This is especially annoying because of the spacing on this paragraph. 162 | 163 | - As you can see here, because I've added a second line, this list item now has a `

      ` tag. 164 | 165 | This is the second line I'm talking about by the way. 166 | 167 | - Finally here's another list item so it's more like a list. 168 | 169 | - A closing list item, but with no nested list, because why not? 170 | 171 | And finally a sentence to close off this section. 172 | 173 | ## There are other elements we need to style 174 | 175 | I almost forgot to mention links, like [this link to the Tailwind CSS website](https://tailwindcss.com). We almost made them blue but that's so yesterday, so we went with dark gray, feels edgier. 176 | 177 | We even included table styles, check it out: 178 | 179 | | Wrestler | Origin | Finisher | 180 | | ----------------------- | ------------ | ------------------ | 181 | | Bret "The Hitman" Hart | Calgary, AB | Sharpshooter | 182 | | Stone Cold Steve Austin | Austin, TX | Stone Cold Stunner | 183 | | Randy Savage | Sarasota, FL | Elbow Drop | 184 | | Vader | Boulder, CO | Vader Bomb | 185 | | Razor Ramon | Chuluota, FL | Razor's Edge | 186 | 187 | We also need to make sure inline code looks good, like if I wanted to talk about `` elements or tell you the good news about `@tailwindcss/typography`. 188 | 189 | ### Sometimes I even use `code` in headings 190 | 191 | Even though it's probably a bad idea, and historically I've had a hard time making it look good. This _"wrap the code blocks in backticks"_ trick works pretty well though really. 192 | 193 | Another thing I've done in the past is put a `code` tag inside of a link, like if I wanted to tell you about the [`tailwindcss/docs`](https://github.com/tailwindcss/docs) repository. I don't love that there is an underline below the backticks but it is absolutely not worth the madness it would require to avoid it. 194 | 195 | #### We haven't used an `h4` yet 196 | 197 | But now we have. Please don't use `h5` or `h6` in your content, Medium only supports two heading levels for a reason, you animals. I honestly considered using a `before` pseudo-element to scream at you if you use an `h5` or `h6`. 198 | 199 | We don't style them at all out of the box because `h4` elements are already so small that they are the same size as the body copy. What are we supposed to do with an `h5`, make it _smaller_ than the body copy? No thanks. 200 | 201 | ### We still need to think about stacked headings though. 202 | 203 | #### Let's make sure we don't screw that up with `h4` elements, either. 204 | 205 | Phew, with any luck we have styled the headings above this text and they look pretty good. 206 | 207 | Let's add a closing paragraph here so things end with a decently sized block of text. I can't explain why I want things to end that way but I have to assume it's because I think things will look weird or unbalanced if there is a heading too close to the end of the document. 208 | 209 | What I've written here is probably long enough, but adding this final sentence can't hurt. 210 | 211 | ## GitHub Flavored Markdown 212 | 213 | I've also added support for GitHub Flavored Mardown using `remark-gfm`. 214 | 215 | With `remark-gfm`, we get a few extra features in our markdown. Example: autolink literals. 216 | 217 | A link like www.example.com or https://example.com would automatically be converted into an `a` tag. 218 | 219 | This works for email links too: contact@example.com. 220 | --------------------------------------------------------------------------------