cf: test remove docs and blog pages and content
This commit is contained in:
parent
c3392320b3
commit
9cb559a48d
@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
slug: fox
|
|
||||||
name: Fox
|
|
||||||
avatar: /images/avatars/fox.png
|
|
||||||
---
|
|
@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
slug: fox
|
|
||||||
name: Fox
|
|
||||||
avatar: /images/avatars/fox.png
|
|
||||||
---
|
|
@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
slug: mkdirs
|
|
||||||
name: Mkdirs
|
|
||||||
avatar: /images/avatars/mkdirs.png
|
|
||||||
---
|
|
@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
slug: mkdirs
|
|
||||||
name: Mkdirs模板
|
|
||||||
avatar: /images/avatars/mkdirs.png
|
|
||||||
---
|
|
@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
slug: mksaas
|
|
||||||
name: MkSaaS
|
|
||||||
avatar: /images/avatars/mksaas.png
|
|
||||||
---
|
|
@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
slug: mksaas
|
|
||||||
name: MkSaaS模板
|
|
||||||
avatar: /images/avatars/mksaas.png
|
|
||||||
---
|
|
@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
slug: company
|
|
||||||
name: Company
|
|
||||||
description: Company news and updates
|
|
||||||
---
|
|
@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
slug: company
|
|
||||||
name: 公司
|
|
||||||
description: 公司新闻和更新
|
|
||||||
---
|
|
@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
slug: news
|
|
||||||
name: News
|
|
||||||
description: News and updates about MkSaaS
|
|
||||||
---
|
|
@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
slug: news
|
|
||||||
name: 新闻
|
|
||||||
description: 最新新闻和更新
|
|
||||||
---
|
|
@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
slug: product
|
|
||||||
name: Product
|
|
||||||
description: Products and services powered by MkSaaS
|
|
||||||
---
|
|
@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
slug: product
|
|
||||||
name: 产品
|
|
||||||
description: 产品和服务
|
|
||||||
---
|
|
@ -1,73 +0,0 @@
|
|||||||
import BlogGridWithPagination from '@/components/blog/blog-grid-with-pagination';
|
|
||||||
import { LOCALES } from '@/i18n/routing';
|
|
||||||
import { getPaginatedBlogPosts } from '@/lib/blog/data';
|
|
||||||
import { constructMetadata } from '@/lib/metadata';
|
|
||||||
import { getUrlWithLocale } from '@/lib/urls/urls';
|
|
||||||
import { allCategories } from 'content-collections';
|
|
||||||
import type { Locale } from 'next-intl';
|
|
||||||
import { getTranslations } from 'next-intl/server';
|
|
||||||
import { notFound } from 'next/navigation';
|
|
||||||
|
|
||||||
// Generate all static params for SSG (locale + category)
|
|
||||||
export function generateStaticParams() {
|
|
||||||
const params: { locale: string; slug: string }[] = [];
|
|
||||||
for (const locale of LOCALES) {
|
|
||||||
const localeCategories = allCategories.filter(
|
|
||||||
(category) => category.locale === locale
|
|
||||||
);
|
|
||||||
for (const category of localeCategories) {
|
|
||||||
params.push({ locale, slug: category.slug });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return params;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate metadata for each static category page (locale + category)
|
|
||||||
export async function generateMetadata({ params }: BlogCategoryPageProps) {
|
|
||||||
const { locale, slug } = await params;
|
|
||||||
const category = allCategories.find(
|
|
||||||
(category) => category.locale === locale && category.slug === slug
|
|
||||||
);
|
|
||||||
if (!category) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
const t = await getTranslations({ locale, namespace: 'Metadata' });
|
|
||||||
const canonicalPath = `/blog/category/${slug}`;
|
|
||||||
return constructMetadata({
|
|
||||||
title: `${category.name} | ${t('title')}`,
|
|
||||||
description: category.description,
|
|
||||||
canonicalUrl: getUrlWithLocale(canonicalPath, locale),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BlogCategoryPageProps {
|
|
||||||
params: Promise<{
|
|
||||||
locale: Locale;
|
|
||||||
slug: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function BlogCategoryPage({
|
|
||||||
params,
|
|
||||||
}: BlogCategoryPageProps) {
|
|
||||||
const { locale, slug } = await params;
|
|
||||||
const category = allCategories.find(
|
|
||||||
(category) => category.locale === locale && category.slug === slug
|
|
||||||
);
|
|
||||||
if (!category) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
const currentPage = 1;
|
|
||||||
const { paginatedPosts, totalPages } = getPaginatedBlogPosts({
|
|
||||||
locale,
|
|
||||||
page: currentPage,
|
|
||||||
category: slug,
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<BlogGridWithPagination
|
|
||||||
posts={paginatedPosts}
|
|
||||||
totalPages={totalPages}
|
|
||||||
routePrefix={`/blog/category/${slug}`}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,84 +0,0 @@
|
|||||||
import BlogGridWithPagination from '@/components/blog/blog-grid-with-pagination';
|
|
||||||
import { websiteConfig } from '@/config/website';
|
|
||||||
import { LOCALES } from '@/i18n/routing';
|
|
||||||
import { getPaginatedBlogPosts } from '@/lib/blog/data';
|
|
||||||
import { constructMetadata } from '@/lib/metadata';
|
|
||||||
import { getUrlWithLocale } from '@/lib/urls/urls';
|
|
||||||
import { allCategories, allPosts } from 'content-collections';
|
|
||||||
import type { Locale } from 'next-intl';
|
|
||||||
import { getTranslations } from 'next-intl/server';
|
|
||||||
import { notFound } from 'next/navigation';
|
|
||||||
|
|
||||||
// Generate all static params for SSG (locale + category + pagination)
|
|
||||||
export function generateStaticParams() {
|
|
||||||
const params: { locale: string; slug: string; page: string }[] = [];
|
|
||||||
for (const locale of LOCALES) {
|
|
||||||
const localeCategories = allCategories.filter(
|
|
||||||
(category) => category.locale === locale
|
|
||||||
);
|
|
||||||
for (const category of localeCategories) {
|
|
||||||
const totalPages = Math.ceil(
|
|
||||||
allPosts.filter(
|
|
||||||
(post) =>
|
|
||||||
post.locale === locale &&
|
|
||||||
post.categories.some((cat) => cat && cat.slug === category.slug)
|
|
||||||
).length / websiteConfig.blog.paginationSize
|
|
||||||
);
|
|
||||||
for (let page = 2; page <= totalPages; page++) {
|
|
||||||
params.push({ locale, slug: category.slug, page: String(page) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return params;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate metadata for each static category page (locale + category + pagination)
|
|
||||||
export async function generateMetadata({ params }: BlogCategoryPageProps) {
|
|
||||||
const { locale, slug, page } = await params;
|
|
||||||
const category = allCategories.find(
|
|
||||||
(category) => category.slug === slug && category.locale === locale
|
|
||||||
);
|
|
||||||
if (!category) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
const t = await getTranslations({ locale, namespace: 'Metadata' });
|
|
||||||
const canonicalPath = `/blog/category/${slug}/page/${page}`;
|
|
||||||
return constructMetadata({
|
|
||||||
title: `${category.name} | ${t('title')}`,
|
|
||||||
description: category.description,
|
|
||||||
canonicalUrl: getUrlWithLocale(canonicalPath, locale),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BlogCategoryPageProps {
|
|
||||||
params: Promise<{
|
|
||||||
locale: Locale;
|
|
||||||
slug: string;
|
|
||||||
page: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function BlogCategoryPage({
|
|
||||||
params,
|
|
||||||
}: BlogCategoryPageProps) {
|
|
||||||
const { locale, slug, page } = await params;
|
|
||||||
const currentPage = Number(page);
|
|
||||||
const category = allCategories.find(
|
|
||||||
(category) => category.slug === slug && category.locale === locale
|
|
||||||
);
|
|
||||||
if (!category) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
const { paginatedPosts, totalPages } = getPaginatedBlogPosts({
|
|
||||||
locale,
|
|
||||||
page: currentPage,
|
|
||||||
category: slug,
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<BlogGridWithPagination
|
|
||||||
posts={paginatedPosts}
|
|
||||||
totalPages={totalPages}
|
|
||||||
routePrefix={`/blog/category/${slug}`}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,42 +0,0 @@
|
|||||||
import { BlogCategoryFilter } from '@/components/blog/blog-category-filter';
|
|
||||||
import Container from '@/components/layout/container';
|
|
||||||
import type { NextPageProps } from '@/types/next-page-props';
|
|
||||||
import { allCategories } from 'content-collections';
|
|
||||||
import { getTranslations } from 'next-intl/server';
|
|
||||||
import type { PropsWithChildren } from 'react';
|
|
||||||
|
|
||||||
interface BlogListLayoutProps extends PropsWithChildren, NextPageProps {}
|
|
||||||
|
|
||||||
export default async function BlogListLayout({
|
|
||||||
children,
|
|
||||||
params,
|
|
||||||
}: BlogListLayoutProps) {
|
|
||||||
const resolvedParams = await params;
|
|
||||||
const { locale } = resolvedParams;
|
|
||||||
const t = await getTranslations('BlogPage');
|
|
||||||
|
|
||||||
// Filter categories by locale
|
|
||||||
const categoryList = allCategories.filter(
|
|
||||||
(category) => category.locale === locale
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mb-16">
|
|
||||||
<div className="mt-8 w-full flex flex-col items-center justify-center gap-8">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="space-y-4">
|
|
||||||
<h1 className="text-center text-3xl font-bold tracking-tight">
|
|
||||||
{t('title')}
|
|
||||||
</h1>
|
|
||||||
<h2 className="text-center text-lg text-muted-foreground">
|
|
||||||
{t('subtitle')}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<BlogCategoryFilter categoryList={categoryList} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Container className="mt-8 px-4">{children}</Container>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,5 +0,0 @@
|
|||||||
import { BlogGridSkeleton } from '@/components/blog/blog-grid';
|
|
||||||
|
|
||||||
export default function Loading() {
|
|
||||||
return <BlogGridSkeleton />;
|
|
||||||
}
|
|
@ -1,45 +0,0 @@
|
|||||||
import BlogGridWithPagination from '@/components/blog/blog-grid-with-pagination';
|
|
||||||
import { LOCALES } from '@/i18n/routing';
|
|
||||||
import { getPaginatedBlogPosts } from '@/lib/blog/data';
|
|
||||||
import { constructMetadata } from '@/lib/metadata';
|
|
||||||
import { getUrlWithLocale } from '@/lib/urls/urls';
|
|
||||||
import type { Locale } from 'next-intl';
|
|
||||||
import { getTranslations } from 'next-intl/server';
|
|
||||||
|
|
||||||
export function generateStaticParams() {
|
|
||||||
return LOCALES.map((locale) => ({ locale }));
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateMetadata({ params }: BlogPageProps) {
|
|
||||||
const { locale } = await params;
|
|
||||||
const t = await getTranslations({ locale, namespace: 'Metadata' });
|
|
||||||
const pt = await getTranslations({ locale, namespace: 'BlogPage' });
|
|
||||||
const canonicalPath = '/blog';
|
|
||||||
return constructMetadata({
|
|
||||||
title: `${pt('title')} | ${t('title')}`,
|
|
||||||
description: pt('description'),
|
|
||||||
canonicalUrl: getUrlWithLocale(canonicalPath, locale),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BlogPageProps {
|
|
||||||
params: Promise<{
|
|
||||||
locale: Locale;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function BlogPage({ params }: BlogPageProps) {
|
|
||||||
const { locale } = await params;
|
|
||||||
const currentPage = 1;
|
|
||||||
const { paginatedPosts, totalPages } = getPaginatedBlogPosts({
|
|
||||||
locale,
|
|
||||||
page: currentPage,
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<BlogGridWithPagination
|
|
||||||
posts={paginatedPosts}
|
|
||||||
totalPages={totalPages}
|
|
||||||
routePrefix={'/blog'}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,65 +0,0 @@
|
|||||||
import BlogGridWithPagination from '@/components/blog/blog-grid-with-pagination';
|
|
||||||
import { websiteConfig } from '@/config/website';
|
|
||||||
import { LOCALES } from '@/i18n/routing';
|
|
||||||
import { getPaginatedBlogPosts } from '@/lib/blog/data';
|
|
||||||
import { constructMetadata } from '@/lib/metadata';
|
|
||||||
import { getUrlWithLocale } from '@/lib/urls/urls';
|
|
||||||
import { allPosts } from 'content-collections';
|
|
||||||
import type { Locale } from 'next-intl';
|
|
||||||
import { getTranslations } from 'next-intl/server';
|
|
||||||
|
|
||||||
export function generateStaticParams() {
|
|
||||||
const paginationSize = websiteConfig.blog.paginationSize;
|
|
||||||
const params: { locale: string; page: string }[] = [];
|
|
||||||
for (const locale of LOCALES) {
|
|
||||||
const publishedPosts = allPosts.filter(
|
|
||||||
(post) => post.published && post.locale === locale
|
|
||||||
);
|
|
||||||
const totalPages = Math.max(
|
|
||||||
1,
|
|
||||||
Math.ceil(publishedPosts.length / paginationSize)
|
|
||||||
);
|
|
||||||
for (let pageNumber = 2; pageNumber <= totalPages; pageNumber++) {
|
|
||||||
params.push({
|
|
||||||
locale,
|
|
||||||
page: String(pageNumber),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return params;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateMetadata({ params }: BlogListPageProps) {
|
|
||||||
const { locale, page } = await params;
|
|
||||||
const t = await getTranslations({ locale, namespace: 'Metadata' });
|
|
||||||
const pt = await getTranslations({ locale, namespace: 'BlogPage' });
|
|
||||||
const canonicalPath = `/blog/page/${page}`;
|
|
||||||
return constructMetadata({
|
|
||||||
title: `${pt('title')} | ${t('title')}`,
|
|
||||||
description: pt('description'),
|
|
||||||
canonicalUrl: getUrlWithLocale(canonicalPath, locale),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BlogListPageProps {
|
|
||||||
params: Promise<{
|
|
||||||
locale: Locale;
|
|
||||||
page: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function BlogListPage({ params }: BlogListPageProps) {
|
|
||||||
const { page, locale } = await params;
|
|
||||||
const currentPage = Number(page);
|
|
||||||
const { paginatedPosts, totalPages } = getPaginatedBlogPosts({
|
|
||||||
locale,
|
|
||||||
page: currentPage,
|
|
||||||
});
|
|
||||||
return (
|
|
||||||
<BlogGridWithPagination
|
|
||||||
posts={paginatedPosts}
|
|
||||||
totalPages={totalPages}
|
|
||||||
routePrefix={'/blog'}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,10 +0,0 @@
|
|||||||
import Container from '@/components/layout/container';
|
|
||||||
import type { PropsWithChildren } from 'react';
|
|
||||||
|
|
||||||
export default function BlogPostLayout({ children }: PropsWithChildren) {
|
|
||||||
return (
|
|
||||||
<Container className="py-8 px-4">
|
|
||||||
<div className="mx-auto">{children}</div>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,256 +0,0 @@
|
|||||||
import AllPostsButton from '@/components/blog/all-posts-button';
|
|
||||||
import BlogGrid from '@/components/blog/blog-grid';
|
|
||||||
import { BlogToc } from '@/components/blog/blog-toc';
|
|
||||||
import { NewsletterCard } from '@/components/newsletter/newsletter-card';
|
|
||||||
import { CustomMDXContent } from '@/components/shared/custom-mdx-content';
|
|
||||||
import { websiteConfig } from '@/config/website';
|
|
||||||
import { LocaleLink } from '@/i18n/navigation';
|
|
||||||
import { LOCALES } from '@/i18n/routing';
|
|
||||||
import { getTableOfContents } from '@/lib/blog/toc';
|
|
||||||
import { formatDate } from '@/lib/formatter';
|
|
||||||
import { constructMetadata } from '@/lib/metadata';
|
|
||||||
import { getUrlWithLocale } from '@/lib/urls/urls';
|
|
||||||
import { type Post, allPosts } from 'content-collections';
|
|
||||||
import { CalendarIcon, ClockIcon, FileTextIcon } from 'lucide-react';
|
|
||||||
import type { Metadata } from 'next';
|
|
||||||
import type { Locale } from 'next-intl';
|
|
||||||
import { getTranslations } from 'next-intl/server';
|
|
||||||
import Image from 'next/image';
|
|
||||||
import { notFound } from 'next/navigation';
|
|
||||||
|
|
||||||
import '@/styles/mdx.css';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the blog post from the params
|
|
||||||
* @param slug - The slug of the blog post
|
|
||||||
* @param locale - The locale of the blog post
|
|
||||||
* @returns The blog post
|
|
||||||
*
|
|
||||||
* How it works:
|
|
||||||
* /[locale]/blog/first-post:
|
|
||||||
* params.slug = ["first-post"]
|
|
||||||
* slug becomes "first-post" after join('/')
|
|
||||||
* Matches post where slugAsParams === "first-post" AND locale === params.locale
|
|
||||||
*/
|
|
||||||
async function getBlogPostFromParams(locale: Locale, slug: string) {
|
|
||||||
// console.log('getBlogPostFromParams', locale, slug);
|
|
||||||
// Find post with matching slug and locale
|
|
||||||
const post = allPosts.find(
|
|
||||||
(post) =>
|
|
||||||
(post.slugAsParams === slug ||
|
|
||||||
(!slug && post.slugAsParams === 'index')) &&
|
|
||||||
post.locale === locale
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!post) {
|
|
||||||
// If no post found with the current locale, try to find one with the default locale
|
|
||||||
const defaultPost = allPosts.find(
|
|
||||||
(post) =>
|
|
||||||
post.slugAsParams === slug || (!slug && post.slugAsParams === 'index')
|
|
||||||
);
|
|
||||||
|
|
||||||
return defaultPost;
|
|
||||||
}
|
|
||||||
|
|
||||||
return post;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* get related posts, random pick from all posts with same locale, different slug,
|
|
||||||
* max size is websiteConfig.blog.relatedPostsSize
|
|
||||||
*/
|
|
||||||
async function getRelatedPosts(post: Post) {
|
|
||||||
const relatedPosts = allPosts
|
|
||||||
.filter((p) => p.locale === post.locale)
|
|
||||||
.filter((p) => p.slugAsParams !== post.slugAsParams)
|
|
||||||
.sort(() => Math.random() - 0.5)
|
|
||||||
.slice(0, websiteConfig.blog.relatedPostsSize);
|
|
||||||
|
|
||||||
return relatedPosts;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function generateStaticParams() {
|
|
||||||
return LOCALES.flatMap((locale) => {
|
|
||||||
const posts = allPosts.filter((post) => post.locale === locale);
|
|
||||||
return posts.map((post) => ({
|
|
||||||
locale,
|
|
||||||
slug: [post.slugAsParams],
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateMetadata({
|
|
||||||
params,
|
|
||||||
}: BlogPostPageProps): Promise<Metadata | undefined> {
|
|
||||||
const { locale, slug } = await params;
|
|
||||||
const post = await getBlogPostFromParams(locale, slug.join('/'));
|
|
||||||
if (!post) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const t = await getTranslations({ locale, namespace: 'Metadata' });
|
|
||||||
|
|
||||||
return constructMetadata({
|
|
||||||
title: `${post.title} | ${t('title')}`,
|
|
||||||
description: post.description,
|
|
||||||
canonicalUrl: getUrlWithLocale(post.slug, locale),
|
|
||||||
image: post.image,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BlogPostPageProps {
|
|
||||||
params: Promise<{
|
|
||||||
locale: Locale;
|
|
||||||
slug: string[];
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function BlogPostPage(props: BlogPostPageProps) {
|
|
||||||
const { locale, slug } = await props.params;
|
|
||||||
const post = await getBlogPostFromParams(locale, slug.join('/'));
|
|
||||||
if (!post) {
|
|
||||||
notFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
const publishDate = post.date;
|
|
||||||
const date = formatDate(new Date(publishDate));
|
|
||||||
const toc = await getTableOfContents(post.content);
|
|
||||||
|
|
||||||
// getTranslations may cause error DYNAMIC_SERVER_USAGE, so we set dynamic to force-static
|
|
||||||
const t = await getTranslations('BlogPage');
|
|
||||||
|
|
||||||
// get related posts
|
|
||||||
const relatedPosts = await getRelatedPosts(post);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-8">
|
|
||||||
{/* content section */}
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
|
||||||
{/* left column (blog post content) */}
|
|
||||||
<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 date and reading time */}
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<CalendarIcon className="size-4 text-muted-foreground" />
|
|
||||||
<span className="text-sm text-muted-foreground leading-none my-auto">
|
|
||||||
{date}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<ClockIcon className="size-4 text-muted-foreground" />
|
|
||||||
<span className="text-sm text-muted-foreground leading-none my-auto">
|
|
||||||
{t('readTime', { minutes: post.estimatedTime })}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</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 */}
|
|
||||||
{/* in order to make the mdx.css work, we need to add the className prose to the div */}
|
|
||||||
{/* https://github.com/tailwindlabs/tailwindcss-typography */}
|
|
||||||
<div className="mt-8 max-w-none prose prose-neutral dark:prose-invert prose-img:rounded-lg">
|
|
||||||
<CustomMDXContent code={post.body} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-start my-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">{t('author')}</h2>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="relative h-8 w-8 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="line-clamp-1">{post.author?.name}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* categories */}
|
|
||||||
<div className="bg-muted/50 rounded-lg p-6">
|
|
||||||
<h2 className="text-lg font-semibold mb-4">{t('categories')}</h2>
|
|
||||||
<ul className="flex flex-wrap gap-4">
|
|
||||||
{post.categories?.filter(Boolean).map(
|
|
||||||
(category) =>
|
|
||||||
category && (
|
|
||||||
<li key={category.slug}>
|
|
||||||
<LocaleLink
|
|
||||||
href={`/blog/category/${category.slug}`}
|
|
||||||
className="text-sm font-medium text-muted-foreground hover:text-primary"
|
|
||||||
>
|
|
||||||
{category.name}
|
|
||||||
</LocaleLink>
|
|
||||||
</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">
|
|
||||||
{t('tableOfContents')}
|
|
||||||
</h2>
|
|
||||||
<div className="max-h-[calc(100vh-18rem)] overflow-y-auto">
|
|
||||||
<BlogToc toc={toc} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer section shows related posts */}
|
|
||||||
{relatedPosts && relatedPosts.length > 0 && (
|
|
||||||
<div className="flex flex-col gap-8 mt-8">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<FileTextIcon className="size-4 text-muted-foreground" />
|
|
||||||
<h2 className="text-lg tracking-wider font-semibold text-gradient_indigo-purple">
|
|
||||||
{t('morePosts')}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<BlogGrid posts={relatedPosts} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* newsletter */}
|
|
||||||
<div className="flex items-center justify-start my-8">
|
|
||||||
<NewsletterCard />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
Loading…
Reference in New Issue
Block a user