This commit is contained in:
oiov
2024-07-26 22:08:57 +08:00
commit 8dc58401d5
233 changed files with 26585 additions and 0 deletions

3
.commitlintrc.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": ["@commitlint/config-conventional"]
}

29
.env.example Normal file
View File

@@ -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=

31
.eslintrc.json Normal file
View File

@@ -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"
}
]
}

43
.gitignore vendored Normal file
View File

@@ -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

4
.husky/commit-msg Normal file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx commitlint --edit $1

4
.husky/pre-commit Normal file
View File

@@ -0,0 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx pretty-quick --staged

1
.nvmrc Normal file
View File

@@ -0,0 +1 @@
v16.18.0

5
.prettierignore Normal file
View File

@@ -0,0 +1,5 @@
dist
node_modules
.next
build
.contentlayer

21
LICENSE.md Normal file
View File

@@ -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.

0
README.md Normal file
View File

View File

@@ -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" }
}
}

View File

@@ -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" };
}
}

18
app/(auth)/layout.tsx Normal file
View File

@@ -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 <div className="min-h-screen">{children}</div>;
}

54
app/(auth)/login/page.tsx Normal file
View File

@@ -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 (
<div className="container flex h-screen w-screen flex-col items-center justify-center">
<Link
href="/"
className={cn(
buttonVariants({ variant: "outline", size: "sm" }),
"absolute left-4 top-4 md:left-8 md:top-8",
)}
>
<>
<Icons.chevronLeft className="mr-2 size-4" />
Back
</>
</Link>
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<div className="flex flex-col space-y-2 text-center">
<Icons.logo className="mx-auto size-6" />
<h1 className="text-2xl font-semibold tracking-tight">
Welcome back
</h1>
<p className="text-sm text-muted-foreground">
Enter your email to sign in to your account
</p>
</div>
<Suspense>
<UserAuthForm />
</Suspense>
<p className="px-8 text-center text-sm text-muted-foreground">
<Link
href="/register"
className="hover:text-brand underline underline-offset-4"
>
Don&apos;t have an account? Sign Up
</Link>
</p>
</div>
</div>
);
}

View File

@@ -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 (
<div className="container grid h-screen w-screen flex-col items-center justify-center lg:max-w-none lg:grid-cols-2 lg:px-0">
<Link
href="/login"
className={cn(
buttonVariants({ variant: "ghost" }),
"absolute right-4 top-4 md:right-8 md:top-8"
)}
>
Login
</Link>
<div className="hidden h-full bg-muted lg:block" />
<div className="lg:p-8">
<div className="mx-auto flex w-full flex-col justify-center space-y-6 sm:w-[350px]">
<div className="flex flex-col space-y-2 text-center">
<Icons.logo className="mx-auto size-6" />
<h1 className="text-2xl font-semibold tracking-tight">
Create an account
</h1>
<p className="text-sm text-muted-foreground">
Enter your email below to create your account
</p>
</div>
<Suspense>
<UserAuthForm type="register" />
</Suspense>
<p className="px-8 text-center text-sm text-muted-foreground">
By clicking continue, you agree to our{" "}
<Link
href="/terms"
className="hover:text-brand underline underline-offset-4"
>
Terms of Service
</Link>{" "}
and{" "}
<Link
href="/privacy"
className="hover:text-brand underline underline-offset-4"
>
Privacy Policy
</Link>
.
</p>
</div>
</div>
</div>
)
}

View File

@@ -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<Metadata> {
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 (
<main className="relative py-6 lg:gap-10 lg:py-8 xl:grid xl:grid-cols-[1fr_300px]">
<div className="mx-auto w-full min-w-0">
<DocsPageHeader heading={doc.title} text={doc.description} />
<div className="pb-4 pt-11">
<Mdx code={doc.body.code} images={images} />
</div>
<hr className="my-4 md:my-6" />
<DocsPager doc={doc} />
</div>
<div className="hidden text-sm xl:block">
<div className="sticky top-16 -mt-10 max-h-[calc(var(--vh)-4rem)] overflow-y-auto pt-8">
<DashboardTableOfContents toc={toc} />
</div>
</div>
</main>
);
}

View File

@@ -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 (
<div className="flex-1 items-start md:grid md:grid-cols-[220px_minmax(0,1fr)] md:gap-5 lg:grid-cols-[240px_minmax(0,1fr)] lg:gap-10">
<aside className="fixed top-14 -ml-2 hidden h-[calc(100vh-3.5rem)] w-full shrink-0 md:sticky md:block">
<ScrollArea className="h-full py-6 pr-6 lg:py-8">
<DocsSidebarNav />
</ScrollArea>
</aside>
{children}
</div>
);
}

21
app/(docs)/layout.tsx Normal file
View File

@@ -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 (
<div className="flex flex-col">
<NavMobile />
<NavBar />
<MaxWidthWrapper className="min-h-screen" large>
{children}
</MaxWidthWrapper>
<SiteFooter className="border-t" />
</div>
);
}

View File

@@ -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<Metadata | undefined> {
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 (
<>
<MaxWidthWrapper className="pt-6 md:pt-10">
<div className="flex flex-col space-y-4">
<div className="flex items-center space-x-4">
<Link
href={`/blog/category/${category.slug}`}
className={cn(
buttonVariants({
variant: "outline",
size: "sm",
rounded: "lg",
}),
"h-8",
)}
>
{category.title}
</Link>
<time
dateTime={post.date}
className="text-sm font-medium text-muted-foreground"
>
{formatDate(post.date)}
</time>
</div>
<h1 className="font-heading text-3xl text-foreground sm:text-4xl">
{post.title}
</h1>
<p className="text-base text-muted-foreground md:text-lg">
{post.description}
</p>
<div className="flex flex-nowrap items-center space-x-5 pt-1 md:space-x-8">
{post.authors.map((author) => (
<Author username={author} key={post._id + author} />
))}
</div>
</div>
</MaxWidthWrapper>
<div className="relative">
<div className="absolute top-52 w-full border-t" />
<MaxWidthWrapper className="grid grid-cols-4 gap-10 pt-8 max-md:px-0">
<div className="relative col-span-4 mb-10 flex flex-col space-y-8 border-y bg-background md:rounded-xl md:border lg:col-span-3">
<BlurImage
alt={post.title}
blurDataURL={thumbnailBlurhash ?? placeholderBlurhash}
className="aspect-[1200/630] border-b object-cover md:rounded-t-xl"
width={1200}
height={630}
priority
placeholder="blur"
src={post.image}
sizes="(max-width: 768px) 770px, 1000px"
/>
<div className="px-[.8rem] pb-10 md:px-8">
<Mdx code={post.body.code} images={images} />
</div>
</div>
<div className="sticky top-20 col-span-1 mt-52 hidden flex-col divide-y divide-muted self-start pb-24 lg:flex">
<DashboardTableOfContents toc={toc} />
</div>
</MaxWidthWrapper>
</div>
<MaxWidthWrapper>
{relatedArticles.length > 0 && (
<div className="flex flex-col space-y-4 pb-16">
<p className="font-heading text-2xl text-foreground">
More Articles
</p>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:gap-6">
{relatedArticles.map((post) => (
<Link
key={post.slug}
href={post.slug}
className="flex flex-col space-y-2 rounded-xl border p-5 transition-colors duration-300 hover:bg-muted/80"
>
<h3 className="font-heading text-xl text-foreground">
{post.title}
</h3>
<p className="line-clamp-2 text-[15px] text-muted-foreground">
{post.description}
</p>
<p className="text-sm text-muted-foreground">
{formatDate(post.date)}
</p>
</Link>
))}
</div>
</div>
)}
</MaxWidthWrapper>
</>
);
}

View File

@@ -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<Metadata | undefined> {
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 (
<article className="container max-w-3xl py-6 lg:py-12">
<div className="space-y-4">
<h1 className="inline-block font-heading text-4xl lg:text-5xl">
{page.title}
</h1>
{page.description && (
<p className="text-xl text-muted-foreground">{page.description}</p>
)}
</div>
<hr className="my-4" />
<Mdx code={page.body.code} />
</article>
);
}

View File

@@ -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<Metadata | undefined> {
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 (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{articles.map((article, idx) => (
<BlogCard key={article._id} data={article} priority={idx <= 2} />
))}
</div>
);
}

View File

@@ -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 (
<>
<BlogHeaderLayout />
<MaxWidthWrapper className="pb-16">{children}</MaxWidthWrapper>
</>
);
}

View File

@@ -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 <BlogPosts posts={posts} />;
}

23
app/(marketing)/error.tsx Normal file
View File

@@ -0,0 +1,23 @@
'use client';
import { Button } from '@/components/ui/button';
export default function Error({
reset,
}: {
reset: () => void;
}) {
return (
<div className="flex min-h-screen flex-col items-center justify-center">
<h2 className="mb-5 text-center">Something went wrong!</h2>
<Button
type="submit"
variant="default"
onClick={() => reset()}
>
Try again
</Button>
</div>
);
}

View File

@@ -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 (
<div className="flex min-h-screen flex-col">
<NavMobile />
<NavBar scroll={true} />
<main className="flex-1">{children}</main>
<SiteFooter />
</div>
);
}

View File

@@ -0,0 +1,27 @@
import Image from "next/image";
import Link from "next/link";
export default function NotFound() {
return (
<div className="flex min-h-screen flex-col items-center justify-center">
<h1 className="text-6xl font-bold">404</h1>
<Image
src="/_static/illustrations/rocket-crashed.svg"
alt="404"
width={400}
height={400}
className="pointer-events-none mb-5 mt-6 dark:invert"
/>
<p className="text-balance px-4 text-center text-2xl font-medium">
Page not found. Back to{" "}
<Link
href="/"
className="text-muted-foreground underline underline-offset-4 hover:text-blue-500"
>
Homepage
</Link>
.
</p>
</div>
);
}

12
app/(marketing)/page.tsx Normal file
View File

@@ -0,0 +1,12 @@
import HeroLanding from "@/components/sections/hero-landing";
import PreviewLanding from "@/components/sections/preview-landing";
export default function IndexPage() {
return (
<>
<HeroLanding />
<PreviewLanding />
</>
);
}

View File

@@ -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}</>;
}

View File

@@ -0,0 +1,23 @@
import { Skeleton } from "@/components/ui/skeleton";
import { DashboardHeader } from "@/components/dashboard/header";
export default function AdminPanelLoading() {
return (
<>
<DashboardHeader
heading="Admin Panel"
text="Access only for users with ADMIN role."
/>
<div className="flex flex-col gap-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Skeleton className="h-32 w-full rounded-lg" />
<Skeleton className="h-32 w-full rounded-lg" />
<Skeleton className="h-32 w-full rounded-lg" />
<Skeleton className="h-32 w-full rounded-lg" />
</div>
<Skeleton className="h-[500px] w-full rounded-lg" />
<Skeleton className="h-[500px] w-full rounded-lg" />
</div>
</>
);
}

View File

@@ -0,0 +1,14 @@
import { Skeleton } from "@/components/ui/skeleton";
import { DashboardHeader } from "@/components/dashboard/header";
export default function OrdersLoading() {
return (
<>
<DashboardHeader
heading="Orders"
text="Check and manage your latest orders."
/>
<Skeleton className="size-full rounded-lg" />
</>
);
}

