init
This commit is contained in:
3
.commitlintrc.json
Normal file
3
.commitlintrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["@commitlint/config-conventional"]
|
||||
}
|
||||
29
.env.example
Normal file
29
.env.example
Normal 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
31
.eslintrc.json
Normal 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
43
.gitignore
vendored
Normal 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
4
.husky/commit-msg
Normal file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npx commitlint --edit $1
|
||||
4
.husky/pre-commit
Normal file
4
.husky/pre-commit
Normal file
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
npx pretty-quick --staged
|
||||
5
.prettierignore
Normal file
5
.prettierignore
Normal file
@@ -0,0 +1,5 @@
|
||||
dist
|
||||
node_modules
|
||||
.next
|
||||
build
|
||||
.contentlayer
|
||||
21
LICENSE.md
Normal file
21
LICENSE.md
Normal 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.
|
||||
38
actions/update-user-name.ts
Normal file
38
actions/update-user-name.ts
Normal 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" }
|
||||
}
|
||||
}
|
||||
40
actions/update-user-role.ts
Normal file
40
actions/update-user-role.ts
Normal 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
18
app/(auth)/layout.tsx
Normal 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
54
app/(auth)/login/page.tsx
Normal 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't have an account? Sign Up
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
app/(auth)/register/page.tsx
Normal file
62
app/(auth)/register/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
89
app/(docs)/docs/[[...slug]]/page.tsx
Normal file
89
app/(docs)/docs/[[...slug]]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
app/(docs)/docs/layout.tsx
Normal file
19
app/(docs)/docs/layout.tsx
Normal 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
21
app/(docs)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
185
app/(marketing)/(blog-post)/blog/[slug]/page.tsx
Normal file
185
app/(marketing)/(blog-post)/blog/[slug]/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
63
app/(marketing)/[slug]/page.tsx
Normal file
63
app/(marketing)/[slug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
app/(marketing)/blog/category/[slug]/page.tsx
Normal file
65
app/(marketing)/blog/category/[slug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
15
app/(marketing)/blog/layout.tsx
Normal file
15
app/(marketing)/blog/layout.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
23
app/(marketing)/blog/page.tsx
Normal file
23
app/(marketing)/blog/page.tsx
Normal 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
23
app/(marketing)/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
app/(marketing)/layout.tsx
Normal file
18
app/(marketing)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
app/(marketing)/not-found.tsx
Normal file
27
app/(marketing)/not-found.tsx
Normal 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
12
app/(marketing)/page.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
18
app/(protected)/admin/layout.tsx
Normal file
18
app/(protected)/admin/layout.tsx
Normal 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}</>;
|
||||
}
|
||||
23
app/(protected)/admin/loading.tsx
Normal file
23
app/(protected)/admin/loading.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
14
app/(protected)/admin/orders/loading.tsx
Normal file
14
app/(protected)/admin/orders/loading.tsx
Normal 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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
34
app/(protected)/admin/orders/page.tsx
Normal file
34
app/(protected)/admin/orders/page.tsx
Normal 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't have any orders yet. Start ordering a product.
|
||||
</EmptyPlaceholder.Description>
|
||||
<Button>Buy Products</Button>
|
||||
</EmptyPlaceholder>
|
||||
</>
|
||||
);
|
||||
}
|
||||
36
app/(protected)/admin/page.tsx
Normal file
36
app/(protected)/admin/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
11
app/(protected)/dashboard/charts/loading.tsx
Normal file
11
app/(protected)/dashboard/charts/loading.tsx
Normal 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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
41
app/(protected)/dashboard/charts/page.tsx
Normal file
41
app/(protected)/dashboard/charts/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
11
app/(protected)/dashboard/loading.tsx
Normal file
11
app/(protected)/dashboard/loading.tsx
Normal 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" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
39
app/(protected)/dashboard/page.tsx
Normal file
39
app/(protected)/dashboard/page.tsx
Normal 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't have any record yet. Start creating record.
|
||||
</EmptyPlaceholder.Description>
|
||||
<Button>Add Record</Button>
|
||||
</EmptyPlaceholder>
|
||||
</>
|
||||
);
|
||||
}
|
||||
18
app/(protected)/dashboard/settings/loading.tsx
Normal file
18
app/(protected)/dashboard/settings/loading.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
33
app/(protected)/dashboard/settings/page.tsx
Normal file
33
app/(protected)/dashboard/settings/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
56
app/(protected)/layout.tsx
Normal file
56
app/(protected)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
app/api/auth/[...nextauth]/route.ts
Normal file
1
app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { GET, POST } from "@/auth"
|
||||
155
app/api/og/route.tsx
Normal file
155
app/api/og/route.tsx
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
39
app/api/record/add/route.ts
Normal file
39
app/api/record/add/route.ts
Normal 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
26
app/api/user/route.ts
Normal 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
53
app/layout.tsx
Normal 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
27
app/not-found.tsx
Normal 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
BIN
app/opengraph-image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 77 KiB |
10
app/robots.ts
Normal file
10
app/robots.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { MetadataRoute } from "next"
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
},
|
||||
}
|
||||
}
|
||||
BIN
assets/fonts/CalSans-SemiBold.ttf
Normal file
BIN
assets/fonts/CalSans-SemiBold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/CalSans-SemiBold.woff2
Normal file
BIN
assets/fonts/CalSans-SemiBold.woff2
Normal file
Binary file not shown.
BIN
assets/fonts/Inter-Bold.ttf
Normal file
BIN
assets/fonts/Inter-Bold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/Inter-Regular.ttf
Normal file
BIN
assets/fonts/Inter-Regular.ttf
Normal file
Binary file not shown.
20
assets/fonts/index.ts
Normal file
20
assets/fonts/index.ts
Normal 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",
|
||||
});
|
||||
BIN
assets/fonts/satoshi-variable.woff2
Normal file
BIN
assets/fonts/satoshi-variable.woff2
Normal file
Binary file not shown.
28
auth.config.ts
Normal file
28
auth.config.ts
Normal 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
67
auth.ts
Normal 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
17
components.json
Normal 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
7
components/analytics.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
"use client"
|
||||
|
||||
import { Analytics as VercelAnalytics } from "@vercel/analytics/react"
|
||||
|
||||
export function Analytics() {
|
||||
return <VercelAnalytics />
|
||||
}
|
||||
101
components/charts/area-chart-stacked.tsx
Normal file
101
components/charts/area-chart-stacked.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
101
components/charts/bar-chart-mixed.tsx
Normal file
101
components/charts/bar-chart-mixed.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
219
components/charts/interactive-bar-chart.tsx
Normal file
219
components/charts/interactive-bar-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
93
components/charts/line-chart-multiple.tsx
Normal file
93
components/charts/line-chart-multiple.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
73
components/charts/radar-chart-simple.tsx
Normal file
73
components/charts/radar-chart-simple.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
components/charts/radial-chart-grid.tsx
Normal file
86
components/charts/radial-chart-grid.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
106
components/charts/radial-shape-chart.tsx
Normal file
106
components/charts/radial-shape-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
components/charts/radial-stacked-chart.tsx
Normal file
111
components/charts/radial-stacked-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
components/charts/radial-text-chart.tsx
Normal file
107
components/charts/radial-text-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
components/content/author.tsx
Normal file
45
components/content/author.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
components/content/blog-card.tsx
Normal file
85
components/content/blog-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
140
components/content/blog-header-layout.tsx
Normal file
140
components/content/blog-header-layout.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
23
components/content/blog-posts.tsx
Normal file
23
components/content/blog-posts.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
components/content/mdx-card.tsx
Normal file
38
components/content/mdx-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
236
components/content/mdx-components.tsx
Normal file
236
components/content/mdx-components.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
56
components/dashboard/delete-account.tsx
Normal file
56
components/dashboard/delete-account.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
15
components/dashboard/form-section-columns.tsx
Normal file
15
components/dashboard/form-section-columns.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
components/dashboard/header.tsx
Normal file
21
components/dashboard/header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
components/dashboard/info-card.tsx
Normal file
23
components/dashboard/info-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
151
components/dashboard/project-switcher.tsx
Normal file
151
components/dashboard/project-switcher.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
components/dashboard/search-command.tsx
Normal file
83
components/dashboard/search-command.tsx
Normal 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"> 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
25
components/dashboard/section-columns.tsx
Normal file
25
components/dashboard/section-columns.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
148
components/dashboard/transactions-list.tsx
Normal file
148
components/dashboard/transactions-list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
components/dashboard/upgrade-card.tsx
Normal file
26
components/dashboard/upgrade-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
components/docs/page-header.tsx
Normal file
36
components/docs/page-header.tsx
Normal 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
64
components/docs/pager.tsx
Normal 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)
|
||||
}, [])
|
||||
}
|
||||
6
components/docs/search.tsx
Normal file
6
components/docs/search.tsx
Normal 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} />;
|
||||
}
|
||||
82
components/docs/sidebar-nav.tsx
Normal file
82
components/docs/sidebar-nav.tsx
Normal 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;
|
||||
}
|
||||
234
components/forms/add-record-form.tsx
Normal file
234
components/forms/add-record-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
components/forms/newsletter-form.tsx
Normal file
75
components/forms/newsletter-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
136
components/forms/user-auth-form.tsx
Normal file
136
components/forms/user-auth-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
components/forms/user-name-form.tsx
Normal file
103
components/forms/user-name-form.tsx
Normal 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"> 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>
|
||||
);
|
||||
}
|
||||
134
components/forms/user-role-form.tsx
Normal file
134
components/forms/user-role-form.tsx
Normal 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"> 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>
|
||||
);
|
||||
}
|
||||
273
components/layout/dashboard-sidebar.tsx
Normal file
273
components/layout/dashboard-sidebar.tsx
Normal 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" />
|
||||
);
|
||||
}
|
||||
142
components/layout/mobile-nav.tsx
Normal file
142
components/layout/mobile-nav.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
43
components/layout/mode-toggle.tsx
Normal file
43
components/layout/mode-toggle.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
136
components/layout/navbar.tsx
Normal file
136
components/layout/navbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
components/layout/site-footer.tsx
Normal file
71
components/layout/site-footer.tsx
Normal 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 © 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>
|
||||
);
|
||||
}
|
||||
187
components/layout/user-account-nav.tsx
Normal file
187
components/layout/user-account-nav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
135
components/modals/delete-account-modal.tsx
Normal file
135
components/modals/delete-account-modal.tsx
Normal 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],
|
||||
);
|
||||
}
|
||||
26
components/modals/providers.tsx
Normal file
26
components/modals/providers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
85
components/modals/sign-in-modal.tsx
Normal file
85
components/modals/sign-in-modal.tsx
Normal 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],
|
||||
);
|
||||
}
|
||||
69
components/sections/hero-landing.tsx
Normal file
69
components/sections/hero-landing.tsx
Normal 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
Reference in New Issue
Block a user