commit 8dc58401d5fb4046f207c7c75a26fa546c35f716 Author: oiov Date: Fri Jul 26 22:08:57 2024 +0800 init diff --git a/.commitlintrc.json b/.commitlintrc.json new file mode 100644 index 0000000..c30e5a9 --- /dev/null +++ b/.commitlintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["@commitlint/config-conventional"] +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c739e39 --- /dev/null +++ b/.env.example @@ -0,0 +1,29 @@ +# ----------------------------------------------------------------------------- +# App - Don't add "/" in the end of the url (same in production) +# ----------------------------------------------------------------------------- +NEXT_PUBLIC_APP_URL=http://localhost:3000 + +# ----------------------------------------------------------------------------- +# Authentication (NextAuth.js) +# ----------------------------------------------------------------------------- +AUTH_SECRET= + +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= + +# ----------------------------------------------------------------------------- +# Database (MySQL - Neon DB) +# ----------------------------------------------------------------------------- +DATABASE_URL='postgres://[user]:[password]@[neon_hostname]/[dbname]?sslmode=require' + +# ----------------------------------------------------------------------------- +# Email (Resend) +# ----------------------------------------------------------------------------- +RESEND_API_KEY= + +# ----------------------------------------------------------------------------- +# Cloudflare +# ----------------------------------------------------------------------------- +CLOUDFLARE_ZONE_ID= +CLOUDFLARE_API_KEY= +CLOUDFLARE_EMAIL= \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..ffd6c44 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,31 @@ +{ + "$schema": "https://json.schemastore.org/eslintrc", + "root": true, + "extends": [ + "next/core-web-vitals", + "prettier", + "plugin:tailwindcss/recommended" + ], + "plugins": ["tailwindcss"], + "rules": { + "@next/next/no-html-link-for-pages": "off", + "react/jsx-key": "off", + "tailwindcss/no-custom-classname": "off", + "tailwindcss/classnames-order": "error" + }, + "settings": { + "tailwindcss": { + "callees": ["cn"], + "config": "tailwind.config.ts" + }, + "next": { + "rootDir": true + } + }, + "overrides": [ + { + "files": ["*.ts", "*.tsx"], + "parser": "@typescript-eslint/parser" + } + ] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0a8ae55 --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# local env files +.env*.local +.env + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# email +/.react-email/ + +.vscode +.contentlayer \ No newline at end of file diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100644 index 0000000..4974c35 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npx commitlint --edit $1 diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..0da96d6 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npx pretty-quick --staged diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..50e4b92 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +v16.18.0 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..3aea320 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +dist +node_modules +.next +build +.contentlayer \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..457c3cf --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 mickasmt + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/actions/update-user-name.ts b/actions/update-user-name.ts new file mode 100644 index 0000000..e0af684 --- /dev/null +++ b/actions/update-user-name.ts @@ -0,0 +1,38 @@ +"use server"; + +import { auth } from "@/auth"; +import { prisma } from "@/lib/db"; +import { userNameSchema } from "@/lib/validations/user"; +import { revalidatePath } from "next/cache"; + +export type FormData = { + name: string; +}; + +export async function updateUserName(userId: string, data: FormData) { + try { + const session = await auth() + + if (!session?.user || session?.user.id !== userId) { + throw new Error("Unauthorized"); + } + + const { name } = userNameSchema.parse(data); + + // Update the user name. + await prisma.user.update({ + where: { + id: userId, + }, + data: { + name: name, + }, + }) + + revalidatePath('/dashboard/settings'); + return { status: "success" }; + } catch (error) { + // console.log(error) + return { status: "error" } + } +} \ No newline at end of file diff --git a/actions/update-user-role.ts b/actions/update-user-role.ts new file mode 100644 index 0000000..92af60b --- /dev/null +++ b/actions/update-user-role.ts @@ -0,0 +1,40 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { auth } from "@/auth"; +import { UserRole } from "@prisma/client"; + +import { prisma } from "@/lib/db"; +import { userRoleSchema } from "@/lib/validations/user"; + +export type FormData = { + role: UserRole; +}; + +export async function updateUserRole(userId: string, data: FormData) { + try { + const session = await auth(); + + if (!session?.user || session?.user.id !== userId) { + throw new Error("Unauthorized"); + } + + const { role } = userRoleSchema.parse(data); + + // Update the user role. + await prisma.user.update({ + where: { + id: userId, + }, + data: { + role: role, + }, + }); + + revalidatePath("/dashboard/settings"); + return { status: "success" }; + } catch (error) { + // console.log(error) + return { status: "error" }; + } +} diff --git a/app/(auth)/layout.tsx b/app/(auth)/layout.tsx new file mode 100644 index 0000000..62830b6 --- /dev/null +++ b/app/(auth)/layout.tsx @@ -0,0 +1,18 @@ +import { redirect } from "next/navigation"; + +import { getCurrentUser } from "@/lib/session"; + +interface AuthLayoutProps { + children: React.ReactNode; +} + +export default async function AuthLayout({ children }: AuthLayoutProps) { + const user = await getCurrentUser(); + + if (user) { + if (user.role === "ADMIN") redirect("/admin"); + redirect("/dashboard"); + } + + return
{children}
; +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 0000000..2f1eeb3 --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -0,0 +1,54 @@ +import { Suspense } from "react"; +import { Metadata } from "next"; +import Link from "next/link"; + +import { cn } from "@/lib/utils"; +import { buttonVariants } from "@/components/ui/button"; +import { UserAuthForm } from "@/components/forms/user-auth-form"; +import { Icons } from "@/components/shared/icons"; + +export const metadata: Metadata = { + title: "Login", + description: "Login to your account", +}; + +export default function LoginPage() { + return ( +
+ + <> + + Back + + +
+
+ +

+ Welcome back +

+

+ Enter your email to sign in to your account +

+
+ + + +

+ + Don't have an account? Sign Up + +

+
+
+ ); +} diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx new file mode 100644 index 0000000..a05ff82 --- /dev/null +++ b/app/(auth)/register/page.tsx @@ -0,0 +1,62 @@ +import Link from "next/link" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" +import { Icons } from "@/components/shared/icons" +import { UserAuthForm } from "@/components/forms/user-auth-form" +import { Suspense } from "react" + +export const metadata = { + title: "Create an account", + description: "Create an account to get started.", +} + +export default function RegisterPage() { + return ( +
+ + Login + +
+
+
+
+ +

+ Create an account +

+

+ Enter your email below to create your account +

+
+ + + +

+ By clicking continue, you agree to our{" "} + + Terms of Service + {" "} + and{" "} + + Privacy Policy + + . +