View File

@@ -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 (
<>
<DashboardHeader
heading="Orders"
text="Check and manage your latest orders."
/>
<EmptyPlaceholder>
<EmptyPlaceholder.Icon name="package" />
<EmptyPlaceholder.Title>No orders listed</EmptyPlaceholder.Title>
<EmptyPlaceholder.Description>
You don&apos;t have any orders yet. Start ordering a product.
</EmptyPlaceholder.Description>
<Button>Buy Products</Button>
</EmptyPlaceholder>
</>
);
}

View File

@@ -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 (
<>
<DashboardHeader
heading="Admin Panel"
text="Access only for users with ADMIN role."
/>
<div className="flex flex-col gap-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-4">
<InfoCard />
<InfoCard />
<InfoCard />
<InfoCard />
</div>
<TransactionsList />
<TransactionsList />
</div>
</>
);
}

View File

@@ -0,0 +1,11 @@
import { Skeleton } from "@/components/ui/skeleton";
import { DashboardHeader } from "@/components/dashboard/header";
export default function ChartsLoading() {
return (
<>
<DashboardHeader heading="Charts" text="List of charts by shadcn-ui." />
<Skeleton className="size-full rounded-lg" />
</>
);
}

View File

@@ -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 (
<>
<DashboardHeader heading="Charts" text="List of charts by shadcn-ui." />
<div className="flex flex-col gap-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 2xl:grid-cols-4">
<RadialTextChart />
<AreaChartStacked />
<BarChartMixed />
<RadarChartSimple />
</div>
<InteractiveBarChart />
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 2xl:grid-cols-4">
<RadialChartGrid />
<RadialShapeChart />
<LineChartMultiple />
<RadialStackedChart />
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,11 @@
import { Skeleton } from "@/components/ui/skeleton";
import { DashboardHeader } from "@/components/dashboard/header";
export default function DashboardLoading() {
return (
<>
<DashboardHeader heading="Dashboard" text="Current Role :" />
<Skeleton className="size-full rounded-lg" />
</>
);
}

View File

@@ -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 (
<>
<DashboardHeader
heading="Dashboard"
// text={`Current Role : ${user?.role}`}
/>
{user && <AddRecordForm user={{ id: user.id, name: user.name || "" }} />}
<EmptyPlaceholder>
<EmptyPlaceholder.Icon name="post" />
<EmptyPlaceholder.Title>No record created</EmptyPlaceholder.Title>
<EmptyPlaceholder.Description>
You don&apos;t have any record yet. Start creating record.
</EmptyPlaceholder.Description>
<Button>Add Record</Button>
</EmptyPlaceholder>
</>
);
}

View File

@@ -0,0 +1,18 @@
import { DashboardHeader } from "@/components/dashboard/header";
import { SkeletonSection } from "@/components/shared/section-skeleton";
export default function DashboardSettingsLoading() {
return (
<>
<DashboardHeader
heading="Settings"
text="Manage account and website settings."
/>
<div className="divide-y divide-muted pb-10">
<SkeletonSection />
<SkeletonSection />
<SkeletonSection card />
</div>
</>
);
}

View File

@@ -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 (
<>
<DashboardHeader
heading="Settings"
text="Manage account and website settings."
/>
<div className="divide-y divide-muted pb-10">
<UserNameForm user={{ id: user.id, name: user.name || "" }} />
<UserRoleForm user={{ id: user.id, role: user.role }} />
<DeleteAccountSection />
</div>
</>
);
}

View File

@@ -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 (
<div className="relative flex min-h-screen w-full">
<DashboardSidebar links={filteredLinks} />
<div className="flex flex-1 flex-col">
<header className="sticky top-0 z-50 flex h-14 bg-background px-4 lg:h-[60px] xl:px-8">
<MaxWidthWrapper className="flex max-w-7xl items-center gap-x-3 px-0">
<MobileSheetSidebar links={filteredLinks} />
<div className="w-full flex-1">
<SearchCommand links={filteredLinks} />
</div>
<ModeToggle />
<UserAccountNav />
</MaxWidthWrapper>
</header>
<main className="flex-1 p-4 xl:px-8">
<MaxWidthWrapper className="flex h-full max-w-7xl flex-col gap-4 px-0 lg:gap-6">
{children}
</MaxWidthWrapper>
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1 @@
export { GET, POST } from "@/auth"

155
app/api/og/route.tsx Normal file
View File

@@ -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(
(
<div
tw="flex relative flex-col p-12 w-full h-full items-start"
style={{
color: paint,
background:
mode === "dark"
? "linear-gradient(90deg, #000 0%, #111 100%)"
: "white",
}}
>
<div
tw="text-5xl"
style={{
fontFamily: "Cal Sans",
fontWeight: "normal",
position: "relative",
background: "linear-gradient(90deg, #6366f1, #a855f7 80%)",
backgroundClip: "text",
color: "transparent",
}}
>
Next Template
</div>
<div tw="flex flex-col flex-1 py-16">
<div
tw="flex text-xl uppercase font-bold tracking-tight"
style={{ fontFamily: "Inter", fontWeight: "normal" }}
>
{values.type}
</div>
{/* Title */}
<div
tw="flex leading-[1.15] text-[80px] font-bold"
style={{
fontFamily: "Cal Sans",
fontWeight: "bold",
marginLeft: "-3px",
fontSize,
}}
>
{heading}
</div>
</div>
<div tw="flex items-center w-full justify-between">
<div
tw="flex items-center text-xl"
style={{ fontFamily: "Inter", fontWeight: "normal" }}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
alt="avatar"
width="65"
src={`https://github.com/${githubName}.png`}
style={{
borderRadius: 128,
}}
/>
<div tw="flex flex-col" style={{ marginLeft: "15px" }}>
<div tw="text-[22px]" style={{ fontFamily: "Cal Sans" }}>
{githubName}
</div>
<div>Open Source Designer</div>
</div>
</div>
<div
tw="flex items-center text-xl"
style={{ fontFamily: "Inter", fontWeight: "normal" }}
>
<svg width="32" height="32" viewBox="0 0 48 48" fill="none">
<path
d="M30 44v-8a9.6 9.6 0 0 0-2-7c6 0 12-4 12-11 .16-2.5-.54-4.96-2-7 .56-2.3.56-4.7 0-7 0 0-2 0-6 3-5.28-1-10.72-1-16 0-4-3-6-3-6-3-.6 2.3-.6 4.7 0 7a10.806 10.806 0 0 0-2 7c0 7 6 11 12 11a9.43 9.43 0 0 0-1.7 3.3c-.34 1.2-.44 2.46-.3 3.7v8"
stroke={paint}
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M18 36c-9.02 4-10-4-14-4"
stroke={paint}
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<div tw="flex ml-2">
github.com/mickasmt/next-auth-roles-template
</div>
</div>
</div>
</div>
),
{
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,
});
}
}

View File

@@ -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,
});
}
}

26
app/api/user/route.ts Normal file
View File

@@ -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 });
});

53
app/layout.tsx Normal file
View File

@@ -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 (
<html lang="en" suppressHydrationWarning>
<head>
<script
defer
src="https://umami.oiov.dev/script.js"
data-website-id="56549e9d-61df-470d-a1b1-cbf12cfafe9d"
></script>
</head>
<body
className={cn(
"min-h-screen bg-background font-sans antialiased",
fontSans.variable,
fontHeading.variable,
fontSatoshi.variable,
)}
>
<SessionProvider>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<ModalProvider>{children}</ModalProvider>
{/* <Analytics /> */}
<Toaster richColors closeButton />
<TailwindIndicator />
</ThemeProvider>
</SessionProvider>
</body>
</html>
);
}

27
app/not-found.tsx Normal file
View File

@@ -0,0 +1,27 @@
import Image from "next/image";
import Link from "next/link";
export default function NotFound() {
return (
<div className="flex min-h-screen flex-col items-center justify-center">
<h1 className="text-6xl font-bold">404</h1>
<Image
src="/_static/illustrations/rocket-crashed.svg"
alt="404"
width={400}
height={400}
className="pointer-events-none mb-5 mt-6 dark:invert"
/>
<p className="text-balance px-4 text-center text-2xl font-medium">
Page not found. Back to{" "}
<Link
href="/"
className="text-muted-foreground underline underline-offset-4 hover:text-blue-500"
>
Homepage
</Link>
.
</p>
</div>
);
}

BIN
app/opengraph-image.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

10
app/robots.ts Normal file
View File

@@ -0,0 +1,10 @@
import { MetadataRoute } from "next"
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
},
}
}

Binary file not shown.

Binary file not shown.

BIN
assets/fonts/Inter-Bold.ttf Normal file

Binary file not shown.

Binary file not shown.

20
assets/fonts/index.ts Normal file
View File

@@ -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",
});

Binary file not shown.

28
auth.config.ts Normal file
View File

@@ -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 <dns@wr.do>",
}),
],
} satisfies NextAuthConfig;

67
auth.ts Normal file
View File

@@ -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"
});

17
components.json Normal file
View File

@@ -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"
}
}

7
components/analytics.tsx Normal file
View File

@@ -0,0 +1,7 @@
"use client"
import { Analytics as VercelAnalytics } from "@vercel/analytics/react"
export function Analytics() {
return <VercelAnalytics />
}

View File

@@ -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 (
<Card className="flex flex-col">
<CardHeader>
{/* <CardTitle>Area Chart - Stacked</CardTitle>
<CardDescription>
Showing total visitors for the last 6 months
</CardDescription> */}
</CardHeader>
<CardContent className="flex-1">
<ChartContainer config={chartConfig}>
<AreaChart
accessibilityLayer
data={chartData}
margin={{
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => value.slice(0, 3)}
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent indicator="dot" />}
/>
<Area
dataKey="mobile"
type="natural"
fill="var(--color-mobile)"
fillOpacity={0.4}
stroke="var(--color-mobile)"
stackId="a"
/>
<Area
dataKey="desktop"
type="natural"
fill="var(--color-desktop)"
fillOpacity={0.4}
stroke="var(--color-desktop)"
stackId="a"
/>
</AreaChart>
</ChartContainer>
</CardContent>
<CardFooter className="flex-col gap-2 text-pretty text-center text-sm">
<div className="flex items-center gap-2 font-medium leading-none">
Trending up by 5.2% this month <TrendingUp className="size-4" />
</div>
<div className="leading-none text-muted-foreground">
January - June 2024
</div>
</CardFooter>
</Card>
);
}

View File

@@ -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 (
<Card className="flex flex-col">
<CardHeader>
{/* <CardTitle>Bar Chart - Mixed</CardTitle>
<CardDescription>January - June 2024</CardDescription> */}
</CardHeader>
<CardContent className="flex-1">
<ChartContainer config={chartConfig}>
<BarChart
accessibilityLayer
data={chartData}
layout="vertical"
margin={{
left: 0,
}}
>
<YAxis
dataKey="browser"
type="category"
tickLine={false}
tickMargin={10}
axisLine={false}
tickFormatter={(value) =>
chartConfig[value as keyof typeof chartConfig]?.label
}
/>
<XAxis dataKey="visitors" type="number" hide />
<ChartTooltip
cursor={false}
content={<ChartTooltipContent hideLabel />}
/>
<Bar dataKey="visitors" layout="vertical" radius={5} />
</BarChart>
</ChartContainer>
</CardContent>
<CardFooter className="flex-col gap-2 text-pretty text-center text-sm">
<div className="flex items-center gap-2 font-medium leading-none">
Trending up by 5.2% this month <TrendingUp className="size-4" />
</div>
<div className="leading-none text-muted-foreground">
Results for the top 5 browsers
</div>
</CardFooter>
</Card>
);
}

