feat: optimize blog list and blog post page

This commit is contained in:
javayhu 2025-02-22 16:52:36 +08:00
parent aa20f3a1e0
commit ddf2c49312
29 changed files with 1352 additions and 15 deletions

View File

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

View File

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

View File

@ -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": {

39
pnpm-lock.yaml generated
View File

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

View File

@ -0,0 +1,5 @@
import { BlogGridSkeleton } from "@/components/blog/blog-grid";
export default function Loading() {
return <BlogGridSkeleton />;
}

View File

@ -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 (
<div className="mb-16">
<div className="mt-8 w-full flex flex-col items-center justify-center gap-8">
<HeaderSection
labelAs="h1"
label="Blog"
titleAs="h2"
title="Read our latest blog posts"
subtitle="Working in progress, will write more helpful posts later"
/>
{/* <BlogCategoryFilter /> */}
</div>
<Container className="mt-8">{children}</Container>
</div>
);
}

View File

@ -0,0 +1,5 @@
import { BlogGridSkeleton } from "@/components/blog/blog-grid";
export default function Loading() {
return <BlogGridSkeleton />;
}

View File

@ -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 (
<div>
{/* when no posts are found */}
{posts?.length === 0 && <EmptyGrid />}
{/* when posts are found */}
{posts && posts?.length > 0 && (
<div>
<BlogGrid posts={posts} />
<div className="mt-8 flex items-center justify-center">
<CustomPagination routePreix="/blog" totalPages={totalPages} />
</div>
</div>
)}
</div>
);
}

View File

@ -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 (
<div className="mb-16">
<Container className="mt-8">{children}</Container>
<Container className="mt-16">
<NewsletterCard />
</Container>
</div>
);
}

View File

@ -0,0 +1,82 @@
import { BlogGridSkeleton } from "@/components/blog/blog-grid";
import { Skeleton } from "@/components/ui/skeleton";
export default function Loading() {
return (
<div className="flex flex-col gap-8">
{/* Content section */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left column */}
<div className="lg:col-span-2 flex flex-col">
{/* Basic information */}
<div className="space-y-8">
{/* blog post image */}
<Skeleton className="w-full aspect-[16/9] rounded-lg" />
{/* blog post title */}
<Skeleton className="h-12 w-1/2" />
{/* blog post description */}
<Skeleton className="h-20 w-full" />
</div>
{/* blog post content */}
<div className="mt-8 space-y-4">
<Skeleton className="h-96 w-full" />
</div>
<div className="flex items-center justify-start mt-16">
{/* <AllPostsButton /> */}
</div>
</div>
{/* Right column (sidebar) */}
<div>
<div className="space-y-4 lg:sticky lg:top-24">
{/* author info */}
<div className="bg-muted/50 rounded-lg p-6">
<Skeleton className="h-8 w-24 mb-4" />
<div className="flex items-center gap-4">
<Skeleton className="h-12 w-12 rounded-full" />
<div>
<Skeleton className="h-8 w-32 mb-2" />
<Skeleton className="h-6 w-24" />
</div>
</div>
</div>
{/* categories */}
<div className="bg-muted/50 rounded-lg p-6">
<Skeleton className="h-8 w-24 mb-4" />
<div className="flex flex-wrap gap-4">
{[...Array(3)].map((_, index) => (
<Skeleton key={index} className="h-6 w-20" />
))}
</div>
</div>
{/* table of contents */}
<div className="bg-muted/50 rounded-lg p-6 hidden lg:block">
<Skeleton className="h-8 w-40 mb-4" />
<div className="space-y-2">
{[...Array(5)].map((_, index) => (
<Skeleton key={index} className="h-6 w-full" />
))}
</div>
</div>
</div>
</div>
</div>
{/* Footer section shows related posts */}
<div className="flex flex-col gap-8 mt-8">
<div className="flex items-center gap-2">
<Skeleton className="h-8 w-6" />
<Skeleton className="h-8 w-32" />
</div>
<BlogGridSkeleton count={3} />
</div>
</div>
);
}

View File