+
+
+
+ ) +} diff --git a/app/(docs)/docs/[[...slug]]/page.tsx b/app/(docs)/docs/[[...slug]]/page.tsx new file mode 100644 index 0000000..7e9d20c --- /dev/null +++ b/app/(docs)/docs/[[...slug]]/page.tsx @@ -0,0 +1,89 @@ +import { notFound } from "next/navigation"; +import { allDocs } from "contentlayer/generated"; + +import { getTableOfContents } from "@/lib/toc"; +import { Mdx } from "@/components/content/mdx-components"; +import { DocsPageHeader } from "@/components/docs/page-header"; +import { DocsPager } from "@/components/docs/pager"; +import { DashboardTableOfContents } from "@/components/shared/toc"; + +import "@/styles/mdx.css"; + +import { Metadata } from "next"; + +import { constructMetadata, getBlurDataURL } from "@/lib/utils"; + +interface DocPageProps { + params: { + slug: string[]; + }; +} + +async function getDocFromParams(params) { + const slug = params.slug?.join("/") || ""; + const doc = allDocs.find((doc) => doc.slugAsParams === slug); + + if (!doc) return null; + + return doc; +} + +export async function generateMetadata({ + params, +}: DocPageProps): Promise { + const doc = await getDocFromParams(params); + + if (!doc) return {}; + + const { title, description } = doc; + + return constructMetadata({ + title: `${title} – Next Template`, + description: description, + }); +} + +export async function generateStaticParams(): Promise< + DocPageProps["params"][] +> { + return allDocs.map((doc) => ({ + slug: doc.slugAsParams.split("/"), + })); +} + +export default async function DocPage({ params }: DocPageProps) { + const doc = await getDocFromParams(params); + + if (!doc) { + notFound(); + } + + const toc = await getTableOfContents(doc.body.raw); + + const [images] = await Promise.all([ + await Promise.all( + doc.images.map(async (src: string) => ({ + src, + blurDataURL: await getBlurDataURL(src), + })), + ), + ]); + + return ( +
+
+ +
+ +
+
+ +
+
+
+ +
+
+
+ ); +} diff --git a/app/(docs)/docs/layout.tsx b/app/(docs)/docs/layout.tsx new file mode 100644 index 0000000..96f981b --- /dev/null +++ b/app/(docs)/docs/layout.tsx @@ -0,0 +1,19 @@ +import { ScrollArea } from "@/components/ui/scroll-area"; +import { DocsSidebarNav } from "@/components/docs/sidebar-nav"; + +interface DocsLayoutProps { + children: React.ReactNode; +} + +export default function DocsLayout({ children }: DocsLayoutProps) { + return ( +
+ + {children} +
+ ); +} diff --git a/app/(docs)/layout.tsx b/app/(docs)/layout.tsx new file mode 100644 index 0000000..b2ad021 --- /dev/null +++ b/app/(docs)/layout.tsx @@ -0,0 +1,21 @@ +import { NavMobile } from "@/components/layout/mobile-nav"; +import { NavBar } from "@/components/layout/navbar"; +import { SiteFooter } from "@/components/layout/site-footer"; +import MaxWidthWrapper from "@/components/shared/max-width-wrapper"; + +interface DocsLayoutProps { + children: React.ReactNode; +} + +export default function DocsLayout({ children }: DocsLayoutProps) { + return ( +
+ + + + {children} + + +
+ ); +} diff --git a/app/(marketing)/(blog-post)/blog/[slug]/page.tsx b/app/(marketing)/(blog-post)/blog/[slug]/page.tsx new file mode 100644 index 0000000..15bb6ea --- /dev/null +++ b/app/(marketing)/(blog-post)/blog/[slug]/page.tsx @@ -0,0 +1,185 @@ +import { notFound } from "next/navigation"; +import { allPosts } from "contentlayer/generated"; + +import { Mdx } from "@/components/content/mdx-components"; + +import "@/styles/mdx.css"; + +import { Metadata } from "next"; +import Image from "next/image"; +import Link from "next/link"; + +import { BLOG_CATEGORIES } from "@/config/blog"; +import { getTableOfContents } from "@/lib/toc"; +import { + cn, + constructMetadata, + formatDate, + getBlurDataURL, + placeholderBlurhash, +} from "@/lib/utils"; +import { buttonVariants } from "@/components/ui/button"; +import Author from "@/components/content/author"; +import BlurImage from "@/components/shared/blur-image"; +import MaxWidthWrapper from "@/components/shared/max-width-wrapper"; +import { DashboardTableOfContents } from "@/components/shared/toc"; + +export async function generateStaticParams() { + return allPosts.map((post) => ({ + slug: post.slugAsParams, + })); +} + +export async function generateMetadata({ + params, +}: { + params: { slug: string }; +}): Promise { + const post = allPosts.find((post) => post.slugAsParams === params.slug); + if (!post) { + return; + } + + const { title, description, image } = post; + + return constructMetadata({ + title: `${title} – Next Template`, + description: description, + image, + }); +} + +export default async function PostPage({ + params, +}: { + params: { + slug: string; + }; +}) { + const post = allPosts.find((post) => post.slugAsParams === params.slug); + + if (!post) { + notFound(); + } + + const category = BLOG_CATEGORIES.find( + (category) => category.slug === post.categories[0], + )!; + + const relatedArticles = + (post.related && + post.related.map( + (slug) => allPosts.find((post) => post.slugAsParams === slug)!, + )) || + []; + + const toc = await getTableOfContents(post.body.raw); + + const [thumbnailBlurhash, images] = await Promise.all([ + getBlurDataURL(post.image), + await Promise.all( + post.images.map(async (src: string) => ({ + src, + blurDataURL: await getBlurDataURL(src), + })), + ), + ]); + + return ( + <> + +
+
+ + {category.title} + + +
+

+ {post.title} +

+

+ {post.description} +

+
+ {post.authors.map((author) => ( + + ))} +
+
+
+ +
+
+ + +
+ +
+ +
+
+ +
+ +
+
+
+ + + {relatedArticles.length > 0 && ( +
+

+ More Articles +

+ +
+ {relatedArticles.map((post) => ( + +

+ {post.title} +

+

+ {post.description} +

+

+ {formatDate(post.date)} +

+ + ))} +
+
+ )} +
+ + ); +} diff --git a/app/(marketing)/[slug]/page.tsx b/app/(marketing)/[slug]/page.tsx new file mode 100644 index 0000000..8163c62 --- /dev/null +++ b/app/(marketing)/[slug]/page.tsx @@ -0,0 +1,63 @@ +import { notFound } from "next/navigation"; +import { allPages } from "contentlayer/generated"; + +import { Mdx } from "@/components/content/mdx-components"; + +import "@/styles/mdx.css"; + +import { Metadata } from "next"; + +import { constructMetadata } from "@/lib/utils"; + +export async function generateStaticParams() { + return allPages.map((page) => ({ + slug: page.slugAsParams, + })); +} + +export async function generateMetadata({ + params, +}: { + params: { slug: string }; +}): Promise { + const page = allPages.find((page) => page.slugAsParams === params.slug); + if (!page) { + return; + } + + const { title, description } = page; + + return constructMetadata({ + title: `${title} – Next Template`, + description: description, + }); +} + +export default async function PagePage({ + params, +}: { + params: { + slug: string; + }; +}) { + const page = allPages.find((page) => page.slugAsParams === params.slug); + + if (!page) { + notFound(); + } + + return ( +
+
+

+ {page.title} +

+ {page.description && ( +

{page.description}

+ )} +
+
+ +
+ ); +} diff --git a/app/(marketing)/blog/category/[slug]/page.tsx b/app/(marketing)/blog/category/[slug]/page.tsx new file mode 100644 index 0000000..42d8057 --- /dev/null +++ b/app/(marketing)/blog/category/[slug]/page.tsx @@ -0,0 +1,65 @@ +import { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { allPosts } from "contentlayer/generated"; + +import { BLOG_CATEGORIES } from "@/config/blog"; +import { constructMetadata, getBlurDataURL } from "@/lib/utils"; +import { BlogCard } from "@/components/content/blog-card"; + +export async function generateStaticParams() { + return BLOG_CATEGORIES.map((category) => ({ + slug: category.slug, + })); +} + +export async function generateMetadata({ + params, +}: { + params: { slug: string }; +}): Promise { + const category = BLOG_CATEGORIES.find( + (category) => category.slug === params.slug, + ); + if (!category) { + return; + } + + const { title, description } = category; + + return constructMetadata({ + title: `${title} Posts – Next Auth Roles Template`, + description, + }); +} + +export default async function BlogCategory({ + params, +}: { + params: { + slug: string; + }; +}) { + const category = BLOG_CATEGORIES.find((ctg) => ctg.slug === params.slug); + + if (!category) { + notFound(); + } + + const articles = await Promise.all( + allPosts + .filter((post) => post.categories.includes(category.slug)) + .sort((a, b) => b.date.localeCompare(a.date)) + .map(async (post) => ({ + ...post, + blurDataURL: await getBlurDataURL(post.image), + })), + ); + + return ( +
+ {articles.map((article, idx) => ( + + ))} +
+ ); +} diff --git a/app/(marketing)/blog/layout.tsx b/app/(marketing)/blog/layout.tsx new file mode 100644 index 0000000..9e40c06 --- /dev/null +++ b/app/(marketing)/blog/layout.tsx @@ -0,0 +1,15 @@ +import { BlogHeaderLayout } from "@/components/content/blog-header-layout"; +import MaxWidthWrapper from "@/components/shared/max-width-wrapper"; + +export default function BlogLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + <> + + {children} + + ); +} diff --git a/app/(marketing)/blog/page.tsx b/app/(marketing)/blog/page.tsx new file mode 100644 index 0000000..abf66f0 --- /dev/null +++ b/app/(marketing)/blog/page.tsx @@ -0,0 +1,23 @@ +import { allPosts } from "contentlayer/generated"; + +import { constructMetadata, getBlurDataURL } from "@/lib/utils"; +import { BlogPosts } from "@/components/content/blog-posts"; + +export const metadata = constructMetadata({ + title: "Blog – Next Template", + description: "Latest news and updates from Next Auth Roles Template.", +}); + +export default async function BlogPage() { + const posts = await Promise.all( + allPosts + .filter((post) => post.published) + .sort((a, b) => b.date.localeCompare(a.date)) + .map(async (post) => ({ + ...post, + blurDataURL: await getBlurDataURL(post.image), + })), + ); + + return ; +} diff --git a/app/(marketing)/error.tsx b/app/(marketing)/error.tsx new file mode 100644 index 0000000..b7735b2 --- /dev/null +++ b/app/(marketing)/error.tsx @@ -0,0 +1,23 @@ +'use client'; + +import { Button } from '@/components/ui/button'; + +export default function Error({ + reset, +}: { + reset: () => void; +}) { + + return ( +
+

Something went wrong!

+ +
+ ); +} \ No newline at end of file diff --git a/app/(marketing)/layout.tsx b/app/(marketing)/layout.tsx new file mode 100644 index 0000000..f7843da --- /dev/null +++ b/app/(marketing)/layout.tsx @@ -0,0 +1,18 @@ +import { NavBar } from "@/components/layout/navbar"; +import { SiteFooter } from "@/components/layout/site-footer"; +import { NavMobile } from "@/components/layout/mobile-nav"; + +interface MarketingLayoutProps { + children: React.ReactNode; +} + +export default function MarketingLayout({ children }: MarketingLayoutProps) { + return ( +
+ + +
{children}
+ +
+ ); +} diff --git a/app/(marketing)/not-found.tsx b/app/(marketing)/not-found.tsx new file mode 100644 index 0000000..de23fbd --- /dev/null +++ b/app/(marketing)/not-found.tsx @@ -0,0 +1,27 @@ +import Image from "next/image"; +import Link from "next/link"; + +export default function NotFound() { + return ( +
+

404

+ 404 +

+ Page not found. Back to{" "} + + Homepage + + . +

+
+ ); +} diff --git a/app/(marketing)/page.tsx b/app/(marketing)/page.tsx new file mode 100644 index 0000000..360609f --- /dev/null +++ b/app/(marketing)/page.tsx @@ -0,0 +1,12 @@ + +import HeroLanding from "@/components/sections/hero-landing"; +import PreviewLanding from "@/components/sections/preview-landing"; + +export default function IndexPage() { + return ( + <> + + + + ); +} diff --git a/app/(protected)/admin/layout.tsx b/app/(protected)/admin/layout.tsx new file mode 100644 index 0000000..e648aea --- /dev/null +++ b/app/(protected)/admin/layout.tsx @@ -0,0 +1,18 @@ +import { notFound, redirect } from "next/navigation"; + +import { getCurrentUser } from "@/lib/session"; + +interface ProtectedLayoutProps { + children: React.ReactNode; +} + +export default async function Dashboard({ children }: ProtectedLayoutProps) { + const user = await getCurrentUser(); + + // if (!user) redirect("/login"); + // if (user.role !== "ADMIN") notFound(); + + if (!user || user.role !== "ADMIN") redirect("/login"); + + return <>{children}; +} diff --git a/app/(protected)/admin/loading.tsx b/app/(protected)/admin/loading.tsx new file mode 100644 index 0000000..261bf55 --- /dev/null +++ b/app/(protected)/admin/loading.tsx @@ -0,0 +1,23 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { DashboardHeader } from "@/components/dashboard/header"; + +export default function AdminPanelLoading() { + return ( + <> + +
+
+ + + + +
+ + +
+ + ); +} diff --git a/app/(protected)/admin/orders/loading.tsx b/app/(protected)/admin/orders/loading.tsx new file mode 100644 index 0000000..b144afc --- /dev/null +++ b/app/(protected)/admin/orders/loading.tsx @@ -0,0 +1,14 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { DashboardHeader } from "@/components/dashboard/header"; + +export default function OrdersLoading() { + return ( + <> + + + + ); +} diff --git a/app/(protected)/admin/orders/page.tsx b/app/(protected)/admin/orders/page.tsx new file mode 100644 index 0000000..6cb66f0 --- /dev/null +++ b/app/(protected)/admin/orders/page.tsx @@ -0,0 +1,34 @@ +import { redirect } from "next/navigation"; + +import { getCurrentUser } from "@/lib/session"; +import { constructMetadata } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { DashboardHeader } from "@/components/dashboard/header"; +import { EmptyPlaceholder } from "@/components/shared/empty-placeholder"; + +export const metadata = constructMetadata({ + title: "Orders – Next Template", + description: "Check and manage your latest orders.", +}); + +export default async function OrdersPage() { + // const user = await getCurrentUser(); + // if (!user || user.role !== "ADMIN") redirect("/login"); + + return ( + <> + + + + No orders listed + + You don't have any orders yet. Start ordering a product. + + + + + ); +} diff --git a/app/(protected)/admin/page.tsx b/app/(protected)/admin/page.tsx new file mode 100644 index 0000000..54d56d9 --- /dev/null +++ b/app/(protected)/admin/page.tsx @@ -0,0 +1,36 @@ +import { redirect } from "next/navigation"; + +import { getCurrentUser } from "@/lib/session"; +import { constructMetadata } from "@/lib/utils"; +import { DashboardHeader } from "@/components/dashboard/header"; +import InfoCard from "@/components/dashboard/info-card"; +import TransactionsList from "@/components/dashboard/transactions-list"; + +export const metadata = constructMetadata({ + title: "Admin – Next Template", + description: "Admin page for only admin management.", +}); + +export default async function AdminPage() { + const user = await getCurrentUser(); + if (!user || user.role !== "ADMIN") redirect("/login"); + + return ( + <> + +
+
+ + + + +
+ + +
+ + ); +} diff --git a/app/(protected)/dashboard/charts/loading.tsx b/app/(protected)/dashboard/charts/loading.tsx new file mode 100644 index 0000000..2640f41 --- /dev/null +++ b/app/(protected)/dashboard/charts/loading.tsx @@ -0,0 +1,11 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { DashboardHeader } from "@/components/dashboard/header"; + +export default function ChartsLoading() { + return ( + <> + + + + ); +} diff --git a/app/(protected)/dashboard/charts/page.tsx b/app/(protected)/dashboard/charts/page.tsx new file mode 100644 index 0000000..b5626b9 --- /dev/null +++ b/app/(protected)/dashboard/charts/page.tsx @@ -0,0 +1,41 @@ +import { constructMetadata } from "@/lib/utils"; +import { AreaChartStacked } from "@/components/charts/area-chart-stacked"; +import { BarChartMixed } from "@/components/charts/bar-chart-mixed"; +import { InteractiveBarChart } from "@/components/charts/interactive-bar-chart"; +import { LineChartMultiple } from "@/components/charts/line-chart-multiple"; +import { RadarChartSimple } from "@/components/charts/radar-chart-simple"; +import { RadialChartGrid } from "@/components/charts/radial-chart-grid"; +import { RadialShapeChart } from "@/components/charts/radial-shape-chart"; +import { RadialStackedChart } from "@/components/charts/radial-stacked-chart"; +import { RadialTextChart } from "@/components/charts/radial-text-chart"; +import { DashboardHeader } from "@/components/dashboard/header"; + +export const metadata = constructMetadata({ + title: "Charts – Next Template", + description: "List of charts by shadcn-ui", +}); + +export default function ChartsPage() { + return ( + <> + +
+
+ + + + +
+ + + +
+ + + + +
+
+ + ); +} diff --git a/app/(protected)/dashboard/loading.tsx b/app/(protected)/dashboard/loading.tsx new file mode 100644 index 0000000..4fc37e7 --- /dev/null +++ b/app/(protected)/dashboard/loading.tsx @@ -0,0 +1,11 @@ +import { Skeleton } from "@/components/ui/skeleton"; +import { DashboardHeader } from "@/components/dashboard/header"; + +export default function DashboardLoading() { + return ( + <> + + + + ); +} diff --git a/app/(protected)/dashboard/page.tsx b/app/(protected)/dashboard/page.tsx new file mode 100644 index 0000000..fc01d79 --- /dev/null +++ b/app/(protected)/dashboard/page.tsx @@ -0,0 +1,39 @@ +import { redirect } from "next/navigation"; + +import { getCurrentUser } from "@/lib/session"; +import { constructMetadata } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { DashboardHeader } from "@/components/dashboard/header"; +import { AddRecordForm } from "@/components/forms/add-record-form"; +import { EmptyPlaceholder } from "@/components/shared/empty-placeholder"; + +export const metadata = constructMetadata({ + title: "Dashboard – Next Template", + description: "Create and manage content.", +}); + +export default async function DashboardPage() { + const user = await getCurrentUser(); + + if (!user?.id) redirect("/login"); + + return ( + <> + + + {user && } + + + + No record created + + You don't have any record yet. Start creating record. + + + + + ); +} diff --git a/app/(protected)/dashboard/settings/loading.tsx b/app/(protected)/dashboard/settings/loading.tsx new file mode 100644 index 0000000..0e1a576 --- /dev/null +++ b/app/(protected)/dashboard/settings/loading.tsx @@ -0,0 +1,18 @@ +import { DashboardHeader } from "@/components/dashboard/header"; +import { SkeletonSection } from "@/components/shared/section-skeleton"; + +export default function DashboardSettingsLoading() { + return ( + <> + +
+ + + +
+ + ); +} diff --git a/app/(protected)/dashboard/settings/page.tsx b/app/(protected)/dashboard/settings/page.tsx new file mode 100644 index 0000000..85ced73 --- /dev/null +++ b/app/(protected)/dashboard/settings/page.tsx @@ -0,0 +1,33 @@ +import { redirect } from "next/navigation"; + +import { getCurrentUser } from "@/lib/session"; +import { constructMetadata } from "@/lib/utils"; +import { DeleteAccountSection } from "@/components/dashboard/delete-account"; +import { DashboardHeader } from "@/components/dashboard/header"; +import { UserNameForm } from "@/components/forms/user-name-form"; +import { UserRoleForm } from "@/components/forms/user-role-form"; + +export const metadata = constructMetadata({ + title: "Settings – Next Template", + description: "Configure your account and website settings.", +}); + +export default async function SettingsPage() { + const user = await getCurrentUser(); + + if (!user?.id) redirect("/login"); + + return ( + <> + +
+ + + +
+ + ); +} diff --git a/app/(protected)/layout.tsx b/app/(protected)/layout.tsx new file mode 100644 index 0000000..010142d --- /dev/null +++ b/app/(protected)/layout.tsx @@ -0,0 +1,56 @@ +import { redirect } from "next/navigation"; + +import { sidebarLinks } from "@/config/dashboard"; +import { getCurrentUser } from "@/lib/session"; +import { SearchCommand } from "@/components/dashboard/search-command"; +import { + DashboardSidebar, + MobileSheetSidebar, +} from "@/components/layout/dashboard-sidebar"; +import { ModeToggle } from "@/components/layout/mode-toggle"; +import { UserAccountNav } from "@/components/layout/user-account-nav"; +import MaxWidthWrapper from "@/components/shared/max-width-wrapper"; + +interface ProtectedLayoutProps { + children: React.ReactNode; +} + +export default async function Dashboard({ children }: ProtectedLayoutProps) { + const user = await getCurrentUser(); + + if (!user) redirect("/login"); + + const filteredLinks = sidebarLinks.map((section) => ({ + ...section, + items: section.items.filter( + ({ authorizeOnly }) => !authorizeOnly || authorizeOnly === user.role, + ), + })); + + return ( +
+ + +
+
+ + + +
+ +
+ + + +
+
+ +
+ + {children} + +
+
+
+ ); +} diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..70c59d1 --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1 @@ +export { GET, POST } from "@/auth" \ No newline at end of file diff --git a/app/api/og/route.tsx b/app/api/og/route.tsx new file mode 100644 index 0000000..3b50df5 --- /dev/null +++ b/app/api/og/route.tsx @@ -0,0 +1,155 @@ +import { ImageResponse } from "@vercel/og"; + +import { ogImageSchema } from "@/lib/validations/og"; + +export const runtime = "edge"; + +const interRegular = fetch( + new URL("../../../assets/fonts/Inter-Regular.ttf", import.meta.url), +).then((res) => res.arrayBuffer()); + +const interBold = fetch( + new URL("../../../assets/fonts/CalSans-SemiBold.ttf", import.meta.url), +).then((res) => res.arrayBuffer()); + +export async function GET(req: Request) { + try { + const fontRegular = await interRegular; + const fontBold = await interBold; + + const url = new URL(req.url); + const values = ogImageSchema.parse(Object.fromEntries(url.searchParams)); + const heading = + values.heading.length > 80 + ? `${values.heading.substring(0, 100)}...` + : values.heading; + + const { mode } = values; + const paint = mode === "dark" ? "#fff" : "#000"; + + const fontSize = heading.length > 80 ? "60px" : "80px"; + + const githubName = "oiov"; + + return new ImageResponse( + ( +
+
+ Next Template +
+ +
+
+ {values.type} +
+ {/* Title */} +
+ {heading} +
+
+ +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + avatar + +
+
+ {githubName} +
+
Open Source Designer
+
+
+ +
+ + + + +
+ github.com/mickasmt/next-auth-roles-template +
+
+
+
+ ), + { + width: 1200, + height: 630, + fonts: [ + { + name: "Inter", + data: fontRegular, + weight: 400, + style: "normal", + }, + { + name: "Cal Sans", + data: fontBold, + weight: 700, + style: "normal", + }, + ], + }, + ); + } catch (error) { + return new Response(`Failed to generate image`, { + status: 500, + }); + } +} diff --git a/app/api/record/add/route.ts b/app/api/record/add/route.ts new file mode 100644 index 0000000..90de76e --- /dev/null +++ b/app/api/record/add/route.ts @@ -0,0 +1,39 @@ +import { env } from "@/env.mjs"; +import { createDNSRecord } from "@/lib/cloudflare"; +import { getCurrentUser } from "@/lib/session"; +import { generateSecret } from "@/lib/utils"; + +export async function POST(req: Request) { + try { + const user = await getCurrentUser(); + const { records } = await req.json(); + const { CLOUDFLARE_ZONE_ID, CLOUDFLARE_API_KEY, CLOUDFLARE_EMAIL } = env; + + if (!CLOUDFLARE_ZONE_ID || !CLOUDFLARE_API_KEY) { + return new Response(`API key and Zone ID are required`, { + status: 400, + }); + } + + const record = { + ...records[0], + id: generateSecret(16), + type: "CNAME", + proxied: false, + }; + + // return Response.json(record); + + const data = await createDNSRecord( + CLOUDFLARE_ZONE_ID, + CLOUDFLARE_API_KEY, + CLOUDFLARE_EMAIL, + record, + ); + return Response.json(data); + } catch (error) { + return new Response(`${error}`, { + status: 500, + }); + } +} diff --git a/app/api/user/route.ts b/app/api/user/route.ts new file mode 100644 index 0000000..7937576 --- /dev/null +++ b/app/api/user/route.ts @@ -0,0 +1,26 @@ +import { auth } from "@/auth"; + +import { prisma } from "@/lib/db"; + +export const DELETE = auth(async (req) => { + if (!req.auth) { + return new Response("Not authenticated", { status: 401 }); + } + + const currentUser = req.auth.user; + if (!currentUser) { + return new Response("Invalid user", { status: 401 }); + } + + try { + await prisma.user.delete({ + where: { + id: currentUser.id, + }, + }); + } catch (error) { + return new Response("Internal server error", { status: 500 }); + } + + return new Response("User deleted successfully!", { status: 200 }); +}); diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..d83c85a --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,53 @@ +import "@/styles/globals.css"; + +import { fontHeading, fontSans, fontSatoshi } from "@/assets/fonts"; +import { SessionProvider } from "next-auth/react"; +import { ThemeProvider } from "next-themes"; + +import { cn, constructMetadata } from "@/lib/utils"; +import { Toaster } from "@/components/ui/sonner"; +import { Analytics } from "@/components/analytics"; +import ModalProvider from "@/components/modals/providers"; +import { TailwindIndicator } from "@/components/tailwind-indicator"; + +interface RootLayoutProps { + children: React.ReactNode; +} + +export const metadata = constructMetadata(); + +export default function RootLayout({ children }: RootLayoutProps) { + return ( + + + + + + + + {children} + {/* */} + + + + + + + ); +} diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..de23fbd --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,27 @@ +import Image from "next/image"; +import Link from "next/link"; + +export default function NotFound() { + return ( +
+

404

+ 404 +

+ Page not found. Back to{" "} + + Homepage + + . +

+
+ ); +} diff --git a/app/opengraph-image.jpg b/app/opengraph-image.jpg new file mode 100644 index 0000000..03d10eb Binary files /dev/null and b/app/opengraph-image.jpg differ diff --git a/app/robots.ts b/app/robots.ts new file mode 100644 index 0000000..f86f167 --- /dev/null +++ b/app/robots.ts @@ -0,0 +1,10 @@ +import { MetadataRoute } from "next" + +export default function robots(): MetadataRoute.Robots { + return { + rules: { + userAgent: "*", + allow: "/", + }, + } +} diff --git a/assets/fonts/CalSans-SemiBold.ttf b/assets/fonts/CalSans-SemiBold.ttf new file mode 100644 index 0000000..4a2950a Binary files /dev/null and b/assets/fonts/CalSans-SemiBold.ttf differ diff --git a/assets/fonts/CalSans-SemiBold.woff2 b/assets/fonts/CalSans-SemiBold.woff2 new file mode 100644 index 0000000..36d71b7 Binary files /dev/null and b/assets/fonts/CalSans-SemiBold.woff2 differ diff --git a/assets/fonts/Inter-Bold.ttf b/assets/fonts/Inter-Bold.ttf new file mode 100644 index 0000000..8e82c70 Binary files /dev/null and b/assets/fonts/Inter-Bold.ttf differ diff --git a/assets/fonts/Inter-Regular.ttf b/assets/fonts/Inter-Regular.ttf new file mode 100644 index 0000000..8d4eebf Binary files /dev/null and b/assets/fonts/Inter-Regular.ttf differ diff --git a/assets/fonts/index.ts b/assets/fonts/index.ts new file mode 100644 index 0000000..af33aa9 --- /dev/null +++ b/assets/fonts/index.ts @@ -0,0 +1,20 @@ +import { Inter as FontSans } from "next/font/google"; +import localFont from "next/font/local"; + +export const fontSans = FontSans({ + subsets: ["latin"], + variable: "--font-sans", +}); + +export const fontHeading = localFont({ + src: "./CalSans-SemiBold.woff2", + variable: "--font-heading", +}); + +export const fontSatoshi = localFont({ + src: "./satoshi-variable.woff2", + variable: "--font-satoshi", + weight: "300 900", + display: "swap", + style: "normal", +}); diff --git a/assets/fonts/satoshi-variable.woff2 b/assets/fonts/satoshi-variable.woff2 new file mode 100644 index 0000000..b00e833 Binary files /dev/null and b/assets/fonts/satoshi-variable.woff2 differ diff --git a/auth.config.ts b/auth.config.ts new file mode 100644 index 0000000..95322ab --- /dev/null +++ b/auth.config.ts @@ -0,0 +1,28 @@ +import type { NextAuthConfig } from "next-auth"; +import Github from "next-auth/providers/github"; +import Google from "next-auth/providers/google"; +import Resend from "next-auth/providers/resend"; + +import { env } from "@/env.mjs"; + +// import { siteConfig } from "@/config/site" +// import { getUserByEmail } from "@/lib/user"; +// import MagicLinkEmail from "@/emails/magic-link-email" +// import { prisma } from "@/lib/db" + +export default { + providers: [ + Google({ + clientId: env.GOOGLE_CLIENT_ID, + clientSecret: env.GOOGLE_CLIENT_SECRET, + }), + Github({ + clientId: env.GITHUB_ID, + clientSecret: env.GITHUB_SECRET, + }), + Resend({ + apiKey: env.RESEND_API_KEY, + from: "wrdo ", + }), + ], +} satisfies NextAuthConfig; diff --git a/auth.ts b/auth.ts new file mode 100644 index 0000000..b524e7b --- /dev/null +++ b/auth.ts @@ -0,0 +1,67 @@ +import authConfig from "@/auth.config"; +import { PrismaAdapter } from "@auth/prisma-adapter"; +import { UserRole } from "@prisma/client"; +import NextAuth, { type DefaultSession } from "next-auth"; + +import { prisma } from "@/lib/db"; +import { getUserById } from "@/lib/user"; + +// More info: https://authjs.dev/getting-started/typescript#module-augmentation +declare module "next-auth" { + interface Session { + user: { + role: UserRole; + } & DefaultSession["user"]; + } +} + +export const { + handlers: { GET, POST }, + auth, +} = NextAuth({ + adapter: PrismaAdapter(prisma), + session: { strategy: "jwt" }, + pages: { + signIn: "/login", + // error: "/auth/error", + }, + callbacks: { + async session({ token, session }) { + if (session.user) { + if (token.sub) { + session.user.id = token.sub; + } + + if (token.email) { + session.user.email = token.email; + } + + if (token.role) { + session.user.role = token.role; + } + + session.user.name = token.name; + session.user.image = token.picture; + } + + return session; + }, + + async jwt({ token }) { + if (!token.sub) return token; + + const dbUser = await getUserById(token.sub); + + if (!dbUser) return token; + + token.name = dbUser.name; + token.email = dbUser.email; + token.picture = dbUser.image; + token.role = dbUser.role; + + return token; + }, + }, + ...authConfig, + // debug: process.env.NODE_ENV !== "production" +}); diff --git a/components.json b/components.json new file mode 100644 index 0000000..d0f8680 --- /dev/null +++ b/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "styles/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} \ No newline at end of file diff --git a/components/analytics.tsx b/components/analytics.tsx new file mode 100644 index 0000000..164e9b7 --- /dev/null +++ b/components/analytics.tsx @@ -0,0 +1,7 @@ +"use client" + +import { Analytics as VercelAnalytics } from "@vercel/analytics/react" + +export function Analytics() { + return +} diff --git a/components/charts/area-chart-stacked.tsx b/components/charts/area-chart-stacked.tsx new file mode 100644 index 0000000..98c20eb --- /dev/null +++ b/components/charts/area-chart-stacked.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { TrendingUp } from "lucide-react"; +import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"; + +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; + +const chartData = [ + { month: "January", desktop: 186, mobile: 80 }, + { month: "February", desktop: 305, mobile: 200 }, + { month: "March", desktop: 237, mobile: 120 }, + { month: "April", desktop: 73, mobile: 190 }, + { month: "May", desktop: 209, mobile: 130 }, + { month: "June", desktop: 214, mobile: 140 }, +]; + +const chartConfig = { + desktop: { + label: "Desktop", + color: "hsl(var(--chart-1))", + }, + mobile: { + label: "Mobile", + color: "hsl(var(--chart-2))", + }, +} satisfies ChartConfig; + +export function AreaChartStacked() { + return ( + + + {/* Area Chart - Stacked + + Showing total visitors for the last 6 months + */} + + + + + + value.slice(0, 3)} + /> + } + /> + + + + + + +
+ Trending up by 5.2% this month +
+
+ January - June 2024 +
+
+
+ ); +} diff --git a/components/charts/bar-chart-mixed.tsx b/components/charts/bar-chart-mixed.tsx new file mode 100644 index 0000000..8e5dc61 --- /dev/null +++ b/components/charts/bar-chart-mixed.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { TrendingUp } from "lucide-react"; +import { Bar, BarChart, XAxis, YAxis } from "recharts"; + +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; + +const chartData = [ + { browser: "chrome", visitors: 275, fill: "var(--color-chrome)" }, + { browser: "safari", visitors: 200, fill: "var(--color-safari)" }, + { browser: "firefox", visitors: 187, fill: "var(--color-firefox)" }, + { browser: "edge", visitors: 173, fill: "var(--color-edge)" }, + { browser: "other", visitors: 90, fill: "var(--color-other)" }, +]; + +const chartConfig = { + visitors: { + label: "Visitors", + }, + chrome: { + label: "Chrome", + color: "hsl(var(--chart-1))", + }, + safari: { + label: "Safari", + color: "hsl(var(--chart-2))", + }, + firefox: { + label: "Firefox", + color: "hsl(var(--chart-3))", + }, + edge: { + label: "Edge", + color: "hsl(var(--chart-4))", + }, + other: { + label: "Other", + color: "hsl(var(--chart-5))", + }, +} satisfies ChartConfig; + +export function BarChartMixed() { + return ( + + + {/* Bar Chart - Mixed + January - June 2024 */} + + + + + + chartConfig[value as keyof typeof chartConfig]?.label + } + /> + + } + /> + + + + + +
+ Trending up by 5.2% this month +
+
+ Results for the top 5 browsers +
+
+
+ ); +} diff --git a/components/charts/interactive-bar-chart.tsx b/components/charts/interactive-bar-chart.tsx new file mode 100644 index 0000000..1690324 --- /dev/null +++ b/components/charts/interactive-bar-chart.tsx @@ -0,0 +1,219 @@ +"use client"; + +import * as React from "react"; +import { Bar, BarChart, CartesianGrid, XAxis } from "recharts"; + +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; + +const chartData = [ + { date: "2024-04-01", desktop: 222, mobile: 150 }, + { date: "2024-04-02", desktop: 97, mobile: 180 }, + { date: "2024-04-03", desktop: 167, mobile: 120 }, + { date: "2024-04-04", desktop: 242, mobile: 260 }, + { date: "2024-04-05", desktop: 373, mobile: 290 }, + { date: "2024-04-06", desktop: 301, mobile: 340 }, + { date: "2024-04-07", desktop: 245, mobile: 180 }, + { date: "2024-04-08", desktop: 409, mobile: 320 }, + { date: "2024-04-09", desktop: 59, mobile: 110 }, + { date: "2024-04-10", desktop: 261, mobile: 190 }, + { date: "2024-04-11", desktop: 327, mobile: 350 }, + { date: "2024-04-12", desktop: 292, mobile: 210 }, + { date: "2024-04-13", desktop: 342, mobile: 380 }, + { date: "2024-04-14", desktop: 137, mobile: 220 }, + { date: "2024-04-15", desktop: 120, mobile: 170 }, + { date: "2024-04-16", desktop: 138, mobile: 190 }, + { date: "2024-04-17", desktop: 446, mobile: 360 }, + { date: "2024-04-18", desktop: 364, mobile: 410 }, + { date: "2024-04-19", desktop: 243, mobile: 180 }, + { date: "2024-04-20", desktop: 89, mobile: 150 }, + { date: "2024-04-21", desktop: 137, mobile: 200 }, + { date: "2024-04-22", desktop: 224, mobile: 170 }, + { date: "2024-04-23", desktop: 138, mobile: 230 }, + { date: "2024-04-24", desktop: 387, mobile: 290 }, + { date: "2024-04-25", desktop: 215, mobile: 250 }, + { date: "2024-04-26", desktop: 75, mobile: 130 }, + { date: "2024-04-27", desktop: 383, mobile: 420 }, + { date: "2024-04-28", desktop: 122, mobile: 180 }, + { date: "2024-04-29", desktop: 315, mobile: 240 }, + { date: "2024-04-30", desktop: 454, mobile: 380 }, + { date: "2024-05-01", desktop: 165, mobile: 220 }, + { date: "2024-05-02", desktop: 293, mobile: 310 }, + { date: "2024-05-03", desktop: 247, mobile: 190 }, + { date: "2024-05-04", desktop: 385, mobile: 420 }, + { date: "2024-05-05", desktop: 481, mobile: 390 }, + { date: "2024-05-06", desktop: 498, mobile: 520 }, + { date: "2024-05-07", desktop: 388, mobile: 300 }, + { date: "2024-05-08", desktop: 149, mobile: 210 }, + { date: "2024-05-09", desktop: 227, mobile: 180 }, + { date: "2024-05-10", desktop: 293, mobile: 330 }, + { date: "2024-05-11", desktop: 335, mobile: 270 }, + { date: "2024-05-12", desktop: 197, mobile: 240 }, + { date: "2024-05-13", desktop: 197, mobile: 160 }, + { date: "2024-05-14", desktop: 448, mobile: 490 }, + { date: "2024-05-15", desktop: 473, mobile: 380 }, + { date: "2024-05-16", desktop: 338, mobile: 400 }, + { date: "2024-05-17", desktop: 499, mobile: 420 }, + { date: "2024-05-18", desktop: 315, mobile: 350 }, + { date: "2024-05-19", desktop: 235, mobile: 180 }, + { date: "2024-05-20", desktop: 177, mobile: 230 }, + { date: "2024-05-21", desktop: 82, mobile: 140 }, + { date: "2024-05-22", desktop: 81, mobile: 120 }, + { date: "2024-05-23", desktop: 252, mobile: 290 }, + { date: "2024-05-24", desktop: 294, mobile: 220 }, + { date: "2024-05-25", desktop: 201, mobile: 250 }, + { date: "2024-05-26", desktop: 213, mobile: 170 }, + { date: "2024-05-27", desktop: 420, mobile: 460 }, + { date: "2024-05-28", desktop: 233, mobile: 190 }, + { date: "2024-05-29", desktop: 78, mobile: 130 }, + { date: "2024-05-30", desktop: 340, mobile: 280 }, + { date: "2024-05-31", desktop: 178, mobile: 230 }, + { date: "2024-06-01", desktop: 178, mobile: 200 }, + { date: "2024-06-02", desktop: 470, mobile: 410 }, + { date: "2024-06-03", desktop: 103, mobile: 160 }, + { date: "2024-06-04", desktop: 439, mobile: 380 }, + { date: "2024-06-05", desktop: 88, mobile: 140 }, + { date: "2024-06-06", desktop: 294, mobile: 250 }, + { date: "2024-06-07", desktop: 323, mobile: 370 }, + { date: "2024-06-08", desktop: 385, mobile: 320 }, + { date: "2024-06-09", desktop: 438, mobile: 480 }, + { date: "2024-06-10", desktop: 155, mobile: 200 }, + { date: "2024-06-11", desktop: 92, mobile: 150 }, + { date: "2024-06-12", desktop: 492, mobile: 420 }, + { date: "2024-06-13", desktop: 81, mobile: 130 }, + { date: "2024-06-14", desktop: 426, mobile: 380 }, + { date: "2024-06-15", desktop: 307, mobile: 350 }, + { date: "2024-06-16", desktop: 371, mobile: 310 }, + { date: "2024-06-17", desktop: 475, mobile: 520 }, + { date: "2024-06-18", desktop: 107, mobile: 170 }, + { date: "2024-06-19", desktop: 341, mobile: 290 }, + { date: "2024-06-20", desktop: 408, mobile: 450 }, + { date: "2024-06-21", desktop: 169, mobile: 210 }, + { date: "2024-06-22", desktop: 317, mobile: 270 }, + { date: "2024-06-23", desktop: 480, mobile: 530 }, + { date: "2024-06-24", desktop: 132, mobile: 180 }, + { date: "2024-06-25", desktop: 141, mobile: 190 }, + { date: "2024-06-26", desktop: 434, mobile: 380 }, + { date: "2024-06-27", desktop: 448, mobile: 490 }, + { date: "2024-06-28", desktop: 149, mobile: 200 }, + { date: "2024-06-29", desktop: 103, mobile: 160 }, + { date: "2024-06-30", desktop: 446, mobile: 400 }, +]; + +const chartConfig = { + views: { + label: "Page Views", + }, + desktop: { + label: "Desktop", + color: "hsl(var(--chart-1))", + }, + mobile: { + label: "Mobile", + color: "hsl(var(--chart-2))", + }, +} satisfies ChartConfig; + +export function InteractiveBarChart() { + const [activeChart, setActiveChart] = + React.useState("desktop"); + + const total = React.useMemo( + () => ({ + desktop: chartData.reduce((acc, curr) => acc + curr.desktop, 0), + mobile: chartData.reduce((acc, curr) => acc + curr.mobile, 0), + }), + [], + ); + + return ( + + +
+ Bar Chart - Interactive + + Showing total visitors for the last 3 months + +
+
+ {["desktop", "mobile"].map((key) => { + const chart = key as keyof typeof chartConfig; + return ( + + ); + })} +
+
+ + + + + { + const date = new Date(value); + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + }); + }} + /> + { + return new Date(value).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); + }} + /> + } + /> + + + + +
+ ); +} diff --git a/components/charts/line-chart-multiple.tsx b/components/charts/line-chart-multiple.tsx new file mode 100644 index 0000000..0b1aeb4 --- /dev/null +++ b/components/charts/line-chart-multiple.tsx @@ -0,0 +1,93 @@ +"use client" + +import { TrendingUp } from "lucide-react" +import { CartesianGrid, Line, LineChart, XAxis } from "recharts" + +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart" +const chartData = [ + { month: "January", desktop: 186, mobile: 80 }, + { month: "February", desktop: 305, mobile: 200 }, + { month: "March", desktop: 237, mobile: 120 }, + { month: "April", desktop: 73, mobile: 190 }, + { month: "May", desktop: 209, mobile: 130 }, + { month: "June", desktop: 214, mobile: 140 }, +] + +const chartConfig = { + desktop: { + label: "Desktop", + color: "hsl(var(--chart-1))", + }, + mobile: { + label: "Mobile", + color: "hsl(var(--chart-2))", + }, +} satisfies ChartConfig + +export function LineChartMultiple() { + return ( + + + Line Chart - Multiple + January - June 2024 + + + + + + value.slice(0, 3)} + /> + } /> + + + + + + +
+ Trending up by 5.2% this month +
+
+ Showing total visitors for the last 6 months +
+
+
+ ) +} diff --git a/components/charts/radar-chart-simple.tsx b/components/charts/radar-chart-simple.tsx new file mode 100644 index 0000000..1e64e75 --- /dev/null +++ b/components/charts/radar-chart-simple.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { TrendingUp } from "lucide-react"; +import { PolarAngleAxis, PolarGrid, Radar, RadarChart } from "recharts"; + +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; + +const chartData = [ + { month: "January", desktop: 186 }, + { month: "February", desktop: 305 }, + { month: "March", desktop: 237 }, + { month: "April", desktop: 273 }, + { month: "May", desktop: 209 }, + { month: "June", desktop: 214 }, +]; + +const chartConfig = { + desktop: { + label: "Desktop", + color: "hsl(var(--chart-1))", + }, +} satisfies ChartConfig; + +export function RadarChartSimple() { + return ( + + {/* + Radar Chart + + Showing total visitors for the last 6 months + + */} + + + + } /> + + + + + + + +
+ Trending up by 5.2% this month +
+
+ January - June 2024 +
+
+
+ ); +} diff --git a/components/charts/radial-chart-grid.tsx b/components/charts/radial-chart-grid.tsx new file mode 100644 index 0000000..2eb9dd2 --- /dev/null +++ b/components/charts/radial-chart-grid.tsx @@ -0,0 +1,86 @@ +"use client" + +import { TrendingUp } from "lucide-react" +import { PolarGrid, RadialBar, RadialBarChart } from "recharts" + +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card" +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart" +const chartData = [ + { browser: "chrome", visitors: 275, fill: "var(--color-chrome)" }, + { browser: "safari", visitors: 200, fill: "var(--color-safari)" }, + { browser: "firefox", visitors: 187, fill: "var(--color-firefox)" }, + { browser: "edge", visitors: 173, fill: "var(--color-edge)" }, + { browser: "other", visitors: 90, fill: "var(--color-other)" }, +] + +const chartConfig = { + visitors: { + label: "Visitors", + }, + chrome: { + label: "Chrome", + color: "hsl(var(--chart-1))", + }, + safari: { + label: "Safari", + color: "hsl(var(--chart-2))", + }, + firefox: { + label: "Firefox", + color: "hsl(var(--chart-3))", + }, + edge: { + label: "Edge", + color: "hsl(var(--chart-4))", + }, + other: { + label: "Other", + color: "hsl(var(--chart-5))", + }, +} satisfies ChartConfig + +export function RadialChartGrid() { + return ( + + {/* + Radial Chart - Grid + January - June 2024 + */} + + + + } + /> + + + + + + +
+ Trending up by 5.2% this month +
+
+ Showing total visitors for the last 6 months +
+
+
+ ) +} diff --git a/components/charts/radial-shape-chart.tsx b/components/charts/radial-shape-chart.tsx new file mode 100644 index 0000000..0076ca3 --- /dev/null +++ b/components/charts/radial-shape-chart.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { TrendingUp } from "lucide-react"; +import { + Label, + PolarGrid, + PolarRadiusAxis, + RadialBar, + RadialBarChart, +} from "recharts"; + +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { ChartConfig, ChartContainer } from "@/components/ui/chart"; + +const chartData = [ + { browser: "safari", visitors: 1260, fill: "var(--color-safari)" }, +]; + +const chartConfig = { + visitors: { + label: "Visitors", + }, + safari: { + label: "Safari", + color: "hsl(var(--chart-2))", + }, +} satisfies ChartConfig; + +export function RadialShapeChart() { + return ( + + {/* + Radial Chart - Shape + January - June 2024 + */} + + + + + + + + + + + +
+ Trending up by 5.2% this month +
+
+ Showing total visitors for the last 6 months +
+
+
+ ); +} diff --git a/components/charts/radial-stacked-chart.tsx b/components/charts/radial-stacked-chart.tsx new file mode 100644 index 0000000..999544a --- /dev/null +++ b/components/charts/radial-stacked-chart.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { TrendingUp } from "lucide-react"; +import { Label, PolarRadiusAxis, RadialBar, RadialBarChart } from "recharts"; + +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + ChartConfig, + ChartContainer, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; + +const chartData = [{ month: "january", desktop: 1260, mobile: 570 }]; + +const chartConfig = { + desktop: { + label: "Desktop", + color: "hsl(var(--chart-1))", + }, + mobile: { + label: "Mobile", + color: "hsl(var(--chart-2))", + }, +} satisfies ChartConfig; + +export function RadialStackedChart() { + const totalVisitors = chartData[0].desktop + chartData[0].mobile; + + return ( + + {/* + Radial Chart - Stacked + January - June 2024 + */} + + + + } + /> + + + + + + + + +
+ Trending up by 5.2% this month +
+
+ Showing total visitors for the last 6 months +
+
+
+ ); +} diff --git a/components/charts/radial-text-chart.tsx b/components/charts/radial-text-chart.tsx new file mode 100644 index 0000000..e17b88d --- /dev/null +++ b/components/charts/radial-text-chart.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { TrendingUp } from "lucide-react"; +import { + Label, + PolarGrid, + PolarRadiusAxis, + RadialBar, + RadialBarChart, +} from "recharts"; + +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { ChartConfig, ChartContainer } from "@/components/ui/chart"; + +const chartData = [ + { browser: "safari", visitors: 200, fill: "var(--color-safari)" }, +]; + +const chartConfig = { + visitors: { + label: "Visitors", + }, + safari: { + label: "Safari", + color: "hsl(var(--chart-2))", + }, +} satisfies ChartConfig; + +export function RadialTextChart() { + return ( + + {/* + Radial Chart - Text + January - June 2024 + */} + + + + + + + + + + + +
+ Trending up by 5.2% this month +
+
+ Total visitors in the last 6 months +
+
+
+ ); +} diff --git a/components/content/author.tsx b/components/content/author.tsx new file mode 100644 index 0000000..878fd24 --- /dev/null +++ b/components/content/author.tsx @@ -0,0 +1,45 @@ +import Image from "next/image"; +import Link from "next/link"; + +import { BLOG_AUTHORS } from "@/config/blog"; + +export default async function Author({ + username, + imageOnly, +}: { + username: string; + imageOnly?: boolean; +}) { + const authors = BLOG_AUTHORS; + + return imageOnly ? ( + {authors[username].name} + ) : ( + + {authors[username].name} +
+

+ {authors[username].name} +

+

@{authors[username].twitter}

+
+ + ); +} diff --git a/components/content/blog-card.tsx b/components/content/blog-card.tsx new file mode 100644 index 0000000..6791dd6 --- /dev/null +++ b/components/content/blog-card.tsx @@ -0,0 +1,85 @@ +import Image from "next/image"; +import Link from "next/link"; +import { Post } from "contentlayer/generated"; + +import { cn, formatDate, placeholderBlurhash } from "@/lib/utils"; + +import BlurImage from "../shared/blur-image"; +import Author from "./author"; + +export function BlogCard({ + data, + priority, + horizontale = false, +}: { + data: Post & { + blurDataURL: string; + }; + priority?: boolean; + horizontale?: boolean; +}) { + return ( +
+ {data.image && ( +
+ +
+ )} +
+
+

+ {data.title} +

+ {data.description && ( +

+ {data.description} +

+ )} +
+
+ {/* */} + +
+ {data.authors.map((author) => ( + + ))} +
+ + {data.date && ( +

+ {formatDate(data.date)} +

+ )} +
+
+ + View Article + +
+ ); +} diff --git a/components/content/blog-header-layout.tsx b/components/content/blog-header-layout.tsx new file mode 100644 index 0000000..8a555d4 --- /dev/null +++ b/components/content/blog-header-layout.tsx @@ -0,0 +1,140 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useParams } from "next/navigation"; +import { Check, List } from "lucide-react"; +import { Drawer } from "vaul"; + +import { BLOG_CATEGORIES } from "@/config/blog"; +import { cn } from "@/lib/utils"; +import MaxWidthWrapper from "@/components/shared/max-width-wrapper"; + +export function BlogHeaderLayout() { + const [open, setOpen] = useState(false); + const { slug } = useParams() as { slug?: string }; + const data = BLOG_CATEGORIES.find((category) => category.slug === slug); + + const closeDrawer = () => { + setOpen(false); + }; + + return ( + <> + +
+

+ {data?.title || "Blog"} +

+

+ {data?.description || + "Latest news and updates from Next Auth Roles Template."} +

+
+ + +
+ + + setOpen(true)} + className="mb-8 flex w-full items-center border-y p-3 text-foreground/90 md:hidden" + > + +

Categories

+
+ + + +
+
+
+
    + + {BLOG_CATEGORIES.map((category) => ( + + ))} + +
+ + + + + + ); +} + +const CategoryLink = ({ + title, + href, + active, + mobile = false, + clickAction, +}: { + title: string; + href: string; + active: boolean; + mobile?: boolean; + clickAction?: () => void; +}) => { + return ( + + {mobile ? ( +
  • +
    + {title} + {active && } +
    +
  • + ) : ( +
  • +
    {title}
    +
  • + )} + + ); +}; diff --git a/components/content/blog-posts.tsx b/components/content/blog-posts.tsx new file mode 100644 index 0000000..574d8c3 --- /dev/null +++ b/components/content/blog-posts.tsx @@ -0,0 +1,23 @@ +import { Post } from "@/.contentlayer/generated"; + +import { BlogCard } from "./blog-card"; + +export function BlogPosts({ + posts, +}: { + posts: (Post & { + blurDataURL: string; + })[]; +}) { + return ( +
    + + +
    + {posts.slice(1).map((post, idx) => ( + + ))} +
    +
    + ); +} diff --git a/components/content/mdx-card.tsx b/components/content/mdx-card.tsx new file mode 100644 index 0000000..c129595 --- /dev/null +++ b/components/content/mdx-card.tsx @@ -0,0 +1,38 @@ +import Link from "next/link" + +import { cn } from "@/lib/utils" + +interface CardProps extends React.HTMLAttributes { + href?: string + disabled?: boolean +} + +export function MdxCard({ + href, + className, + children, + disabled, + ...props +}: CardProps) { + return ( +
    +
    +
    + {children} +
    +
    + {href && ( + + View + + )} +
    + ) +} diff --git a/components/content/mdx-components.tsx b/components/content/mdx-components.tsx new file mode 100644 index 0000000..0dc808e --- /dev/null +++ b/components/content/mdx-components.tsx @@ -0,0 +1,236 @@ +import * as React from "react"; +import Link from "next/link"; +import { useMDXComponent } from "next-contentlayer2/hooks"; + +import { cn } from "@/lib/utils"; +import { MdxCard } from "@/components/content/mdx-card"; +import BlurImage from "@/components/shared/blur-image"; +import { Callout } from "@/components/shared/callout"; +import { CopyButton } from "@/components/shared/copy-button"; + +const components = { + h1: ({ className, ...props }) => ( +

    + ), + h2: ({ className, ...props }) => ( +

    + ), + h3: ({ className, ...props }) => ( +

    + ), + h4: ({ className, ...props }) => ( +

    + ), + h5: ({ className, ...props }) => ( +

    + ), + h6: ({ className, ...props }) => ( +
    + ), + a: ({ className, ...props }) => ( + + ), + p: ({ className, ...props }) => ( +

    + ), + ul: ({ className, ...props }) => ( +

      + ), + ol: ({ className, ...props }) => ( +
        + ), + li: ({ className, ...props }) => ( +
      1. + ), + blockquote: ({ className, ...props }) => ( +
        *]:text-muted-foreground", + className, + )} + {...props} + /> + ), + img: ({ + className, + alt, + ...props + }: React.ImgHTMLAttributes) => ( + // eslint-disable-next-line @next/next/no-img-element + {alt} + ), + hr: ({ ...props }) =>
        , + table: ({ className, ...props }: React.HTMLAttributes) => ( +
        + + + ), + tr: ({ className, ...props }: React.HTMLAttributes) => ( + + ), + th: ({ className, ...props }) => ( +
        + ), + td: ({ className, ...props }) => ( + + ), + pre: ({ + className, + __rawString__, + ...props + }: React.HTMLAttributes & { __rawString__?: string }) => ( +
        +
        +      {__rawString__ && (
        +        
        +      )}
        +    
        + ), + code: ({ className, ...props }) => ( + + ), + Callout, + Card: MdxCard, + Step: ({ className, ...props }: React.ComponentProps<"h3">) => ( +

        + ), + Steps: ({ ...props }) => ( +
        + ), + Link: ({ className, ...props }: React.ComponentProps) => ( + + ), + LinkedCard: ({ className, ...props }: React.ComponentProps) => ( + + ), +}; + +interface MdxProps { + code: string; + images?: { alt: string; src: string; blurDataURL: string }[]; +} + +export function Mdx({ code, images }: MdxProps) { + const Component = useMDXComponent(code); + + const MDXImage = (props: any) => { + if (!images) return null; + const blurDataURL = images.find( + (image) => image.src === props.src, + )?.blurDataURL; + + return ( +
        + +
        + ); + }; + + return ( +
        + +
        + ); +} diff --git a/components/dashboard/delete-account.tsx b/components/dashboard/delete-account.tsx new file mode 100644 index 0000000..5619461 --- /dev/null +++ b/components/dashboard/delete-account.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { siteConfig } from "@/config/site"; +import { Button } from "@/components/ui/button"; +import { SectionColumns } from "@/components/dashboard/section-columns"; +import { useDeleteAccountModal } from "@/components/modals/delete-account-modal"; +import { Icons } from "@/components/shared/icons"; + +export function DeleteAccountSection() { + const { setShowDeleteAccountModal, DeleteAccountModal } = + useDeleteAccountModal(); + + const userPaidPlan = true; + + return ( + <> + + +
        +
        +
        + Are you sure ? + + {userPaidPlan ? ( +
        +
        + +
        + Active Subscription +
        + ) : null} +
        +
        + Permanently delete your {siteConfig.name} account + {userPaidPlan ? " and your subscription" : ""}. This action cannot + be undone - please proceed with caution. +
        +
        +
        + +
        +
        +
        + + ); +} diff --git a/components/dashboard/form-section-columns.tsx b/components/dashboard/form-section-columns.tsx new file mode 100644 index 0000000..fa2df5b --- /dev/null +++ b/components/dashboard/form-section-columns.tsx @@ -0,0 +1,15 @@ +import React from "react"; + +interface SectionColumnsType { + title: string; + children: React.ReactNode; +} + +export function FormSectionColumns({ title, children }: SectionColumnsType) { + return ( +
        +

        {title}

        +
        {children}
        +
        + ); +} diff --git a/components/dashboard/header.tsx b/components/dashboard/header.tsx new file mode 100644 index 0000000..060080f --- /dev/null +++ b/components/dashboard/header.tsx @@ -0,0 +1,21 @@ +interface DashboardHeaderProps { + heading: string; + text?: string; + children?: React.ReactNode; +} + +export function DashboardHeader({ + heading, + text, + children, +}: DashboardHeaderProps) { + return ( +
        +
        +

        {heading}

        + {text &&

        {text}

        } +
        + {children} +
        + ); +} diff --git a/components/dashboard/info-card.tsx b/components/dashboard/info-card.tsx new file mode 100644 index 0000000..5497719 --- /dev/null +++ b/components/dashboard/info-card.tsx @@ -0,0 +1,23 @@ +import { Users } from "lucide-react" + +import { + Card, + CardContent, + CardHeader, + CardTitle, +} from "@/components/ui/card" + +export default function InfoCard() { + return ( + + + Subscriptions + + + +
        +2350
        +

        +180.1% from last month

        +
        +
        + ) +} diff --git a/components/dashboard/project-switcher.tsx b/components/dashboard/project-switcher.tsx new file mode 100644 index 0000000..c3c5e66 --- /dev/null +++ b/components/dashboard/project-switcher.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { Check, ChevronsUpDown, Plus } from "lucide-react"; +import { useSession } from "next-auth/react"; + +import { cn } from "@/lib/utils"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; + +type ProjectType = { + title: string; + slug: string; + color: string; +}; + +const projects: ProjectType[] = [ + { + title: "Project 1", + slug: "project-number-one", + color: "bg-red-500", + }, + { + title: "Project 2", + slug: "project-number-two", + color: "bg-blue-500", + }, +]; +const selected: ProjectType = projects[1]; + +export default function ProjectSwitcher({ + large = false, +}: { + large?: boolean; +}) { + const { data: session, status } = useSession(); + const [openPopover, setOpenPopover] = useState(false); + + if (!projects || status === "loading") { + return ; + } + + return ( +
        + + + + + + + + +
        + ); +} + +function ProjectList({ + selected, + projects, + setOpenPopover, +}: { + selected: ProjectType; + projects: ProjectType[]; + setOpenPopover: (open: boolean) => void; +}) { + return ( +
        + {projects.map(({ slug, color }) => ( + setOpenPopover(false)} + > +
        + + {slug} + + {selected.slug === slug && ( + + + )} + + ))} + +
        + ); +} + +function ProjectSwitcherPlaceholder() { + return ( +
        +
        +
        + ); +} diff --git a/components/dashboard/search-command.tsx b/components/dashboard/search-command.tsx new file mode 100644 index 0000000..411115c --- /dev/null +++ b/components/dashboard/search-command.tsx @@ -0,0 +1,83 @@ +"use client"; + +import React from "react"; +import { useRouter } from "next/navigation"; +import { SidebarNavItem } from "@/types"; + +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { + CommandDialog, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { Icons } from "@/components/shared/icons"; + +export function SearchCommand({ links }: { links: SidebarNavItem[] }) { + const [open, setOpen] = React.useState(false); + const router = useRouter(); + + React.useEffect(() => { + const down = (e: KeyboardEvent) => { + if (e.key === "k" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + setOpen((open) => !open); + } + }; + document.addEventListener("keydown", down); + return () => document.removeEventListener("keydown", down); + }, []); + + const runCommand = React.useCallback((command: () => unknown) => { + setOpen(false); + command(); + }, []); + + return ( + <> + + + + + + No results found. + {links.map((section) => ( + + {section.items.map((item) => { + const Icon = Icons[item.icon || "arrowRight"]; + return ( + { + runCommand(() => router.push(item.href as string)); + }} + > + + {item.title} + + ); + })} + + ))} + + + + ); +} diff --git a/components/dashboard/section-columns.tsx b/components/dashboard/section-columns.tsx new file mode 100644 index 0000000..77ba7ba --- /dev/null +++ b/components/dashboard/section-columns.tsx @@ -0,0 +1,25 @@ +import React from "react"; + +interface SectionColumnsType { + title: string; + description?: string; + children: React.ReactNode; +} + +export function SectionColumns({ + title, + description, + children, +}: SectionColumnsType) { + return ( +
        +
        +

        {title}

        +

        + {description} +

        +
        +
        {children}
        +
        + ); +} diff --git a/components/dashboard/transactions-list.tsx b/components/dashboard/transactions-list.tsx new file mode 100644 index 0000000..5cb0b0d --- /dev/null +++ b/components/dashboard/transactions-list.tsx @@ -0,0 +1,148 @@ +import Link from "next/link"; +import { ArrowUpRight } from "lucide-react"; + +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +export default function TransactionsList() { + return ( + + +
        + Transactions + + Recent transactions from your store. + +
        + +
        + + + + + Customer + Type + Status + Date + Amount + + + + + +
        Liam Johnson
        +
        + liam@example.com +
        +
        + Sale + + + Approved + + + + 2023-06-23 + + $250.00 +
        + + +
        Olivia Smith
        +
        + olivia@example.com +
        +
        + Refund + + + Declined + + + + 2023-06-24 + + $150.00 +
        + + +
        Noah Williams
        +
        + noah@example.com +
        +
        + + Subscription + + + + Approved + + + + 2023-06-25 + + $350.00 +
        + + +
        Emma Brown
        +
        + emma@example.com +
        +
        + Sale + + + Approved + + + + 2023-06-26 + + $450.00 +
        + + +
        Liam Johnson
        +
        + liam@example.com +
        +
        + Sale + + + Approved + + + + 2023-06-27 + + $550.00 +
        +
        +
        +
        +
        + ); +} diff --git a/components/dashboard/upgrade-card.tsx b/components/dashboard/upgrade-card.tsx new file mode 100644 index 0000000..bc5df9d --- /dev/null +++ b/components/dashboard/upgrade-card.tsx @@ -0,0 +1,26 @@ +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +export function UpgradeCard() { + return ( + + + Upgrade to Pro + + Unlock all features and get unlimited access to our support team. + + + + + + + ); +} diff --git a/components/docs/page-header.tsx b/components/docs/page-header.tsx new file mode 100644 index 0000000..80fd9ee --- /dev/null +++ b/components/docs/page-header.tsx @@ -0,0 +1,36 @@ +import { cn } from "@/lib/utils"; + +import { Icons } from "../shared/icons"; + +interface DocsPageHeaderProps extends React.HTMLAttributes { + heading: string; + text?: string; +} + +export function DocsPageHeader({ + heading, + text, + className, + ...props +}: DocsPageHeaderProps) { + return ( + <> +
        +
        Docs
        + +
        + {heading} +
        +
        + +
        +

        + {heading} +

        + {text && ( +

        {text}

        + )} +
        + + ); +} diff --git a/components/docs/pager.tsx b/components/docs/pager.tsx new file mode 100644 index 0000000..e7013a2 --- /dev/null +++ b/components/docs/pager.tsx @@ -0,0 +1,64 @@ +import Link from "next/link" +import { Doc } from "contentlayer/generated" + +import { docsConfig } from "@/config/docs" +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" +import { Icons } from "@/components/shared/icons" + +interface DocsPagerProps { + doc: Doc +} + +export function DocsPager({ doc }: DocsPagerProps) { + const pager = getPagerForDoc(doc) + + if (!pager) { + return null + } + + return ( +
        + {pager?.prev && ( + + + {pager.prev.title} + + )} + {pager?.next && ( + + {pager.next.title} + + + )} +
        + ) +} + +export function getPagerForDoc(doc: Doc) { + const flattenedLinks = [null, ...flatten(docsConfig.sidebarNav), null] + const activeIndex = flattenedLinks.findIndex( + (link) => doc.slug === link?.href + ) + const prev = activeIndex !== 0 ? flattenedLinks[activeIndex - 1] : null + const next = + activeIndex !== flattenedLinks.length - 1 + ? flattenedLinks[activeIndex + 1] + : null + return { + prev, + next, + } +} + +export function flatten(links: { items?}[]) { + return links.reduce((flat, link) => { + return flat.concat(link.items ? flatten(link.items) : link) + }, []) +} diff --git a/components/docs/search.tsx b/components/docs/search.tsx new file mode 100644 index 0000000..f201543 --- /dev/null +++ b/components/docs/search.tsx @@ -0,0 +1,6 @@ +import { docsConfig } from "@/config/docs"; +import { SearchCommand } from "@/components/dashboard/search-command"; + +export function DocsSearch() { + return ; +} diff --git a/components/docs/sidebar-nav.tsx b/components/docs/sidebar-nav.tsx new file mode 100644 index 0000000..59a7f48 --- /dev/null +++ b/components/docs/sidebar-nav.tsx @@ -0,0 +1,82 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +import { NavItem } from "types"; +import { docsConfig } from "@/config/docs"; +import { cn } from "@/lib/utils"; + +export interface DocsSidebarNavProps { + setOpen?: (boolean) => void; +} + +export function DocsSidebarNav({ setOpen }: DocsSidebarNavProps) { + const pathname = usePathname(); + const items = docsConfig.sidebarNav; + + return items.length > 0 ? ( +
        + {items.map((item) => ( +
        +

        + {item.title} +

        + {item.items ? ( + + ) : null} +
        + ))} +
        + ) : null; +} + +interface DocsSidebarNavItemsProps { + items: NavItem[]; + pathname: string | null; + setOpen?: (boolean) => void; +} + +export function DocsSidebarNavItems({ + items, + setOpen, + pathname, +}: DocsSidebarNavItemsProps) { + return items?.length > 0 ? ( +
        + {items.map((item, index) => + !item.disabled && item.href ? ( + { + if (setOpen) setOpen(false); + }} + className={cn( + "flex w-full items-center rounded-md px-2 py-1.5 text-muted-foreground hover:underline", + { + "font-medium text-blue-600 dark:text-blue-400": + pathname === item.href, + }, + )} + target={item.external ? "_blank" : ""} + rel={item.external ? "noreferrer" : ""} + > + {item.title} + + ) : ( + + {item.title} + + ), + )} +
        + ) : null; +} diff --git a/components/forms/add-record-form.tsx b/components/forms/add-record-form.tsx new file mode 100644 index 0000000..fbc431b --- /dev/null +++ b/components/forms/add-record-form.tsx @@ -0,0 +1,234 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { User } from "@prisma/client"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; + +import { + CreateDNSRecord, + RECORD_TYPE_ENUMS, + RecordType, + TTL_ENUMS, +} from "@/lib/cloudflare"; +import { createRecordSchema } from "@/lib/validations/record"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Icons } from "@/components/shared/icons"; + +import { FormSectionColumns } from "../dashboard/form-section-columns"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../ui/select"; +import { Switch } from "../ui/switch"; + +export type FormData = CreateDNSRecord; + +interface AddRecordFormProps { + user: Pick; +} + +export function AddRecordForm({ user }: AddRecordFormProps) { + const [isPending, startTransition] = useTransition(); + const [isShow, setShow] = useState(false); + + const { + handleSubmit, + register, + formState: { errors }, + } = useForm({ + resolver: zodResolver(createRecordSchema), + defaultValues: { + type: "CNAME", + ttl: 1, + proxied: false, + }, + }); + + const onSubmit = handleSubmit((data) => { + startTransition(async () => { + // const response = await fetch("/api/record/add", { + // method: "POST", + // headers: { + // "Content-Type": "application/json", + // }, + // body: JSON.stringify({ + // records: [data], + // }), + // }); + // if (!response.ok) { + // toast.error("Something went wrong.", { + // description: "emmm...", + // }); + // } + // const res = await response.json(); + // toast.success(`Created record [${res?.result?.name}] successfully`); + }); + }); + + return isShow ? ( +
        +
        + + +

        + Only supports CNAME. +

        +
        + +
        + + +
        +
        + {errors?.name ? ( +

        + {errors.name.message} +

        + ) : ( +

        + Required. Use @ for root +

        + )} +
        +
        + +
        + + +
        +
        + {errors?.content ? ( +

        + {errors.content.message} +

        + ) : ( +

        + Required. E.g. www.example.com +

        + )} +
        +
        +
        + +
        + + +

        + Optional. Time To Live. +

        +
        + +
        + + +
        +

        + Enter your comment here (up to 100 characters) +

        +
        + {/* +
        + + +
        +

        Proxy status

        +
        */} +
        + +
        + + +
        +
        + ) : ( + + ); +} diff --git a/components/forms/newsletter-form.tsx b/components/forms/newsletter-form.tsx new file mode 100644 index 0000000..b16fe0f --- /dev/null +++ b/components/forms/newsletter-form.tsx @@ -0,0 +1,75 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { toast } from "@/components/ui/use-toast"; + +const FormSchema = z.object({ + email: z.string().email({ + message: "Enter a valid email.", + }), +}); + +export function NewsletterForm() { + const form = useForm>({ + resolver: zodResolver(FormSchema), + defaultValues: { + email: "", + }, + }); + + function onSubmit(data: z.infer) { + form.reset(); + toast({ + title: "You submitted the following values:", + description: ( +
        +          {JSON.stringify(data, null, 2)}
        +        
        + ), + }); + } + + return ( +
        + + ( + + Subscribe to our newsletter + + + + + + )} + /> + + + + ); +} diff --git a/components/forms/user-auth-form.tsx b/components/forms/user-auth-form.tsx new file mode 100644 index 0000000..1bf7619 --- /dev/null +++ b/components/forms/user-auth-form.tsx @@ -0,0 +1,136 @@ +"use client"; + +import * as React from "react"; +import { useSearchParams } from "next/navigation"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { signIn } from "next-auth/react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import * as z from "zod"; + +import { cn } from "@/lib/utils"; +import { userAuthSchema } from "@/lib/validations/auth"; +import { buttonVariants } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Icons } from "@/components/shared/icons"; + +interface UserAuthFormProps extends React.HTMLAttributes { + type?: string; +} + +type FormData = z.infer; + +export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) { + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ + resolver: zodResolver(userAuthSchema), + }); + const [isLoading, setIsLoading] = React.useState(false); + const [isGoogleLoading, setIsGoogleLoading] = React.useState(false); + const [isGithubLoading, setIsGithubLoading] = React.useState(false); + const searchParams = useSearchParams(); + + async function onSubmit(data: FormData) { + setIsLoading(true); + + const signInResult = await signIn("resend", { + email: data.email.toLowerCase(), + redirect: false, + callbackUrl: searchParams?.get("from") || "/dashboard", + }); + + setIsLoading(false); + + if (!signInResult?.ok) { + return toast.error("Something went wrong.", { + description: "Your sign in request failed. Please try again.", + }); + } + + return toast.success("Check your email", { + description: "We sent you a login link. Be sure to check your spam too.", + }); + } + + return ( +
        +
        +
        +
        + + + {errors?.email && ( +

        + {errors.email.message} +

        + )} +
        + +
        +
        +
        +
        + +
        +
        + + Or continue with + +
        +
        + + + +
        + ); +} diff --git a/components/forms/user-name-form.tsx b/components/forms/user-name-form.tsx new file mode 100644 index 0000000..6af960d --- /dev/null +++ b/components/forms/user-name-form.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { updateUserName, type FormData } from "@/actions/update-user-name"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { User } from "@prisma/client"; +import { useSession } from "next-auth/react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; + +import { userNameSchema } from "@/lib/validations/user"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { SectionColumns } from "@/components/dashboard/section-columns"; +import { Icons } from "@/components/shared/icons"; + +interface UserNameFormProps { + user: Pick; +} + +export function UserNameForm({ user }: UserNameFormProps) { + const { update } = useSession(); + const [updated, setUpdated] = useState(false); + const [isPending, startTransition] = useTransition(); + const updateUserNameWithId = updateUserName.bind(null, user.id); + + const checkUpdate = (value) => { + setUpdated(user.name !== value); + }; + + const { + handleSubmit, + register, + formState: { errors }, + } = useForm({ + resolver: zodResolver(userNameSchema), + defaultValues: { + name: user?.name || "", + }, + }); + + const onSubmit = handleSubmit((data) => { + startTransition(async () => { + const { status } = await updateUserNameWithId(data); + + if (status !== "success") { + toast.error("Something went wrong.", { + description: "Your name was not updated. Please try again.", + }); + } else { + await update(); + setUpdated(false); + toast.success("Your name has been updated."); + } + }); + }); + + return ( +
        + +
        + + checkUpdate(e.target.value)} + /> + +
        +
        + {errors?.name && ( +

        + {errors.name.message} +

        + )} +

        Max 32 characters

        +
        +
        +
        + ); +} diff --git a/components/forms/user-role-form.tsx b/components/forms/user-role-form.tsx new file mode 100644 index 0000000..cf30e97 --- /dev/null +++ b/components/forms/user-role-form.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { updateUserRole, type FormData } from "@/actions/update-user-role"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { User, UserRole } from "@prisma/client"; +import { useSession } from "next-auth/react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { z } from "zod"; + +import { userRoleSchema } from "@/lib/validations/user"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { SectionColumns } from "@/components/dashboard/section-columns"; +import { Icons } from "@/components/shared/icons"; + +interface UserNameFormProps { + user: Pick; +} + +export function UserRoleForm({ user }: UserNameFormProps) { + const { update } = useSession(); + const [updated, setUpdated] = useState(false); + const [isPending, startTransition] = useTransition(); + const updateUserRoleWithId = updateUserRole.bind(null, user.id); + + const roles = Object.values(UserRole); + const [role, setRole] = useState(user.role); + + const form = useForm({ + resolver: zodResolver(userRoleSchema), + values: { + role: role, + }, + }); + + const onSubmit = (data: z.infer) => { + startTransition(async () => { + const { status } = await updateUserRoleWithId(data); + + if (status !== "success") { + toast.error("Something went wrong.", { + description: "Your role was not updated. Please try again.", + }); + } else { + await update(); + setUpdated(false); + toast.success("Your role has been updated."); + } + }); + }; + + return ( +
        + + +
        + ( + + Role + + + + )} + /> + +
        +
        +

        + Remove this field on real production +

        +
        +
        +
        + + ); +} diff --git a/components/layout/dashboard-sidebar.tsx b/components/layout/dashboard-sidebar.tsx new file mode 100644 index 0000000..56505be --- /dev/null +++ b/components/layout/dashboard-sidebar.tsx @@ -0,0 +1,273 @@ +"use client"; + +import { Fragment, useEffect, useState } from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { NavItem, SidebarNavItem } from "@/types"; +import { Menu, PanelLeftClose, PanelRightClose } from "lucide-react"; + +import { siteConfig } from "@/config/site"; +import { cn } from "@/lib/utils"; +import { useMediaQuery } from "@/hooks/use-media-query"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import ProjectSwitcher from "@/components/dashboard/project-switcher"; +import { UpgradeCard } from "@/components/dashboard/upgrade-card"; +import { Icons } from "@/components/shared/icons"; + +interface DashboardSidebarProps { + links: SidebarNavItem[]; +} + +export function DashboardSidebar({ links }: DashboardSidebarProps) { + const path = usePathname(); + + // NOTE: Use this if you want save in local storage -- Credits: Hosna Qasmei + // + // const [isSidebarExpanded, setIsSidebarExpanded] = useState(() => { + // if (typeof window !== "undefined") { + // const saved = window.localStorage.getItem("sidebarExpanded"); + // return saved !== null ? JSON.parse(saved) : true; + // } + // return true; + // }); + + // useEffect(() => { + // if (typeof window !== "undefined") { + // window.localStorage.setItem( + // "sidebarExpanded", + // JSON.stringify(isSidebarExpanded), + // ); + // } + // }, [isSidebarExpanded]); + + const { isTablet } = useMediaQuery(); + const [isSidebarExpanded, setIsSidebarExpanded] = useState(!isTablet); + + const toggleSidebar = () => { + setIsSidebarExpanded(!isSidebarExpanded); + }; + + useEffect(() => { + setIsSidebarExpanded(!isTablet); + }, [isTablet]); + + return ( + +
        + + + +
        +
        + ); +} + +export function MobileSheetSidebar({ links }: DashboardSidebarProps) { + const path = usePathname(); + const [open, setOpen] = useState(false); + const { isSm, isMobile } = useMediaQuery(); + + if (isSm || isMobile) { + return ( + + + + + + +
        + +
        +
        +
        +
        + ); + } + + return ( +
        + ); +} diff --git a/components/layout/mobile-nav.tsx b/components/layout/mobile-nav.tsx new file mode 100644 index 0000000..87005c3 --- /dev/null +++ b/components/layout/mobile-nav.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { useSelectedLayoutSegment } from "next/navigation"; +import { Menu, X } from "lucide-react"; +import { useSession } from "next-auth/react"; + +import { docsConfig } from "@/config/docs"; +import { marketingConfig } from "@/config/marketing"; +import { siteConfig } from "@/config/site"; +import { cn } from "@/lib/utils"; +import { DocsSidebarNav } from "@/components/docs/sidebar-nav"; +import { Icons } from "@/components/shared/icons"; + +import { ModeToggle } from "./mode-toggle"; + +export function NavMobile() { + const { data: session } = useSession(); + const [open, setOpen] = useState(false); + const selectedLayout = useSelectedLayoutSegment(); + const documentation = selectedLayout === "docs"; + + const configMap = { + docs: docsConfig.mainNav, + }; + + const links = + (selectedLayout && configMap[selectedLayout]) || marketingConfig.mainNav; + + // prevent body scroll when modal is open + useEffect(() => { + if (open) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = "auto"; + } + }, [open]); + + return ( + <> + + + + + ); +} diff --git a/components/layout/mode-toggle.tsx b/components/layout/mode-toggle.tsx new file mode 100644 index 0000000..c60ede8 --- /dev/null +++ b/components/layout/mode-toggle.tsx @@ -0,0 +1,43 @@ +"use client" + +import * as React from "react" +import { useTheme } from "next-themes" + +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Icons } from "@/components/shared/icons" + +export function ModeToggle() { + const { setTheme } = useTheme() + + return ( + + + + + + setTheme("light")}> + + Light + + setTheme("dark")}> + + Dark + + setTheme("system")}> + + System + + + + ) +} diff --git a/components/layout/navbar.tsx b/components/layout/navbar.tsx new file mode 100644 index 0000000..d3afc2e --- /dev/null +++ b/components/layout/navbar.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { useContext } from "react"; +import Link from "next/link"; +import { useSelectedLayoutSegment } from "next/navigation"; +import { useSession } from "next-auth/react"; + +import { docsConfig } from "@/config/docs"; +import { marketingConfig } from "@/config/marketing"; +import { siteConfig } from "@/config/site"; +import { cn } from "@/lib/utils"; +import { useScroll } from "@/hooks/use-scroll"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { DocsSearch } from "@/components/docs/search"; +import { ModalContext } from "@/components/modals/providers"; +import { Icons } from "@/components/shared/icons"; +import MaxWidthWrapper from "@/components/shared/max-width-wrapper"; + +interface NavBarProps { + scroll?: boolean; + large?: boolean; +} + +export function NavBar({ scroll = false }: NavBarProps) { + const scrolled = useScroll(50); + const { data: session, status } = useSession(); + const { setShowSignInModal } = useContext(ModalContext); + + const selectedLayout = useSelectedLayoutSegment(); + const documentation = selectedLayout === "docs"; + + const configMap = { + docs: docsConfig.mainNav, + }; + + const links = + (selectedLayout && configMap[selectedLayout]) || marketingConfig.mainNav; + + return ( +
        + +
        + + + + {siteConfig.name} + + + + {links && links.length > 0 ? ( + + ) : null} +
        + +
        + {/* right header for docs */} + {documentation ? ( +
        +
        + +
        +
        + +
        +
        + + + GitHub + +
        +
        + ) : null} + + {session ? ( + + + + ) : status === "unauthenticated" ? ( + + + + ) : ( + + )} +
        +
        +
        + ); +} diff --git a/components/layout/site-footer.tsx b/components/layout/site-footer.tsx new file mode 100644 index 0000000..e575ccc --- /dev/null +++ b/components/layout/site-footer.tsx @@ -0,0 +1,71 @@ +import * as React from "react"; +import Link from "next/link"; + +import { footerLinks, siteConfig } from "@/config/site"; +import { cn } from "@/lib/utils"; +import { ModeToggle } from "@/components/layout/mode-toggle"; + +import { NewsletterForm } from "../forms/newsletter-form"; +import { Icons } from "../shared/icons"; + +export function SiteFooter({ className }: React.HTMLAttributes) { + return ( +
        +
        + {footerLinks.map((section) => ( +
        + + {section.title} + +
          + {section.items?.map((link) => ( +
        • + + {link.title} + +
        • + ))} +
        +
        + ))} +
        + +
        +
        + +
        +
        + {/* + Copyright © 2024. All rights reserved. + */} +

        + Built by{" "} + + oiov + +

        + +
        + + + + +
        +
        +
        +
        + ); +} diff --git a/components/layout/user-account-nav.tsx b/components/layout/user-account-nav.tsx new file mode 100644 index 0000000..5a67c74 --- /dev/null +++ b/components/layout/user-account-nav.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { LayoutDashboard, Lock, LogOut, Settings } from "lucide-react"; +import { signOut, useSession } from "next-auth/react"; +import { Drawer } from "vaul"; + +import { useMediaQuery } from "@/hooks/use-media-query"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { UserAvatar } from "@/components/shared/user-avatar"; + +export function UserAccountNav() { + const { data: session } = useSession(); + const user = session?.user; + + const [open, setOpen] = useState(false); + const closeDrawer = () => { + setOpen(false); + }; + + const { isMobile } = useMediaQuery(); + + if (!user) + return ( +
        + ); + + if (isMobile) { + return ( + + setOpen(true)}> + + + + + +
        +
        +
        + +
        +
        + {user.name &&

        {user.name}

        } + {user.email && ( +

        + {user?.email} +

        + )} +
        +
        + +
          + {user.role === "ADMIN" ? ( +
        • + + +

          Admin

          + +
        • + ) : null} + +
        • + + +

          Dashboard

          + +
        • + +
        • + + +

          Settings

          + +
        • + +
        • { + event.preventDefault(); + signOut({ + callbackUrl: `${window.location.origin}/`, + }); + }} + > +
          + +

          Log out

          +
          +
        • +
        + + + + + ); + } + + return ( + + + + + +
        +
        + {user.name &&

        {user.name}

        } + {user.email && ( +

        + {user?.email} +

        + )} +
        +
        + + + {user.role === "ADMIN" ? ( + + + +

        Admin

        + +
        + ) : null} + + + + +

        Dashboard

        + +
        + + + + +

        Settings

        + +
        + + { + event.preventDefault(); + signOut({ + callbackUrl: `${window.location.origin}/`, + }); + }} + > +
        + +

        Log out

        +
        +
        +
        +
        + ); +} diff --git a/components/modals/delete-account-modal.tsx b/components/modals/delete-account-modal.tsx new file mode 100644 index 0000000..7aa0856 --- /dev/null +++ b/components/modals/delete-account-modal.tsx @@ -0,0 +1,135 @@ +import { + Dispatch, + SetStateAction, + useCallback, + useMemo, + useState, +} from "react"; +import { signOut, useSession } from "next-auth/react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Modal } from "@/components/ui/modal"; +import { UserAvatar } from "@/components/shared/user-avatar"; + +function DeleteAccountModal({ + showDeleteAccountModal, + setShowDeleteAccountModal, +}: { + showDeleteAccountModal: boolean; + setShowDeleteAccountModal: Dispatch>; +}) { + const { data: session } = useSession(); + const [deleting, setDeleting] = useState(false); + + async function deleteAccount() { + setDeleting(true); + await fetch(`/api/user`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + }).then(async (res) => { + if (res.status === 200) { + // delay to allow for the route change to complete + await new Promise((resolve) => + setTimeout(() => { + signOut({ + callbackUrl: `${window.location.origin}/`, + }); + resolve(null); + }, 500), + ); + } else { + setDeleting(false); + const error = await res.text(); + throw error; + } + }); + } + + return ( + +
        + +

        Delete Account

        +

        + Warning: This will permanently delete your account and your + active subscription! +

        + + {/* TODO: Use getUserSubscriptionPlan(session.user.id) to display the user's subscription if he have a paid plan */} +
        + +
        { + e.preventDefault(); + toast.promise(deleteAccount(), { + loading: "Deleting account...", + success: "Account deleted successfully!", + error: (err) => err, + }); + }} + className="flex flex-col space-y-6 bg-accent px-4 py-8 text-left sm:px-16" + > +
        + + +
        + + +
        +
        + ); +} + +export function useDeleteAccountModal() { + const [showDeleteAccountModal, setShowDeleteAccountModal] = useState(false); + + const DeleteAccountModalCallback = useCallback(() => { + return ( + + ); + }, [showDeleteAccountModal, setShowDeleteAccountModal]); + + return useMemo( + () => ({ + setShowDeleteAccountModal, + DeleteAccountModal: DeleteAccountModalCallback, + }), + [setShowDeleteAccountModal, DeleteAccountModalCallback], + ); +} diff --git a/components/modals/providers.tsx b/components/modals/providers.tsx new file mode 100644 index 0000000..afe2de1 --- /dev/null +++ b/components/modals/providers.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { createContext, Dispatch, ReactNode, SetStateAction } from "react"; + +import { useSignInModal } from "@/components/modals//sign-in-modal"; + +export const ModalContext = createContext<{ + setShowSignInModal: Dispatch>; +}>({ + setShowSignInModal: () => {}, +}); + +export default function ModalProvider({ children }: { children: ReactNode }) { + const { SignInModal, setShowSignInModal } = useSignInModal(); + + return ( + + + {children} + + ); +} diff --git a/components/modals/sign-in-modal.tsx b/components/modals/sign-in-modal.tsx new file mode 100644 index 0000000..1300834 --- /dev/null +++ b/components/modals/sign-in-modal.tsx @@ -0,0 +1,85 @@ +import { + Dispatch, + SetStateAction, + useCallback, + useMemo, + useState, +} from "react"; +import { signIn } from "next-auth/react"; + +import { siteConfig } from "@/config/site"; +import { Button } from "@/components/ui/button"; +import { Modal } from "@/components/ui/modal"; +import { Icons } from "@/components/shared/icons"; + +function SignInModal({ + showSignInModal, + setShowSignInModal, +}: { + showSignInModal: boolean; + setShowSignInModal: Dispatch>; +}) { + const [signInClicked, setSignInClicked] = useState(false); + + return ( + +
        +
        + + + +

        + Sign In +

        +

        + This is strictly for demo purposes - only your email and profile + picture will be stored. +

        +
        + +
        + +
        +
        +
        + ); +} + +export function useSignInModal() { + const [showSignInModal, setShowSignInModal] = useState(false); + + const SignInModalCallback = useCallback(() => { + return ( + + ); + }, [showSignInModal, setShowSignInModal]); + + return useMemo( + () => ({ + setShowSignInModal, + SignInModal: SignInModalCallback, + }), + [setShowSignInModal, SignInModalCallback], + ); +} diff --git a/components/sections/hero-landing.tsx b/components/sections/hero-landing.tsx new file mode 100644 index 0000000..4618966 --- /dev/null +++ b/components/sections/hero-landing.tsx @@ -0,0 +1,69 @@ +import Link from "next/link"; + +import { siteConfig } from "@/config/site"; +import { cn, nFormatter } from "@/lib/utils"; +import { buttonVariants } from "@/components/ui/button"; +import { Icons } from "@/components/shared/icons"; + +export default async function HeroLanding() { + return ( +
        +
        + + 🎉 Free Next SaaS Starter Here! + + +

        + Next.js Template with{" "} + + Auth & User Roles! + +

        + +

        + Minimalist. Sturdy. Open Source.
        Focus on your own idea + and... Nothing else! +

        + +
        + + Installation Guide + + + + +

        + Star on GitHub +

        + +
        +
        +
        + ); +} diff --git a/components/sections/preview-landing.tsx b/components/sections/preview-landing.tsx new file mode 100644 index 0000000..0de06bd --- /dev/null +++ b/components/sections/preview-landing.tsx @@ -0,0 +1,36 @@ +import darkPreview from "@/public/_static/images/dark-preview.jpg"; +import lightPreview from "@/public/_static/images/light-preview.jpg"; + +import BlurImage from "@/components/shared/blur-image"; +import MaxWidthWrapper from "@/components/shared/max-width-wrapper"; + +export default function PreviewLanding() { + return ( +
        + +
        +
        + + +
        +
        +
        +
        + ); +} diff --git a/components/shared/blur-image.tsx b/components/shared/blur-image.tsx new file mode 100644 index 0000000..62181ed --- /dev/null +++ b/components/shared/blur-image.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { useState } from "react"; +import type { ComponentProps } from "react"; +import Image from "next/image"; + +import { cn } from "@/lib/utils"; + +export default function BlurImage(props: ComponentProps) { + const [isLoading, setLoading] = useState(true); + + return ( + {props.alt} setLoading(false)} + /> + ); +} diff --git a/components/shared/callout.tsx b/components/shared/callout.tsx new file mode 100644 index 0000000..21ab452 --- /dev/null +++ b/components/shared/callout.tsx @@ -0,0 +1,84 @@ +import { + AlertTriangle, + Ban, + CircleAlert, + CircleCheckBig, + FileText, + Info, + Lightbulb, +} from "lucide-react"; + +import { cn } from "@/lib/utils"; + +interface CalloutProps { + twClass?: string; + children?: React.ReactNode; + type?: keyof typeof dataCallout; +} + +const dataCallout = { + default: { + icon: Info, + classes: + "border-zinc-200 bg-gray-50 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-200", + }, + danger: { + icon: CircleAlert, + classes: + "border-red-200 bg-red-50 text-red-900 dark:bg-red-950 dark:text-red-200", + }, + error: { + icon: Ban, + classes: + "border-red-200 bg-red-50 text-red-900 dark:bg-red-950 dark:text-red-200", + }, + idea: { + icon: Lightbulb, + classes: + "border-blue-200 bg-blue-50 text-blue-800 dark:bg-blue-950 dark:text-blue-200", + }, + info: { + icon: Info, + classes: + "border-blue-200 bg-blue-50 text-blue-800 dark:bg-blue-950 dark:text-blue-200", + }, + note: { + icon: FileText, + classes: + "border-blue-200 bg-blue-50 text-blue-800 dark:bg-blue-950 dark:text-blue-200", + }, + success: { + icon: CircleCheckBig, + classes: + "border-green-200 bg-green-50 text-green-800 dark:bg-green-400/20 dark:text-green-300", + }, + warning: { + icon: AlertTriangle, + classes: + "border-orange-200 bg-orange-50 text-orange-800 dark:bg-orange-400/20 dark:text-orange-300", + }, +}; + +export function Callout({ + children, + twClass, + type = "default", + ...props +}: CalloutProps) { + const { icon: Icon, classes } = dataCallout[type]; + return ( +
        +
        + +
        +
        {children}
        +
        + ); +} diff --git a/components/shared/copy-button.tsx b/components/shared/copy-button.tsx new file mode 100644 index 0000000..8c05d7b --- /dev/null +++ b/components/shared/copy-button.tsx @@ -0,0 +1,47 @@ +"use client"; + +import React from "react"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +import { Icons } from "./icons"; + +interface CopyButtonProps extends React.HTMLAttributes { + value: string; +} + +export function CopyButton({ value, className, ...props }: CopyButtonProps) { + const [hasCopied, setHasCopied] = React.useState(false); + + React.useEffect(() => { + setTimeout(() => { + setHasCopied(false); + }, 2000); + }, [hasCopied]); + + const handleCopyValue = (value: string) => { + navigator.clipboard.writeText(value); + setHasCopied(true); + }; + + return ( + + ); +} diff --git a/components/shared/empty-placeholder.tsx b/components/shared/empty-placeholder.tsx new file mode 100644 index 0000000..dd11607 --- /dev/null +++ b/components/shared/empty-placeholder.tsx @@ -0,0 +1,86 @@ +import * as React from "react"; + +import { cn } from "@/lib/utils"; +import { Icons } from "@/components/shared/icons"; + +interface EmptyPlaceholderProps extends React.HTMLAttributes {} + +export function EmptyPlaceholder({ + className, + children, + ...props +}: EmptyPlaceholderProps) { + return ( +
        +
        + {children} +
        +
        + ); +} + +interface EmptyPlaceholderIconProps + extends Partial> { + name: keyof typeof Icons; + ref?: + | ((instance: SVGSVGElement | null) => void) + | React.RefObject + | null; +} + +EmptyPlaceholder.Icon = function EmptyPlaceholderIcon({ + name, + className, + ...props +}: EmptyPlaceholderIconProps) { + const Icon = Icons[name]; + + if (!Icon) { + return null; + } + + return ( +
        + +
        + ); +}; + +interface EmptyPlaceholderTitleProps + extends React.HTMLAttributes {} + +EmptyPlaceholder.Title = function EmptyPlaceholderTitle({ + className, + ...props +}: EmptyPlaceholderTitleProps) { + return ( +

        + ); +}; + +interface EmptyPlaceholderDescriptionProps + extends React.HTMLAttributes {} + +EmptyPlaceholder.Description = function EmptyPlaceholderDescription({ + className, + ...props +}: EmptyPlaceholderDescriptionProps) { + return ( +

        + ); +}; diff --git a/components/shared/header-section.tsx b/components/shared/header-section.tsx new file mode 100644 index 0000000..ee7ef5a --- /dev/null +++ b/components/shared/header-section.tsx @@ -0,0 +1,25 @@ +interface HeaderSectionProps { + label?: string; + title: string; + subtitle?: string; +} + +export function HeaderSection({ label, title, subtitle }: HeaderSectionProps) { + return ( +

        + {label ? ( +
        + {label} +
        + ) : null} +

        + {title} +

        + {subtitle ? ( +

        + {subtitle} +

        + ) : null} +
        + ); +} diff --git a/components/shared/icons.tsx b/components/shared/icons.tsx new file mode 100644 index 0000000..f20b9ad --- /dev/null +++ b/components/shared/icons.tsx @@ -0,0 +1,118 @@ +import { + AlertTriangle, + ArrowRight, + ArrowUpRight, + BookOpen, + Check, + ChevronLeft, + ChevronRight, + Copy, + File, + FileText, + Flame, + HelpCircle, + Home, + Image, + Laptop, + LayoutPanelLeft, + LineChart, + Loader2, + LucideIcon, + LucideProps, + MessagesSquare, + Moon, + MoreVertical, + Package, + Plus, + Search, + Settings, + SunMedium, + Trash2, + User, + X, +} from "lucide-react"; + +export type Icon = LucideIcon; + +export const Icons = { + add: Plus, + arrowRight: ArrowRight, + arrowUpRight: ArrowUpRight, + chevronLeft: ChevronLeft, + chevronRight: ChevronRight, + bookOpen: BookOpen, + check: Check, + close: X, + copy: Copy, + dashboard: LayoutPanelLeft, + ellipsis: MoreVertical, + gitHub: ({ ...props }: LucideProps) => ( + + ), + google: ({ ...props }: LucideProps) => ( + + ), + help: HelpCircle, + home: Home, + laptop: Laptop, + lineChart: LineChart, + logo: Flame, + media: Image, + messages: MessagesSquare, + moon: Moon, + page: File, + package: Package, + post: FileText, + search: Search, + settings: Settings, + spinner: Loader2, + sun: SunMedium, + trash: Trash2, + twitter: ({ ...props }: LucideProps) => ( + + ), + user: User, + warning: AlertTriangle, +}; diff --git a/components/shared/max-width-wrapper.tsx b/components/shared/max-width-wrapper.tsx new file mode 100644 index 0000000..f35027d --- /dev/null +++ b/components/shared/max-width-wrapper.tsx @@ -0,0 +1,25 @@ +import { ReactNode } from "react"; + +import { cn } from "@/lib/utils"; + +export default function MaxWidthWrapper({ + className, + children, + large = false, +}: { + className?: string; + large?: boolean; + children: ReactNode; +}) { + return ( +
        + {children} +
        + ); +} diff --git a/components/shared/section-skeleton.tsx b/components/shared/section-skeleton.tsx new file mode 100644 index 0000000..fb811e6 --- /dev/null +++ b/components/shared/section-skeleton.tsx @@ -0,0 +1,25 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export function SkeletonSection({ card = false }: { card?: boolean }) { + return ( +
        +
        + + +
        +
        + {card ? ( + + ) : ( + <> +
        + + +
        + + + )} +
        +
        + ); +} diff --git a/components/shared/toc.tsx b/components/shared/toc.tsx new file mode 100644 index 0000000..b21264d --- /dev/null +++ b/components/shared/toc.tsx @@ -0,0 +1,114 @@ +"use client"; + +import * as React from "react"; + +import { useMounted } from "@/hooks/use-mounted"; +import { TableOfContents } from "@/lib/toc"; +import { cn } from "@/lib/utils"; + +interface TocProps { + toc: TableOfContents; +} + +export function DashboardTableOfContents({ toc }: TocProps) { + const itemIds = React.useMemo( + () => + toc.items + ? toc.items + .flatMap((item) => [item.url, item?.items?.map((item) => item.url)]) + .flat() + .filter(Boolean) + .map((id) => id?.split("#")[1]) + : [], + [toc], + ); + const activeHeading = useActiveItem(itemIds); + const mounted = useMounted(); + + if (!toc?.items) { + return null; + } + + return mounted ? ( +
        +

        On This Page

        + +
        + ) : null; +} + +function useActiveItem(itemIds: (string | undefined)[]) { + const [activeId, setActiveId] = React.useState(""); + + React.useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + setActiveId(entry.target.id); + } + }); + }, + { rootMargin: `0% 0% -80% 0%` }, + ); + + itemIds?.forEach((id) => { + if (!id) { + return; + } + + const element = document.getElementById(id); + if (element) { + observer.observe(element); + } + }); + + return () => { + itemIds?.forEach((id) => { + if (!id) { + return; + } + + const element = document.getElementById(id); + if (element) { + observer.unobserve(element); + } + }); + }; + }, [itemIds]); + + return activeId; +} + +interface TreeProps { + tree: TableOfContents; + level?: number; + activeItem?: string | null; +} + +function Tree({ tree, level = 1, activeItem }: TreeProps) { + return tree?.items?.length && level < 3 ? ( +
          + {tree.items.map((item, index) => { + return ( +
        • + + {item.title} + + {item.items?.length ? ( + + ) : null} +
        • + ); + })} +
        + ) : null; +} diff --git a/components/shared/user-avatar.tsx b/components/shared/user-avatar.tsx new file mode 100644 index 0000000..71eae76 --- /dev/null +++ b/components/shared/user-avatar.tsx @@ -0,0 +1,24 @@ +import { User } from "@prisma/client" +import { AvatarProps } from "@radix-ui/react-avatar" + +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { Icons } from "@/components/shared/icons" + +interface UserAvatarProps extends AvatarProps { + user: Pick +} + +export function UserAvatar({ user, ...props }: UserAvatarProps) { + return ( + + {user.image ? ( + + ) : ( + + {user.name} + + + )} + + ) +} diff --git a/components/tailwind-indicator.tsx b/components/tailwind-indicator.tsx new file mode 100644 index 0000000..0e5b25b --- /dev/null +++ b/components/tailwind-indicator.tsx @@ -0,0 +1,14 @@ +export function TailwindIndicator() { + if (process.env.NODE_ENV === "production") return null; + + return ( +
        +
        xs
        +
        sm
        +
        md
        +
        lg
        +
        xl
        +
        2xl
        +
        + ); +} diff --git a/components/ui/accordion.tsx b/components/ui/accordion.tsx new file mode 100644 index 0000000..83f11f4 --- /dev/null +++ b/components/ui/accordion.tsx @@ -0,0 +1,60 @@ +"use client" + +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
        {children}
        +
        +)) +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/components/ui/alert-dialog.tsx b/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..297b248 --- /dev/null +++ b/components/ui/alert-dialog.tsx @@ -0,0 +1,141 @@ +"use client" + +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
        +) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
        +) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx new file mode 100644 index 0000000..6cd63fb --- /dev/null +++ b/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { VariantProps, cva } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border p-4 [&>svg]:absolute [&>svg]:text-foreground [&>svg]:left-4 [&>svg]:top-4 [&>svg+div]:translate-y-[-3px] [&:has(svg)]:pl-11", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "text-destructive border-destructive/50 dark:border-destructive [&>svg]:text-destructive text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
        +)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
        +)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
        +)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/components/ui/aspect-ratio.tsx b/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..d6a5226 --- /dev/null +++ b/components/ui/aspect-ratio.tsx @@ -0,0 +1,7 @@ +"use client" + +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" + +const AspectRatio = AspectRatioPrimitive.Root + +export { AspectRatio } diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx new file mode 100644 index 0000000..a1ab22e --- /dev/null +++ b/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/components/ui/badge.tsx b/components/ui/badge.tsx new file mode 100644 index 0000000..bb37dc8 --- /dev/null +++ b/components/ui/badge.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { VariantProps, cva } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + "inline-flex items-center border rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", + { + variants: { + variant: { + default: + "bg-primary hover:bg-primary/80 border-transparent text-primary-foreground", + secondary: + "bg-secondary hover:bg-secondary/80 border-transparent text-secondary-foreground", + destructive: + "bg-destructive hover:bg-destructive/80 border-transparent text-destructive-foreground", + outline: "text-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
        + ) +} + +export { Badge, badgeVariants } diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000..6dc25c6 --- /dev/null +++ b/components/ui/button.tsx @@ -0,0 +1,63 @@ +import * as React from "react"; +import { cva, VariantProps } from "class-variance-authority"; + +import { cn } from "@/lib/utils"; + +const buttonVariants = cva( + "inline-flex items-center justify-center text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed ring-offset-background select-none", + { + variants: { + variant: { + default: "bg-primary text-primary-foreground hover:bg-primary/90", + destructive: + "bg-destructive text-destructive-foreground hover:bg-destructive/90", + outline: + "border border-input hover:bg-accent hover:text-accent-foreground", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/80", + ghost: "hover:bg-accent hover:text-accent-foreground", + link: "underline-offset-4 hover:underline text-primary", + disable: + "border border-input bg-transparent text-neutral-600 cursor-not-allowed", + }, + size: { + default: "h-10 py-2 px-4", + sm: "h-9 px-3", + lg: "h-11 px-8", + icon: "size-10", + }, + rounded: { + default: "rounded-md", + sm: "rounded-sm", + lg: "rounded-lg", + xl: "rounded-xl", + "2xl": "rounded-2xl", + full: "rounded-full", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + rounded: "default", + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps {} + +const Button = React.forwardRef( + ({ className, variant, size, rounded, ...props }, ref) => { + return ( +