View File

@@ -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<keyof typeof chartConfig>("desktop");
const total = React.useMemo(
() => ({
desktop: chartData.reduce((acc, curr) => acc + curr.desktop, 0),
mobile: chartData.reduce((acc, curr) => acc + curr.mobile, 0),
}),
[],
);
return (
<Card>
<CardHeader className="flex flex-col items-stretch space-y-0 border-b p-0 sm:flex-row">
<div className="flex flex-1 flex-col justify-center gap-1 px-6 py-5 sm:py-6">
<CardTitle>Bar Chart - Interactive</CardTitle>
<CardDescription>
Showing total visitors for the last 3 months
</CardDescription>
</div>
<div className="flex">
{["desktop", "mobile"].map((key) => {
const chart = key as keyof typeof chartConfig;
return (
<button
key={chart}
data-active={activeChart === chart}
className="relative z-30 flex flex-1 flex-col justify-center gap-1 border-t px-6 py-4 text-left even:border-l data-[active=true]:bg-muted/50 sm:border-l sm:border-t-0 sm:px-8 sm:py-6"
onClick={() => setActiveChart(chart)}
>
<span className="text-xs text-muted-foreground">
{chartConfig[chart].label}
</span>
<span className="text-lg font-bold leading-none sm:text-3xl">
{total[key as keyof typeof total].toLocaleString()}
</span>
</button>
);
})}
</div>
</CardHeader>
<CardContent className="px-2 sm:p-6">
<ChartContainer
config={chartConfig}
className="aspect-auto h-[250px] w-full"
>
<BarChart
accessibilityLayer
data={chartData}
margin={{
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="date"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={32}
tickFormatter={(value) => {
const date = new Date(value);
return date.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
});
}}
/>
<ChartTooltip
content={
<ChartTooltipContent
className="w-[150px]"
nameKey="views"
labelFormatter={(value) => {
return new Date(value).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}}
/>
}
/>
<Bar dataKey={activeChart} fill={`var(--color-${activeChart})`} />
</BarChart>
</ChartContainer>
</CardContent>
</Card>
);
}

View File

@@ -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 (
<Card>
<CardHeader>
<CardTitle>Line Chart - Multiple</CardTitle>
<CardDescription>January - June 2024</CardDescription>
</CardHeader>
<CardContent>
<ChartContainer config={chartConfig}>
<LineChart
accessibilityLayer
data={chartData}
margin={{
left: 12,
right: 12,
}}
>
<CartesianGrid vertical={false} />
<XAxis
dataKey="month"
tickLine={false}
axisLine={false}
tickMargin={8}
tickFormatter={(value) => value.slice(0, 3)}
/>
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
<Line
dataKey="desktop"
type="monotone"
stroke="var(--color-desktop)"
strokeWidth={2}
dot={false}
/>
<Line
dataKey="mobile"
type="monotone"
stroke="var(--color-mobile)"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ChartContainer>
</CardContent>
<CardFooter className="flex-col gap-2 text-pretty text-center text-sm">
<div className="flex items-center gap-2 font-medium leading-none">
Trending up by 5.2% this month <TrendingUp className="size-4" />
</div>
<div className="leading-none text-muted-foreground">
Showing total visitors for the last 6 months
</div>
</CardFooter>
</Card>
)
}

View File

@@ -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 (
<Card>
{/* <CardHeader className="items-center pb-4">
<CardTitle>Radar Chart</CardTitle>
<CardDescription>
Showing total visitors for the last 6 months
</CardDescription>
</CardHeader> */}
<CardContent className="pb-0">
<ChartContainer
config={chartConfig}
className="mx-auto aspect-square max-h-[350px] 2xl:max-h-[250px]"
>
<RadarChart data={chartData}>
<ChartTooltip cursor={false} content={<ChartTooltipContent />} />
<PolarAngleAxis dataKey="month" />
<PolarGrid />
<Radar
dataKey="desktop"
fill="var(--color-desktop)"
fillOpacity={0.6}
/>
</RadarChart>
</ChartContainer>
</CardContent>
<CardFooter className="flex-col gap-2 text-pretty text-center text-sm">
<div className="flex items-center gap-2 font-medium leading-none">
Trending up by 5.2% this month <TrendingUp className="size-4" />
</div>
<div className="leading-none text-muted-foreground">
January - June 2024
</div>
</CardFooter>
</Card>
);
}

View File

@@ -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 (
<Card className="flex flex-col">
{/* <CardHeader className="items-center pb-0">
<CardTitle>Radial Chart - Grid</CardTitle>
<CardDescription>January - June 2024</CardDescription>
</CardHeader> */}
<CardContent className="flex-1 pb-0">
<ChartContainer
config={chartConfig}
className="mx-auto aspect-square max-h-[250px]"
>
<RadialBarChart data={chartData} innerRadius={30} outerRadius={100}>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent hideLabel nameKey="browser" />}
/>
<PolarGrid gridType="circle" />
<RadialBar dataKey="visitors" />
</RadialBarChart>
</ChartContainer>
</CardContent>
<CardFooter className="flex-col gap-2 text-sm">
<div className="flex items-center gap-2 font-medium leading-none">
Trending up by 5.2% this month <TrendingUp className="size-4" />
</div>
<div className="leading-none text-muted-foreground">
Showing total visitors for the last 6 months
</div>
</CardFooter>
</Card>
)
}

View File

@@ -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 (
<Card className="flex flex-col">
{/* <CardHeader className="items-center pb-0">
<CardTitle>Radial Chart - Shape</CardTitle>
<CardDescription>January - June 2024</CardDescription>
</CardHeader> */}
<CardContent className="flex-1 pb-0">
<ChartContainer
config={chartConfig}
className="mx-auto aspect-square max-h-[250px]"
>
<RadialBarChart
data={chartData}
endAngle={100}
innerRadius={80}
outerRadius={140}
>
<PolarGrid
gridType="circle"
radialLines={false}
stroke="none"
className="first:fill-muted last:fill-background"
polarRadius={[86, 74]}
/>
<RadialBar dataKey="visitors" background />
<PolarRadiusAxis tick={false} tickLine={false} axisLine={false}>
<Label
content={({ viewBox }) => {
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
return (
<text
x={viewBox.cx}
y={viewBox.cy}
textAnchor="middle"
dominantBaseline="middle"
>
<tspan
x={viewBox.cx}
y={viewBox.cy}
className="fill-foreground text-4xl font-bold"
>
{chartData[0].visitors.toLocaleString()}
</tspan>
<tspan
x={viewBox.cx}
y={(viewBox.cy || 0) + 24}
className="fill-muted-foreground"
>
Visitors
</tspan>
</text>
);
}
}}
/>
</PolarRadiusAxis>
</RadialBarChart>
</ChartContainer>
</CardContent>
<CardFooter className="flex-col gap-2 text-pretty text-center text-sm">
<div className="flex items-center gap-2 font-medium leading-none">
Trending up by 5.2% this month <TrendingUp className="size-4" />
</div>
<div className="leading-none text-muted-foreground">
Showing total visitors for the last 6 months
</div>
</CardFooter>
</Card>
);
}

View File

@@ -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 (
<Card className="flex flex-col">
{/* <CardHeader className="items-center pb-0">
<CardTitle>Radial Chart - Stacked</CardTitle>
<CardDescription>January - June 2024</CardDescription>
</CardHeader> */}
<CardContent className="flex flex-1 items-center pb-0">
<ChartContainer
config={chartConfig}
className="mx-auto aspect-square w-full max-w-[250px]"
>
<RadialBarChart
data={chartData}
endAngle={180}
innerRadius={80}
outerRadius={130}
>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent hideLabel />}
/>
<PolarRadiusAxis tick={false} tickLine={false} axisLine={false}>
<Label
content={({ viewBox }) => {
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
return (
<text x={viewBox.cx} y={viewBox.cy} textAnchor="middle">
<tspan
x={viewBox.cx}
y={(viewBox.cy || 0) - 16}
className="fill-foreground text-2xl font-bold"
>
{totalVisitors.toLocaleString()}
</tspan>
<tspan
x={viewBox.cx}
y={(viewBox.cy || 0) + 4}
className="fill-muted-foreground"
>
Visitors
</tspan>
</text>
);
}
}}
/>
</PolarRadiusAxis>
<RadialBar
dataKey="desktop"
stackId="a"
cornerRadius={5}
fill="var(--color-desktop)"
className="stroke-transparent stroke-2"
/>
<RadialBar
dataKey="mobile"
fill="var(--color-mobile)"
stackId="a"
cornerRadius={5}
className="stroke-transparent stroke-2"
/>
</RadialBarChart>
</ChartContainer>
</CardContent>
<CardFooter className="flex-col gap-2 text-pretty text-center text-sm">
<div className="flex items-center gap-2 font-medium leading-none">
Trending up by 5.2% this month <TrendingUp className="size-4" />
</div>
<div className="leading-none text-muted-foreground">
Showing total visitors for the last 6 months
</div>
</CardFooter>
</Card>
);
}

View File

@@ -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 (
<Card className="flex flex-col">
{/* <CardHeader className="items-center pb-0">
<CardTitle>Radial Chart - Text</CardTitle>
<CardDescription>January - June 2024</CardDescription>
</CardHeader> */}
<CardContent className="flex-1 pb-0">
<ChartContainer
config={chartConfig}
className="mx-auto aspect-square max-h-[250px]"
>
<RadialBarChart
data={chartData}
startAngle={0}
endAngle={250}
innerRadius={80}
outerRadius={110}
>
<PolarGrid
gridType="circle"
radialLines={false}
stroke="none"
className="first:fill-muted last:fill-background"
polarRadius={[86, 74]}
/>
<RadialBar dataKey="visitors" background cornerRadius={10} />
<PolarRadiusAxis tick={false} tickLine={false} axisLine={false}>
<Label
content={({ viewBox }) => {
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
return (
<text
x={viewBox.cx}
y={viewBox.cy}
textAnchor="middle"
dominantBaseline="middle"
>
<tspan
x={viewBox.cx}
y={viewBox.cy}
className="fill-foreground text-4xl font-bold"
>
{chartData[0].visitors.toLocaleString()}
</tspan>
<tspan
x={viewBox.cx}
y={(viewBox.cy || 0) + 24}
className="fill-muted-foreground"
>
Visitors
</tspan>
</text>
);
}
}}
/>
</PolarRadiusAxis>
</RadialBarChart>
</ChartContainer>
</CardContent>
<CardFooter className="flex-col gap-2 text-pretty text-center text-sm">
<div className="flex items-center gap-2 font-medium leading-none">
Trending up by 5.2% this month <TrendingUp className="size-4" />
</div>
<div className="leading-none text-muted-foreground">
Total visitors in the last 6 months
</div>
</CardFooter>
</Card>
);
}

View File