@ -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 <BlogPost post={post} />;
// return <BlogPost post={post} />;
// 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 (
<div className="flex flex-col gap-8">
{/* Content section */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left column */}
<div className="lg:col-span-2 flex flex-col">
{/* Basic information */}
<div className="space-y-8">
{/* blog post image */}
<div className="group overflow-hidden relative aspect-[16/9] rounded-lg transition-all border">
{post.image && (
<Image
src={post.image}
alt={post.title || "image for blog post"}
title={post.title || "image for blog post"}
loading="eager"
fill
className="object-cover"
/>
)}
</div>
{/* blog post title */}
<h1 className="text-3xl font-bold">{post.title}</h1>
{/* blog post description */}
<p className="text-lg text-muted-foreground">{post.description}</p>
</div>
{/* blog post content */}
<div className="mt-4">
<Mdx code={post.body.code} />
{/* {markdownContent && <BlogCustomMdx source={markdownContent} />} */}
</div>
<div className="flex items-center justify-start mt-16">
<AllPostsButton />
</div>
</div>
{/* Right column (sidebar) */}
<div>
<div className="space-y-4 lg:sticky lg:top-24">
{/* author info */}
<div className="bg-muted/50 rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Publisher</h2>
<div className="flex items-center gap-4">
<div className="relative h-12 w-12 flex-shrink-0">
{post.author?.avatar && (
<Image
src={post?.author?.avatar}
alt={`avatar for ${post.author.name}`}
className="rounded-full object-cover border"
fill
/>
)}
</div>
<div>
<span>{post.author?.name}</span>
<p className="text-sm text-muted-foreground">{date}</p>
</div>
</div>
</div>
{/* categories */}
<div className="bg-muted/50 rounded-lg p-6">
<h2 className="text-lg font-semibold mb-4">Categories</h2>
<ul className="flex flex-wrap gap-4">
{post.categories?.map((category) => (
<li key={category.slug}>
<Link
href={`/blog/category/${category.slug}`}
className="text-sm link-underline"
>
{category.name}
</Link>
</li>
))}
</ul>
</div>
{/* table of contents */}
<div className="bg-muted/50 rounded-lg p-6 hidden lg:block">
<h2 className="text-lg font-semibold mb-4">Table of Contents</h2>
<div className="max-h-[calc(100vh-18rem)] overflow-y-auto">
<BlogToc toc={toc} />
</div>
</div>
</div>
</div>
</div>
{/* Footer section shows related posts */}
{/* {post.relatedPosts && post.relatedPosts.length > 0 && (
<div className="flex flex-col gap-8 mt-8">
<div className="flex items-center gap-2">
<FileTextIcon className="w-4 h-4 text-indigo-500" />
<h2 className="text-lg tracking-wider font-semibold text-gradient_indigo-purple">
More Posts
</h2>
</div>
<BlogGrid posts={post.relatedPosts} />
</div>
)} */}
</div>
);
}

View File

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

View File

@ -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 (
<Button
size="lg"
variant="outline"
className="inline-flex items-center gap-2 group"
asChild
>
<Link href="/blog" prefetch={false}>
<ArrowLeftIcon
className="w-5 h-5
transition-transform duration-200 group-hover:-translate-x-1"
/>
<span>All Posts</span>
</Link>
</Button>
);
}

View File

@ -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 (
<div className="group cursor-pointer flex flex-col gap-4">
{/* Image container */}
<div className="group overflow-hidden relative aspect-[16/9] rounded-lg transition-all">
<Link href={postUrl}>
{post.image && (
<div className="relative w-full h-full">
<Image
src={post.image}
alt={post.title || "image for blog post"}
title={post.title || "image for blog post"}
className="object-cover hover:scale-105 transition-transform duration-300"
fill
/>
{post.categories && post.categories.length > 0 && (
<div className="absolute left-2 bottom-2 opacity-100 transition-opacity duration-300">
<div className="flex flex-wrap gap-1">
{post.categories.map((category, index) => (
<span
key={category.slug}
className="text-xs font-medium text-white bg-black bg-opacity-50 px-2 py-1 rounded-md"
>
{category.name}
</span>
))}
</div>
</div>
)}
</div>
)}
</Link>
</div>
{/* Post info container */}
<div className="flex flex-col flex-grow">
<div>
{/* Post title */}
<h3 className="text-lg line-clamp-2 font-medium">
<Link href={postUrl}>
<span
className="bg-gradient-to-r from-green-200 to-green-100
bg-[length:0px_10px] bg-left-bottom bg-no-repeat
transition-[background-size]
duration-500
hover:bg-[length:100%_3px]
group-hover:bg-[length:100%_10px]
dark:from-purple-800 dark:to-purple-900"
>
{post.title}
</span>
</Link>
</h3>
{/* Post excerpt, hidden for now */}
<div className="hidden">
{post.description && (
<p className="mt-2 line-clamp-3 text-sm text-gray-500 dark:text-gray-400">
<Link href={postUrl}>{post.description}</Link>
</p>
)}
</div>
</div>
{/* Author and date */}
<div className="mt-auto pt-4 flex items-center justify-between space-x-4 text-muted-foreground">
<div className="flex items-center gap-2">
<div className="relative h-5 w-5 flex-shrink-0">
{post?.author?.avatar && (
<Image
src={post?.author?.avatar}
alt={`avatar for ${post?.author?.name}`}
className="rounded-full object-cover border"
fill
/>
)}
</div>
<span className="truncate text-sm">{post?.author?.name}</span>
</div>
<time className="truncate text-sm" dateTime={date}>
{date}
</time>
</div>
</div>
</div>
);
}
export function BlogCardSkeleton() {
return (
<div className="group cursor-pointer flex flex-col gap-4">
<div className="group overflow-hidden relative aspect-[4/3] rounded-lg transition-all">
<Skeleton className="w-full aspect-[4/3] rounded-lg" />
</div>
<Skeleton className="h-12 w-full" />
<div className="flex items-center justify-between gap-2">
<Skeleton className="h-8 w-32" />
<Skeleton className="h-8 w-32" />
</div>
</div>
);
}

