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) => ({
|
schema: (z) => ({
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
description: z.string(),
|
description: z.string(),
|
||||||
|
image: z.string(),
|
||||||
date: z.string().datetime(),
|
date: z.string().datetime(),
|
||||||
published: z.boolean().default(true),
|
published: z.boolean().default(true),
|
||||||
categories: z.array(z.string()),
|
categories: z.array(z.string()),
|
||||||
@ -95,6 +96,7 @@ export const posts = defineCollection({
|
|||||||
...data,
|
...data,
|
||||||
author: blogAuthor,
|
author: blogAuthor,
|
||||||
categories: blogCategories,
|
categories: blogCategories,
|
||||||
|
image: getBaseUrl() + data.image,
|
||||||
slug: `/${data._meta.path}`,
|
slug: `/${data._meta.path}`,
|
||||||
slugAsParams: data._meta.path.split(path.sep).slice(1).join('/'),
|
slugAsParams: data._meta.path.split(path.sep).slice(1).join('/'),
|
||||||
body: {
|
body: {
|
||||||
|
@ -3,6 +3,44 @@ import { withContentCollections } from "@content-collections/next";
|
|||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
/* 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
|
// https://www.content-collections.dev/docs/quickstart/next
|
||||||
|
@ -35,6 +35,7 @@
|
|||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"drizzle-orm": "^0.39.3",
|
"drizzle-orm": "^0.39.3",
|
||||||
"lucide-react": "^0.475.0",
|
"lucide-react": "^0.475.0",
|
||||||
|
"mdast-util-toc": "^7.1.0",
|
||||||
"motion": "^12.4.3",
|
"motion": "^12.4.3",
|
||||||
"next": "15.1.7",
|
"next": "15.1.7",
|
||||||
"next-plausible": "^3.12.4",
|
"next-plausible": "^3.12.4",
|
||||||
@ -46,6 +47,7 @@
|
|||||||
"rehype-autolink-headings": "^7.1.0",
|
"rehype-autolink-headings": "^7.1.0",
|
||||||
"rehype-pretty-code": "^0.14.0",
|
"rehype-pretty-code": "^0.14.0",
|
||||||
"rehype-slug": "^6.0.0",
|
"rehype-slug": "^6.0.0",
|
||||||
|
"remark": "^15.0.1",
|
||||||
"remark-code-import": "^1.2.0",
|
"remark-code-import": "^1.2.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"resend": "^4.1.2",
|
"resend": "^4.1.2",
|
||||||
@ -54,6 +56,7 @@
|
|||||||
"stripe": "^17.6.0",
|
"stripe": "^17.6.0",
|
||||||
"tailwind-merge": "^3.0.1",
|
"tailwind-merge": "^3.0.1",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"unist-util-visit": "^5.0.0",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
39
pnpm-lock.yaml
generated
39
pnpm-lock.yaml
generated
@ -86,6 +86,9 @@ importers:
|
|||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.475.0
|
specifier: ^0.475.0
|
||||||
version: 0.475.0(react@19.0.0)
|
version: 0.475.0(react@19.0.0)
|
||||||
|
mdast-util-toc:
|
||||||
|
specifier: ^7.1.0
|
||||||
|
version: 7.1.0
|
||||||
motion:
|
motion:
|
||||||
specifier: ^12.4.3
|
specifier: ^12.4.3
|
||||||
version: 12.4.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
version: 12.4.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||||
@ -119,6 +122,9 @@ importers:
|
|||||||
rehype-slug:
|
rehype-slug:
|
||||||
specifier: ^6.0.0
|
specifier: ^6.0.0
|
||||||
version: 6.0.0
|
version: 6.0.0
|
||||||
|
remark:
|
||||||
|
specifier: ^15.0.1
|
||||||
|
version: 15.0.1
|
||||||
remark-code-import:
|
remark-code-import:
|
||||||
specifier: ^1.2.0
|
specifier: ^1.2.0
|
||||||
version: 1.2.0
|
version: 1.2.0
|
||||||
@ -143,6 +149,9 @@ importers:
|
|||||||
tailwindcss-animate:
|
tailwindcss-animate:
|
||||||
specifier: ^1.0.7
|
specifier: ^1.0.7
|
||||||
version: 1.0.7(tailwindcss@3.4.17)
|
version: 1.0.7(tailwindcss@3.4.17)
|
||||||
|
unist-util-visit:
|
||||||
|
specifier: ^5.0.0
|
||||||
|
version: 5.0.0
|
||||||
zod:
|
zod:
|
||||||
specifier: ^3.24.2
|
specifier: ^3.24.2
|
||||||
version: 3.24.2
|
version: 3.24.2
|
||||||
@ -1785,6 +1794,9 @@ packages:
|
|||||||
'@types/text-table@0.2.5':
|
'@types/text-table@0.2.5':
|
||||||
resolution: {integrity: sha512-hcZhlNvMkQG/k1vcZ6yHOl6WAYftQ2MLfTHcYRZ2xYZFD8tGVnE3qFV0lj1smQeDSR7/yY0PyuUalauf33bJeA==}
|
resolution: {integrity: sha512-hcZhlNvMkQG/k1vcZ6yHOl6WAYftQ2MLfTHcYRZ2xYZFD8tGVnE3qFV0lj1smQeDSR7/yY0PyuUalauf33bJeA==}
|
||||||
|
|
||||||
|
'@types/ungap__structured-clone@1.2.0':
|
||||||
|
resolution: {integrity: sha512-ZoaihZNLeZSxESbk9PUAPZOlSpcKx81I1+4emtULDVmBLkYutTcMlCj2K9VNlf9EWODxdO6gkAqEaLorXwZQVA==}
|
||||||
|
|
||||||
'@types/unist@2.0.11':
|
'@types/unist@2.0.11':
|
||||||
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
|
resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==}
|
||||||
|
|
||||||
@ -2625,6 +2637,9 @@ packages:
|
|||||||
mdast-util-to-string@4.0.0:
|
mdast-util-to-string@4.0.0:
|
||||||
resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
|
resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
|
||||||
|
|
||||||
|
mdast-util-toc@7.1.0:
|
||||||
|
resolution: {integrity: sha512-2TVKotOQzqdY7THOdn2gGzS9d1Sdd66bvxUyw3aNpWfcPXCLYSJCCgfPy30sEtuzkDraJgqF35dzgmz6xlvH/w==}
|
||||||
|
|
||||||
mdx-bundler@10.1.0:
|
mdx-bundler@10.1.0:
|
||||||
resolution: {integrity: sha512-HtyVyqzBz/rG3hjca2ZggI5Ghx8YdBeAfnDIgqiMVOZyum1WcmBsdPKYN0AhV4SNaxyohASIrwC8DiXtU55FUA==}
|
resolution: {integrity: sha512-HtyVyqzBz/rG3hjca2ZggI5Ghx8YdBeAfnDIgqiMVOZyum1WcmBsdPKYN0AhV4SNaxyohASIrwC8DiXtU55FUA==}
|
||||||
engines: {node: '>=18', npm: '>=6'}
|
engines: {node: '>=18', npm: '>=6'}
|
||||||
@ -3206,6 +3221,9 @@ packages:
|
|||||||
remark-stringify@11.0.0:
|
remark-stringify@11.0.0:
|
||||||
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
|
resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==}
|
||||||
|
|
||||||
|
remark@15.0.1:
|
||||||
|
resolution: {integrity: sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A==}
|
||||||
|
|
||||||
require-directory@2.1.1:
|
require-directory@2.1.1:
|
||||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@ -4945,6 +4963,8 @@ snapshots:
|
|||||||
|
|
||||||
'@types/text-table@0.2.5': {}
|
'@types/text-table@0.2.5': {}
|
||||||
|
|
||||||
|
'@types/ungap__structured-clone@1.2.0': {}
|
||||||
|
|
||||||
'@types/unist@2.0.11': {}
|
'@types/unist@2.0.11': {}
|
||||||
|
|
||||||
'@types/unist@3.0.3': {}
|
'@types/unist@3.0.3': {}
|
||||||
@ -5951,6 +5971,16 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/mdast': 4.0.4
|
'@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):
|
mdx-bundler@10.1.0(acorn@8.14.0)(esbuild@0.21.5):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/runtime': 7.26.9
|
'@babel/runtime': 7.26.9
|
||||||
@ -6741,6 +6771,15 @@ snapshots:
|
|||||||
mdast-util-to-markdown: 2.1.2
|
mdast-util-to-markdown: 2.1.2
|
||||||
unified: 11.0.5
|
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: {}
|
require-directory@2.1.1: {}
|
||||||
|
|
||||||
resend@4.1.2(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
|
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 { BlogPost } from '@/components/blog/blog-post';
|
||||||
import { getBaseUrl } from '@/lib/urls/get-base-url';
|
import { getBaseUrl } from '@/lib/urls/get-base-url';
|
||||||
import type { NextPageProps } from '@/types/next-page-props';
|
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 '@/app/mdx.css';
|
||||||
|
import AllPostsButton from '@/components/blog/all-posts-button';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the blog post from the params
|
* Gets the blog post from the params
|
||||||
@ -71,5 +79,124 @@ export default async function BlogPostPage(props: NextPageProps) {
|
|||||||
if (!post) {
|
if (!post) {
|
||||||
return notFound();
|
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",
|
name: "MkSaaS",
|
||||||
title: "MkSaaS - The Best AI SaaS Boilerplate",
|
title: "MkSaaS - The Best AI SaaS Boilerplate",
|
||||||
tagline:
|
tagline:
|
||||||
"Launch AI SaaS websites in minutes, simply and effortlessly",
|
"Launch AI SaaS Websites in hours, simply and effortlessly",
|
||||||
description:
|
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: [
|
keywords: [
|
||||||
"SaaS",
|
"SaaS",
|
||||||
"SaaS Website",
|
"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())
|
.map((v) => v && v[0].toUpperCase())
|
||||||
.join('');
|
.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