@@ -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 ? (
<Image
src={authors[username].image}
alt={authors[username].name}
width={32}
height={32}
className="size-8 rounded-full transition-all group-hover:brightness-90"
/>
) : (
<Link
href={`https://twitter.com/${authors[username].twitter}`}
className="group flex w-max items-center space-x-2.5"
target="_blank"
rel="noopener noreferrer"
>
<Image
src={authors[username].image}
alt={authors[username].name}
width={40}
height={40}
className="size-8 rounded-full transition-all group-hover:brightness-90 md:size-10"
/>
<div className="flex flex-col -space-y-0.5">
<p className="font-semibold text-foreground max-md:text-sm">
{authors[username].name}
</p>
<p className="text-sm text-muted-foreground">@{authors[username].twitter}</p>
</div>
</Link>
);
}

View File

@@ -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 (
<article
className={cn(
"group relative",
horizontale
? "grid grid-cols-1 gap-3 md:grid-cols-2 md:gap-6"
: "flex flex-col space-y-2",
)}
>
{data.image && (
<div className="w-full overflow-hidden rounded-xl border">
<BlurImage
alt={data.title}
blurDataURL={data.blurDataURL ?? placeholderBlurhash}
className={cn(
"size-full object-cover object-center",
horizontale ? "lg:h-72" : null,
)}
width={800}
height={400}
priority={priority}
placeholder="blur"
src={data.image}
sizes="(max-width: 768px) 750px, 600px"
/>
</div>
)}
<div
className={cn(
"flex flex-1 flex-col",
horizontale ? "justify-center" : "justify-between",
)}
>
<div className="w-full">
<h2 className="my-1.5 line-clamp-2 font-heading text-2xl">
{data.title}
</h2>
{data.description && (
<p className="line-clamp-2 text-muted-foreground">
{data.description}
</p>
)}
</div>
<div className="mt-4 flex items-center space-x-3">
{/* <Author username={data.authors[0]} imageOnly /> */}
<div className="flex items-center -space-x-2">
{data.authors.map((author) => (
<Author username={author} key={data._id + author} imageOnly />
))}
</div>
{data.date && (
<p className="text-sm text-muted-foreground">
{formatDate(data.date)}
</p>
)}
</div>
</div>
<Link href={data.slug} className="absolute inset-0">
<span className="sr-only">View Article</span>
</Link>
</article>
);
}

View File

@@ -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 (
<>
<MaxWidthWrapper className="py-6 md:pb-8 md:pt-10">
<div className="max-w-screen-sm">
<h1 className="font-heading text-3xl md:text-4xl">
{data?.title || "Blog"}
</h1>
<p className="mt-3.5 text-base text-muted-foreground md:text-lg">
{data?.description ||
"Latest news and updates from Next Auth Roles Template."}
</p>
</div>
<nav className="mt-8 hidden w-full md:flex">
<ul
role="list"
className="flex w-full flex-1 gap-x-2 border-b text-[15px] text-muted-foreground"
>
<CategoryLink title="All" href="/blog" active={!slug} />
{BLOG_CATEGORIES.map((category) => (
<CategoryLink
key={category.slug}
title={category.title}
href={`/blog/category/${category.slug}`}
active={category.slug === slug}
/>
))}
<CategoryLink title="Guides" href="/guides" active={false} />
</ul>
</nav>
</MaxWidthWrapper>
<Drawer.Root open={open} onClose={closeDrawer}>
<Drawer.Trigger
onClick={() => setOpen(true)}
className="mb-8 flex w-full items-center border-y p-3 text-foreground/90 md:hidden"
>
<List className="size-[18px]" />
<p className="ml-2.5 text-sm font-medium">Categories</p>
</Drawer.Trigger>
<Drawer.Overlay
className="fixed inset-0 z-40 bg-background/80 backdrop-blur-sm"
onClick={closeDrawer}
/>
<Drawer.Portal>
<Drawer.Content className="fixed inset-x-0 bottom-0 z-50 mt-24 overflow-hidden rounded-t-[10px] border bg-background">
<div className="sticky top-0 z-20 flex w-full items-center justify-center bg-inherit">
<div className="my-3 h-1.5 w-16 rounded-full bg-muted-foreground/20" />
</div>
<ul role="list" className="mb-14 w-full p-3 text-muted-foreground">
<CategoryLink
title="All"
href="/blog"
active={!slug}
clickAction={closeDrawer}
mobile
/>
{BLOG_CATEGORIES.map((category) => (
<CategoryLink
key={category.slug}
title={category.title}
href={`/blog/category/${category.slug}`}
active={category.slug === slug}
clickAction={closeDrawer}
mobile
/>
))}
<CategoryLink
title="Guides"
href="/guides"
active={false}
mobile
/>
</ul>
</Drawer.Content>
<Drawer.Overlay />
</Drawer.Portal>
</Drawer.Root>
</>
);
}
const CategoryLink = ({
title,
href,
active,
mobile = false,
clickAction,
}: {
title: string;
href: string;
active: boolean;
mobile?: boolean;
clickAction?: () => void;
}) => {
return (
<Link href={href} onClick={clickAction}>
{mobile ? (
<li className="rounded-lg text-foreground hover:bg-muted">
<div className="flex items-center justify-between px-3 py-2 text-sm">
<span>{title}</span>
{active && <Check className="size-4" />}
</div>
</li>
) : (
<li
className={cn(
"-mb-px border-b-2 border-transparent font-medium text-muted-foreground hover:text-foreground",
{
"border-blue-600 text-foreground dark:border-blue-400": active,
},
)}
>
<div className="px-3 pb-3">{title}</div>
</li>
)}
</Link>
);
};

View File

@@ -0,0 +1,23 @@
import { Post } from "@/.contentlayer/generated";
import { BlogCard } from "./blog-card";
export function BlogPosts({
posts,
}: {
posts: (Post & {
blurDataURL: string;
})[];
}) {
return (
<main className="space-y-8">
<BlogCard data={posts[0]} horizontale priority />
<div className="grid gap-8 md:grid-cols-2 md:gap-x-6 md:gap-y-10 xl:grid-cols-3">
{posts.slice(1).map((post, idx) => (
<BlogCard data={post} key={post._id} priority={idx <= 2} />
))}
</div>
</main>
);
}

View File

@@ -0,0 +1,38 @@
import Link from "next/link"
import { cn } from "@/lib/utils"
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
href?: string
disabled?: boolean
}
export function MdxCard({
href,
className,
children,
disabled,
...props
}: CardProps) {
return (
<div
className={cn(
"group relative rounded-lg border p-6 shadow-md transition-shadow hover:shadow-lg",
disabled && "cursor-not-allowed opacity-60",
className
)}
{...props}
>
<div className="flex flex-col justify-between space-y-4">
<div className="space-y-2 [&>h3]:!mt-0 [&>h4]:!mt-0 [&>p]:text-muted-foreground">
{children}
</div>
</div>
{href && (
<Link href={disabled ? "#" : href} className="absolute inset-0">
<span className="sr-only">View</span>
</Link>
)}
</div>
)
}

View File

@@ -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 }) => (
<h1
className={cn(
"mt-2 scroll-m-20 text-4xl font-bold tracking-tight",
className,
)}
{...props}
/>
),
h2: ({ className, ...props }) => (
<h2
className={cn(
"mt-10 scroll-m-20 border-b pb-1 text-2xl font-semibold tracking-tight first:mt-0",
className,
)}
{...props}
/>
),
h3: ({ className, ...props }) => (
<h3
className={cn(
"mt-8 scroll-m-20 text-xl font-semibold tracking-tight",
className,
)}
{...props}
/>
),
h4: ({ className, ...props }) => (
<h4
className={cn(
"mt-8 scroll-m-20 text-lg font-semibold tracking-tight",
className,
)}
{...props}
/>
),
h5: ({ className, ...props }) => (
<h5
className={cn(
"mt-8 scroll-m-20 text-lg font-semibold tracking-tight",
className,
)}
{...props}
/>
),
h6: ({ className, ...props }) => (
<h6
className={cn(
"mt-8 scroll-m-20 text-base font-semibold tracking-tight",
className,
)}
{...props}
/>
),
a: ({ className, ...props }) => (
<a
className={cn("font-medium underline underline-offset-4", className)}
{...props}
/>
),
p: ({ className, ...props }) => (
<p
className={cn("leading-7 [&:not(:first-child)]:mt-6", className)}
{...props}
/>
),
ul: ({ className, ...props }) => (
<ul className={cn("my-6 ml-6 list-disc", className)} {...props} />
),
ol: ({ className, ...props }) => (
<ol className={cn("my-6 ml-6 list-decimal", className)} {...props} />
),
li: ({ className, ...props }) => (
<li className={cn("mt-2", className)} {...props} />
),
blockquote: ({ className, ...props }) => (
<blockquote
className={cn(
"mt-6 border-l-2 pl-6 italic [&>*]:text-muted-foreground",
className,
)}
{...props}
/>
),
img: ({
className,
alt,
...props
}: React.ImgHTMLAttributes<HTMLImageElement>) => (
// eslint-disable-next-line @next/next/no-img-element
<img className={cn("rounded-md border", className)} alt={alt} {...props} />
),
hr: ({ ...props }) => <hr className="my-4 md:my-8" {...props} />,
table: ({ className, ...props }: React.HTMLAttributes<HTMLTableElement>) => (
<div className="my-6 w-full overflow-y-auto">
<table className={cn("w-full", className)} {...props} />
</div>
),
tr: ({ className, ...props }: React.HTMLAttributes<HTMLTableRowElement>) => (
<tr
className={cn("m-0 border-t p-0 even:bg-muted", className)}
{...props}
/>
),
th: ({ className, ...props }) => (
<th
className={cn(
"border px-4 py-2 text-left font-bold [&[align=center]]:text-center [&[align=right]]:text-right",
className,
)}
{...props}
/>
),
td: ({ className, ...props }) => (
<td
className={cn(
"border px-4 py-2 text-left [&[align=center]]:text-center [&[align=right]]:text-right",
className,
)}
{...props}
/>
),
pre: ({
className,
__rawString__,
...props
}: React.HTMLAttributes<HTMLPreElement> & { __rawString__?: string }) => (
<div className="group relative w-full overflow-hidden">
<pre
className={cn(
"max-h-[650px] overflow-x-auto rounded-lg border bg-zinc-900 py-4 dark:bg-zinc-900",
className,
)}
{...props}
/>
{__rawString__ && (
<CopyButton
value={__rawString__}
className={cn(
"absolute right-4 top-4 z-20",
"duration-250 opacity-0 transition-all group-hover:opacity-100",
)}
/>
)}
</div>
),
code: ({ className, ...props }) => (
<code
className={cn(
"relative rounded-md border bg-muted px-[0.4rem] py-1 font-mono text-sm text-foreground",
className,
)}
{...props}
/>
),
Callout,
Card: MdxCard,
Step: ({ className, ...props }: React.ComponentProps<"h3">) => (
<h3
className={cn(
"mt-8 scroll-m-20 font-heading text-xl font-semibold tracking-tight",
className,
)}
{...props}
/>
),
Steps: ({ ...props }) => (
<div
className="[&>h3]:step steps mb-12 ml-4 border-l pl-8 [counter-reset:step]"
{...props}
/>
),
Link: ({ className, ...props }: React.ComponentProps<typeof Link>) => (
<Link
className={cn("font-medium underline underline-offset-4", className)}
{...props}
/>
),
LinkedCard: ({ className, ...props }: React.ComponentProps<typeof Link>) => (
<Link
className={cn(
"flex w-full flex-col items-center rounded-xl border bg-card p-6 text-card-foreground shadow transition-colors hover:bg-muted/50 sm:p-10",
className,
)}
{...props}
/>
),
};
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 (
<div className="mt-5 w-full overflow-hidden rounded-lg border">
<BlurImage
{...props}
blurDataURL={blurDataURL}
className="size-full object-cover object-center"
/>
</div>
);
};
return (
<div className="mdx">
<Component
components={{
...components,
Image: MDXImage,
}}
/>
</div>
);
}