View File

@ -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 (
<div>
{posts?.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{posts.map((post) => (
<BlogCard key={post.slug} post={post} />
))}
</div>
)}
</div>
);
}
export function BlogGridSkeleton({
count = POSTS_PER_PAGE,
}: { count?: number }) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{[...Array(count)].map((_, index) => (
<BlogCardSkeleton key={index} />
))}
</div>
);
}

View File

@ -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 ? (
<div className="space-y-2">
<Tree tree={toc} activeItem={activeHeading} />
</div>
) : null;
}
function useActiveItem(itemIds: (string | undefined)[]) {
const [activeId, setActiveId] = React.useState<string>("");
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 ? (
<ul className={cn("m-0 list-none", { "pl-4": level !== 1 })}>
{tree.items.map((item, index) => {
return (
<li key={index} className={cn("mt-0 pt-1")}>
<a
href={item.url}
className={cn(
"inline-block text-sm no-underline",
item.url === `#${activeItem}`
? "font-medium text-primary"
: "text-muted-foreground",
)}
>
{item.title}
</a>
{item.items?.length ? (
<Tree tree={item} level={level + 1} activeItem={activeItem} />
) : null}
</li>
);
})}
</ul>
) : null;
}

View File

@ -0,0 +1,22 @@
"use client";
import { HeaderSection } from "@/components/shared/header-section";
import { NewsletterForm } from "@/components/newsletter/newsletter-form";
export function NewsletterCard() {
return (
<div className="w-full px-4 py-8 md:p-12 bg-muted rounded-lg">
<div className="flex flex-col items-center justify-center gap-8">
<HeaderSection
labelAs="h2"
label="Newsletter"
title="Join the Community"
titleAs="h3"
subtitle="Subscribe to our newsletter for the latest news and updates"
/>
<NewsletterForm />
</div>
</div>
);
}

View File

@ -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<NewsletterFormData>({
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 (
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="flex items-center justify-center"
>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className="relative space-y-0">
<FormLabel className="sr-only">Email</FormLabel>
<FormControl className="rounded-r-none">
<Input
type="email"
className={cn(
"w-[280px] sm:w-[320px] md:w-[400px] h-12 rounded-r-none",
"focus-visible:ring-0 focus-visible:ring-offset-0 focus:border-primary focus:border-2 focus:border-r-0",
)}
placeholder="Enter your email"
{...field}
/>
</FormControl>
<FormMessage className="pt-2 text-sm" />
</FormItem>
)}
/>
<Button
type="submit"
className="rounded-l-none size-12"
disabled={isPending}
>
{isPending ? (
<Icons.spinner className="size-6 animate-spin" aria-hidden="true" />
) : (
<PaperPlaneIcon className="size-6" aria-hidden="true" />
)}
<span className="sr-only">Subscribe</span>
</Button>
</form>
</Form>
);
}

View File

@ -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 (
<div>
{/* <div
className={cn(
"mt-6 flex items-start space-x-3 rounded-lg border px-4 py-3 text-[15.6px] dark:border-none",
classes,
twClass,
)}
{...props}
>
<div className="mt-1 shrink-0">
<Icon className="size-5" />
</div>
<div className="[&>p]:my-0">{children}</div>
</div> */}
</div>
);
}

View File

