feat: optimize blog list and blog post page
This commit is contained in:
parent
aa20f3a1e0
commit
ddf2c49312
@ -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: {
|
||||
|
@ -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
|
||||
|
@ -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
39
pnpm-lock.yaml
generated
@ -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):
|
||||
|
@ -0,0 +1,5 @@
|
||||
import { BlogGridSkeleton } from "@/components/blog/blog-grid";
|
||||
|
||||
export default function Loading() {
|
||||
return <BlogGridSkeleton />;
|
||||
}
|
24
src/app/(marketing)/blog/(blog)/layout.tsx
Normal file
24
src/app/(marketing)/blog/(blog)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
5
src/app/(marketing)/blog/(blog)/loading.tsx
Normal file
5
src/app/(marketing)/blog/(blog)/loading.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { BlogGridSkeleton } from "@/components/blog/blog-grid";
|
||||
|
||||
export default function Loading() {
|
||||
return <BlogGridSkeleton />;
|
||||
}
|
61
src/app/(marketing)/blog/(blog)/page.tsx
Normal file
61
src/app/(marketing)/blog/(blog)/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
17
src/app/(marketing)/blog/[...slug]/layout.tsx
Normal file
17
src/app/(marketing)/blog/[...slug]/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
82
src/app/(marketing)/blog/[...slug]/loading.tsx
Normal file
82
src/app/(marketing)/blog/[...slug]/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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 />;
|
||||
}
|
24
src/components/blog/all-posts-button.tsx
Normal file
24
src/components/blog/all-posts-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
121
src/components/blog/blog-card.tsx
Normal file
121
src/components/blog/blog-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
34
src/components/blog/blog-grid.tsx
Normal file
34
src/components/blog/blog-grid.tsx
Normal 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>
|
||||
);
|
||||
}
|
112
src/components/blog/blog-toc.tsx
Normal file
112
src/components/blog/blog-toc.tsx
Normal 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;
|
||||
}
|
22
src/components/newsletter/newsletter-card.tsx
Normal file
22
src/components/newsletter/newsletter-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
95
src/components/newsletter/newsletter-form.tsx
Normal file
95
src/components/newsletter/newsletter-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
86
src/components/shared/callout.tsx
Normal file
86
src/components/shared/callout.tsx
Normal 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>
|
||||
);
|
||||
}
|
46
src/components/shared/copy-button.tsx
Normal file
46
src/components/shared/copy-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
9
src/components/shared/empty-grid.tsx
Normal file
9
src/components/shared/empty-grid.tsx
Normal 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>
|
||||
);
|
||||
}
|
56
src/components/shared/header-section.tsx
Normal file
56
src/components/shared/header-section.tsx
Normal 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>
|
||||
);
|
||||
}
|
124
src/components/shared/pagination.tsx
Normal file
124
src/components/shared/pagination.tsx
Normal 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,
|
||||
];
|
||||
};
|
117
src/components/ui/pagination.tsx
Normal file
117
src/components/ui/pagination.tsx
Normal 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,
|
||||
}
|
@ -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
11
src/hooks/use-mounted.ts
Normal 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
1
src/lib/constants.ts
Normal file
@ -0,0 +1 @@
|
||||
export const POSTS_PER_PAGE = 9;
|
77
src/lib/toc.ts
Normal file
77
src/lib/toc.ts
Normal 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;
|
||||
}
|
@ -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}`;
|
||||
}
|
Loading…
Reference in New Issue
Block a user