View File

@@ -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 (
<>
<DeleteAccountModal />
<SectionColumns
title="Delete Account"
description="This is a danger zone - Be careful !"
>
<div className="flex flex-col gap-4 rounded-xl border border-red-400 p-4 dark:border-red-900">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className="text-[15px] font-medium">Are you sure ?</span>
{userPaidPlan ? (
<div className="flex items-center gap-1 rounded-md bg-red-600/10 p-1 pr-2 text-xs font-medium text-red-600 dark:bg-red-500/10 dark:text-red-500">
<div className="m-0.5 rounded-full bg-red-600 p-[3px]">
<Icons.close size={10} className="text-background" />
</div>
Active Subscription
</div>
) : null}
</div>
<div className="text-balance text-sm text-muted-foreground">
Permanently delete your {siteConfig.name} account
{userPaidPlan ? " and your subscription" : ""}. This action cannot
be undone - please proceed with caution.
</div>
</div>
<div className="flex items-center gap-2">
<Button
type="submit"
variant="destructive"
onClick={() => setShowDeleteAccountModal(true)}
>
<Icons.trash className="mr-2 size-4" />
<span>Delete Account</span>
</Button>
</div>
</div>
</SectionColumns>
</>
);
}

View File

@@ -0,0 +1,15 @@
import React from "react";
interface SectionColumnsType {
title: string;
children: React.ReactNode;
}
export function FormSectionColumns({ title, children }: SectionColumnsType) {
return (
<div className="grid grid-cols-1 items-center gap-x-12 gap-y-2 py-2">
<h2 className="col-span-4 text-lg font-semibold leading-none">{title}</h2>
<div className="col-span-6">{children}</div>
</div>
);
}

View File

@@ -0,0 +1,21 @@
interface DashboardHeaderProps {
heading: string;
text?: string;
children?: React.ReactNode;
}
export function DashboardHeader({
heading,
text,
children,
}: DashboardHeaderProps) {
return (
<div className="flex items-center justify-between">
<div className="grid gap-1">
<h1 className="font-heading text-2xl font-semibold">{heading}</h1>
{text && <p className="text-base text-muted-foreground">{text}</p>}
</div>
{children}
</div>
);
}

View File

@@ -0,0 +1,23 @@
import { Users } from "lucide-react"
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "@/components/ui/card"
export default function InfoCard() {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Subscriptions</CardTitle>
<Users className="size-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">+2350</div>
<p className="text-xs text-muted-foreground">+180.1% from last month</p>
</CardContent>
</Card>
)
}

View File

@@ -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 <ProjectSwitcherPlaceholder />;
}
return (
<div>
<Popover open={openPopover} onOpenChange={setOpenPopover}>
<PopoverTrigger>
<Button
className="h-8 px-2"
variant={openPopover ? "secondary" : "ghost"}
onClick={() => setOpenPopover(!openPopover)}
>
<div className="flex items-center space-x-3 pr-2">
<div
className={cn(
"size-3 shrink-0 rounded-full",
selected.color,
)}
/>
<div className="flex items-center space-x-3">
<span
className={cn(
"inline-block truncate text-sm font-medium xl:max-w-[120px]",
large ? "w-full" : "max-w-[80px]",
)}
>
{selected.slug}
</span>
</div>
</div>
<ChevronsUpDown
className="size-4 text-muted-foreground"
aria-hidden="true"
/>
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="max-w-60 p-2">
<ProjectList
selected={selected}
projects={projects}
setOpenPopover={setOpenPopover}
/>
</PopoverContent>
</Popover>
</div>
);
}
function ProjectList({
selected,
projects,
setOpenPopover,
}: {
selected: ProjectType;
projects: ProjectType[];
setOpenPopover: (open: boolean) => void;
}) {
return (
<div className="flex flex-col gap-1">
{projects.map(({ slug, color }) => (
<Link
key={slug}
className={cn(
buttonVariants({ variant: "ghost" }),
"relative flex h-9 items-center gap-3 p-3 text-muted-foreground hover:text-foreground",
)}
href="#"
onClick={() => setOpenPopover(false)}
>
<div className={cn("size-3 shrink-0 rounded-full", color)} />
<span
className={`flex-1 truncate text-sm ${
selected.slug === slug
? "font-medium text-foreground"
: "font-normal"
}`}
>
{slug}
</span>
{selected.slug === slug && (
<span className="absolute inset-y-0 right-0 flex items-center pr-3 text-foreground">
<Check size={18} aria-hidden="true" />
</span>
)}
</Link>
))}
<Button
variant="outline"
className="relative flex h-9 items-center justify-center gap-2 p-2"
onClick={() => {
setOpenPopover(false);
}}
>
<Plus size={18} className="absolute left-2.5 top-2" />
<span className="flex-1 truncate text-center">New Project</span>
</Button>
</div>
);
}
function ProjectSwitcherPlaceholder() {
return (
<div className="flex animate-pulse items-center space-x-1.5 rounded-lg px-1.5 py-2 sm:w-60">
<div className="h-8 w-36 animate-pulse rounded-md bg-muted xl:w-[180px]" />
</div>
);
}

View File

@@ -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 (
<>
<Button
variant="outline"
className={cn(
"relative h-9 w-full justify-start rounded-md bg-muted/50 text-sm font-normal text-muted-foreground shadow-none sm:pr-12 md:w-72",
)}
onClick={() => setOpen(true)}
>
<span className="inline-flex">
Search
<span className="hidden sm:inline-flex">&nbsp;documentation</span>...
</span>
<kbd className="pointer-events-none absolute right-[0.3rem] top-[0.45rem] hidden h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-100 sm:flex">
<span className="text-xs"></span>K
</kbd>
</Button>
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder="Type a command or search..." />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
{links.map((section) => (
<CommandGroup key={section.title} heading={section.title}>
{section.items.map((item) => {
const Icon = Icons[item.icon || "arrowRight"];
return (
<CommandItem
key={item.title}
onSelect={() => {
runCommand(() => router.push(item.href as string));
}}
>
<Icon className="mr-2 size-5" />
{item.title}
</CommandItem>
);
})}
</CommandGroup>
))}
</CommandList>
</CommandDialog>
</>
);
}

View File

@@ -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 (
<div className="grid grid-cols-1 gap-x-10 gap-y-4 py-8 md:grid-cols-10">
<div className="col-span-4 space-y-1.5">
<h2 className="text-lg font-semibold leading-none">{title}</h2>
<p className="text-balance text-sm text-muted-foreground">
{description}
</p>
</div>
<div className="col-span-6">{children}</div>
</div>
);
}

View File

@@ -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 (
<Card className="xl:col-span-2">
<CardHeader className="flex flex-row items-center">
<div className="grid gap-2">
<CardTitle>Transactions</CardTitle>
<CardDescription className="text-balance">
Recent transactions from your store.
</CardDescription>
</div>
<Button size="sm" className="ml-auto shrink-0 gap-1 px-4">
<Link href="#" className="flex items-center gap-2">
<span>View All</span>
<ArrowUpRight className="hidden size-4 sm:block" />
</Link>
</Button>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Customer</TableHead>
<TableHead className="hidden xl:table-column">Type</TableHead>
<TableHead className="hidden xl:table-column">Status</TableHead>
<TableHead className="hidden xl:table-column">Date</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>
<div className="font-medium">Liam Johnson</div>
<div className="hidden text-sm text-muted-foreground md:inline">
liam@example.com
</div>
</TableCell>
<TableCell className="hidden xl:table-column">Sale</TableCell>
<TableCell className="hidden xl:table-column">
<Badge className="text-xs" variant="outline">
Approved
</Badge>
</TableCell>
<TableCell className="hidden md:table-cell lg:hidden xl:table-column">
2023-06-23
</TableCell>
<TableCell className="text-right">$250.00</TableCell>
</TableRow>
<TableRow>
<TableCell>
<div className="font-medium">Olivia Smith</div>
<div className="hidden text-sm text-muted-foreground md:inline">
olivia@example.com
</div>
</TableCell>
<TableCell className="hidden xl:table-column">Refund</TableCell>
<TableCell className="hidden xl:table-column">
<Badge className="text-xs" variant="outline">
Declined
</Badge>
</TableCell>
<TableCell className="hidden md:table-cell lg:hidden xl:table-column">
2023-06-24
</TableCell>
<TableCell className="text-right">$150.00</TableCell>
</TableRow>
<TableRow>
<TableCell>
<div className="font-medium">Noah Williams</div>
<div className="hidden text-sm text-muted-foreground md:inline">
noah@example.com
</div>
</TableCell>
<TableCell className="hidden xl:table-column">
Subscription
</TableCell>
<TableCell className="hidden xl:table-column">
<Badge className="text-xs" variant="outline">
Approved
</Badge>
</TableCell>
<TableCell className="hidden md:table-cell lg:hidden xl:table-column">
2023-06-25
</TableCell>
<TableCell className="text-right">$350.00</TableCell>
</TableRow>
<TableRow>
<TableCell>
<div className="font-medium">Emma Brown</div>
<div className="hidden text-sm text-muted-foreground md:inline">
emma@example.com
</div>
</TableCell>
<TableCell className="hidden xl:table-column">Sale</TableCell>
<TableCell className="hidden xl:table-column">
<Badge className="text-xs" variant="outline">
Approved
</Badge>
</TableCell>
<TableCell className="hidden md:table-cell lg:hidden xl:table-column">
2023-06-26
</TableCell>
<TableCell className="text-right">$450.00</TableCell>
</TableRow>
<TableRow>
<TableCell>
<div className="font-medium">Liam Johnson</div>
<div className="hidden text-sm text-muted-foreground md:inline">
liam@example.com
</div>
</TableCell>
<TableCell className="hidden xl:table-column">Sale</TableCell>
<TableCell className="hidden xl:table-column">
<Badge className="text-xs" variant="outline">
Approved
</Badge>
</TableCell>
<TableCell className="hidden md:table-cell lg:hidden xl:table-column">
2023-06-27
</TableCell>
<TableCell className="text-right">$550.00</TableCell>
</TableRow>
</TableBody>
</Table>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,26 @@
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
export function UpgradeCard() {
return (
<Card className="md:max-xl:rounded-none md:max-xl:border-none md:max-xl:shadow-none">
<CardHeader className="md:max-xl:px-4">
<CardTitle>Upgrade to Pro</CardTitle>
<CardDescription>
Unlock all features and get unlimited access to our support team.
</CardDescription>
</CardHeader>
<CardContent className="md:max-xl:px-4">
<Button size="sm" className="w-full">
Upgrade
</Button>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,36 @@
import { cn } from "@/lib/utils";
import { Icons } from "../shared/icons";
interface DocsPageHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
heading: string;
text?: string;
}
export function DocsPageHeader({
heading,
text,
className,
...props
}: DocsPageHeaderProps) {
return (
<>
<div className="mb-4 flex items-center space-x-1 text-sm text-muted-foreground">
<div className="truncate">Docs</div>
<Icons.chevronRight className="size-4" />
<div className="font-medium text-blue-600 dark:text-blue-400">
{heading}
</div>
</div>
<div className={cn("space-y-2", className)} {...props}>
<h1 className="inline-block scroll-m-20 font-heading text-4xl">
{heading}
</h1>
{text && (
<p className="text-balance text-lg text-muted-foreground">{text}</p>
)}
</div>
</>
);
}

64
components/docs/pager.tsx Normal file
View File

@@ -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 (
<div className="flex flex-row items-center justify-between">
{pager?.prev && (
<Link
href={pager.prev.href}
className={cn(buttonVariants({ variant: "outline" }))}
>
<Icons.chevronLeft className="mr-2 size-4" />
{pager.prev.title}
</Link>
)}
{pager?.next && (
<Link
href={pager.next.href}
className={cn(buttonVariants({ variant: "outline" }), "ml-auto")}
>
{pager.next.title}
<Icons.chevronRight className="ml-2 size-4" />
</Link>
)}
</div>
)
}
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)
}, [])
}