@ -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<HTMLButtonElement> {
value: string;
}
export function CopyButton({ value, className, ...props }: CopyButtonProps) {
const [hasCopied, setHasCopied] = React.useState(false);
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
useEffect(() => {
setTimeout(() => {
setHasCopied(false);
}, 2000);
}, [hasCopied]);
const handleCopyValue = (value: string) => {
navigator.clipboard.writeText(value);
setHasCopied(true);
};
return (
<Button
size="sm"
variant="ghost"
className={cn(
"z-10 size-[30px] border border-white/25 bg-zinc-900 p-1.5 text-primary-foreground hover:text-foreground dark:text-foreground",
className,
)}
onClick={() => handleCopyValue(value)}
{...props}
>
<span className="sr-only">Copy</span>
{hasCopied ? (
<CheckIcon className="size-4" />
) : (
<CopyIcon className="size-4" />
)}
</Button>
);
}

View File

@ -0,0 +1,9 @@
export default function EmptyGrid() {
return (
<div>
<div className="my-8 h-32 w-full flex items-center justify-center">
<p className="font-medium text-muted-foreground">Nothing found.</p>
</div>
</div>
);
}

View File

@ -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 (
<div
id={id}
className={cn("flex flex-col items-center text-center gap-4", className)}
>
{label ? (
<LabelComponent className="uppercase tracking-wider text-gradient_indigo-purple font-semibold">
{label}
</LabelComponent>
) : null}
{title ? (
<TitleComponent className="text-2xl md:text-4xl">
{title}
</TitleComponent>
) : null}
{subtitle ? (
<SubtitleComponent className="text-balance text-lg text-foreground/80">
{subtitle}
</SubtitleComponent>
) : null}
{children}
</div>
);
}

View File

@ -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 (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={
currentPage > 1
? () => handlePageChange(currentPage - 1)
: undefined
}
aria-disabled={currentPage <= 1}
className={
currentPage <= 1
? "pointer-events-none text-gray-300 dark:text-gray-600"
: "cursor-pointer"
}
/>
</PaginationItem>
{allPages.map((page, index) => (
// biome-ignore lint/suspicious/noArrayIndexKey: <explanation>
<PaginationItem key={`${page}-${index}`}>
{page === "..." ? (
<PaginationEllipsis />
) : (
<PaginationLink
onClick={() => handlePageChange(page)}
isActive={currentPage === page}
className={currentPage === page ? "" : "cursor-pointer"}
>
{page}
</PaginationLink>
)}
</PaginationItem>
))}
<PaginationItem>
<PaginationNext
onClick={
currentPage < totalPages
? () => handlePageChange(currentPage + 1)
: undefined
}
aria-disabled={currentPage >= totalPages}
className={
currentPage >= totalPages
? "pointer-events-none text-gray-300 dark:text-gray-600"
: "cursor-pointer"
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
);
}
/**
* 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,
];
};

View File

@ -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">) => (
<nav
role="navigation"
aria-label="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
Pagination.displayName = "Pagination"
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<"ul">
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
))
PaginationContent.displayName = "PaginationContent"
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<"li">
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn("", className)} {...props} />
))
PaginationItem.displayName = "PaginationItem"
type PaginationLinkProps = {
isActive?: boolean
} & Pick<ButtonProps, "size"> &
React.ComponentProps<"a">
const PaginationLink = ({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? "page" : undefined}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
PaginationLink.displayName = "PaginationLink"
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 pl-2.5", className)}
{...props}
>
<ChevronLeft className="h-4 w-4" />
<span>Previous</span>
</PaginationLink>
)
PaginationPrevious.displayName = "PaginationPrevious"
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 pr-2.5", className)}
{...props}
>
<span>Next</span>
<ChevronRight className="h-4 w-4" />
</PaginationLink>
)
PaginationNext.displayName = "PaginationNext"
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<"span">) => (
<span
aria-hidden
className={cn("flex h-9 w-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More pages</span>
</span>
)
PaginationEllipsis.displayName = "PaginationEllipsis"
export {
Pagination,
PaginationContent,
PaginationEllipsis,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
}

View File

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

11
src/hooks/use-mounted.ts Normal file
View File

@ -0,0 +1,11 @@
import * as React from "react";
export function useMounted() {
const [mounted, setMounted] = React.useState(false);
React.useEffect(() => {
setMounted(true);
}, []);
return mounted;
}

1
src/lib/constants.ts Normal file
View File

@ -0,0 +1 @@
export const POSTS_PER_PAGE = 9;

77
src/lib/toc.ts Normal file
View File

@ -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<TableOfContents> {
const result = await remark().use(getToc).process(content);
return result.data;
}

View File

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