diff --git a/content-collections.ts b/content-collections.ts
index a89fec7..c29130b 100644
--- a/content-collections.ts
+++ b/content-collections.ts
@@ -68,6 +68,7 @@ export const posts = defineCollection({
schema: (z) => ({
title: z.string(),
description: z.string(),
+ image: z.string(),
date: z.string().datetime(),
published: z.boolean().default(true),
categories: z.array(z.string()),
@@ -95,6 +96,7 @@ export const posts = defineCollection({
...data,
author: blogAuthor,
categories: blogCategories,
+ image: getBaseUrl() + data.image,
slug: `/${data._meta.path}`,
slugAsParams: data._meta.path.split(path.sep).slice(1).join('/'),
body: {
diff --git a/next.config.ts b/next.config.ts
index 3d6e341..06bb143 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -3,6 +3,44 @@ import { withContentCollections } from "@content-collections/next";
const nextConfig: NextConfig = {
/* config options here */
+
+ // https://nextjs.org/docs/architecture/nextjs-compiler#remove-console
+ // Remove all console.* calls in production only
+ compiler: {
+ removeConsole: process.env.NODE_ENV === "production",
+ },
+
+ images: {
+ // https://vercel.com/docs/image-optimization/managing-image-optimization-costs#minimizing-image-optimization-costs
+ // vercel has limits on image optimization, 1000 images per month
+ unoptimized: true,
+ // https://medium.com/@niniroula/nextjs-upgrade-next-image-and-dangerouslyallowsvg-c934060d79f8
+ // The requested resource "https://cdn.sanity.io/images/58a2mkbj/preview/xxx.svg?fit=max&auto=format" has type "image/svg+xml"
+ // but dangerouslyAllowSVG is disabled
+ dangerouslyAllowSVG: true,
+ remotePatterns: [
+ {
+ protocol: "https",
+ hostname: "avatars.githubusercontent.com",
+ },
+ {
+ protocol: "https",
+ hostname: "lh3.googleusercontent.com",
+ },
+ {
+ protocol: "https",
+ hostname: "randomuser.me",
+ },
+ {
+ protocol: "https",
+ hostname: "cdn.sanity.io", // https://www.sanity.io/learn/course/day-one-with-sanity-studio/bringing-content-to-a-next-js-front-end
+ },
+ {
+ protocol: "https",
+ hostname: "via.placeholder.com", // https://www.sanity.io/learn/course/day-one-with-sanity-studio/bringing-content-to-a-next-js-front-end
+ },
+ ],
+ },
};
// https://www.content-collections.dev/docs/quickstart/next
diff --git a/package.json b/package.json
index bf9f1df..cbe94b5 100644
--- a/package.json
+++ b/package.json
@@ -35,6 +35,7 @@
"dotenv": "^16.4.7",
"drizzle-orm": "^0.39.3",
"lucide-react": "^0.475.0",
+ "mdast-util-toc": "^7.1.0",
"motion": "^12.4.3",
"next": "15.1.7",
"next-plausible": "^3.12.4",
@@ -46,6 +47,7 @@
"rehype-autolink-headings": "^7.1.0",
"rehype-pretty-code": "^0.14.0",
"rehype-slug": "^6.0.0",
+ "remark": "^15.0.1",
"remark-code-import": "^1.2.0",
"remark-gfm": "^4.0.1",
"resend": "^4.1.2",
@@ -54,6 +56,7 @@
"stripe": "^17.6.0",
"tailwind-merge": "^3.0.1",
"tailwindcss-animate": "^1.0.7",
+ "unist-util-visit": "^5.0.0",
"zod": "^3.24.2"
},
"devDependencies": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ec94f4d..a084961 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -86,6 +86,9 @@ importers:
lucide-react:
specifier: ^0.475.0
version: 0.475.0(react@19.0.0)
+ mdast-util-toc:
+ specifier: ^7.1.0
+ version: 7.1.0
motion:
specifier: ^12.4.3
version: 12.4.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@@ -119,6 +122,9 @@ importers:
rehype-slug:
specifier: ^6.0.0
version: 6.0.0
+ remark:
+ specifier: ^15.0.1
+ version: 15.0.1
remark-code-import:
specifier: ^1.2.0
version: 1.2.0
@@ -143,6 +149,9 @@ importers:
tailwindcss-animate:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@3.4.17)
+ unist-util-visit:
+ specifier: ^5.0.0
+ version: 5.0.0
zod:
specifier: ^3.24.2
version: 3.24.2
@@ -1785,6 +1794,9 @@ packages:
'@types/text-table@0.2.5':
resolution: {integrity: sha512-hcZhlNvMkQG/k1vcZ6yHOl6WAYftQ2MLfTHcYRZ2xYZFD8tGVnE3qFV0lj1smQeDSR7/yY0PyuUalauf33bJeA==}
+ '@types/ungap__structured-clone@1.2.0':
+ resolution: {integrity: sha512-ZoaihZNLeZSxESbk9PUAPZOlSpcKx81I1+4emtULDVmBLkYutTcMlCj2K9VNlf9EWODxdO6gkAqEaLorXwZQVA==}
+
'@types/unist@2.0.11':
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
@@ -2625,6 +2637,9 @@ packages:
mdast-util-to-string@4.0.0:
resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
+ mdast-util-toc@7.1.0:
+ resolution: {integrity: sha512-2TVKotOQzqdY7THOdn2gGzS9d1Sdd66bvxUyw3aNpWfcPXCLYSJCCgfPy30sEtuzkDraJgqF35dzgmz6xlvH/w==}
+
mdx-bundler@10.1.0:
resolution: {integrity: sha512-HtyVyqzBz/rG3hjca2ZggI5Ghx8YdBeAfnDIgqiMVOZyum1WcmBsdPKYN0AhV4SNaxyohASIrwC8DiXtU55FUA==}
engines: {node: '>=18', npm: '>=6'}
@@ -3206,6 +3221,9 @@ packages:
remark-stringify@11.0.0:
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
+ remark@15.0.1:
+ resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==}
+
require-directory@2.1.1:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
@@ -4945,6 +4963,8 @@ snapshots:
'@types/text-table@0.2.5': {}
+ '@types/ungap__structured-clone@1.2.0': {}
+
'@types/unist@2.0.11': {}
'@types/unist@3.0.3': {}
@@ -5951,6 +5971,16 @@ snapshots:
dependencies:
'@types/mdast': 4.0.4
+ mdast-util-toc@7.1.0:
+ dependencies:
+ '@types/mdast': 4.0.4
+ '@types/ungap__structured-clone': 1.2.0
+ '@ungap/structured-clone': 1.3.0
+ github-slugger: 2.0.0
+ mdast-util-to-string: 4.0.0
+ unist-util-is: 6.0.0
+ unist-util-visit: 5.0.0
+
mdx-bundler@10.1.0(acorn@8.14.0)(esbuild@0.21.5):
dependencies:
'@babel/runtime': 7.26.9
@@ -6741,6 +6771,15 @@ snapshots:
mdast-util-to-markdown: 2.1.2
unified: 11.0.5
+ remark@15.0.1:
+ dependencies:
+ '@types/mdast': 4.0.4
+ remark-parse: 11.0.0
+ remark-stringify: 11.0.0
+ unified: 11.0.5
+ transitivePeerDependencies:
+ - supports-color
+
require-directory@2.1.1: {}
resend@4.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
diff --git a/src/app/(marketing)/blog/(blog)/category/[slug]/loading.tsx b/src/app/(marketing)/blog/(blog)/category/[slug]/loading.tsx
new file mode 100644
index 0000000..029f260
--- /dev/null
+++ b/src/app/(marketing)/blog/(blog)/category/[slug]/loading.tsx
@@ -0,0 +1,5 @@
+import { BlogGridSkeleton } from "@/components/blog/blog-grid";
+
+export default function Loading() {
+ return ;
+}
diff --git a/src/app/(marketing)/blog/(blog)/layout.tsx b/src/app/(marketing)/blog/(blog)/layout.tsx
new file mode 100644
index 0000000..4c87061
--- /dev/null
+++ b/src/app/(marketing)/blog/(blog)/layout.tsx
@@ -0,0 +1,24 @@
+import Container from "@/components/container";
+import { HeaderSection } from "@/components/shared/header-section";
+
+export default async function BlogListLayout({
+ children,
+}: { children: React.ReactNode }) {
+ return (
+
+
+
+
+ {/* */}
+
+
+
{children}
+
+ );
+}
diff --git a/src/app/(marketing)/blog/(blog)/loading.tsx b/src/app/(marketing)/blog/(blog)/loading.tsx
new file mode 100644
index 0000000..029f260
--- /dev/null
+++ b/src/app/(marketing)/blog/(blog)/loading.tsx
@@ -0,0 +1,5 @@
+import { BlogGridSkeleton } from "@/components/blog/blog-grid";
+
+export default function Loading() {
+ return ;
+}
diff --git a/src/app/(marketing)/blog/(blog)/page.tsx b/src/app/(marketing)/blog/(blog)/page.tsx
new file mode 100644
index 0000000..d08540b
--- /dev/null
+++ b/src/app/(marketing)/blog/(blog)/page.tsx
@@ -0,0 +1,61 @@
+import BlogGrid from "@/components/blog/blog-grid";
+import EmptyGrid from "@/components/shared/empty-grid";
+import CustomPagination from "@/components/shared/pagination";
+import { siteConfig } from "@/config/site";
+import { POSTS_PER_PAGE } from "@/lib/constants";
+import { constructMetadata } from "@/lib/metadata";
+import { NextPageProps } from "@/types/next-page-props";
+import { allPosts } from "content-collections";
+
+export const metadata = constructMetadata({
+ title: "Blog",
+ description: "Read our latest blog posts",
+ canonicalUrl: `${siteConfig.url}/blog`,
+});
+
+export default async function BlogIndexPage(props: NextPageProps) {
+ const searchParams = await props.searchParams;
+ console.log("BlogIndexPage, searchParams", searchParams);
+ const page = typeof searchParams?.page === 'string' ? Number(searchParams.page) : 1;
+ const posts = await Promise.all(
+ allPosts
+ .filter((post) => post.published)
+ .sort((a, b) => b.date.localeCompare(a.date))
+ .slice( (page - 1) * POSTS_PER_PAGE, page * POSTS_PER_PAGE, )
+ // .map(async (post) => ({
+ // ...post,
+ // blurDataURL: await getBlurDataURL(post.image),
+ // })),
+ );
+ // const posts = allPosts.slice(
+ // (currentPage - 1) * POSTS_PER_PAGE,
+ // currentPage * POSTS_PER_PAGE,
+ // );
+ const totalCount = allPosts.length;
+ const totalPages = Math.ceil(totalCount / POSTS_PER_PAGE);
+
+ console.log(
+ "BlogIndexPage, totalCount",
+ totalCount,
+ ", totalPages",
+ totalPages,
+ );
+
+ return (
+
+ {/* when no posts are found */}
+ {posts?.length === 0 &&
}
+
+ {/* when posts are found */}
+ {posts && posts?.length > 0 && (
+
+ )}
+
+ );
+}
diff --git a/src/app/(marketing)/blog/[...slug]/layout.tsx b/src/app/(marketing)/blog/[...slug]/layout.tsx
new file mode 100644
index 0000000..1527603
--- /dev/null
+++ b/src/app/(marketing)/blog/[...slug]/layout.tsx
@@ -0,0 +1,17 @@
+import Container from "@/components/container";
+import { NewsletterCard } from "@/components/newsletter/newsletter-card";
+import type React from "react";
+
+export default function BlogPostLayout({
+ children,
+}: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+
+
+
+
+ );
+}
diff --git a/src/app/(marketing)/blog/[...slug]/loading.tsx b/src/app/(marketing)/blog/[...slug]/loading.tsx
new file mode 100644
index 0000000..75201d0
--- /dev/null
+++ b/src/app/(marketing)/blog/[...slug]/loading.tsx
@@ -0,0 +1,82 @@
+import { BlogGridSkeleton } from "@/components/blog/blog-grid";
+import { Skeleton } from "@/components/ui/skeleton";
+
+export default function Loading() {
+ return (
+
+ {/* Content section */}
+
+ {/* Left column */}
+
+ {/* Basic information */}
+
+ {/* blog post image */}
+
+
+ {/* blog post title */}
+
+
+ {/* blog post description */}
+
+
+
+ {/* blog post content */}
+
+
+
+
+
+
+
+ {/* Right column (sidebar) */}
+
+
+ {/* author info */}
+
+
+ {/* categories */}
+
+
+
+ {[...Array(3)].map((_, index) => (
+
+ ))}
+
+
+
+ {/* table of contents */}
+
+
+
+ {[...Array(5)].map((_, index) => (
+
+ ))}
+
+
+
+
+
+
+ {/* Footer section shows related posts */}
+
+
+ );
+}
diff --git a/src/app/(marketing)/blog/[...slug]/page.tsx b/src/app/(marketing)/blog/[...slug]/page.tsx
index cc3bf71..1867aa4 100644
--- a/src/app/(marketing)/blog/[...slug]/page.tsx
+++ b/src/app/(marketing)/blog/[...slug]/page.tsx
@@ -4,8 +4,16 @@ import { allPosts } from 'content-collections';
import { BlogPost } from '@/components/blog/blog-post';
import { getBaseUrl } from '@/lib/urls/get-base-url';
import type { NextPageProps } from '@/types/next-page-props';
+import BlogGrid from '@/components/blog/blog-grid';
+import { getTableOfContents } from '@/lib/toc';
+import { getLocaleDate } from '@/lib/utils';
+import Link from 'next/link';
+import Image from 'next/image';
+import { BlogToc } from '@/components/blog/blog-toc';
+import { Mdx } from '@/components/marketing/blog/mdx-component';
import '@/app/mdx.css';
+import AllPostsButton from '@/components/blog/all-posts-button';
/**
* Gets the blog post from the params
@@ -71,5 +79,124 @@ export default async function BlogPostPage(props: NextPageProps) {
if (!post) {
return notFound();
}
- return ;
+ // return ;
+
+ // console.log("PostPage, post", post);
+ // const imageProps = post?.image ? urlForImage(post?.image) : null;
+ // const imageBlurDataURL = post?.image?.blurDataURL || null;
+ const publishDate = post.date;
+ const date = getLocaleDate(publishDate);
+ // const markdownContent = portableTextToMarkdown(post.body);
+ // console.log("markdownContent", markdownContent);
+
+ const toc = await getTableOfContents(post.content);
+
+ return (
+
+ {/* Content section */}
+
+ {/* Left column */}
+
+ {/* Basic information */}
+
+ {/* blog post image */}
+
+ {post.image && (
+
+ )}
+
+
+ {/* blog post title */}
+
{post.title}
+
+ {/* blog post description */}
+
{post.description}
+
+
+ {/* blog post content */}
+
+
+ {/* {markdownContent && } */}
+
+
+
+
+
+ {/* Right column (sidebar) */}
+
+
+ {/* author info */}
+
+
Publisher
+
+
+ {post.author?.avatar && (
+
+ )}
+
+
+
{post.author?.name}
+
+
{date}
+
+
+
+
+ {/* categories */}
+
+
Categories
+
+ {post.categories?.map((category) => (
+ -
+
+ {category.name}
+
+
+ ))}
+
+
+
+ {/* table of contents */}
+
+
Table of Contents
+
+
+
+
+
+
+
+
+ {/* Footer section shows related posts */}
+ {/* {post.relatedPosts && post.relatedPosts.length > 0 && (
+
+
+
+
+ More Posts
+
+
+
+
+
+ )} */}
+
+ );
}
diff --git a/src/app/(marketing)/blog/page.tsx b/src/app/(marketing)/blog/page.tsx
deleted file mode 100644
index d71638d..0000000
--- a/src/app/(marketing)/blog/page.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import * as React from 'react';
-import type { Metadata } from 'next';
-import { BlogPosts } from '@/components/blog/blog-posts';
-import { createTitle } from '@/lib/utils';
-
-export const metadata: Metadata = {
- title: createTitle('Blog')
-};
-
-export default function BlogPage(): React.JSX.Element {
- return ;
-}
diff --git a/src/components/blog/all-posts-button.tsx b/src/components/blog/all-posts-button.tsx
new file mode 100644
index 0000000..2b5e4ee
--- /dev/null
+++ b/src/components/blog/all-posts-button.tsx
@@ -0,0 +1,24 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { ArrowLeftIcon } from "lucide-react";
+import Link from "next/link";
+
+export default function AllPostsButton() {
+ return (
+
+ );
+}
diff --git a/src/components/blog/blog-card.tsx b/src/components/blog/blog-card.tsx
new file mode 100644
index 0000000..a246620
--- /dev/null
+++ b/src/components/blog/blog-card.tsx
@@ -0,0 +1,121 @@
+import { Skeleton } from "@/components/ui/skeleton";
+import { getBaseUrl } from "@/lib/urls/get-base-url";
+import { getLocaleDate } from "@/lib/utils";
+import { Post } from "content-collections";
+import Image from "next/image";
+import Link from "next/link";
+
+type BlogCardProps = {
+ post: Post;
+};
+
+export default function BlogCard({ post }: BlogCardProps) {
+ const publishDate = post.date;
+ const date = getLocaleDate(publishDate);
+ // const postUrlPrefix = "/blog";
+ const postUrlPrefix = getBaseUrl();
+ const postUrl = `${postUrlPrefix}${post.slug}`;
+
+ return (
+
+ {/* Image container */}
+
+
+ {post.image && (
+
+
+
+ {post.categories && post.categories.length > 0 && (
+
+
+ {post.categories.map((category, index) => (
+
+ {category.name}
+
+ ))}
+
+
+ )}
+
+ )}
+
+
+
+ {/* Post info container */}
+
+
+ {/* Post title */}
+
+
+
+ {post.title}
+
+
+
+
+ {/* Post excerpt, hidden for now */}
+
+ {post.description && (
+
+ {post.description}
+
+ )}
+
+
+
+ {/* Author and date */}
+
+
+
+ {post?.author?.avatar && (
+
+ )}
+
+
{post?.author?.name}
+
+
+
+
+
+
+ );
+}
+
+export function BlogCardSkeleton() {
+ return (
+
+ );
+}
diff --git a/src/components/blog/blog-grid.tsx b/src/components/blog/blog-grid.tsx
new file mode 100644
index 0000000..0deaf94
--- /dev/null
+++ b/src/components/blog/blog-grid.tsx
@@ -0,0 +1,34 @@
+import BlogCard, { BlogCardSkeleton } from "@/components/blog/blog-card";
+import { POSTS_PER_PAGE } from "@/lib/constants";
+import { Post } from "content-collections";
+
+interface BlogGridProps {
+ posts: Post[];
+}
+
+export default function BlogGrid({ posts }: BlogGridProps) {
+ // console.log('BlogGrid, posts', posts);
+ return (
+
+ {posts?.length > 0 && (
+
+ {posts.map((post) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+export function BlogGridSkeleton({
+ count = POSTS_PER_PAGE,
+}: { count?: number }) {
+ return (
+
+ {[...Array(count)].map((_, index) => (
+
+ ))}
+
+ );
+}
diff --git a/src/components/blog/blog-toc.tsx b/src/components/blog/blog-toc.tsx
new file mode 100644
index 0000000..2fe3889
--- /dev/null
+++ b/src/components/blog/blog-toc.tsx
@@ -0,0 +1,112 @@
+"use client";
+
+import { useMounted } from "@/hooks/use-mounted";
+import type { TableOfContents } from "@/lib/toc";
+import { cn } from "@/lib/utils";
+import * as React from "react";
+
+interface TocProps {
+ toc: TableOfContents;
+}
+
+export function BlogToc({ toc }: TocProps) {
+ const itemIds = React.useMemo(
+ () =>
+ toc.items
+ ? toc.items
+ .flatMap((item) => [item.url, item?.items?.map((item) => item.url)])
+ .flat()
+ .filter(Boolean)
+ .map((id) => id?.split("#")[1])
+ : [],
+ [toc],
+ );
+ const activeHeading = useActiveItem(itemIds);
+ const mounted = useMounted();
+
+ if (!toc?.items) {
+ return null;
+ }
+
+ return mounted ? (
+
+
+
+ ) : null;
+}
+
+function useActiveItem(itemIds: (string | undefined)[]) {
+ const [activeId, setActiveId] = React.useState("");
+
+ React.useEffect(() => {
+ const observer = new IntersectionObserver(
+ (entries) => {
+ for (const entry of entries) {
+ if (entry.isIntersecting) {
+ setActiveId(entry.target.id);
+ }
+ }
+ },
+ { rootMargin: "0% 0% -80% 0%" },
+ );
+
+ for (const id of itemIds) {
+ if (!id) {
+ continue;
+ }
+
+ const element = document.getElementById(id);
+ if (element) {
+ observer.observe(element);
+ }
+ }
+
+ return () => {
+ for (const id of itemIds) {
+ if (!id) {
+ continue;
+ }
+
+ const element = document.getElementById(id);
+ if (element) {
+ observer.unobserve(element);
+ }
+ }
+ };
+ }, [itemIds]);
+
+ return activeId;
+}
+
+interface TreeProps {
+ tree: TableOfContents;
+ level?: number;
+ activeItem?: string | null;
+}
+
+function Tree({ tree, level = 1, activeItem }: TreeProps) {
+ return tree?.items?.length && level < 3 ? (
+
+ {tree.items.map((item, index) => {
+ return (
+ -
+
+ {item.title}
+
+ {item.items?.length ? (
+
+ ) : null}
+
+ );
+ })}
+
+ ) : null;
+}
diff --git a/src/components/newsletter/newsletter-card.tsx b/src/components/newsletter/newsletter-card.tsx
new file mode 100644
index 0000000..9a24c64
--- /dev/null
+++ b/src/components/newsletter/newsletter-card.tsx
@@ -0,0 +1,22 @@
+"use client";
+
+import { HeaderSection } from "@/components/shared/header-section";
+import { NewsletterForm } from "@/components/newsletter/newsletter-form";
+
+export function NewsletterCard() {
+ return (
+
+ );
+}
diff --git a/src/components/newsletter/newsletter-form.tsx b/src/components/newsletter/newsletter-form.tsx
new file mode 100644
index 0000000..b3199a2
--- /dev/null
+++ b/src/components/newsletter/newsletter-form.tsx
@@ -0,0 +1,95 @@
+"use client";
+
+// import { subscribeToNewsletter } from "@/actions/subscribe-to-newsletter";
+import { Icons } from "@/components/icons/icons";
+import { Button } from "@/components/ui/button";
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import { type NewsletterFormData, NewsletterFormSchema } from "@/lib/schemas";
+import { cn } from "@/lib/utils";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { PaperPlaneIcon } from "@radix-ui/react-icons";
+import { useTransition } from "react";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+
+export function NewsletterForm() {
+ const [isPending, startTransition] = useTransition();
+
+ const form = useForm({
+ resolver: zodResolver(NewsletterFormSchema),
+ defaultValues: {
+ email: "",
+ },
+ });
+
+ function onSubmit(data: NewsletterFormData) {
+ // startTransition(async () => {
+ // subscribeToNewsletter({ email: data.email })
+ // .then((data) => {
+ // switch (data.status) {
+ // case "success":
+ // toast.success("Thank you for subscribing to our newsletter");
+ // form.reset();
+ // break;
+ // default:
+ // toast.error("Something went wrong, please try again");
+ // }
+ // })
+ // .catch((error) => {
+ // console.error("NewsletterForm, onSubmit, error:", error);
+ // toast.error("Something went wrong");
+ // });
+ // });
+ }
+
+ return (
+
+
+ );
+}
diff --git a/src/components/shared/callout.tsx b/src/components/shared/callout.tsx
new file mode 100644
index 0000000..8d4a2bc
--- /dev/null
+++ b/src/components/shared/callout.tsx
@@ -0,0 +1,86 @@
+import {
+ AlertTriangle,
+ Ban,
+ CircleAlert,
+ CircleCheckBig,
+ FileText,
+ Info,
+ Lightbulb,
+} from "lucide-react";
+
+import { cn } from "@/lib/utils";
+
+interface CalloutProps {
+ twClass?: string;
+ children?: React.ReactNode;
+ type?: keyof typeof dataCallout;
+}
+
+const dataCallout = {
+ default: {
+ icon: Info,
+ classes:
+ "border-zinc-200 bg-gray-50 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-200",
+ },
+ danger: {
+ icon: CircleAlert,
+ classes:
+ "border-red-200 bg-red-50 text-red-900 dark:bg-red-950 dark:text-red-200",
+ },
+ error: {
+ icon: Ban,
+ classes:
+ "border-red-200 bg-red-50 text-red-900 dark:bg-red-950 dark:text-red-200",
+ },
+ idea: {
+ icon: Lightbulb,
+ classes:
+ "border-blue-200 bg-blue-50 text-blue-800 dark:bg-blue-950 dark:text-blue-200",
+ },
+ info: {
+ icon: Info,
+ classes:
+ "border-blue-200 bg-blue-50 text-blue-800 dark:bg-blue-950 dark:text-blue-200",
+ },
+ note: {
+ icon: FileText,
+ classes:
+ "border-blue-200 bg-blue-50 text-blue-800 dark:bg-blue-950 dark:text-blue-200",
+ },
+ success: {
+ icon: CircleCheckBig,
+ classes:
+ "border-green-200 bg-green-50 text-green-800 dark:bg-green-400/20 dark:text-green-300",
+ },
+ warning: {
+ icon: AlertTriangle,
+ classes:
+ "border-orange-200 bg-orange-50 text-orange-800 dark:bg-orange-400/20 dark:text-orange-300",
+ },
+};
+
+export function Callout({
+ children,
+ twClass,
+ type = "default",
+ ...props
+}: CalloutProps) {
+ const { icon: Icon, classes } = dataCallout[type];
+ return (
+
+ );
+}
diff --git a/src/components/shared/copy-button.tsx b/src/components/shared/copy-button.tsx
new file mode 100644
index 0000000..f1ef284
--- /dev/null
+++ b/src/components/shared/copy-button.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+import { CheckIcon, CopyIcon } from "lucide-react";
+import React, { useEffect } from "react";
+
+interface CopyButtonProps extends React.HTMLAttributes {
+ value: string;
+}
+
+export function CopyButton({ value, className, ...props }: CopyButtonProps) {
+ const [hasCopied, setHasCopied] = React.useState(false);
+
+ // biome-ignore lint/correctness/useExhaustiveDependencies:
+ useEffect(() => {
+ setTimeout(() => {
+ setHasCopied(false);
+ }, 2000);
+ }, [hasCopied]);
+
+ const handleCopyValue = (value: string) => {
+ navigator.clipboard.writeText(value);
+ setHasCopied(true);
+ };
+
+ return (
+
+ );
+}
diff --git a/src/components/shared/empty-grid.tsx b/src/components/shared/empty-grid.tsx
new file mode 100644
index 0000000..30bfd53
--- /dev/null
+++ b/src/components/shared/empty-grid.tsx
@@ -0,0 +1,9 @@
+export default function EmptyGrid() {
+ return (
+
+ );
+}
diff --git a/src/components/shared/header-section.tsx b/src/components/shared/header-section.tsx
new file mode 100644
index 0000000..11f8455
--- /dev/null
+++ b/src/components/shared/header-section.tsx
@@ -0,0 +1,56 @@
+import { cn } from "@/lib/utils";
+
+interface HeaderSectionProps {
+ id?: string;
+ label?: string;
+ labelAs?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p";
+ title?: string;
+ titleAs?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p";
+ subtitle?: string;
+ subtitleAs?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "p";
+ className?: string;
+ children?: React.ReactNode;
+}
+
+/**
+ * different pages may use this component as different heading style for SEO friendly
+ */
+export function HeaderSection({
+ id,
+ label,
+ labelAs = "p",
+ title,
+ titleAs = "p",
+ subtitle,
+ subtitleAs = "p",
+ className,
+ children,
+}: HeaderSectionProps) {
+ const LabelComponent = labelAs;
+ const TitleComponent = titleAs;
+ const SubtitleComponent = subtitleAs;
+ return (
+
+ {label ? (
+
+ {label}
+
+ ) : null}
+ {title ? (
+
+ {title}
+
+ ) : null}
+ {subtitle ? (
+
+ {subtitle}
+
+ ) : null}
+
+ {children}
+
+ );
+}
diff --git a/src/components/shared/pagination.tsx b/src/components/shared/pagination.tsx
new file mode 100644
index 0000000..af1fac0
--- /dev/null
+++ b/src/components/shared/pagination.tsx
@@ -0,0 +1,124 @@
+"use client";
+
+import {
+ Pagination,
+ PaginationContent,
+ PaginationEllipsis,
+ PaginationItem,
+ PaginationLink,
+ PaginationNext,
+ PaginationPrevious,
+} from "@/components/ui/pagination";
+import { useRouter, useSearchParams } from "next/navigation";
+
+type CustomPaginationProps = {
+ totalPages: number;
+ routePreix: string;
+};
+
+export default function CustomPagination({
+ totalPages,
+ routePreix,
+}: CustomPaginationProps) {
+ const router = useRouter();
+ const searchParams = useSearchParams();
+ const currentPage = Number(searchParams.get("page")) || 1;
+
+ const handlePageChange = (page: number | string) => {
+ const params = new URLSearchParams(searchParams);
+ params.set("page", page.toString());
+ router.push(`${routePreix}?${params.toString()}`);
+ };
+
+ const allPages = generatePagination(currentPage, totalPages);
+
+ return (
+
+
+
+ 1
+ ? () => handlePageChange(currentPage - 1)
+ : undefined
+ }
+ aria-disabled={currentPage <= 1}
+ className={
+ currentPage <= 1
+ ? "pointer-events-none text-gray-300 dark:text-gray-600"
+ : "cursor-pointer"
+ }
+ />
+
+ {allPages.map((page, index) => (
+ // biome-ignore lint/suspicious/noArrayIndexKey:
+
+ {page === "..." ? (
+
+ ) : (
+ handlePageChange(page)}
+ isActive={currentPage === page}
+ className={currentPage === page ? "" : "cursor-pointer"}
+ >
+ {page}
+
+ )}
+
+ ))}
+
+
+ handlePageChange(currentPage + 1)
+ : undefined
+ }
+ aria-disabled={currentPage >= totalPages}
+ className={
+ currentPage >= totalPages
+ ? "pointer-events-none text-gray-300 dark:text-gray-600"
+ : "cursor-pointer"
+ }
+ />
+
+
+
+ );
+}
+
+/**
+ * Generate an array of page numbers to display in the pagination component
+ */
+const generatePagination = (currentPage: number, totalPages: number) => {
+ // If the total number of pages is 7 or less,
+ // display all pages without any ellipsis.
+ if (totalPages <= 7) {
+ return Array.from({ length: totalPages }, (_, i) => i + 1);
+ }
+
+ // If the current page is among the first 3 pages,
+ // show the first 3, an ellipsis, and the last 2 pages.
+ if (currentPage <= 3) {
+ return [1, 2, 3, "...", totalPages - 1, totalPages];
+ }
+
+ // If the current page is among the last 3 pages,
+ // show the first 2, an ellipsis, and the last 3 pages.
+ if (currentPage >= totalPages - 2) {
+ return [1, 2, "...", totalPages - 2, totalPages - 1, totalPages];
+ }
+
+ // If the current page is somewhere in the middle,
+ // show the first page, an ellipsis, the current page and its neighbors,
+ // another ellipsis, and the last page.
+ return [
+ 1,
+ "...",
+ currentPage - 1,
+ currentPage,
+ currentPage + 1,
+ "...",
+ totalPages,
+ ];
+};
diff --git a/src/components/ui/pagination.tsx b/src/components/ui/pagination.tsx
new file mode 100644
index 0000000..ea40d19
--- /dev/null
+++ b/src/components/ui/pagination.tsx
@@ -0,0 +1,117 @@
+import * as React from "react"
+import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+import { ButtonProps, buttonVariants } from "@/components/ui/button"
+
+const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => (
+
+)
+Pagination.displayName = "Pagination"
+
+const PaginationContent = React.forwardRef<
+ HTMLUListElement,
+ React.ComponentProps<"ul">
+>(({ className, ...props }, ref) => (
+
+))
+PaginationContent.displayName = "PaginationContent"
+
+const PaginationItem = React.forwardRef<
+ HTMLLIElement,
+ React.ComponentProps<"li">
+>(({ className, ...props }, ref) => (
+
+))
+PaginationItem.displayName = "PaginationItem"
+
+type PaginationLinkProps = {
+ isActive?: boolean
+} & Pick &
+ React.ComponentProps<"a">
+
+const PaginationLink = ({
+ className,
+ isActive,
+ size = "icon",
+ ...props
+}: PaginationLinkProps) => (
+
+)
+PaginationLink.displayName = "PaginationLink"
+
+const PaginationPrevious = ({
+ className,
+ ...props
+}: React.ComponentProps) => (
+
+
+ Previous
+
+)
+PaginationPrevious.displayName = "PaginationPrevious"
+
+const PaginationNext = ({
+ className,
+ ...props
+}: React.ComponentProps) => (
+
+ Next
+
+
+)
+PaginationNext.displayName = "PaginationNext"
+
+const PaginationEllipsis = ({
+ className,
+ ...props
+}: React.ComponentProps<"span">) => (
+
+
+ More pages
+
+)
+PaginationEllipsis.displayName = "PaginationEllipsis"
+
+export {
+ Pagination,
+ PaginationContent,
+ PaginationEllipsis,
+ PaginationItem,
+ PaginationLink,
+ PaginationNext,
+ PaginationPrevious,
+}
diff --git a/src/config/site.ts b/src/config/site.ts
index eb7e2b1..6bfd5ea 100644
--- a/src/config/site.ts
+++ b/src/config/site.ts
@@ -6,9 +6,9 @@ export const siteConfig: SiteConfig = {
name: "MkSaaS",
title: "MkSaaS - The Best AI SaaS Boilerplate",
tagline:
- "Launch AI SaaS websites in minutes, simply and effortlessly",
+ "Launch AI SaaS Websites in hours, simply and effortlessly",
description:
- "MkSaaS is the best AI SaaS boilerplate. Launch AI SaaS websites in minutes, simply and effortlessly",
+ "MkSaaS is the best AI SaaS boilerplate. Launch AI SaaS websites in hours, simply and effortlessly",
keywords: [
"SaaS",
"SaaS Website",
diff --git a/src/hooks/use-mounted.ts b/src/hooks/use-mounted.ts
new file mode 100644
index 0000000..57bb851
--- /dev/null
+++ b/src/hooks/use-mounted.ts
@@ -0,0 +1,11 @@
+import * as React from "react";
+
+export function useMounted() {
+ const [mounted, setMounted] = React.useState(false);
+
+ React.useEffect(() => {
+ setMounted(true);
+ }, []);
+
+ return mounted;
+}
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
new file mode 100644
index 0000000..24c5781
--- /dev/null
+++ b/src/lib/constants.ts
@@ -0,0 +1 @@
+export const POSTS_PER_PAGE = 9;
diff --git a/src/lib/toc.ts b/src/lib/toc.ts
new file mode 100644
index 0000000..8a8a006
--- /dev/null
+++ b/src/lib/toc.ts
@@ -0,0 +1,77 @@
+// @ts-nocheck
+import { toc } from "mdast-util-toc";
+import { remark } from "remark";
+import { visit } from "unist-util-visit";
+
+const textTypes = ["text", "emphasis", "strong", "inlineCode"];
+
+function flattenNode(node) {
+ const p = [];
+ visit(node, (node) => {
+ if (!textTypes.includes(node.type)) return;
+ p.push(node.value);
+ });
+ return p.join("");
+}
+
+interface Item {
+ title: string;
+ url: string;
+ items?: Item[];
+}
+
+interface Items {
+ items?: Item[];
+}
+
+function getItems(node, current): Items {
+ if (!node) {
+ return {};
+ }
+
+ if (node.type === "paragraph") {
+ visit(node, (item) => {
+ if (item.type === "link") {
+ current.url = item.url;
+ current.title = flattenNode(node);
+ }
+
+ if (item.type === "text") {
+ current.title = flattenNode(node);
+ }
+ });
+
+ return current;
+ }
+
+ if (node.type === "list") {
+ current.items = node.children.map((i) => getItems(i, {}));
+
+ return current;
+ }
+ if (node.type === "listItem") {
+ const heading = getItems(node.children[0], {});
+
+ if (node.children.length > 1) {
+ getItems(node.children[1], heading);
+ }
+
+ return heading;
+ }
+
+ return {};
+}
+
+const getToc = () => (node, file) => {
+ const table = toc(node);
+ file.data = getItems(table.map, {});
+};
+
+export type TableOfContents = Items;
+
+export async function getTableOfContents(
+ content: string,
+): Promise {
+ const result = await remark().use(getToc).process(content);
+ return result.data;
+}
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index e4d42eb..69a4c8e 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -51,3 +51,14 @@ export function getInitials(name: string): string {
.map((v) => v && v[0].toUpperCase())
.join('');
}
+
+/**
+ * get locale date string, like "2024/10/01"
+ */
+export function getLocaleDate(input: string | number): string {
+ const date = new Date(input);
+ const year = date.getFullYear();
+ const month = (date.getMonth() + 1).toString().padStart(2, "0");
+ const day = date.getDate().toString().padStart(2, "0");
+ return `${year}/${month}/${day}`;
+}
\ No newline at end of file