View File

@@ -0,0 +1,6 @@
import { docsConfig } from "@/config/docs";
import { SearchCommand } from "@/components/dashboard/search-command";
export function DocsSearch() {
return <SearchCommand links={docsConfig.sidebarNav} />;
}

View File

@@ -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 ? (
<div className="w-full">
{items.map((item) => (
<div key={item.title} className={cn("pb-8")}>
<h4 className="mb-1 rounded-md py-1 text-base font-medium md:px-2 md:text-sm">
{item.title}
</h4>
{item.items ? (
<DocsSidebarNavItems
setOpen={setOpen}
items={item.items}
pathname={pathname}
/>
) : null}
</div>
))}
</div>
) : null;
}
interface DocsSidebarNavItemsProps {
items: NavItem[];
pathname: string | null;
setOpen?: (boolean) => void;
}
export function DocsSidebarNavItems({
items,
setOpen,
pathname,
}: DocsSidebarNavItemsProps) {
return items?.length > 0 ? (
<div className="grid grid-flow-row auto-rows-max text-[15px] md:text-sm">
{items.map((item, index) =>
!item.disabled && item.href ? (
<Link
key={item.title + item.href}
href={item.href}
onClick={() => {
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}
</Link>
) : (
<span
key={item.title + item.href}
className="flex w-full cursor-not-allowed items-center rounded-md px-2 py-1.5 opacity-60"
>
{item.title}
</span>
),
)}
</div>
) : null;
}

View File

@@ -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<User, "id" | "name">;
}
export function AddRecordForm({ user }: AddRecordFormProps) {
const [isPending, startTransition] = useTransition();
const [isShow, setShow] = useState(false);
const {
handleSubmit,
register,
formState: { errors },
} = useForm<FormData>({
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 ? (
<form
className="rounded-lg border border-dashed p-4 shadow-sm animate-in fade-in-50"
onSubmit={onSubmit}
>
<div className="items-center justify-start gap-4 md:flex">
<FormSectionColumns title="Type">
<Select
onValueChange={(value: RecordType) => {}}
name={"type"}
defaultValue="CNAME"
disabled
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
{RECORD_TYPE_ENUMS.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="p-1 text-[13px] text-muted-foreground">
Only supports CNAME.
</p>
</FormSectionColumns>
<FormSectionColumns title="Name">
<div className="flex w-full items-center gap-2">
<Label className="sr-only" htmlFor="name">
Name (required)
</Label>
<Input
id="name"
className="flex-1"
size={32}
{...register("name")}
/>
</div>
<div className="flex flex-col justify-between p-1">
{errors?.name ? (
<p className="pb-0.5 text-[13px] text-red-600">
{errors.name.message}
</p>
) : (
<p className="pb-0.5 text-[13px] text-muted-foreground">
Required. Use @ for root
</p>
)}
</div>
</FormSectionColumns>
<FormSectionColumns title="Target">
<div className="flex w-full items-center gap-2">
<Label className="sr-only" htmlFor="target">
Target
</Label>
<Input
id="content"
className="flex-1"
size={32}
{...register("content")}
/>
</div>
<div className="flex flex-col justify-between p-1">
{errors?.content ? (
<p className="pb-0.5 text-[13px] text-red-600">
{errors.content.message}
</p>
) : (
<p className="pb-0.5 text-[13px] text-muted-foreground">
Required. E.g. www.example.com
</p>
)}
</div>
</FormSectionColumns>
</div>
<div className="flex items-center justify-between gap-4">
<FormSectionColumns title="TTL">
<Select
onValueChange={(value: RecordType) => {}}
name={"ttl"}
defaultValue="1"
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a time" />
</SelectTrigger>
<SelectContent>
{TTL_ENUMS.map((ttl) => (
<SelectItem key={ttl.value} value={ttl.value}>
{ttl.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="p-1 text-[13px] text-muted-foreground">
Optional. Time To Live.
</p>
</FormSectionColumns>
<FormSectionColumns title="Comment">
<div className="flex items-center gap-2">
<Label className="sr-only" htmlFor="comment">
Comment
</Label>
<Input
id="comment"
className="flex-1"
size={100}
{...register("comment")}
/>
</div>
<p className="p-1 text-[13px] text-muted-foreground">
Enter your comment here (up to 100 characters)
</p>
</FormSectionColumns>
{/* <FormSectionColumns title="Proxy">
<div className="flex w-full items-center gap-2">
<Label className="sr-only" htmlFor="proxy">
Proxy
</Label>
<Switch id="proxied" {...register("proxied")} />
</div>
<p className="p-1 text-[13px] text-muted-foreground">Proxy status</p>
</FormSectionColumns> */}
</div>
<div className="flex justify-end gap-3">
<Button
type="reset"
variant={"destructive"}
className="w-[80px] px-0"
onClick={() => setShow(false)}
>
Cancle
</Button>
<Button
type="submit"
variant={"default"}
disabled={isPending}
className="w-[80px] shrink-0 px-0"
>
{isPending ? (
<Icons.spinner className="size-4 animate-spin" />
) : (
<p>Save</p>
)}
</Button>
</div>
</form>
) : (
<Button
className="w-[120px]"
variant="default"
onClick={() => setShow(true)}
>
Add record
</Button>
);
}

View File

@@ -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<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
defaultValues: {
email: "",
},
});
function onSubmit(data: z.infer<typeof FormSchema>) {
form.reset();
toast({
title: "You submitted the following values:",
description: (
<pre className="mt-2 w-[340px] rounded-md bg-slate-950 p-4">
<code className="text-white">{JSON.stringify(data, null, 2)}</code>
</pre>
),
});
}
return (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="w-full space-y-2 sm:max-w-sm"
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Subscribe to our newsletter</FormLabel>
<FormControl>
<Input
type="email"
className="rounded-lg px-4"
placeholder="janedoe@example.com"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" size="sm" rounded="lg" className="px-4">
Subscribe
</Button>
</form>
</Form>
);
}

View File

@@ -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<HTMLDivElement> {
type?: string;
}
type FormData = z.infer<typeof userAuthSchema>;
export function UserAuthForm({ className, type, ...props }: UserAuthFormProps) {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormData>({
resolver: zodResolver(userAuthSchema),
});
const [isLoading, setIsLoading] = React.useState<boolean>(false);
const [isGoogleLoading, setIsGoogleLoading] = React.useState<boolean>(false);
const [isGithubLoading, setIsGithubLoading] = React.useState<boolean>(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 (
<div className={cn("grid gap-6", className)} {...props}>
<form onSubmit={handleSubmit(onSubmit)}>
<div className="grid gap-2">
<div className="grid gap-1">
<Label className="sr-only" htmlFor="email">
Email
</Label>
<Input
id="email"
placeholder="name@example.com"
type="email"
autoCapitalize="none"
autoComplete="email"
autoCorrect="off"
disabled={isLoading || isGoogleLoading}
{...register("email")}
/>
{errors?.email && (
<p className="px-1 text-xs text-red-600">
{errors.email.message}
</p>
)}
</div>
<button className={cn(buttonVariants())} disabled={isLoading}>
{isLoading && (
<Icons.spinner className="mr-2 size-4 animate-spin" />
)}
{type === "register" ? "Sign Up with Email" : "Sign In with Email"}
</button>
</div>
</form>
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">
Or continue with
</span>
</div>
</div>
<button
type="button"
className={cn(buttonVariants({ variant: "outline" }))}
onClick={() => {
setIsGoogleLoading(true);
signIn("google");
}}
disabled={isLoading || isGoogleLoading}
>
{isGoogleLoading ? (
<Icons.spinner className="mr-2 size-4 animate-spin" />
) : (
<Icons.google className="mr-2 size-4" />
)}{" "}
Google
</button>
<button
type="button"
className={cn(buttonVariants({ variant: "outline" }))}
onClick={() => {
setIsGithubLoading(true);
signIn("github");
}}
disabled={isLoading || isGithubLoading}
>
{isGithubLoading ? (
<Icons.spinner className="mr-2 size-4 animate-spin" />
) : (
<Icons.gitHub className="mr-2 size-4" />
)}{" "}
Github
</button>
</div>
);
}

View File

@@ -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<User, "id" | "name">;
}
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<FormData>({
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 (
<form onSubmit={onSubmit}>
<SectionColumns
title="Your Name"
description="Please enter a display name you are comfortable with."
>
<div className="flex w-full items-center gap-2">
<Label className="sr-only" htmlFor="name">
Name
</Label>
<Input
id="name"
className="flex-1"
size={32}
{...register("name")}
onChange={(e) => checkUpdate(e.target.value)}
/>
<Button
type="submit"
variant={updated ? "default" : "disable"}
disabled={isPending || !updated}
className="w-[67px] shrink-0 px-0 sm:w-[130px]"
>
{isPending ? (
<Icons.spinner className="size-4 animate-spin" />
) : (
<p>
Save
<span className="hidden sm:inline-flex">&nbsp;Changes</span>
</p>
)}
</Button>
</div>
<div className="flex flex-col justify-between p-1">
{errors?.name && (
<p className="pb-0.5 text-[13px] text-red-600">
{errors.name.message}
</p>
)}
<p className="text-[13px] text-muted-foreground">Max 32 characters</p>
</div>
</SectionColumns>
</form>
);
}

View File

@@ -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<User, "id" | "role">;
}
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<FormData>({
resolver: zodResolver(userRoleSchema),
values: {
role: role,
},
});
const onSubmit = (data: z.infer<typeof userRoleSchema>) => {
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 (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<SectionColumns
title="Your Role"
description="Select the role what you want for test the app."
>
<div className="flex w-full items-center gap-2">
<FormField
control={form.control}
name="role"
render={({ field }) => (
<FormItem className="w-full space-y-0">
<FormLabel className="sr-only">Role</FormLabel>
<Select
// TODO:(FIX) Option value not update. Use useState for the moment
onValueChange={(value: UserRole) => {
setUpdated(user.role !== value);
setRole(value);
// field.onChange;
}}
name={field.name}
defaultValue={user.role}
>
<FormControl>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a role" />
</SelectTrigger>
</FormControl>
<SelectContent>
{roles.map((role) => (
<SelectItem key={role} value={role.toString()}>
{role}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
variant={updated ? "default" : "disable"}
disabled={isPending || !updated}
className="w-[67px] shrink-0 px-0 sm:w-[130px]"
>
{isPending ? (
<Icons.spinner className="size-4 animate-spin" />
) : (
<p>
Save
<span className="hidden sm:inline-flex">&nbsp;Changes</span>
</p>
)}
</Button>
</div>
<div className="flex flex-col justify-between p-1">
<p className="text-[13px] text-muted-foreground">
Remove this field on real production
</p>
</div>
</SectionColumns>
</form>
</Form>
);
}

View File

@@ -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 (
<TooltipProvider delayDuration={0}>
<div className="sticky top-0 h-full">
<ScrollArea className="h-full overflow-y-auto border-r">
<aside
className={cn(
isSidebarExpanded ? "w-[220px] xl:w-[260px]" : "w-[68px]",
"hidden h-screen md:block",
)}
>
<div className="flex h-full max-h-screen flex-1 flex-col gap-2">
<div className="flex h-14 items-center p-4 lg:h-[60px]">
{isSidebarExpanded ? <ProjectSwitcher /> : null}
<Button
variant="ghost"
size="icon"
className="ml-auto size-9 lg:size-8"
onClick={toggleSidebar}
>
{isSidebarExpanded ? (
<PanelLeftClose
size={18}
className="stroke-muted-foreground"
/>
) : (
<PanelRightClose
size={18}
className="stroke-muted-foreground"
/>
)}
<span className="sr-only">Toggle Sidebar</span>
</Button>
</div>
<nav className="flex flex-1 flex-col gap-8 px-4 pt-4">
{links.map((section) => (
<section
key={section.title}
className="flex flex-col gap-0.5"
>
{isSidebarExpanded ? (
<p className="text-xs text-muted-foreground">
{section.title}
</p>
) : (
<div className="h-4" />
)}
{section.items.map((item) => {
const Icon = Icons[item.icon || "arrowRight"];
return (
item.href && (
<Fragment key={`link-fragment-${item.title}`}>
{isSidebarExpanded ? (
<Link
key={`link-${item.title}`}
href={item.disabled ? "#" : item.href}
className={cn(
"flex items-center gap-3 rounded-md p-2 text-sm font-medium hover:bg-muted",
path === item.href
? "bg-muted"
: "text-muted-foreground hover:text-accent-foreground",
item.disabled &&
"cursor-not-allowed opacity-80 hover:bg-transparent hover:text-muted-foreground",
)}
>
<Icon className="size-5" />
{item.title}
{item.badge && (
<Badge className="ml-auto flex size-5 shrink-0 items-center justify-center rounded-full">
{item.badge}
</Badge>
)}
</Link>
) : (
<Tooltip key={`tooltip-${item.title}`}>
<TooltipTrigger asChild>
<Link
key={`link-tooltip-${item.title}`}
href={item.disabled ? "#" : item.href}
className={cn(
"flex items-center gap-3 rounded-md py-2 text-sm font-medium hover:bg-muted",
path === item.href
? "bg-muted"
: "text-muted-foreground hover:text-accent-foreground",
item.disabled &&
"cursor-not-allowed opacity-80 hover:bg-transparent hover:text-muted-foreground",
)}
>
<span className="flex size-full items-center justify-center">
<Icon className="size-5" />
</span>
</Link>
</TooltipTrigger>
<TooltipContent side="right">
{item.title}
</TooltipContent>
</Tooltip>
)}
</Fragment>
)
);
})}
</section>
))}
</nav>
{/* <div className="mt-auto xl:p-4">
{isSidebarExpanded ? <UpgradeCard /> : null}
</div> */}
</div>
</aside>
</ScrollArea>
</div>
</TooltipProvider>
);
}
export function MobileSheetSidebar({ links }: DashboardSidebarProps) {
const path = usePathname();
const [open, setOpen] = useState(false);
const { isSm, isMobile } = useMediaQuery();
if (isSm || isMobile) {
return (
<Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild>
<Button
variant="outline"
size="icon"
className="size-9 shrink-0 md:hidden"
>
<Menu className="size-5" />
<span className="sr-only">Toggle navigation menu</span>
</Button>
</SheetTrigger>
<SheetContent side="left" className="flex flex-col p-0">
<ScrollArea className="h-full overflow-y-auto">
<div className="flex h-screen flex-col">
<nav className="flex flex-1 flex-col gap-y-8 p-6 text-lg font-medium">
<Link
href="#"
className="flex items-center gap-2 text-lg font-semibold"
>
<Icons.logo className="size-6" />
<span className="font-satoshi text-lg font-bold">
{siteConfig.name}
</span>
</Link>
<ProjectSwitcher large />
{links.map((section) => (
<section
key={section.title}
className="flex flex-col gap-0.5"
>
<p className="text-xs text-muted-foreground">
{section.title}
</p>
{section.items.map((item) => {
const Icon = Icons[item.icon || "arrowRight"];
return (
item.href && (
<Fragment key={`link-fragment-${item.title}`}>
<Link
key={`link-${item.title}`}
onClick={() => {
if (!item.disabled) setOpen(false);
}}
href={item.disabled ? "#" : item.href}
className={cn(
"flex items-center gap-3 rounded-md p-2 text-sm font-medium hover:bg-muted",
path === item.href
? "bg-muted"
: "text-muted-foreground hover:text-accent-foreground",
item.disabled &&
"cursor-not-allowed opacity-80 hover:bg-transparent hover:text-muted-foreground",
)}
>
<Icon className="size-5" />
{item.title}
{item.badge && (
<Badge className="ml-auto flex size-5 shrink-0 items-center justify-center rounded-full">
{item.badge}
</Badge>
)}
</Link>
</Fragment>
)
);
})}
</section>
))}
{/* <div className="mt-auto">
<UpgradeCard />
</div> */}
</nav>
</div>
</ScrollArea>
</SheetContent>
</Sheet>
);
}
return (
<div className="flex size-9 animate-pulse rounded-lg bg-muted md:hidden" />
);
}

View File

@@ -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 (
<>
<button
onClick={() => setOpen(!open)}
className={cn(
"fixed right-2 top-2.5 z-50 rounded-full p-2 transition-colors duration-200 hover:bg-muted focus:outline-none active:bg-muted md:hidden",
open && "hover:bg-muted active:bg-muted",
)}
>
{open ? (
<X className="size-5 text-muted-foreground" />
) : (
<Menu className="size-5 text-muted-foreground" />
)}
</button>
<nav
className={cn(
"fixed inset-0 z-20 hidden w-full overflow-auto bg-background px-5 py-16 lg:hidden",
open && "block",
)}
>
<ul className="grid divide-y divide-muted">
{links &&
links.length > 0 &&
links.map(({ title, href }) => (
<li key={href} className="py-3">
<Link
href={href}
onClick={() => setOpen(false)}
className="flex w-full font-medium capitalize"
>
{title}
</Link>
</li>
))}
{session ? (
<>
{session.user.role === "ADMIN" ? (
<li className="py-3">
<Link
href="/admin"
onClick={() => setOpen(false)}
className="flex w-full font-medium capitalize"
>
Admin
</Link>
</li>
) : null}
<li className="py-3">
<Link
href="/dashboard"
onClick={() => setOpen(false)}
className="flex w-full font-medium capitalize"
>
Dashboard
</Link>
</li>
</>
) : (
<>
<li className="py-3">
<Link
href="/login"
onClick={() => setOpen(false)}
className="flex w-full font-medium capitalize"
>
Sign in
</Link>
</li>
<li className="py-3">
<Link
href="/register"
onClick={() => setOpen(false)}
className="flex w-full font-medium capitalize"
>
Sign up
</Link>
</li>
</>
)}
</ul>
{documentation ? (
<div className="mt-8 block md:hidden">
<DocsSidebarNav setOpen={setOpen} />
</div>
) : null}
<div className="mt-5 flex items-center justify-end space-x-4">
<Link href={siteConfig.links.github} target="_blank" rel="noreferrer">
<Icons.gitHub className="size-6" />
<span className="sr-only">GitHub</span>
</Link>
<ModeToggle />
</div>
</nav>
</>
);
}

View File

@@ -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 (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="size-8 px-0">
<Icons.sun className="rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Icons.moon className="absolute rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
<Icons.sun className="mr-2 size-4" />
<span>Light</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
<Icons.moon className="mr-2 size-4" />
<span>Dark</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
<Icons.laptop className="mr-2 size-4" />
<span>System</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -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 (
<header
className={`sticky top-0 z-40 flex w-full justify-center bg-background/60 backdrop-blur-xl transition-all ${
scroll ? (scrolled ? "border-b" : "bg-transparent") : "border-b"
}`}
>
<MaxWidthWrapper
className="flex h-14 items-center justify-between py-4"
large={documentation}
>
<div className="flex gap-6 md:gap-10">
<Link href="/" className="flex items-center space-x-1.5">
<Icons.logo />
<span className="font-satoshi text-xl font-bold">
{siteConfig.name}
</span>
</Link>
{links && links.length > 0 ? (
<nav className="hidden gap-6 md:flex">
{links.map((item, index) => (
<Link
key={index}
href={item.disabled ? "#" : item.href}
prefetch={true}
className={cn(
"flex items-center text-lg font-medium transition-colors hover:text-foreground/80 sm:text-sm",
item.href.startsWith(`/${selectedLayout}`)
? "text-foreground"
: "text-foreground/60",
item.disabled && "cursor-not-allowed opacity-80",
)}
>
{item.title}
</Link>
))}
</nav>
) : null}
</div>
<div className="flex items-center space-x-3">
{/* right header for docs */}
{documentation ? (
<div className="hidden flex-1 items-center space-x-4 sm:justify-end lg:flex">
<div className="hidden lg:flex lg:grow-0">
<DocsSearch />
</div>
<div className="flex lg:hidden">
<Icons.search className="size-6 text-muted-foreground" />
</div>
<div className="flex space-x-4">
<Link
href={siteConfig.links.github}
target="_blank"
rel="noreferrer"
>
<Icons.gitHub className="size-7" />
<span className="sr-only">GitHub</span>
</Link>
</div>
</div>
) : null}
{session ? (
<Link
href={session.user.role === "ADMIN" ? "/admin" : "/dashboard"}
className="hidden md:block"
>
<Button
className="gap-2 px-4"
variant="default"
size="sm"
rounded="xl"
>
<span>Dashboard</span>
</Button>
</Link>
) : status === "unauthenticated" ? (
<Link href="login">
<Button
className="hidden gap-2 px-4 md:flex"
variant="link"
size="sm"
rounded="lg"
>
<span>Sign in</span>
<Icons.arrowRight className="size-4" />
</Button>
</Link>
) : (
<Skeleton className="hidden h-9 w-24 rounded-xl lg:flex" />
)}
</div>
</MaxWidthWrapper>
</header>
);
}

View File

@@ -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<HTMLElement>) {
return (
<footer className={cn("border-t", className)}>
<div className="container grid max-w-6xl grid-cols-2 gap-6 py-14 md:grid-cols-5">
{footerLinks.map((section) => (
<div key={section.title}>
<span className="text-sm font-medium text-foreground">
{section.title}
</span>
<ul className="mt-4 list-inside space-y-3">
{section.items?.map((link) => (
<li key={link.title}>
<Link
href={link.href}
className="text-sm text-muted-foreground hover:text-primary"
>
{link.title}
</Link>
</li>
))}
</ul>
</div>
))}
<div className="col-span-full flex flex-col items-end sm:col-span-1 md:col-span-2">
<NewsletterForm />
</div>
</div>
<div className="border-t py-4">
<div className="container flex max-w-6xl items-center justify-between">
{/* <span className="text-muted-foreground text-sm">
Copyright &copy; 2024. All rights reserved.
</span> */}
<p className="text-left text-sm text-muted-foreground">
Built by{" "}
<Link
href={siteConfig.links.twitter}
target="_blank"
rel="noreferrer"
className="font-medium underline underline-offset-4"
>
oiov
</Link>
</p>
<div className="flex items-center gap-3">
<Link
href={siteConfig.links.github}
target="_blank"
rel="noreferrer"
className="font-medium underline underline-offset-4"
>
<Icons.gitHub className="size-5" />
</Link>
<ModeToggle />
</div>
</div>
</div>
</footer>
);
}

View File

@@ -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 (
<div className="size-8 animate-pulse rounded-full border bg-muted" />
);
if (isMobile) {
return (
<Drawer.Root open={open} onClose={closeDrawer}>
<Drawer.Trigger onClick={() => setOpen(true)}>
<UserAvatar
user={{ name: user.name || null, image: user.image || null }}
className="size-9 border"
/>
</Drawer.Trigger>
<Drawer.Portal>
<Drawer.Overlay
className="fixed inset-0 z-40 h-full bg-background/80 backdrop-blur-sm"
onClick={closeDrawer}
/>
<Drawer.Content className="fixed inset-x-0 bottom-0 z-50 mt-24 overflow-hidden rounded-t-[10px] border bg-background px-3 text-sm">
<div className="sticky top-0 z-20 flex w-full items-center justify-center bg-inherit">
<div className="my-3 h-1.5 w-16 rounded-full bg-muted-foreground/20" />
</div>
<div className="flex items-center justify-start gap-2 p-2">
<div className="flex flex-col">
{user.name && <p className="font-medium">{user.name}</p>}
{user.email && (
<p className="w-[200px] truncate text-muted-foreground">
{user?.email}
</p>
)}
</div>
</div>
<ul role="list" className="mb-14 mt-1 w-full text-muted-foreground">
{user.role === "ADMIN" ? (
<li className="rounded-lg text-foreground hover:bg-muted">
<Link
href="/admin"
onClick={closeDrawer}
className="flex w-full items-center gap-3 px-2.5 py-2"
>
<Lock className="size-4" />
<p className="text-sm">Admin</p>
</Link>
</li>
) : null}
<li className="rounded-lg text-foreground hover:bg-muted">
<Link
href="/dashboard"
onClick={closeDrawer}
className="flex w-full items-center gap-3 px-2.5 py-2"
>
<LayoutDashboard className="size-4" />
<p className="text-sm">Dashboard</p>
</Link>
</li>
<li className="rounded-lg text-foreground hover:bg-muted">
<Link
href="/dashboard/settings"
onClick={closeDrawer}
className="flex w-full items-center gap-3 px-2.5 py-2"
>
<Settings className="size-4" />
<p className="text-sm">Settings</p>
</Link>
</li>
<li
className="rounded-lg text-foreground hover:bg-muted"
onClick={(event) => {
event.preventDefault();
signOut({
callbackUrl: `${window.location.origin}/`,
});
}}
>
<div className="flex w-full items-center gap-3 px-2.5 py-2">
<LogOut className="size-4" />
<p className="text-sm">Log out </p>
</div>
</li>
</ul>
</Drawer.Content>
<Drawer.Overlay />
</Drawer.Portal>
</Drawer.Root>
);
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger>
<UserAvatar
user={{ name: user.name || null, image: user.image || null }}
className="size-8 border"
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<div className="flex items-center justify-start gap-2 p-2">
<div className="flex flex-col space-y-1 leading-none">
{user.name && <p className="font-medium">{user.name}</p>}
{user.email && (
<p className="w-[200px] truncate text-sm text-muted-foreground">
{user?.email}
</p>
)}
</div>
</div>
<DropdownMenuSeparator />
{user.role === "ADMIN" ? (
<DropdownMenuItem asChild>
<Link href="/admin" className="flex items-center space-x-2.5">
<Lock className="size-4" />
<p className="text-sm">Admin</p>
</Link>
</DropdownMenuItem>
) : null}
<DropdownMenuItem asChild>
<Link href="/dashboard" className="flex items-center space-x-2.5">
<LayoutDashboard className="size-4" />
<p className="text-sm">Dashboard</p>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link
href="/dashboard/settings"
className="flex items-center space-x-2.5"
>
<Settings className="size-4" />
<p className="text-sm">Settings</p>
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer"
onSelect={(event) => {
event.preventDefault();
signOut({
callbackUrl: `${window.location.origin}/`,
});
}}
>
<div className="flex items-center space-x-2.5">
<LogOut className="size-4" />
<p className="text-sm">Log out </p>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -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<SetStateAction<boolean>>;
}) {
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 (
<Modal
showModal={showDeleteAccountModal}
setShowModal={setShowDeleteAccountModal}
className="gap-0"
>
<div className="flex flex-col items-center justify-center space-y-3 border-b p-4 pt-8 sm:px-16">
<UserAvatar
user={{
name: session?.user?.name || null,
image: session?.user?.image || null,
}}
/>
<h3 className="text-lg font-semibold">Delete Account</h3>
<p className="text-center text-sm text-muted-foreground">
<b>Warning:</b> This will permanently delete your account and your
active subscription!
</p>
{/* TODO: Use getUserSubscriptionPlan(session.user.id) to display the user's subscription if he have a paid plan */}
</div>
<form
onSubmit={async (e) => {
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"
>
<div>
<label htmlFor="verification" className="block text-sm">
To verify, type{" "}
<span className="font-semibold text-black dark:text-white">
confirm delete account
</span>{" "}
below
</label>
<Input
type="text"
name="verification"
id="verification"
pattern="confirm delete account"
required
autoFocus={false}
autoComplete="off"
className="mt-1 w-full border bg-background"
/>
</div>
<Button
variant={deleting ? "disable" : "destructive"}
disabled={deleting}
>
Confirm delete account
</Button>
</form>
</Modal>
);
}
export function useDeleteAccountModal() {
const [showDeleteAccountModal, setShowDeleteAccountModal] = useState(false);
const DeleteAccountModalCallback = useCallback(() => {
return (
<DeleteAccountModal
showDeleteAccountModal={showDeleteAccountModal}
setShowDeleteAccountModal={setShowDeleteAccountModal}
/>
);
}, [showDeleteAccountModal, setShowDeleteAccountModal]);
return useMemo(
() => ({
setShowDeleteAccountModal,
DeleteAccountModal: DeleteAccountModalCallback,
}),
[setShowDeleteAccountModal, DeleteAccountModalCallback],
);
}

View File

@@ -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<SetStateAction<boolean>>;
}>({
setShowSignInModal: () => {},
});
export default function ModalProvider({ children }: { children: ReactNode }) {
const { SignInModal, setShowSignInModal } = useSignInModal();
return (
<ModalContext.Provider
value={{
setShowSignInModal,
}}
>
<SignInModal />
{children}
</ModalContext.Provider>
);
}

View File

@@ -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<SetStateAction<boolean>>;
}) {
const [signInClicked, setSignInClicked] = useState(false);
return (
<Modal showModal={showSignInModal} setShowModal={setShowSignInModal}>
<div className="w-full">
<div className="flex flex-col items-center justify-center space-y-3 border-b bg-background px-4 py-6 pt-8 text-center md:px-16">
<a href={siteConfig.url}>
<Icons.logo className="size-10" />
</a>
<h3 className="font-satoshi text-2xl font-black">
Sign In
</h3>
<p className="text-sm text-gray-500">
This is strictly for demo purposes - only your email and profile
picture will be stored.
</p>
</div>
<div className="flex flex-col space-y-4 bg-secondary/50 px-4 py-8 md:px-16">
<Button
variant="default"
disabled={signInClicked}
onClick={() => {
setSignInClicked(true);
signIn("google", { redirect: false }).then(() =>
setTimeout(() => {
setShowSignInModal(false);
}, 400),
);
}}
>
{signInClicked ? (
<Icons.spinner className="mr-2 size-4 animate-spin" />
) : (
<Icons.google className="mr-2 size-4" />
)}{" "}
Sign In with Google
</Button>
</div>
</div>
</Modal>
);
}
export function useSignInModal() {
const [showSignInModal, setShowSignInModal] = useState(false);
const SignInModalCallback = useCallback(() => {
return (
<SignInModal
showSignInModal={showSignInModal}
setShowSignInModal={setShowSignInModal}
/>
);
}, [showSignInModal, setShowSignInModal]);
return useMemo(
() => ({
setShowSignInModal,
SignInModal: SignInModalCallback,
}),
[setShowSignInModal, SignInModalCallback],
);
}

View File

@@ -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 (
<section className="space-y-6 py-12 sm:py-20 lg:py-24">
<div className="container flex max-w-screen-md flex-col items-center gap-5 text-center">
<Link
href="https://next-saas-stripe-starter.vercel.app/"
className={cn(
buttonVariants({ variant: "outline", size: "sm", rounded: "xl" }),
"px-4",
)}
target="_blank"
>
<span className="mr-3">🎉</span> Free Next SaaS Starter Here!
</Link>
<h1 className="text-balance font-satoshi text-[40px] font-black leading-[1.15] tracking-tight sm:text-5xl md:text-6xl md:leading-[1.15]">
Next.js Template with{" "}
<span className="bg-gradient-to-r from-violet-600 via-blue-600 to-cyan-500 bg-clip-text text-transparent">
Auth & User Roles!
</span>
</h1>
<p className="max-w-2xl text-balance text-muted-foreground sm:text-lg">
Minimalist. Sturdy. <b>Open Source</b>. <br /> Focus on your own idea
and... Nothing else!
</p>
<div className="flex justify-center space-x-2">
<Link
href="/docs"
prefetch={true}
className={cn(
buttonVariants({ rounded: "xl", size: "lg" }),
"gap-2 px-5 text-[15px]",
)}
>
<span>Installation Guide</span>
<Icons.arrowRight className="size-4" />
</Link>
<Link
href="https://github.com/mickasmt/next-auth-roles-template"
target="_blank"
rel="noreferrer"
className={cn(
buttonVariants({
variant: "outline",
rounded: "xl",
size: "lg",
}),
"px-4 text-[15px]",
)}
>
<Icons.gitHub className="mr-2 size-4" />
<p>
<span className="hidden sm:inline-block">Star on</span> GitHub
</p>
</Link>
</div>
</div>
</section>
);
}

Some files were not shown because too many files have changed in this diff Show More