├── .env.sample
├── .eslintrc.json
├── .gitignore
├── README.md
├── app
├── (home)
│ ├── (default)
│ │ ├── loading.tsx
│ │ └── page.tsx
│ ├── blog
│ │ └── [id]
│ │ │ ├── components
│ │ │ ├── Content.tsx
│ │ │ └── Skeleton.tsx
│ │ │ └── page.tsx
│ ├── error.tsx
│ └── layout.tsx
├── api
│ ├── blog
│ │ └── route.ts
│ └── stripe
│ │ └── webhook
│ │ └── route.ts
├── auth
│ ├── callback
│ │ └── route.ts
│ └── error
│ │ └── page.tsx
├── dashboard
│ ├── blog
│ │ ├── components
│ │ │ ├── BlogForm.tsx
│ │ │ ├── BlogNav.tsx
│ │ │ ├── BlogTable.tsx
│ │ │ ├── DeleteAlert.tsx
│ │ │ └── SwitchForm.tsx
│ │ ├── create
│ │ │ └── page.tsx
│ │ ├── edit
│ │ │ └── [id]
│ │ │ │ ├── components
│ │ │ │ └── EditForm.tsx
│ │ │ │ └── page.tsx
│ │ └── schema
│ │ │ └── index.ts
│ ├── components
│ │ └── NavLinks.tsx
│ ├── layout.tsx
│ ├── loading.tsx
│ ├── page.tsx
│ └── user
│ │ └── page.tsx
├── favicon.ico
├── globals.css
└── layout.tsx
├── components.json
├── components
├── Footer.tsx
├── SessisonProvider.tsx
├── markdown
│ ├── CopyButton.tsx
│ └── MarkdownPreview.tsx
├── nav
│ ├── HoverUnderLine.tsx
│ ├── LoginForm.tsx
│ ├── Navbar.tsx
│ └── Profile.tsx
├── stripe
│ ├── Checkout.tsx
│ └── ManageBill.tsx
├── theme-provider.tsx
└── ui
│ ├── alert-dialog.tsx
│ ├── button.tsx
│ ├── form.tsx
│ ├── input.tsx
│ ├── label.tsx
│ ├── popover.tsx
│ ├── switch.tsx
│ ├── textarea.tsx
│ ├── toast.tsx
│ ├── toaster.tsx
│ └── use-toast.ts
├── lib
├── actions
│ ├── blog.ts
│ ├── stripe.ts
│ └── user.ts
├── data.ts
├── icon
│ └── index.ts
├── store
│ └── user.ts
├── supabase
│ └── index.ts
├── types
│ ├── index.ts
│ └── supabase.ts
└── utils.ts
├── middleware.ts
├── next.config.js
├── package-lock.json
├── package.json
├── postcss.config.js
├── public
├── next.svg
├── og-dashboard.png
├── og.png
├── profile.png
└── vercel.svg
├── tailwind.config.js
└── tsconfig.json
/.env.sample:
--------------------------------------------------------------------------------
1 | # supabase
2 | NEXT_PUBLIC_SUPABASE_URL=
3 | NEXT_PUBLIC_SUPABASE_ANON_KEY=
4 | SERVICE_ROLE=
5 |
6 |
7 | # stripe
8 | NEXT_PUBLIC_STRIPE_PUBLIC_KEY=pk_test_
9 | STRIPE_SK_KEY=sk_test_
10 | STRIPE_ENDPOINT_SECRET=whsec
11 | PRO_PRCIE_ID=price_
12 |
13 |
14 |
15 | SITE_URL="http://localhost:3000"
16 |
17 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
Full stack blog sass 👋
2 |
3 |
4 | ## Support
5 |
6 | please give like to my video and subscript to my channel 🙏
7 | link here 👉 https://youtube.com/@DailyWebCoding?si=7vd8IWx53e6azYPB
8 |
9 | ## Getting Started
10 |
11 | First, run the development server:
12 |
13 | ```bash
14 | npm i
15 | ```
16 |
17 | ```bash
18 | npm run dev
19 | ```
20 |
21 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
22 |
--------------------------------------------------------------------------------
/app/(home)/(default)/loading.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export default function loading() {
4 | return (
5 |
6 | {[1, 2, 3, 4, 5]?.map((_, index) => {
7 | return (
8 |
18 | );
19 | })}
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/app/(home)/(default)/page.tsx:
--------------------------------------------------------------------------------
1 | import Link from "next/link";
2 | import React from "react";
3 | import Image from "next/image";
4 | import { readBlog } from "@/lib/actions/blog";
5 |
6 | export default async function Home() {
7 | let { data: blogs } = await readBlog();
8 |
9 | if (!blogs?.length) {
10 | blogs = [];
11 | }
12 |
13 | return (
14 |
15 | {blogs.map((blog, index) => {
16 | return (
17 |
22 |
23 |
31 |
32 |
33 |
34 | {new Date(blog.created_at).toDateString()}
35 |
36 |
37 |
38 | {blog.title}
39 |
40 |
41 |
42 | );
43 | })}
44 |
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/app/(home)/blog/[id]/components/Content.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import MarkdownPreview from "@/components/markdown/MarkdownPreview";
3 | import { Database } from "@/lib/types/supabase";
4 | import { createBrowserClient } from "@supabase/ssr";
5 | import React, { useEffect, useState, useTransition } from "react";
6 | import { BlogContentLoading } from "./Skeleton";
7 | import Checkout from "@/components/stripe/Checkout";
8 |
9 | export default function Content({ blogId }: { blogId: string }) {
10 | const [loading, setLoading] = useState(true);
11 |
12 | const [blog, setBlog] = useState<{
13 | blog_id: string;
14 | content: string;
15 | created_at: string;
16 | } | null>();
17 |
18 | const supabase = createBrowserClient(
19 | process.env.NEXT_PUBLIC_SUPABASE_URL!,
20 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
21 | );
22 |
23 | const readBlogContent = async () => {
24 | const { data } = await supabase
25 | .from("blog_content")
26 | .select("*")
27 | .eq("blog_id", blogId)
28 | .single();
29 | setBlog(data);
30 | setLoading(false);
31 | };
32 |
33 | useEffect(() => {
34 | readBlogContent();
35 |
36 | // eslint-disable-next-line
37 | }, []);
38 |
39 | if (loading) {
40 | return ;
41 | }
42 |
43 | if (!blog?.content) {
44 | return ;
45 | }
46 |
47 | return ;
48 | }
49 |
--------------------------------------------------------------------------------
/app/(home)/blog/[id]/components/Skeleton.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export function BlogContentLoading() {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/app/(home)/blog/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { IBlog } from "@/lib/types";
3 | import Image from "next/image";
4 | import Content from "./components/Content";
5 |
6 | export async function generateStaticParams() {
7 | const { data: blogs } = await fetch(
8 | process.env.SITE_URL + "/api/blog?id=*"
9 | ).then((res) => res.json());
10 |
11 | return blogs;
12 | }
13 |
14 | export async function generateMetadata({ params }: { params: { id: string } }) {
15 | const { data: blog } = (await fetch(
16 | process.env.SITE_URL + "/api/blog?id=" + params.id
17 | ).then((res) => res.json())) as { data: IBlog };
18 |
19 | return {
20 | title: blog?.title,
21 | authors: {
22 | name: "chensokheng",
23 | },
24 | openGraph: {
25 | title: blog?.title,
26 | url: "https://dailyblog-demo.vercel.app/blog" + params.id,
27 | siteName: "Daily Blog",
28 | images: blog?.image_url,
29 | type: "website",
30 | },
31 | keywords: ["daily web coding", "chensokheng", "dailywebcoding"],
32 | };
33 | }
34 |
35 | export default async function page({ params }: { params: { id: string } }) {
36 | const { data: blog } = (await fetch(
37 | process.env.SITE_URL + "/api/blog?id=" + params.id
38 | ).then((res) => res.json())) as { data: IBlog };
39 |
40 | if (!blog?.id) {
41 | return Not found ;
42 | }
43 |
44 | return (
45 |
46 |
47 |
48 | {blog?.title}
49 |
50 |
51 | {new Date(blog?.created_at!).toDateString()}
52 |
53 |
54 |
55 |
56 |
64 |
65 |
66 |
67 | );
68 | }
69 |
--------------------------------------------------------------------------------
/app/(home)/error.tsx:
--------------------------------------------------------------------------------
1 | "use client"; // Error components must be Client Components
2 |
3 | import { useEffect } from "react";
4 |
5 | export default function Error({
6 | error,
7 | reset,
8 | }: {
9 | error: Error & { digest?: string };
10 | reset: () => void;
11 | }) {
12 | useEffect(() => {
13 | // Log the error to an error reporting service
14 | console.error(error);
15 | }, [error]);
16 |
17 | return (
18 |
19 |
Something went wrong!
20 | reset()
24 | }
25 | >
26 | Try again
27 |
28 |
29 | );
30 | }
31 |
--------------------------------------------------------------------------------
/app/(home)/layout.tsx:
--------------------------------------------------------------------------------
1 | import React, { ReactNode } from "react";
2 | import Footer from "../../components/Footer";
3 |
4 | export default function Layout({ children }: { children: ReactNode }) {
5 | return (
6 | <>
7 | {children}
8 |
9 | >
10 | );
11 | }
12 |
--------------------------------------------------------------------------------
/app/api/blog/route.ts:
--------------------------------------------------------------------------------
1 | import { Database } from "@/lib/types/supabase";
2 | import { createClient } from "@supabase/supabase-js";
3 |
4 | export async function GET(request: Request) {
5 | const supabase = await createClient(
6 | process.env.NEXT_PUBLIC_SUPABASE_URL!,
7 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
8 | );
9 |
10 | const { searchParams } = new URL(request.url);
11 |
12 | const id = searchParams.get("id");
13 |
14 | if (id === "*") {
15 | const result = await supabase.from("blog").select("id").limit(10);
16 | return Response.json({ ...result });
17 | } else if (id) {
18 | const result = await supabase
19 | .from("blog")
20 | .select("*")
21 | .eq("id", id)
22 | .single();
23 | return Response.json({ ...result });
24 | }
25 | return Response.json({});
26 | }
27 |
--------------------------------------------------------------------------------
/app/api/stripe/webhook/route.ts:
--------------------------------------------------------------------------------
1 | import Stripe from "stripe";
2 | import { headers } from "next/headers";
3 | import { createSupbaseAdmin } from "@/lib/supabase";
4 | import { buffer } from "node:stream/consumers";
5 |
6 | const endpointSecret = process.env.STRIPE_ENDPOINT_SECRET!;
7 |
8 | const stripe = new Stripe(process.env.STRIPE_SK_KEY!);
9 |
10 | export async function POST(req: any) {
11 | const rawBody = await buffer(req.body);
12 | try {
13 | const sig = headers().get("stripe-signature");
14 | let event;
15 | try {
16 | event = stripe.webhooks.constructEvent(
17 | rawBody,
18 | sig!,
19 | endpointSecret
20 | );
21 | } catch (err: any) {
22 | return Response.json({ error: `Webhook Error ${err?.message!} ` });
23 | }
24 | switch (event.type) {
25 | case "customer.subscription.deleted":
26 | const deleteSubscription = event.data.object;
27 | await onCacnelSubscription(
28 | deleteSubscription.status === "active",
29 | deleteSubscription.id
30 | );
31 | break;
32 | case "customer.updated":
33 | const customer = event.data.object;
34 | const subscription = await stripe.subscriptions.list({
35 | customer: customer.id,
36 | });
37 | if (subscription.data.length) {
38 | const sub = subscription.data[0];
39 | await onSuccessSubscription(
40 | sub.id,
41 | customer.id,
42 | sub.status === "active",
43 | customer.email!
44 | );
45 | }
46 | default:
47 | console.log(`Unhandled event type ${event.type}`);
48 | }
49 | return Response.json({});
50 | } catch (e) {
51 | return Response.json({ error: `Webhook Error}` });
52 | }
53 | }
54 |
55 | const onSuccessSubscription = async (
56 | subscription_id: string,
57 | customer_id: string,
58 | status: boolean,
59 | email: string
60 | ) => {
61 | const supabase = await createSupbaseAdmin();
62 | const { data } = await supabase
63 | .from("users")
64 | .update({
65 | stripe_subscriptoin_id: subscription_id,
66 | stripe_customer_id: customer_id,
67 | subscription_status: status,
68 | })
69 | .eq("email", email)
70 | .select("id")
71 | .single();
72 | await supabase.auth.admin.updateUserById(data?.id!, {
73 | user_metadata: { stripe_customer_id: null },
74 | });
75 | };
76 |
77 | const onCacnelSubscription = async (
78 | status: boolean,
79 | subscription_id: string
80 | ) => {
81 | const supabase = await createSupbaseAdmin();
82 | const { data, error } = await supabase
83 | .from("users")
84 | .update({
85 | stripe_subscriptoin_id: null,
86 | stripe_customer_id: null,
87 | subscription_status: status,
88 | })
89 | .eq("stripe_subscriptoin_id", subscription_id)
90 | .select("id")
91 | .single();
92 |
93 | await supabase.auth.admin.updateUserById(data?.id!, {
94 | user_metadata: { stripe_customer_id: null },
95 | });
96 | };
97 |
--------------------------------------------------------------------------------
/app/auth/callback/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from "next/server";
2 | import { cookies } from "next/headers";
3 | import { createServerClient, type CookieOptions } from "@supabase/ssr";
4 | import { Database } from "@/lib/types/supabase";
5 | export async function GET(request: Request) {
6 | const requestUrl = new URL(request.url);
7 | const isAuth = cookies().get("supabase-auth-token");
8 |
9 | if (isAuth) {
10 | return NextResponse.redirect(requestUrl.origin);
11 | }
12 |
13 | const { searchParams } = new URL(request.url);
14 | const code = searchParams.get("code");
15 | const next = searchParams.get("next") ?? "/";
16 |
17 | if (code) {
18 | const cookieStore = cookies();
19 | const supabase = createServerClient(
20 | process.env.NEXT_PUBLIC_SUPABASE_URL!,
21 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
22 | {
23 | cookies: {
24 | get(name: string) {
25 | return cookieStore.get(name)?.value;
26 | },
27 | set(name: string, value: string, options: CookieOptions) {
28 | cookieStore.set({ name, value, ...options });
29 | },
30 | remove(name: string, options: CookieOptions) {
31 | cookieStore.set({ name, value: "", ...options });
32 | },
33 | },
34 | }
35 | );
36 |
37 | const { error } = await supabase.auth.exchangeCodeForSession(code);
38 |
39 | if (!error) {
40 | return NextResponse.redirect(requestUrl.origin + next);
41 | }
42 | } else {
43 | console.log("no code?");
44 | }
45 |
46 | // return the user to an error page with instructions
47 | return NextResponse.redirect(requestUrl.origin + "/auth/error");
48 | }
49 |
--------------------------------------------------------------------------------
/app/auth/error/page.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export default function page() {
4 | return page
;
5 | }
6 |
--------------------------------------------------------------------------------
/app/dashboard/blog/components/BlogForm.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { zodResolver } from "@hookform/resolvers/zod";
4 | import { useForm } from "react-hook-form";
5 | import * as z from "zod";
6 | import {
7 | Form,
8 | FormControl,
9 | FormDescription,
10 | FormField,
11 | FormItem,
12 | FormLabel,
13 | FormMessage,
14 | } from "@/components/ui/form";
15 | import { Input } from "@/components/ui/input";
16 | import { Textarea } from "@/components/ui/textarea";
17 | import MarkdownPreview from "@/components/markdown/MarkdownPreview";
18 | import Image from "next/image";
19 | import { cn } from "@/lib/utils";
20 | import {
21 | EyeOpenIcon,
22 | Pencil1Icon,
23 | RocketIcon,
24 | StarIcon,
25 | } from "@radix-ui/react-icons";
26 | import { ReactNode, useState, useTransition } from "react";
27 | import { IBlogDetial, IBlogForm } from "@/lib/types";
28 | import { Switch } from "@/components/ui/switch";
29 | import { BsSave } from "react-icons/bs";
30 | import { BlogFormSchema, BlogFormSchemaType } from "../schema";
31 |
32 | export default function BlogForm({
33 | onHandleSubmit,
34 | defaultBlog,
35 | }: {
36 | defaultBlog: IBlogDetial;
37 | onHandleSubmit: (data: BlogFormSchemaType) => void;
38 | }) {
39 | const [isPending, startTransition] = useTransition();
40 | const [isPreview, setPreivew] = useState(false);
41 |
42 | const form = useForm>({
43 | mode: "all",
44 | resolver: zodResolver(BlogFormSchema),
45 | defaultValues: {
46 | title: defaultBlog?.title,
47 | content: defaultBlog?.blog_content.content,
48 | image_url: defaultBlog?.image_url,
49 | is_premium: defaultBlog?.is_premium,
50 | is_published: defaultBlog?.is_published,
51 | },
52 | });
53 |
54 | const onSubmit = (data: z.infer) => {
55 | startTransition(() => {
56 | onHandleSubmit(data);
57 | });
58 | };
59 |
60 | return (
61 |
310 |
311 | );
312 | }
313 |
314 | const ImgaeEror = ({ src }: { src: string }) => {
315 | try {
316 | return ;
317 | } catch {
318 | return Invalid ;
319 | }
320 | };
321 |
--------------------------------------------------------------------------------
/app/dashboard/blog/components/BlogNav.tsx:
--------------------------------------------------------------------------------
1 | import HoverUnderLine from "@/components/nav/HoverUnderLine";
2 | import Link from "next/link";
3 | import React from "react";
4 |
5 | export default function BlogNav({ path }: { path: string }) {
6 | return (
7 |
8 |
9 |
10 |
11 | / dashboard
12 |
13 |
14 |
15 |
16 | {path}
17 |
18 |
19 | );
20 | }
21 |
--------------------------------------------------------------------------------
/app/dashboard/blog/components/BlogTable.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { EyeOpenIcon, Pencil1Icon, TrashIcon } from "@radix-ui/react-icons";
3 | import { Button } from "@/components/ui/button";
4 | import Link from "next/link";
5 |
6 | import { IBlog } from "@/lib/types";
7 | import SwitchForm from "./SwitchForm";
8 | import DeleteAlert from "./DeleteAlert";
9 | import { readBlogAdmin, updateBlogById } from "@/lib/actions/blog";
10 |
11 | export default async function BlogTable() {
12 | const { data: blogs } = await readBlogAdmin();
13 |
14 | return (
15 | <>
16 |
17 |
18 |
19 |
Title
20 | Premium
21 | Publish
22 |
23 |
24 | {blogs?.map((blog, index) => {
25 | const updatePremium = updateBlogById.bind(
26 | null,
27 | blog.id,
28 | {
29 | is_premium: !blog.is_premium,
30 | } as IBlog
31 | );
32 |
33 | const updatePulished = updateBlogById.bind(
34 | null,
35 | blog.id,
36 | {
37 | is_published: !blog.is_published,
38 | } as IBlog
39 | );
40 |
41 | return (
42 |
43 |
44 | {blog.title}
45 |
46 |
51 |
52 |
57 |
58 |
59 |
60 | );
61 | })}
62 |
63 |
64 |
65 | >
66 | );
67 | }
68 |
69 | const Actions = ({ id }: { id: string }) => {
70 | return (
71 |
72 | {/* TODO: change to id */}
73 |
74 |
75 |
76 | View
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 | Edit
85 |
86 |
87 |
88 | );
89 | };
90 |
--------------------------------------------------------------------------------
/app/dashboard/blog/components/DeleteAlert.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import {
3 | AlertDialog,
4 | AlertDialogCancel,
5 | AlertDialogContent,
6 | AlertDialogDescription,
7 | AlertDialogFooter,
8 | AlertDialogHeader,
9 | AlertDialogTitle,
10 | AlertDialogTrigger,
11 | } from "@/components/ui/alert-dialog";
12 | import { Button } from "@/components/ui/button";
13 | import { TrashIcon } from "@radix-ui/react-icons";
14 | import { deleteBlogById } from "../../../../lib/actions/blog";
15 | import { ChangeEvent, useTransition } from "react";
16 | import { PostgrestSingleResponse } from "@supabase/supabase-js";
17 | import { toast } from "@/components/ui/use-toast";
18 | import { AiOutlineLoading3Quarters } from "react-icons/ai";
19 | import { cn } from "@/lib/utils";
20 | export default function DeleteAlert({ id }: { id: string }) {
21 | const [isPending, startTransition] = useTransition();
22 |
23 | const onSubmit = (e: ChangeEvent) => {
24 | e.preventDefault();
25 | startTransition(async () => {
26 | const { error } = JSON.parse(
27 | await deleteBlogById(id)
28 | ) as PostgrestSingleResponse;
29 | if (error) {
30 | toast({
31 | title: "Fail to update ",
32 | description: (
33 |
34 | {error?.message}
35 |
36 | ),
37 | });
38 | } else {
39 | toast({
40 | title: "Successfully delete 🎉",
41 | });
42 | }
43 | });
44 | };
45 |
46 | return (
47 |
48 |
49 |
50 |
51 | Delete
52 |
53 |
54 |
55 |
56 |
57 | Are you absolutely sure?
58 |
59 |
60 | This action cannot be undone. This will permanently
61 | delete your blog and remove your data from our servers.
62 |
63 |
64 |
65 | Cancel
66 |
67 |
68 | {" "}
73 | Continue
74 |
75 |
76 |
77 |
78 |
79 | );
80 | }
81 |
--------------------------------------------------------------------------------
/app/dashboard/blog/components/SwitchForm.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { Switch } from "@/components/ui/switch";
3 | import { toast } from "@/components/ui/use-toast";
4 | import { ChangeEvent } from "react";
5 |
6 | export default function SwitchForm({
7 | checked,
8 | onSubmit,
9 | name,
10 | }: {
11 | checked: boolean;
12 | onSubmit: () => Promise;
13 | name: string;
14 | }) {
15 | const handleonSubmit = async (e: ChangeEvent) => {
16 | e.preventDefault();
17 | const { error } = JSON.parse(await onSubmit());
18 | if (!error) {
19 | toast({
20 | title: `Successfully update ${name} 🎉`,
21 | });
22 | }
23 | };
24 | return (
25 |
26 |
27 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/app/dashboard/blog/create/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import React from "react";
3 |
4 | import { toast } from "@/components/ui/use-toast";
5 | import { defaultCreateBlog } from "@/lib/data";
6 | import { PostgrestSingleResponse } from "@supabase/supabase-js";
7 | import BlogForm from "../components/BlogForm";
8 | import { createBlog } from "../../../../lib/actions/blog";
9 | import { BlogFormSchemaType } from "../schema";
10 | import { useRouter } from "next/navigation";
11 |
12 | export default function CreateForm() {
13 | const router = useRouter();
14 |
15 | const onHandleSubmit = async (data: BlogFormSchemaType) => {
16 | const result = JSON.parse(await createBlog(data));
17 |
18 | const { error } = result as PostgrestSingleResponse;
19 | if (error?.message) {
20 | toast({
21 | title: "Fail to create a post 😢",
22 | description: (
23 |
24 | {error.message}
25 |
26 | ),
27 | });
28 | } else {
29 | toast({
30 | title: "Successfully create a post 🎉",
31 | description: data.title,
32 | });
33 | router.push("/dashboard");
34 | }
35 | };
36 |
37 | return (
38 |
42 | );
43 | }
44 |
--------------------------------------------------------------------------------
/app/dashboard/blog/edit/[id]/components/EditForm.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import React from "react";
3 |
4 | import { toast } from "@/components/ui/use-toast";
5 |
6 | import BlogForm from "../../../components/BlogForm";
7 | import { IBlogDetial } from "@/lib/types";
8 | import { BlogFormSchemaType } from "../../../schema";
9 | import { updateBlogDetail } from "../../../../../../lib/actions/blog";
10 | import { PostgrestSingleResponse } from "@supabase/supabase-js";
11 | import { redirect, useRouter } from "next/navigation";
12 |
13 | export default function EditForm({ blog }: { blog: IBlogDetial }) {
14 | const router = useRouter();
15 |
16 | const onHandleSubmit = async (data: BlogFormSchemaType) => {
17 | const result = JSON.parse(
18 | await updateBlogDetail(blog?.id!, data)
19 | ) as PostgrestSingleResponse;
20 | if (result.error) {
21 | toast({
22 | title: "Fail to update ",
23 | description: (
24 |
25 |
26 | {result.error?.message}
27 |
28 |
29 | ),
30 | });
31 | } else {
32 | toast({
33 | title: "Successfully update 🎉",
34 | });
35 | router.push("/dashboard");
36 | }
37 | };
38 |
39 | return ;
40 | }
41 |
--------------------------------------------------------------------------------
/app/dashboard/blog/edit/[id]/page.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import EditForm from "./components/EditForm";
3 | import { IBlogDetial } from "@/lib/types";
4 | import { readBlogDeatailById } from "@/lib/actions/blog";
5 |
6 | export default async function Edit({ params }: { params: { id: string } }) {
7 | const { data: blog } = await readBlogDeatailById(params.id);
8 | return ;
9 | }
10 |
--------------------------------------------------------------------------------
/app/dashboard/blog/schema/index.ts:
--------------------------------------------------------------------------------
1 | import * as z from "zod";
2 |
3 | export const BlogFormSchema = z
4 | .object({
5 | title: z.string().min(10, {
6 | message: "title is too short",
7 | }),
8 | content: z.string().min(50, {
9 | message: "Content is too short",
10 | }),
11 | image_url: z.string().url({
12 | message: "Invalid url",
13 | }),
14 | is_premium: z.boolean(),
15 | is_published: z.boolean(),
16 | })
17 | .refine(
18 | (data) => {
19 | const image_url = data.image_url;
20 | try {
21 | const url = new URL(image_url);
22 | return url.hostname === "images.unsplash.com";
23 | } catch {
24 | return false;
25 | }
26 | },
27 | {
28 | message: "Currently we are supporting only the image from unsplash",
29 | path: ["image_url"],
30 | }
31 | );
32 |
33 | export type BlogFormSchemaType = z.infer;
34 |
--------------------------------------------------------------------------------
/app/dashboard/components/NavLinks.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { cn } from "@/lib/utils";
3 | import { PersonIcon, ReaderIcon } from "@radix-ui/react-icons";
4 | import Link from "next/link";
5 | import { usePathname } from "next/navigation";
6 | import React from "react";
7 |
8 | export default function NavLinks() {
9 | const pathname = usePathname();
10 | const links = [
11 | {
12 | href: "/dashboard",
13 | Icon: ReaderIcon,
14 | text: "dashboard",
15 | },
16 |
17 | {
18 | href: "/dashboard/user",
19 | Icon: PersonIcon,
20 | text: "users",
21 | },
22 | ];
23 |
24 | return (
25 |
26 | {links.map(({ href, Icon, text }, index) => {
27 | return (
28 |
36 | / {text}
37 |
38 | );
39 | })}
40 |
41 | );
42 | }
43 |
--------------------------------------------------------------------------------
/app/dashboard/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 |
3 | import React, { ReactNode } from "react";
4 | import NavLinks from "./components/NavLinks";
5 |
6 | export const metadata: Metadata = {
7 | metadataBase: new URL("https://dailyblog-demo.vercel.app/"),
8 |
9 | title: {
10 | template: "%s | Dashboard",
11 | default: "Dashboard",
12 | },
13 | authors: {
14 | name: "chensokheng",
15 | },
16 |
17 | description:
18 | "Empower your decision-making with our intuitive dashboard. Gain valuable insights at a glance with interactive visualizations and real-time analytics. Our dashboard provides a centralized hub for monitoring key metrics, tracking progress, and making data-driven decisions. Streamline your workflow, enhance collaboration, and stay ahead of the curve with customizable widgets and personalized dashboards. Experience the power of data in a user-friendly interface designed to optimize productivity and drive results.",
19 | openGraph: {
20 | title: "Dashboard",
21 | description:
22 | "Empower your decision-making with our intuitive dashboard. Gain valuable insights at a glance with interactive visualizations and real-time analytics. Our dashboard provides a centralized hub for monitoring key metrics, tracking progress, and making data-driven decisions. Streamline your workflow, enhance collaboration, and stay ahead of the curve with customizable widgets and personalized dashboards. Experience the power of data in a user-friendly interface designed to optimize productivity and drive results.",
23 | url: "https://dailyblog-demo.vercel.app/",
24 | siteName: "Daily Blog",
25 | images: "/og-dashboard.png",
26 | type: "website",
27 | },
28 | keywords: ["daily web coding", "chensokheng", "dailywebcoding"],
29 | };
30 |
31 | export default function Layout({ children }: { children: ReactNode }) {
32 | return (
33 | <>
34 |
35 | {children}
36 | >
37 | );
38 | }
39 |
--------------------------------------------------------------------------------
/app/dashboard/loading.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | export default function loading() {
4 | return (
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 | );
13 | }
14 |
--------------------------------------------------------------------------------
/app/dashboard/page.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import BlogTable from "./blog/components/BlogTable";
3 | import { Button } from "@/components/ui/button";
4 | import Link from "next/link";
5 | import { PlusIcon } from "@radix-ui/react-icons";
6 |
7 | export default function Blog() {
8 | return (
9 |
10 |
11 |
Blogs
12 |
13 |
17 | Create
18 |
19 |
20 |
21 |
22 |
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/app/dashboard/user/page.tsx:
--------------------------------------------------------------------------------
1 | import { readUsers } from "@/lib/actions/user";
2 | import { users } from "@/lib/data";
3 | import { cn } from "@/lib/utils";
4 | import Image from "next/image";
5 | import React from "react";
6 |
7 | export default async function page() {
8 | const { data } = await readUsers();
9 |
10 | return (
11 |
12 |
13 |
14 |
Name
15 | Subscription
16 | Customer
17 |
18 |
19 | {data?.map((user, index) => {
20 | return (
21 |
25 |
26 |
33 |
{user.display_name}
34 |
35 |
36 |
39 |
40 |
{user.stripe_customer_id}
41 |
42 |
43 | );
44 | })}
45 |
46 |
47 |
48 | );
49 | }
50 | const SubscriptionStatus = ({ status }: { status: boolean }) => {
51 | return (
52 |
53 |
61 | {status ? "Active" : "Inactive"}
62 |
63 |
64 | );
65 | };
66 |
--------------------------------------------------------------------------------
/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Chensokheng/next-saas-blog/246a533f39e08807a498199ae9ec86f32e50e824/app/favicon.ico
--------------------------------------------------------------------------------
/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @layer base {
6 | :root {
7 | --background: 0 0% 100%;
8 | --foreground: 240 10% 3.9%;
9 |
10 | --card: 0 0% 100%;
11 | --card-foreground: 240 10% 3.9%;
12 |
13 | --popover: 0 0% 100%;
14 | --popover-foreground: 240 10% 3.9%;
15 |
16 | --primary: 240 5.9% 10%;
17 | --primary-foreground: 0 0% 98%;
18 |
19 | --secondary: 240 4.8% 95.9%;
20 | --secondary-foreground: 240 5.9% 10%;
21 |
22 | --muted: 240 4.8% 95.9%;
23 | --muted-foreground: 240 3.8% 46.1%;
24 |
25 | --accent: 240 4.8% 95.9%;
26 | --accent-foreground: 240 5.9% 10%;
27 |
28 | --destructive: 0 84.2% 60.2%;
29 | --destructive-foreground: 0 0% 98%;
30 |
31 | --border: 240 5.9% 90%;
32 | --input: 240 5.9% 90%;
33 | --ring: 240 10% 3.9%;
34 |
35 | --radius: 0.5rem;
36 | }
37 |
38 | .dark {
39 | --background: 240 10% 3.9%;
40 | --foreground: 0 0% 98%;
41 |
42 | --card: 240 10% 3.9%;
43 | --card-foreground: 0 0% 98%;
44 |
45 | --popover: 240 10% 3.9%;
46 | --popover-foreground: 0 0% 98%;
47 |
48 | --primary: 0 0% 98%;
49 | --primary-foreground: 240 5.9% 10%;
50 |
51 | --secondary: 240 3.7% 15.9%;
52 | --secondary-foreground: 0 0% 98%;
53 |
54 | --muted: 240 3.7% 15.9%;
55 | --muted-foreground: 240 5% 64.9%;
56 |
57 | --accent: 240 3.7% 15.9%;
58 | --accent-foreground: 0 0% 98%;
59 |
60 | --destructive: 0 62.8% 30.6%;
61 | --destructive-foreground: 0 0% 98%;
62 |
63 | --border: 240 3.7% 15.9%;
64 | --input: 240 3.7% 15.9%;
65 | --ring: 240 4.9% 83.9%;
66 | }
67 | }
68 |
69 | @layer base {
70 | * {
71 | @apply border-border;
72 | }
73 | body {
74 | @apply bg-background text-foreground;
75 | }
76 | }
--------------------------------------------------------------------------------
/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Inter } from "next/font/google";
3 | import "./globals.css";
4 | import { ThemeProvider } from "@/components/theme-provider";
5 | import { cn } from "@/lib/utils";
6 | import Navbar from "../components/nav/Navbar";
7 | import { Toaster } from "@/components/ui/toaster";
8 | import SessisonProvider from "../components/SessisonProvider";
9 |
10 | const inter = Inter({ subsets: ["latin"] });
11 |
12 | export const metadata: Metadata = {
13 | metadataBase: new URL("https://dailyblog-demo.vercel.app/"),
14 |
15 | title: {
16 | template: "%s | Daily Blog",
17 | default: "Daily Blog",
18 | },
19 | authors: {
20 | name: "chensokheng",
21 | },
22 |
23 | description:
24 | "Explore a world of captivating stories and insightful articles on our blog. From the latest trends to in-depth analyses, our blog covers a wide range of topics to keep you informed and entertained. Join our community of readers and discover thought-provoking content that sparks curiosity and fosters discussion. Stay updated with our diverse collection of blog posts, written by passionate contributors who share their expertise and unique perspectives. Engage with a platform that goes beyond the ordinary, providing you with enriching content that resonates with your interests.",
25 | openGraph: {
26 | title: "Daily Blog",
27 | description:
28 | "Explore a world of captivating stories and insightful articles on our blog. From the latest trends to in-depth analyses, our blog covers a wide range of topics to keep you informed and entertained. Join our community of readers and discover thought-provoking content that sparks curiosity and fosters discussion. Stay updated with our diverse collection of blog posts, written by passionate contributors who share their expertise and unique perspectives. Engage with a platform that goes beyond the ordinary, providing you with enriching content that resonates with your interests.",
29 | url: "https://dailyblog-demo.vercel.app/",
30 | siteName: "Daily Blog",
31 | images: "/og.png",
32 | type: "website",
33 | },
34 | keywords: ["daily web coding", "chensokheng", "dailywebcoding"],
35 | };
36 |
37 | export default function RootLayout({
38 | children,
39 | }: {
40 | children: React.ReactNode;
41 | }) {
42 | return (
43 |
44 |
47 |
53 |
54 |
55 | {children}
56 |
57 |
58 |
59 |
60 |
61 |
62 | );
63 | }
64 |
--------------------------------------------------------------------------------
/components.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://ui.shadcn.com/schema.json",
3 | "style": "new-york",
4 | "rsc": true,
5 | "tsx": true,
6 | "tailwind": {
7 | "config": "tailwind.config.js",
8 | "css": "app/globals.css",
9 | "baseColor": "zinc",
10 | "cssVariables": true
11 | },
12 | "aliases": {
13 | "components": "@/components",
14 | "utils": "@/lib/utils"
15 | }
16 | }
--------------------------------------------------------------------------------
/components/Footer.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {
3 | GitHubLogoIcon,
4 | DiscIcon,
5 | LinkedInLogoIcon,
6 | DiscordLogoIcon,
7 | } from "@radix-ui/react-icons";
8 | export default function Footer() {
9 | return (
10 |
11 |
12 |
13 |
14 |
Daily Media
15 |
16 | Explore a world of coding insights and knowledge on
17 | our blog website, where every article is a step
18 | towards mastering the art of programming and staying
19 | ahead in the dynamic tech landscape
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 | © 2023 Chensokheng.All right reserved
31 |
32 |
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/components/SessisonProvider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { useUser } from "@/lib/store/user";
3 | import { Database } from "@/lib/types/supabase";
4 | import { createBrowserClient } from "@supabase/ssr";
5 | import React, { useEffect } from "react";
6 |
7 | export default function SessisonProvider() {
8 | const setUser = useUser((state) => state.setUser);
9 |
10 | const supabase = createBrowserClient(
11 | process.env.NEXT_PUBLIC_SUPABASE_URL!,
12 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
13 | );
14 |
15 | useEffect(() => {
16 | readSession();
17 | // eslint-disable-next-line
18 | }, []);
19 |
20 | const readSession = async () => {
21 | const { data: userSesssion } = await supabase.auth.getSession();
22 |
23 | if (userSesssion.session) {
24 | const { data } = await supabase
25 | .from("users")
26 | .select("*")
27 | .eq("id", userSesssion.session?.user.id)
28 | .single();
29 | setUser(data);
30 | }
31 | };
32 |
33 | return <>>;
34 | }
35 |
--------------------------------------------------------------------------------
/components/markdown/CopyButton.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import { useState } from "react";
4 | import { BsCopy } from "react-icons/bs";
5 | import { IoCheckmarkOutline } from "react-icons/io5";
6 |
7 | export default function CopyButton({ id }: { id: string }) {
8 | const [onCopy, setOnCopy] = useState(false);
9 | const [onSuccess, setSuccess] = useState(false);
10 |
11 | const handleCopy = async () => {
12 | let text = document.getElementById(id)!.textContent;
13 | try {
14 | await navigator.clipboard.writeText(text!);
15 | setOnCopy(true);
16 | } catch (err) {
17 | console.error("Failed to copy: ", err);
18 | }
19 | };
20 | return (
21 |
25 |
{
30 | setTimeout(() => {
31 | setSuccess(false);
32 | setOnCopy(false);
33 | }, 500);
34 | }}
35 | />
36 |
37 |
38 | {
43 | if (onCopy) {
44 | setSuccess(true);
45 | }
46 | }}
47 | />
48 |
49 |
50 | );
51 | }
52 |
--------------------------------------------------------------------------------
/components/markdown/MarkdownPreview.tsx:
--------------------------------------------------------------------------------
1 | import { icons } from "@/lib/icon";
2 | import React from "react";
3 | import Markdown from "react-markdown";
4 | import rehypeHighlight from "rehype-highlight";
5 |
6 | import CopyButton from "./CopyButton";
7 | import "highlight.js/styles/atom-one-dark.min.css";
8 | import { cn } from "@/lib/utils";
9 | import { PiTerminalThin } from "react-icons/pi";
10 |
11 | export default function MarkdownPreview({
12 | content,
13 | className = "sm:p-10",
14 | }: {
15 | content: string;
16 | className?: string;
17 | }) {
18 | return (
19 | {
24 | return ;
25 | },
26 | h2: ({ node, ...props }) => {
27 | return (
28 |
32 | );
33 | },
34 | h3: ({ node, ...props }) => {
35 | return (
36 |
40 | );
41 | },
42 | code: ({ node, className, children, ...props }) => {
43 | const match = /language-(\w+)/.exec(className || "");
44 | const id = (Math.floor(Math.random() * 100) + 1).toString();
45 | if (match?.length) {
46 | let Icon = PiTerminalThin;
47 | const isMatch = icons.hasOwnProperty(match[1]);
48 | if (isMatch) {
49 | Icon = icons[match[1] as keyof typeof icons];
50 | }
51 |
52 | return (
53 |
54 |
55 |
56 |
57 |
58 | {/* @ts-ignore */}
59 | {node?.data?.meta}
60 |
61 |
62 |
63 |
64 |
65 |
66 | {children}
67 |
68 |
69 |
70 | );
71 | } else {
72 | return (
73 | // TODO: convert to code block
74 |
78 | {children}
79 |
80 | );
81 | }
82 | },
83 | }}
84 | >
85 | {content}
86 |
87 | );
88 | }
89 |
--------------------------------------------------------------------------------
/components/nav/HoverUnderLine.tsx:
--------------------------------------------------------------------------------
1 | import { cn } from "@/lib/utils";
2 | import React, { ReactNode } from "react";
3 |
4 | export default function HoverUnderLine({
5 | children,
6 | className = "bg-green-500",
7 | }: {
8 | children: ReactNode;
9 | className?: string;
10 | }) {
11 | return (
12 |
13 | {children}
14 |
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/components/nav/LoginForm.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { Button } from "@/components/ui/button";
3 | import { GitHubLogoIcon } from "@radix-ui/react-icons";
4 | import { createBrowserClient } from "@supabase/ssr";
5 | import { usePathname } from "next/navigation";
6 | import React from "react";
7 |
8 | export default function LoginForm() {
9 | const pathname = usePathname();
10 | const supabase = createBrowserClient(
11 | process.env.NEXT_PUBLIC_SUPABASE_URL!,
12 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
13 | );
14 |
15 | const handleLogin = () => {
16 | supabase.auth.signInWithOAuth({
17 | provider: "github",
18 | options: {
19 | redirectTo: `${location.origin}/auth/callback?next=${pathname}`,
20 | },
21 | });
22 | };
23 |
24 | return (
25 |
30 | Login
31 |
32 | );
33 | }
34 |
--------------------------------------------------------------------------------
/components/nav/Navbar.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import React from "react";
3 | import HoverUnderLine from "./HoverUnderLine";
4 | import Link from "next/link";
5 | import LoginForm from "./LoginForm";
6 | import { useUser } from "@/lib/store/user";
7 | import Profile from "./Profile";
8 |
9 | export default function Navbar() {
10 | const user = useUser((state) => state.user);
11 |
12 | return (
13 |
14 |
15 |
16 | DailyMedia
17 |
18 |
19 | {user ? : }
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/components/nav/Profile.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {
3 | Popover,
4 | PopoverContent,
5 | PopoverTrigger,
6 | } from "@/components/ui/popover";
7 | import Image from "next/image";
8 | import { useUser } from "@/lib/store/user";
9 | import { Button } from "@/components/ui/button";
10 | import { DashboardIcon, LockOpen1Icon } from "@radix-ui/react-icons";
11 | import Link from "next/link";
12 | import { createBrowserClient } from "@supabase/ssr";
13 | import ManageBill from "../stripe/ManageBill";
14 |
15 | export default function Profile() {
16 | const supabase = createBrowserClient(
17 | process.env.NEXT_PUBLIC_SUPABASE_URL!,
18 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
19 | );
20 | const user = useUser((state) => state.user);
21 | const setUser = useUser((state) => state.setUser);
22 |
23 | const handleLogout = async () => {
24 | await supabase.auth.signOut();
25 | setUser(null);
26 | };
27 | const isAdmin = user?.role === "admin";
28 | const isSub = user?.stripe_customer_id;
29 |
30 | return (
31 |
32 |
33 |
40 |
41 |
42 |
43 |
{user?.display_name}
44 |
{user?.email}
45 |
46 | {!isAdmin && isSub && (
47 |
48 | )}
49 |
50 | {isAdmin && (
51 |
52 |
56 | Dashboard
57 |
58 |
59 | )}
60 |
61 |
66 | Log out
67 |
68 |
69 |
70 | );
71 | }
72 |
--------------------------------------------------------------------------------
/components/stripe/Checkout.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import React, { ChangeEvent, useTransition } from "react";
3 | import { LightningBoltIcon } from "@radix-ui/react-icons";
4 | import { useUser } from "@/lib/store/user";
5 | import { cn } from "@/lib/utils";
6 | import { loadStripe } from "@stripe/stripe-js";
7 | import { checkout } from "@/lib/actions/stripe";
8 | import { usePathname } from "next/navigation";
9 | import LoginForm from "../nav/LoginForm";
10 | export default function Checkout() {
11 | const pathname = usePathname();
12 |
13 | const [isPending, startTransition] = useTransition();
14 |
15 | const user = useUser((state) => state.user);
16 |
17 | const handleCheckOut = (e: ChangeEvent) => {
18 | e.preventDefault();
19 | startTransition(async () => {
20 | const data = JSON.parse(
21 | await checkout(user?.email!, location.origin + pathname)
22 | );
23 | const result = await loadStripe(
24 | process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY!
25 | );
26 | await result?.redirectToCheckout({ sessionId: data.id });
27 | });
28 | };
29 |
30 | if (!user) {
31 | return (
32 |
33 | to continue
34 |
35 | );
36 | }
37 |
38 | return (
39 |
47 |
51 |
52 |
58 | Upgrade to pro
59 |
60 |
61 | Unlock all Daily blog contents
62 |
63 |
64 |
65 | );
66 | }
67 |
--------------------------------------------------------------------------------
/components/stripe/ManageBill.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import { Button } from "@/components/ui/button";
3 | import { manageBillingPortal } from "@/lib/actions/stripe";
4 | import { cn } from "@/lib/utils";
5 | import { BackpackIcon } from "@radix-ui/react-icons";
6 | import React, { ChangeEvent, useTransition } from "react";
7 | import { AiOutlineLoading3Quarters } from "react-icons/ai";
8 |
9 | export default function ManageBill({ customerId }: { customerId: string }) {
10 | const [isPending, startTransition] = useTransition();
11 | const onSubmit = (e: ChangeEvent) => {
12 | e.preventDefault();
13 | startTransition(async () => {
14 | const data = JSON.parse(await manageBillingPortal(customerId));
15 | window.location.href = data.url;
16 | });
17 | };
18 |
19 | return (
20 |
21 |
25 |
26 |
29 | Billing
30 |
31 |
32 |
33 |
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/components/theme-provider.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import { ThemeProvider as NextThemesProvider } from "next-themes";
5 | import { type ThemeProviderProps } from "next-themes/dist/types";
6 |
7 | export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
8 | return {children} ;
9 | }
10 |
--------------------------------------------------------------------------------
/components/ui/alert-dialog.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
5 |
6 | import { cn } from "@/lib/utils"
7 | import { buttonVariants } from "@/components/ui/button"
8 |
9 | const AlertDialog = AlertDialogPrimitive.Root
10 |
11 | const AlertDialogTrigger = AlertDialogPrimitive.Trigger
12 |
13 | const AlertDialogPortal = AlertDialogPrimitive.Portal
14 |
15 | const AlertDialogOverlay = React.forwardRef<
16 | React.ElementRef,
17 | React.ComponentPropsWithoutRef
18 | >(({ className, ...props }, ref) => (
19 |
27 | ))
28 | AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
29 |
30 | const AlertDialogContent = React.forwardRef<
31 | React.ElementRef,
32 | React.ComponentPropsWithoutRef
33 | >(({ className, ...props }, ref) => (
34 |
35 |
36 |
44 |
45 | ))
46 | AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
47 |
48 | const AlertDialogHeader = ({
49 | className,
50 | ...props
51 | }: React.HTMLAttributes) => (
52 |
59 | )
60 | AlertDialogHeader.displayName = "AlertDialogHeader"
61 |
62 | const AlertDialogFooter = ({
63 | className,
64 | ...props
65 | }: React.HTMLAttributes) => (
66 |
73 | )
74 | AlertDialogFooter.displayName = "AlertDialogFooter"
75 |
76 | const AlertDialogTitle = React.forwardRef<
77 | React.ElementRef,
78 | React.ComponentPropsWithoutRef
79 | >(({ className, ...props }, ref) => (
80 |
85 | ))
86 | AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
87 |
88 | const AlertDialogDescription = React.forwardRef<
89 | React.ElementRef,
90 | React.ComponentPropsWithoutRef
91 | >(({ className, ...props }, ref) => (
92 |
97 | ))
98 | AlertDialogDescription.displayName =
99 | AlertDialogPrimitive.Description.displayName
100 |
101 | const AlertDialogAction = React.forwardRef<
102 | React.ElementRef,
103 | React.ComponentPropsWithoutRef
104 | >(({ className, ...props }, ref) => (
105 |
110 | ))
111 | AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
112 |
113 | const AlertDialogCancel = React.forwardRef<
114 | React.ElementRef,
115 | React.ComponentPropsWithoutRef
116 | >(({ className, ...props }, ref) => (
117 |
126 | ))
127 | AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
128 |
129 | export {
130 | AlertDialog,
131 | AlertDialogPortal,
132 | AlertDialogOverlay,
133 | AlertDialogTrigger,
134 | AlertDialogContent,
135 | AlertDialogHeader,
136 | AlertDialogFooter,
137 | AlertDialogTitle,
138 | AlertDialogDescription,
139 | AlertDialogAction,
140 | AlertDialogCancel,
141 | }
142 |
--------------------------------------------------------------------------------
/components/ui/button.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Slot } from "@radix-ui/react-slot"
3 | import { cva, type VariantProps } from "class-variance-authority"
4 |
5 | import { cn } from "@/lib/utils"
6 |
7 | const buttonVariants = cva(
8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
9 | {
10 | variants: {
11 | variant: {
12 | default:
13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90",
14 | destructive:
15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
16 | outline:
17 | "border border-input bg-transparent shadow-sm hover:bg-accent hover:text-accent-foreground",
18 | secondary:
19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
20 | ghost: "hover:bg-accent hover:text-accent-foreground",
21 | link: "text-primary underline-offset-4 hover:underline",
22 | },
23 | size: {
24 | default: "h-9 px-4 py-2",
25 | sm: "h-8 rounded-md px-3 text-xs",
26 | lg: "h-10 rounded-md px-8",
27 | icon: "h-9 w-9",
28 | },
29 | },
30 | defaultVariants: {
31 | variant: "default",
32 | size: "default",
33 | },
34 | }
35 | )
36 |
37 | export interface ButtonProps
38 | extends React.ButtonHTMLAttributes,
39 | VariantProps {
40 | asChild?: boolean
41 | }
42 |
43 | const Button = React.forwardRef(
44 | ({ className, variant, size, asChild = false, ...props }, ref) => {
45 | const Comp = asChild ? Slot : "button"
46 | return (
47 |
52 | )
53 | }
54 | )
55 | Button.displayName = "Button"
56 |
57 | export { Button, buttonVariants }
58 |
--------------------------------------------------------------------------------
/components/ui/form.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import * as LabelPrimitive from "@radix-ui/react-label"
3 | import { Slot } from "@radix-ui/react-slot"
4 | import {
5 | Controller,
6 | ControllerProps,
7 | FieldPath,
8 | FieldValues,
9 | FormProvider,
10 | useFormContext,
11 | } from "react-hook-form"
12 |
13 | import { cn } from "@/lib/utils"
14 | import { Label } from "@/components/ui/label"
15 |
16 | const Form = FormProvider
17 |
18 | type FormFieldContextValue<
19 | TFieldValues extends FieldValues = FieldValues,
20 | TName extends FieldPath = FieldPath
21 | > = {
22 | name: TName
23 | }
24 |
25 | const FormFieldContext = React.createContext(
26 | {} as FormFieldContextValue
27 | )
28 |
29 | const FormField = <
30 | TFieldValues extends FieldValues = FieldValues,
31 | TName extends FieldPath = FieldPath
32 | >({
33 | ...props
34 | }: ControllerProps) => {
35 | return (
36 |
37 |
38 |
39 | )
40 | }
41 |
42 | const useFormField = () => {
43 | const fieldContext = React.useContext(FormFieldContext)
44 | const itemContext = React.useContext(FormItemContext)
45 | const { getFieldState, formState } = useFormContext()
46 |
47 | const fieldState = getFieldState(fieldContext.name, formState)
48 |
49 | if (!fieldContext) {
50 | throw new Error("useFormField should be used within ")
51 | }
52 |
53 | const { id } = itemContext
54 |
55 | return {
56 | id,
57 | name: fieldContext.name,
58 | formItemId: `${id}-form-item`,
59 | formDescriptionId: `${id}-form-item-description`,
60 | formMessageId: `${id}-form-item-message`,
61 | ...fieldState,
62 | }
63 | }
64 |
65 | type FormItemContextValue = {
66 | id: string
67 | }
68 |
69 | const FormItemContext = React.createContext(
70 | {} as FormItemContextValue
71 | )
72 |
73 | const FormItem = React.forwardRef<
74 | HTMLDivElement,
75 | React.HTMLAttributes
76 | >(({ className, ...props }, ref) => {
77 | const id = React.useId()
78 |
79 | return (
80 |
81 |
82 |
83 | )
84 | })
85 | FormItem.displayName = "FormItem"
86 |
87 | const FormLabel = React.forwardRef<
88 | React.ElementRef,
89 | React.ComponentPropsWithoutRef
90 | >(({ className, ...props }, ref) => {
91 | const { error, formItemId } = useFormField()
92 |
93 | return (
94 |
100 | )
101 | })
102 | FormLabel.displayName = "FormLabel"
103 |
104 | const FormControl = React.forwardRef<
105 | React.ElementRef,
106 | React.ComponentPropsWithoutRef
107 | >(({ ...props }, ref) => {
108 | const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
109 |
110 | return (
111 |
122 | )
123 | })
124 | FormControl.displayName = "FormControl"
125 |
126 | const FormDescription = React.forwardRef<
127 | HTMLParagraphElement,
128 | React.HTMLAttributes
129 | >(({ className, ...props }, ref) => {
130 | const { formDescriptionId } = useFormField()
131 |
132 | return (
133 |
139 | )
140 | })
141 | FormDescription.displayName = "FormDescription"
142 |
143 | const FormMessage = React.forwardRef<
144 | HTMLParagraphElement,
145 | React.HTMLAttributes
146 | >(({ className, children, ...props }, ref) => {
147 | const { error, formMessageId } = useFormField()
148 | const body = error ? String(error?.message) : children
149 |
150 | if (!body) {
151 | return null
152 | }
153 |
154 | return (
155 |
161 | {body}
162 |
163 | )
164 | })
165 | FormMessage.displayName = "FormMessage"
166 |
167 | export {
168 | useFormField,
169 | Form,
170 | FormItem,
171 | FormLabel,
172 | FormControl,
173 | FormDescription,
174 | FormMessage,
175 | FormField,
176 | }
177 |
--------------------------------------------------------------------------------
/components/ui/input.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | export interface InputProps
6 | extends React.InputHTMLAttributes {}
7 |
8 | const Input = React.forwardRef(
9 | ({ className, type, ...props }, ref) => {
10 | return (
11 |
20 | );
21 | }
22 | );
23 | Input.displayName = "Input";
24 |
25 | export { Input };
26 |
--------------------------------------------------------------------------------
/components/ui/label.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as LabelPrimitive from "@radix-ui/react-label"
5 | import { cva, type VariantProps } from "class-variance-authority"
6 |
7 | import { cn } from "@/lib/utils"
8 |
9 | const labelVariants = cva(
10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
11 | )
12 |
13 | const Label = React.forwardRef<
14 | React.ElementRef,
15 | React.ComponentPropsWithoutRef &
16 | VariantProps
17 | >(({ className, ...props }, ref) => (
18 |
23 | ))
24 | Label.displayName = LabelPrimitive.Root.displayName
25 |
26 | export { Label }
27 |
--------------------------------------------------------------------------------
/components/ui/popover.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import * as React from "react"
4 | import * as PopoverPrimitive from "@radix-ui/react-popover"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const Popover = PopoverPrimitive.Root
9 |
10 | const PopoverTrigger = PopoverPrimitive.Trigger
11 |
12 | const PopoverContent = React.forwardRef<
13 | React.ElementRef,
14 | React.ComponentPropsWithoutRef
15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
16 |
17 |
27 |
28 | ))
29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName
30 |
31 | export { Popover, PopoverTrigger, PopoverContent }
32 |
--------------------------------------------------------------------------------
/components/ui/switch.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 |
3 | import * as React from "react";
4 | import * as SwitchPrimitives from "@radix-ui/react-switch";
5 |
6 | import { cn } from "@/lib/utils";
7 |
8 | const Switch = React.forwardRef<
9 | React.ElementRef,
10 | React.ComponentPropsWithoutRef
11 | >(({ className, ...props }, ref) => (
12 |
20 |
25 |
26 | ));
27 | Switch.displayName = SwitchPrimitives.Root.displayName;
28 |
29 | export { Switch };
30 |
--------------------------------------------------------------------------------
/components/ui/textarea.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 |
3 | import { cn } from "@/lib/utils";
4 |
5 | export interface TextareaProps
6 | extends React.TextareaHTMLAttributes {}
7 |
8 | const Textarea = React.forwardRef(
9 | ({ className, ...props }, ref) => {
10 | return (
11 |
19 | );
20 | }
21 | );
22 | Textarea.displayName = "Textarea";
23 |
24 | export { Textarea };
25 |
--------------------------------------------------------------------------------
/components/ui/toast.tsx:
--------------------------------------------------------------------------------
1 | import * as React from "react"
2 | import { Cross2Icon } from "@radix-ui/react-icons"
3 | import * as ToastPrimitives from "@radix-ui/react-toast"
4 | import { cva, type VariantProps } from "class-variance-authority"
5 |
6 | import { cn } from "@/lib/utils"
7 |
8 | const ToastProvider = ToastPrimitives.Provider
9 |
10 | const ToastViewport = React.forwardRef<
11 | React.ElementRef,
12 | React.ComponentPropsWithoutRef
13 | >(({ className, ...props }, ref) => (
14 |
22 | ))
23 | ToastViewport.displayName = ToastPrimitives.Viewport.displayName
24 |
25 | const toastVariants = cva(
26 | "group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
27 | {
28 | variants: {
29 | variant: {
30 | default: "border bg-background text-foreground",
31 | destructive:
32 | "destructive group border-destructive bg-destructive text-destructive-foreground",
33 | },
34 | },
35 | defaultVariants: {
36 | variant: "default",
37 | },
38 | }
39 | )
40 |
41 | const Toast = React.forwardRef<
42 | React.ElementRef,
43 | React.ComponentPropsWithoutRef &
44 | VariantProps
45 | >(({ className, variant, ...props }, ref) => {
46 | return (
47 |
52 | )
53 | })
54 | Toast.displayName = ToastPrimitives.Root.displayName
55 |
56 | const ToastAction = React.forwardRef<
57 | React.ElementRef,
58 | React.ComponentPropsWithoutRef
59 | >(({ className, ...props }, ref) => (
60 |
68 | ))
69 | ToastAction.displayName = ToastPrimitives.Action.displayName
70 |
71 | const ToastClose = React.forwardRef<
72 | React.ElementRef,
73 | React.ComponentPropsWithoutRef
74 | >(({ className, ...props }, ref) => (
75 |
84 |
85 |
86 | ))
87 | ToastClose.displayName = ToastPrimitives.Close.displayName
88 |
89 | const ToastTitle = React.forwardRef<
90 | React.ElementRef,
91 | React.ComponentPropsWithoutRef
92 | >(({ className, ...props }, ref) => (
93 |
98 | ))
99 | ToastTitle.displayName = ToastPrimitives.Title.displayName
100 |
101 | const ToastDescription = React.forwardRef<
102 | React.ElementRef,
103 | React.ComponentPropsWithoutRef
104 | >(({ className, ...props }, ref) => (
105 |
110 | ))
111 | ToastDescription.displayName = ToastPrimitives.Description.displayName
112 |
113 | type ToastProps = React.ComponentPropsWithoutRef
114 |
115 | type ToastActionElement = React.ReactElement
116 |
117 | export {
118 | type ToastProps,
119 | type ToastActionElement,
120 | ToastProvider,
121 | ToastViewport,
122 | Toast,
123 | ToastTitle,
124 | ToastDescription,
125 | ToastClose,
126 | ToastAction,
127 | }
128 |
--------------------------------------------------------------------------------
/components/ui/toaster.tsx:
--------------------------------------------------------------------------------
1 | "use client"
2 |
3 | import {
4 | Toast,
5 | ToastClose,
6 | ToastDescription,
7 | ToastProvider,
8 | ToastTitle,
9 | ToastViewport,
10 | } from "@/components/ui/toast"
11 | import { useToast } from "@/components/ui/use-toast"
12 |
13 | export function Toaster() {
14 | const { toasts } = useToast()
15 |
16 | return (
17 |
18 | {toasts.map(function ({ id, title, description, action, ...props }) {
19 | return (
20 |
21 |
22 | {title && {title} }
23 | {description && (
24 | {description}
25 | )}
26 |
27 | {action}
28 |
29 |
30 | )
31 | })}
32 |
33 |
34 | )
35 | }
36 |
--------------------------------------------------------------------------------
/components/ui/use-toast.ts:
--------------------------------------------------------------------------------
1 | // Inspired by react-hot-toast library
2 | import * as React from "react"
3 |
4 | import type {
5 | ToastActionElement,
6 | ToastProps,
7 | } from "@/components/ui/toast"
8 |
9 | const TOAST_LIMIT = 1
10 | const TOAST_REMOVE_DELAY = 1000000
11 |
12 | type ToasterToast = ToastProps & {
13 | id: string
14 | title?: React.ReactNode
15 | description?: React.ReactNode
16 | action?: ToastActionElement
17 | }
18 |
19 | const actionTypes = {
20 | ADD_TOAST: "ADD_TOAST",
21 | UPDATE_TOAST: "UPDATE_TOAST",
22 | DISMISS_TOAST: "DISMISS_TOAST",
23 | REMOVE_TOAST: "REMOVE_TOAST",
24 | } as const
25 |
26 | let count = 0
27 |
28 | function genId() {
29 | count = (count + 1) % Number.MAX_VALUE
30 | return count.toString()
31 | }
32 |
33 | type ActionType = typeof actionTypes
34 |
35 | type Action =
36 | | {
37 | type: ActionType["ADD_TOAST"]
38 | toast: ToasterToast
39 | }
40 | | {
41 | type: ActionType["UPDATE_TOAST"]
42 | toast: Partial
43 | }
44 | | {
45 | type: ActionType["DISMISS_TOAST"]
46 | toastId?: ToasterToast["id"]
47 | }
48 | | {
49 | type: ActionType["REMOVE_TOAST"]
50 | toastId?: ToasterToast["id"]
51 | }
52 |
53 | interface State {
54 | toasts: ToasterToast[]
55 | }
56 |
57 | const toastTimeouts = new Map>()
58 |
59 | const addToRemoveQueue = (toastId: string) => {
60 | if (toastTimeouts.has(toastId)) {
61 | return
62 | }
63 |
64 | const timeout = setTimeout(() => {
65 | toastTimeouts.delete(toastId)
66 | dispatch({
67 | type: "REMOVE_TOAST",
68 | toastId: toastId,
69 | })
70 | }, TOAST_REMOVE_DELAY)
71 |
72 | toastTimeouts.set(toastId, timeout)
73 | }
74 |
75 | export const reducer = (state: State, action: Action): State => {
76 | switch (action.type) {
77 | case "ADD_TOAST":
78 | return {
79 | ...state,
80 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
81 | }
82 |
83 | case "UPDATE_TOAST":
84 | return {
85 | ...state,
86 | toasts: state.toasts.map((t) =>
87 | t.id === action.toast.id ? { ...t, ...action.toast } : t
88 | ),
89 | }
90 |
91 | case "DISMISS_TOAST": {
92 | const { toastId } = action
93 |
94 | // ! Side effects ! - This could be extracted into a dismissToast() action,
95 | // but I'll keep it here for simplicity
96 | if (toastId) {
97 | addToRemoveQueue(toastId)
98 | } else {
99 | state.toasts.forEach((toast) => {
100 | addToRemoveQueue(toast.id)
101 | })
102 | }
103 |
104 | return {
105 | ...state,
106 | toasts: state.toasts.map((t) =>
107 | t.id === toastId || toastId === undefined
108 | ? {
109 | ...t,
110 | open: false,
111 | }
112 | : t
113 | ),
114 | }
115 | }
116 | case "REMOVE_TOAST":
117 | if (action.toastId === undefined) {
118 | return {
119 | ...state,
120 | toasts: [],
121 | }
122 | }
123 | return {
124 | ...state,
125 | toasts: state.toasts.filter((t) => t.id !== action.toastId),
126 | }
127 | }
128 | }
129 |
130 | const listeners: Array<(state: State) => void> = []
131 |
132 | let memoryState: State = { toasts: [] }
133 |
134 | function dispatch(action: Action) {
135 | memoryState = reducer(memoryState, action)
136 | listeners.forEach((listener) => {
137 | listener(memoryState)
138 | })
139 | }
140 |
141 | type Toast = Omit
142 |
143 | function toast({ ...props }: Toast) {
144 | const id = genId()
145 |
146 | const update = (props: ToasterToast) =>
147 | dispatch({
148 | type: "UPDATE_TOAST",
149 | toast: { ...props, id },
150 | })
151 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
152 |
153 | dispatch({
154 | type: "ADD_TOAST",
155 | toast: {
156 | ...props,
157 | id,
158 | open: true,
159 | onOpenChange: (open) => {
160 | if (!open) dismiss()
161 | },
162 | },
163 | })
164 |
165 | return {
166 | id: id,
167 | dismiss,
168 | update,
169 | }
170 | }
171 |
172 | function useToast() {
173 | const [state, setState] = React.useState(memoryState)
174 |
175 | React.useEffect(() => {
176 | listeners.push(setState)
177 | return () => {
178 | const index = listeners.indexOf(setState)
179 | if (index > -1) {
180 | listeners.splice(index, 1)
181 | }
182 | }
183 | }, [state])
184 |
185 | return {
186 | ...state,
187 | toast,
188 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
189 | }
190 | }
191 |
192 | export { useToast, toast }
193 |
--------------------------------------------------------------------------------
/lib/actions/blog.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { createSupabaseServerClient } from "@/lib/supabase";
4 | import { IBlog } from "@/lib/types";
5 | import { revalidatePath, unstable_noStore } from "next/cache";
6 | import { BlogFormSchemaType } from "../../app/dashboard/blog/schema";
7 |
8 | const DASHBOARD = "/dashboard/blog";
9 |
10 | export async function createBlog(data: {
11 | content: string;
12 | title: string;
13 | image_url: string;
14 | is_premium: boolean;
15 | is_published: boolean;
16 | }) {
17 | const { ["content"]: excludedKey, ...blog } = data;
18 |
19 | const supabase = await createSupabaseServerClient();
20 | const blogResult = await supabase
21 | .from("blog")
22 | .insert(blog)
23 | .select("id")
24 | .single();
25 |
26 | if (blogResult.error?.message && !blogResult.data) {
27 | return JSON.stringify(blogResult);
28 | } else {
29 | const result = await supabase
30 | .from("blog_content")
31 | .insert({ blog_id: blogResult?.data?.id!, content: data.content });
32 |
33 | revalidatePath(DASHBOARD);
34 | return JSON.stringify(result);
35 | }
36 | }
37 |
38 | export async function readBlog() {
39 | const supabase = await createSupabaseServerClient();
40 | return supabase
41 | .from("blog")
42 | .select("*")
43 | .eq("is_published", true)
44 | .order("created_at", { ascending: true });
45 | }
46 |
47 | export async function readBlogAdmin() {
48 | // await new Promise((resolve) => setTimeout(resolve, 2000));
49 |
50 | const supabase = await createSupabaseServerClient();
51 | return supabase
52 | .from("blog")
53 | .select("*")
54 | .order("created_at", { ascending: true });
55 | }
56 |
57 | export async function readBlogById(blogId: string) {
58 | const supabase = await createSupabaseServerClient();
59 | return supabase.from("blog").select("*").eq("id", blogId).single();
60 | }
61 | export async function readBlogIds() {
62 | const supabase = await createSupabaseServerClient();
63 | return supabase.from("blog").select("id");
64 | }
65 |
66 | export async function readBlogDeatailById(blogId: string) {
67 | const supabase = await createSupabaseServerClient();
68 | return await supabase
69 | .from("blog")
70 | .select("*,blog_content(*)")
71 | .eq("id", blogId)
72 | .single();
73 | }
74 |
75 | export async function readBlogContent(blogId: string) {
76 | unstable_noStore();
77 | const supabase = await createSupabaseServerClient();
78 | return await supabase
79 | .from("blog_content")
80 | .select("content")
81 | .eq("blog_id", blogId)
82 | .single();
83 | }
84 |
85 | export async function updateBlogById(blogId: string, data: IBlog) {
86 | const supabase = await createSupabaseServerClient();
87 | const result = await supabase.from("blog").update(data).eq("id", blogId);
88 | revalidatePath(DASHBOARD);
89 | revalidatePath("/blog/" + blogId);
90 | return JSON.stringify(result);
91 | }
92 |
93 | export async function updateBlogDetail(
94 | blogId: string,
95 | data: BlogFormSchemaType
96 | ) {
97 | const { ["content"]: excludedKey, ...blog } = data;
98 |
99 | const supabase = await createSupabaseServerClient();
100 | const resultBlog = await supabase
101 | .from("blog")
102 | .update(blog)
103 | .eq("id", blogId);
104 | if (resultBlog.error) {
105 | return JSON.stringify(resultBlog);
106 | } else {
107 | const result = await supabase
108 | .from("blog_content")
109 | .update({ content: data.content })
110 | .eq("blog_id", blogId);
111 | revalidatePath(DASHBOARD);
112 | revalidatePath("/blog/" + blogId);
113 |
114 | return JSON.stringify(result);
115 | }
116 | }
117 |
118 | export async function deleteBlogById(blogId: string) {
119 | const supabase = await createSupabaseServerClient();
120 | const result = await supabase.from("blog").delete().eq("id", blogId);
121 | revalidatePath(DASHBOARD);
122 | revalidatePath("/blog/" + blogId);
123 | return JSON.stringify(result);
124 | }
125 |
--------------------------------------------------------------------------------
/lib/actions/stripe.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 | import Stripe from "stripe";
3 | const stripe = new Stripe(process.env.STRIPE_SK_KEY!);
4 |
5 | export async function checkout(email: string, redirectTo: string) {
6 | return JSON.stringify(
7 | await stripe.checkout.sessions.create({
8 | success_url: redirectTo || process.env.SITE_URL,
9 | cancel_url: process.env.SITE_URL,
10 | customer_email: email,
11 | line_items: [{ price: process.env.PRO_PRCIE_ID, quantity: 1 }],
12 | mode: "subscription",
13 | })
14 | );
15 | }
16 |
17 | export async function manageBillingPortal(customer_id: string) {
18 | return JSON.stringify(
19 | await stripe.billingPortal.sessions.create({
20 | customer: customer_id,
21 | return_url: process.env.SITE_URL,
22 | })
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/lib/actions/user.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 |
3 | import { createSupabaseServerClient } from "../supabase";
4 |
5 | export async function readUsers() {
6 | const supabase = await createSupabaseServerClient();
7 | return supabase
8 | .from("users")
9 | .select("*")
10 | .order("created_at", { ascending: true });
11 | }
12 |
--------------------------------------------------------------------------------
/lib/data.ts:
--------------------------------------------------------------------------------
1 | import { IBlogDetial } from "./types";
2 |
3 | export const blogDeafultValue = `
4 | ## Serendipity Chronicles: Tales from a Random Blog
5 |
6 | As we wrap up our adventures, let's reflect on the serendipitous moments that defined this journey. The JavaScript snippet below captures a moment of serendipity in code:
7 |
8 | \`\`\`js @app/lib/serendipityMoments.js
9 | const serendipityMoments = [
10 | "Unexpectedly meeting a fellow adventurer",
11 | "Discovering a hidden gem in a random location",
12 | "Finding the perfect solution when least expected"
13 | ];
14 |
15 | const randomSerendipity = serendipityMoments[Math.floor(Math.random() * serendipityMoments.length)];
16 | console.log(\`Serendipity at its finest: \${randomSerendipity}\`);
17 | \`\`\`
18 |
19 | `;
20 |
21 | export const blogs = [
22 | {
23 | id: `1`,
24 | title: "Random Blog Adventures",
25 | image_url:
26 | "https://images.unsplash.com/photo-1700164805522-c3f2f8885144?q=80&w=3732&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
27 | created_at: "2023-05-15",
28 | is_premium: true,
29 | is_published: true,
30 | content: blogDeafultValue,
31 | },
32 | {
33 | id: "2",
34 | title: "Exploring the Unknown: A Random Blog Journey",
35 | image_url:
36 | "https://images.unsplash.com/photo-1700130862148-8bea5f545bfe?q=80&w=3570&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
37 |
38 | created_at: "2023-06-22",
39 | is_premium: false,
40 | is_published: false,
41 |
42 | content: blogDeafultValue,
43 | },
44 | {
45 | id: "3",
46 | title: "City Lights at Night",
47 | image_url:
48 | "https://images.unsplash.com/photo-1699968237129-b8d83b25ecd9?q=80&w=3557&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
49 |
50 | created_at: "2023-08-10",
51 | is_premium: false,
52 | is_published: false,
53 | content: blogDeafultValue,
54 | },
55 | {
56 | id: "4",
57 | title: "Unleashing Creativity: The Surprising Benefits of Doodling",
58 | image_url:
59 | "https://images.unsplash.com/photo-1699100329878-7f28bb780787?q=80&w=3732&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
60 | created_at: "2023-10-05",
61 | is_premium: true,
62 | is_published: false,
63 |
64 | content: blogDeafultValue,
65 | },
66 | {
67 | id: "5",
68 | title: "Unleashing Creativity: The Surprising Benefits of Doodling",
69 | image_url:
70 | "https://images.unsplash.com/photo-1700316740839-f5afe22536e4?q=80&w=3732&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
71 | created_at: "2023-10-05",
72 | is_premium: false,
73 | is_published: false,
74 | content: blogDeafultValue,
75 | },
76 | ];
77 |
78 | export const defaultCreateBlog: IBlogDetial = {
79 | id: "",
80 | title: "",
81 | image_url: "",
82 | created_at: "",
83 | is_premium: false,
84 | is_published: false,
85 | blog_content: {
86 | created_at: "",
87 | content: "",
88 | blog_id: "",
89 | },
90 | };
91 | export const users = [
92 | {
93 | display_name: "John Doe",
94 | image_url: "/profile.png",
95 | subscription_status: "Active",
96 | customer_id: "123456",
97 | email: "john.doe@example.com",
98 | },
99 | {
100 | display_name: "Alice Smith",
101 | image_url: "/profile.png",
102 | subscription_status: "Inactive",
103 | customer_id: "789012",
104 | email: "alice.smith@example.com",
105 | },
106 | {
107 | display_name: "Bob Johnson",
108 | image_url: "/profile.png",
109 | subscription_status: "Active",
110 | customer_id: "345678",
111 | email: "bob.johnson@example.com",
112 | },
113 | {
114 | display_name: "Eva Brown",
115 | image_url: "/profile.png",
116 | subscription_status: "Active",
117 | customer_id: "901234",
118 | email: "eva.brown@example.com",
119 | },
120 | ];
121 |
--------------------------------------------------------------------------------
/lib/icon/index.ts:
--------------------------------------------------------------------------------
1 | import { SiJavascript } from "react-icons/si";
2 |
3 | export const icons = {
4 | js: SiJavascript,
5 | };
6 |
--------------------------------------------------------------------------------
/lib/store/user.ts:
--------------------------------------------------------------------------------
1 | import { User } from "@supabase/supabase-js";
2 | import { create } from "zustand";
3 | import { Iuser } from "../types";
4 |
5 | interface UserState {
6 | user: Iuser | null;
7 | setUser: (user: Iuser | null) => void;
8 | }
9 |
10 | export const useUser = create()((set) => ({
11 | user: null,
12 | setUser: (user: Iuser | null) => set(() => ({ user })),
13 | }));
14 |
--------------------------------------------------------------------------------
/lib/supabase/index.ts:
--------------------------------------------------------------------------------
1 | "use server";
2 | import { createServerClient, type CookieOptions } from "@supabase/ssr";
3 | import { cookies } from "next/headers";
4 | import { Database } from "@/lib/types/supabase";
5 | import { createClient } from "@supabase/supabase-js";
6 |
7 | export async function createSupabaseServerClient() {
8 | const cookieStore = cookies();
9 | return createServerClient(
10 | process.env.NEXT_PUBLIC_SUPABASE_URL!,
11 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
12 | {
13 | cookies: {
14 | get(name: string) {
15 | return cookieStore.get(name)?.value;
16 | },
17 | },
18 | }
19 | );
20 | }
21 |
22 | export async function createSupbaseAdmin() {
23 | return createClient(
24 | process.env.NEXT_PUBLIC_SUPABASE_URL!,
25 | process.env.SERVICE_ROLE!,
26 | {
27 | auth: {
28 | autoRefreshToken: false,
29 | persistSession: false,
30 | },
31 | }
32 | );
33 | }
34 |
35 | export async function fetchCacheSupabase(query: string) {
36 | const cookieStore = cookies();
37 |
38 | const authToken = cookieStore.get(
39 | "sb-yymdoqdtmbfsrfydgfef-auth-token"
40 | )?.value;
41 |
42 | let headers = {};
43 | if (authToken) {
44 | const { access_token } = JSON.parse(authToken);
45 | headers = {
46 | Authorization: `Bearer ${access_token}`,
47 | };
48 | }
49 |
50 | const res = await fetch(
51 | process.env.NEXT_PUBLIC_SUPABASE_URL! + "/rest/v1/" + query,
52 | {
53 | headers: {
54 | apikey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
55 | ...headers,
56 | },
57 | cache: "force-cache",
58 | }
59 | );
60 | return await res.json();
61 | }
62 |
--------------------------------------------------------------------------------
/lib/types/index.ts:
--------------------------------------------------------------------------------
1 | export type IBlog = {
2 | id: string;
3 | title: string;
4 | image_url: string;
5 | created_at: string;
6 | is_premium: boolean;
7 | content: string;
8 | is_published: boolean;
9 | };
10 |
11 | export type IBlogDetial = {
12 | created_at: string;
13 | id: string;
14 | image_url: string;
15 | is_premium: boolean;
16 | is_published: boolean;
17 | title: string;
18 | blog_content: {
19 | blog_id: string;
20 | content: string;
21 | created_at: string;
22 | };
23 | } | null;
24 |
25 | export type IBlogForm = {
26 | created_at: string;
27 | id: string;
28 | image_url: string;
29 | is_premium: boolean;
30 | is_published: boolean;
31 | title: string;
32 | blog_content: {
33 | blog_id: string;
34 | content: string;
35 | created_at: string;
36 | };
37 | };
38 |
39 | export type Iuser = {
40 | created_at: string;
41 | display_name: string;
42 | email: string;
43 | id: string;
44 | image_url: string;
45 | role: string;
46 | stripe_customer_id: string | null;
47 | stripe_subscriptoin_id: string | null;
48 | subscription_status: boolean;
49 | } | null;
50 |
--------------------------------------------------------------------------------
/lib/types/supabase.ts:
--------------------------------------------------------------------------------
1 | export type Json =
2 | | string
3 | | number
4 | | boolean
5 | | null
6 | | { [key: string]: Json | undefined }
7 | | Json[]
8 |
9 | export interface Database {
10 | public: {
11 | Tables: {
12 | blog: {
13 | Row: {
14 | created_at: string
15 | id: string
16 | image_url: string
17 | is_premium: boolean
18 | is_published: boolean
19 | title: string
20 | }
21 | Insert: {
22 | created_at?: string
23 | id?: string
24 | image_url: string
25 | is_premium?: boolean
26 | is_published?: boolean
27 | title: string
28 | }
29 | Update: {
30 | created_at?: string
31 | id?: string
32 | image_url?: string
33 | is_premium?: boolean
34 | is_published?: boolean
35 | title?: string
36 | }
37 | Relationships: []
38 | }
39 | blog_content: {
40 | Row: {
41 | blog_id: string
42 | content: string
43 | created_at: string
44 | }
45 | Insert: {
46 | blog_id: string
47 | content: string
48 | created_at?: string
49 | }
50 | Update: {
51 | blog_id?: string
52 | content?: string
53 | created_at?: string
54 | }
55 | Relationships: [
56 | {
57 | foreignKeyName: "blog_content_blog_id_fkey"
58 | columns: ["blog_id"]
59 | isOneToOne: true
60 | referencedRelation: "blog"
61 | referencedColumns: ["id"]
62 | }
63 | ]
64 | }
65 | users: {
66 | Row: {
67 | created_at: string
68 | display_name: string
69 | email: string
70 | id: string
71 | image_url: string
72 | role: string
73 | stripe_customer_id: string | null
74 | stripe_subscriptoin_id: string | null
75 | subscription_status: boolean
76 | }
77 | Insert: {
78 | created_at?: string
79 | display_name: string
80 | email: string
81 | id: string
82 | image_url: string
83 | role?: string
84 | stripe_customer_id?: string | null
85 | stripe_subscriptoin_id?: string | null
86 | subscription_status?: boolean
87 | }
88 | Update: {
89 | created_at?: string
90 | display_name?: string
91 | email?: string
92 | id?: string
93 | image_url?: string
94 | role?: string
95 | stripe_customer_id?: string | null
96 | stripe_subscriptoin_id?: string | null
97 | subscription_status?: boolean
98 | }
99 | Relationships: []
100 | }
101 | }
102 | Views: {
103 | [_ in never]: never
104 | }
105 | Functions: {
106 | is_admin: {
107 | Args: {
108 | user_id: string
109 | }
110 | Returns: boolean
111 | }
112 | is_premium: {
113 | Args: {
114 | blog_id: string
115 | }
116 | Returns: boolean
117 | }
118 | is_publish: {
119 | Args: {
120 | blog_id: string
121 | }
122 | Returns: boolean
123 | }
124 | is_sub: {
125 | Args: {
126 | user_id: string
127 | }
128 | Returns: boolean
129 | }
130 | }
131 | Enums: {
132 | [_ in never]: never
133 | }
134 | CompositeTypes: {
135 | [_ in never]: never
136 | }
137 | }
138 | }
139 |
--------------------------------------------------------------------------------
/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import { type ClassValue, clsx } from "clsx";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export function cn(...inputs: ClassValue[]) {
5 | return twMerge(clsx(inputs));
6 | }
7 |
8 | // # Getting Started with Next.js
9 |
10 | // Next.js is a popular React framework that makes it easy to build server-side rendered React applications. In this blog post, we'll go through the basics of getting started with Next.js.
11 |
12 | // ## Installation
13 |
14 | // To start using Next.js, you need to install it in your project. You can do this using npm or yarn. Open your terminal and run:
15 |
16 | // ```bash
17 | // npm install next
18 | // # or
19 | // yarn add next
20 | // ```
21 |
22 | // ## Creating a Simple Next.js App
23 |
24 | // Once Next.js is installed, you can create a simple app by creating a pages directory and adding an index.js file inside it. The pages directory is a special directory in Next.js where each file becomes a route in your application.
25 |
26 | // ```javascript
27 | // // pages/index.js
28 | // import React from 'react';
29 |
30 | // const HomePage = () => {
31 | // return (
32 | //
33 | //
Welcome to My Next.js App
34 | //
This is a simple Next.js app.
35 | //
36 | // );
37 | // };
38 |
39 | // export default HomePage;
40 | // ```
41 |
42 | // ## Running Your Next.js App
43 |
44 | // To run your Next.js app, use the following command:
45 |
46 | // ```bash
47 | // npm run dev
48 | // # or
49 | // yarn dev
50 | // ```
51 |
52 | // This command starts the development server, and you can access your app at [http://localhost:3000](http://localhost:3000).
53 |
54 | // ## Conclusion
55 |
56 | // That's it! You've just created a basic Next.js app. Next.js provides a powerful framework for building React applications with features like automatic code splitting, server-side rendering, and more.
57 |
58 | // Explore the [Next.js documentation](https://nextjs.org/docs) for more advanced features and customization options.
59 | // `;
60 |
--------------------------------------------------------------------------------
/middleware.ts:
--------------------------------------------------------------------------------
1 | import { createServerClient, type CookieOptions } from "@supabase/ssr";
2 | import { NextResponse, type NextRequest } from "next/server";
3 |
4 | export async function middleware(request: NextRequest) {
5 | const { pathname } = request.nextUrl;
6 |
7 | let response = NextResponse.next({
8 | request: {
9 | headers: request.headers,
10 | },
11 | });
12 |
13 | const supabase = createServerClient(
14 | process.env.NEXT_PUBLIC_SUPABASE_URL!,
15 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
16 | {
17 | cookies: {
18 | get(name: string) {
19 | return request.cookies.get(name)?.value;
20 | },
21 | set(name: string, value: string, options: CookieOptions) {
22 | request.cookies.set({
23 | name,
24 | value,
25 | ...options,
26 | });
27 | response = NextResponse.next({
28 | request: {
29 | headers: request.headers,
30 | },
31 | });
32 | response.cookies.set({
33 | name,
34 | value,
35 | ...options,
36 | });
37 | },
38 | remove(name: string, options: CookieOptions) {
39 | request.cookies.set({
40 | name,
41 | value: "",
42 | ...options,
43 | });
44 | response = NextResponse.next({
45 | request: {
46 | headers: request.headers,
47 | },
48 | });
49 | response.cookies.set({
50 | name,
51 | value: "",
52 | ...options,
53 | });
54 | },
55 | },
56 | }
57 | );
58 |
59 | await supabase.auth.getSession();
60 |
61 | const { data } = await supabase.auth.getSession();
62 |
63 | if (data.session) {
64 | if (
65 | // protect this page only admin can access this /dashboard/members
66 | data.session.user.user_metadata.role !== "admin"
67 | ) {
68 | return NextResponse.redirect(new URL("/", request.url));
69 | }
70 | } else {
71 | return NextResponse.redirect(new URL("/", request.url));
72 | }
73 | }
74 |
75 | export const config = {
76 | matcher: ["/dashboard/:path*"],
77 | };
78 |
--------------------------------------------------------------------------------
/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {
3 | images: {
4 | remotePatterns: [
5 | {
6 | protocol: "https",
7 | hostname: "images.unsplash.com",
8 | },
9 | {
10 | protocol: "https",
11 | hostname: "source.unsplash.com",
12 | },
13 | {
14 | protocol: "https",
15 | hostname: "avatars.githubusercontent.com",
16 | },
17 | ],
18 | },
19 | };
20 |
21 | module.exports = nextConfig;
22 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "next-saas-blog",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@hookform/resolvers": "^3.3.2",
13 | "@radix-ui/react-alert-dialog": "^1.0.5",
14 | "@radix-ui/react-icons": "^1.3.0",
15 | "@radix-ui/react-label": "^2.0.2",
16 | "@radix-ui/react-popover": "^1.0.7",
17 | "@radix-ui/react-slot": "^1.0.2",
18 | "@radix-ui/react-switch": "^1.0.3",
19 | "@radix-ui/react-toast": "^1.1.5",
20 | "@stripe/stripe-js": "^2.2.0",
21 | "@supabase/ssr": "^0.0.10",
22 | "@supabase/supabase-js": "^2.38.5",
23 | "class-variance-authority": "^0.7.0",
24 | "clsx": "^2.0.0",
25 | "next": "14.0.3",
26 | "next-themes": "^0.2.1",
27 | "react": "^18",
28 | "react-dom": "^18",
29 | "react-hook-form": "^7.48.2",
30 | "react-icons": "^4.12.0",
31 | "react-markdown": "^9.0.1",
32 | "rehype-highlight": "^7.0.0",
33 | "sharp": "^0.32.6",
34 | "stripe": "^14.5.0",
35 | "tailwind-merge": "^2.0.0",
36 | "tailwindcss-animate": "^1.0.7",
37 | "zod": "^3.22.4",
38 | "zustand": "^4.4.6"
39 | },
40 | "devDependencies": {
41 | "@types/node": "^20",
42 | "@types/react": "^18",
43 | "@types/react-dom": "^18",
44 | "autoprefixer": "^10.0.1",
45 | "eslint": "^8",
46 | "eslint-config-next": "14.0.3",
47 | "postcss": "^8",
48 | "tailwindcss": "^3.3.0",
49 | "typescript": "^5"
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | }
7 |
--------------------------------------------------------------------------------
/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/og-dashboard.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Chensokheng/next-saas-blog/246a533f39e08807a498199ae9ec86f32e50e824/public/og-dashboard.png
--------------------------------------------------------------------------------
/public/og.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Chensokheng/next-saas-blog/246a533f39e08807a498199ae9ec86f32e50e824/public/og.png
--------------------------------------------------------------------------------
/public/profile.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Chensokheng/next-saas-blog/246a533f39e08807a498199ae9ec86f32e50e824/public/profile.png
--------------------------------------------------------------------------------
/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | darkMode: ["class"],
4 | content: [
5 | "./pages/**/*.{ts,tsx}",
6 | "./components/**/*.{ts,tsx}",
7 | "./app/**/*.{ts,tsx}",
8 | "./src/**/*.{ts,tsx}",
9 | ],
10 | theme: {
11 | container: {
12 | center: true,
13 | padding: "2rem",
14 | screens: {
15 | "2xl": "1400px",
16 | },
17 | },
18 | extend: {
19 | backgroundImage: {
20 | "graident-dark":
21 | "radial-gradient(76.33% 76.59% at 50.15% 6.06%, #1A1A1A 0%, rgba(26, 26, 26, 0.38) 100%)",
22 | },
23 | height: {
24 | "70vh": "70vh",
25 | },
26 | colors: {
27 | border: "hsl(var(--border))",
28 | input: "hsl(var(--input))",
29 | ring: "hsl(var(--ring))",
30 | background: "hsl(var(--background))",
31 | foreground: "hsl(var(--foreground))",
32 | primary: {
33 | DEFAULT: "hsl(var(--primary))",
34 | foreground: "hsl(var(--primary-foreground))",
35 | },
36 | secondary: {
37 | DEFAULT: "hsl(var(--secondary))",
38 | foreground: "hsl(var(--secondary-foreground))",
39 | },
40 | destructive: {
41 | DEFAULT: "hsl(var(--destructive))",
42 | foreground: "hsl(var(--destructive-foreground))",
43 | },
44 | muted: {
45 | DEFAULT: "hsl(var(--muted))",
46 | foreground: "hsl(var(--muted-foreground))",
47 | },
48 | accent: {
49 | DEFAULT: "hsl(var(--accent))",
50 | foreground: "hsl(var(--accent-foreground))",
51 | },
52 | popover: {
53 | DEFAULT: "hsl(var(--popover))",
54 | foreground: "hsl(var(--popover-foreground))",
55 | },
56 | card: {
57 | DEFAULT: "hsl(var(--card))",
58 | foreground: "hsl(var(--card-foreground))",
59 | },
60 | },
61 | borderRadius: {
62 | lg: "var(--radius)",
63 | md: "calc(var(--radius) - 2px)",
64 | sm: "calc(var(--radius) - 4px)",
65 | },
66 | keyframes: {
67 | "accordion-down": {
68 | from: { height: 0 },
69 | to: { height: "var(--radix-accordion-content-height)" },
70 | },
71 | "accordion-up": {
72 | from: { height: "var(--radix-accordion-content-height)" },
73 | to: { height: 0 },
74 | },
75 | },
76 | animation: {
77 | "accordion-down": "accordion-down 0.2s ease-out",
78 | "accordion-up": "accordion-up 0.2s ease-out",
79 | },
80 | },
81 | },
82 | plugins: [require("tailwindcss-animate")],
83 | };
84 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "noEmit": true,
9 | "esModuleInterop": true,
10 | "module": "esnext",
11 | "moduleResolution": "bundler",
12 | "resolveJsonModule": true,
13 | "isolatedModules": true,
14 | "jsx": "preserve",
15 | "incremental": true,
16 | "plugins": [
17 | {
18 | "name": "next"
19 | }
20 | ],
21 | "paths": {
22 | "@/*": ["./*"]
23 | }
24 | },
25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
26 | "exclude": ["node_modules"]
27 | }
28 |
--------------------------------------------------------------------------------