refactor(blog) refactor blog home page

This commit is contained in:
javayhu 2025-06-17 11:28:20 +08:00
parent e0f408fb07
commit 53ab869f07
9 changed files with 104 additions and 54 deletions

View File

@ -26,6 +26,8 @@ export const docs = defineDocs({
/** /**
* Changelog * Changelog
*
* title is required, but description is optional in frontmatter
*/ */
export const changelog = defineCollections({ export const changelog = defineCollections({
type: 'doc', type: 'doc',
@ -39,6 +41,8 @@ export const changelog = defineCollections({
/** /**
* Pages, like privacy policy, terms of service, etc. * Pages, like privacy policy, terms of service, etc.
*
* title is required, but description is optional in frontmatter
*/ */
export const pages = defineCollections({ export const pages = defineCollections({
type: 'doc', type: 'doc',
@ -52,39 +56,48 @@ export const pages = defineCollections({
/** /**
* Blog authors * Blog authors
* *
* description is optional, but we must add it to the schema * description is optional in frontmatter, but we must add it to the schema
*/ */
export const author = defineCollections({ export const author = defineCollections({
type: 'doc', type: 'doc',
dir: 'content/author', dir: 'content/author',
schema: frontmatterSchema.extend({ schema: z.object({
name: z.string(), name: z.string(),
avatar: z.string(), avatar: z.string(),
description: z.string().optional(),
}), }),
}); });
/** /**
* Blog categories * Blog categories
*
* description is optional in frontmatter, but we must add it to the schema
*/ */
export const category = defineCollections({ export const category = defineCollections({
type: 'doc', type: 'doc',
dir: 'content/category', dir: 'content/category',
schema: frontmatterSchema.extend({ schema: z.object({
name: z.string(), name: z.string(),
description: z.string().optional(),
}), }),
}); });
/** /**
* Blog posts * Blog posts
*
* dtitle is required, but description is optional in frontmatter
*/ */
export const blog = defineCollections({ export const blog = defineCollections({
type: 'doc', type: 'doc',
dir: 'content/blog', dir: 'content/blog',
schema: frontmatterSchema.extend({ schema: (ctx) => {
image: z.string(), // console.log('ctx', ctx); // {source, path}
date: z.string().date(), return frontmatterSchema.extend({
published: z.boolean().default(true), image: z.string(),
categories: z.array(z.string()), date: z.string().date(),
author: z.string(), published: z.boolean().default(true),
}), categories: z.array(z.string()),
author: z.string(),
});
},
}); });

View File

@ -1,24 +1,28 @@
import { BlogCategoryFilter } from '@/components/blog/blog-category-filter'; import { BlogCategoryFilter } from '@/components/blog/blog-category-filter';
import Container from '@/components/layout/container'; import Container from '@/components/layout/container';
import type { NextPageProps } from '@/types/next-page-props'; import { categorySource } from '@/lib/docs/source';
import { allCategories } from 'content-collections';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import type { PropsWithChildren } from 'react'; import type { PropsWithChildren } from 'react';
interface BlogListLayoutProps extends PropsWithChildren, NextPageProps {} interface BlogListLayoutProps extends PropsWithChildren {
params: Promise<{ locale: string }>;
}
export default async function BlogListLayout({ export default async function BlogListLayout({
children, children,
params, params,
}: BlogListLayoutProps) { }: BlogListLayoutProps) {
const resolvedParams = await params; const { locale } = await params;
const { locale } = resolvedParams;
const t = await getTranslations('BlogPage'); const t = await getTranslations('BlogPage');
// Filter categories by locale // Filter categories by locale
const categoryList = allCategories.filter( const language = locale as string;
(category) => category.locale === locale const categoryList = categorySource.getPages(language).map((category) => ({
); slug: category.slugs[0],
name: category.data.name,
description: category.data.description || '',
}));
// console.log('categoryList', categoryList);
return ( return (
<div className="mb-16"> <div className="mb-16">

View File

@ -1,20 +1,25 @@
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { LocaleLink } from '@/i18n/navigation'; import { LocaleLink } from '@/i18n/navigation';
import { PLACEHOLDER_IMAGE } from '@/lib/constants'; import { PLACEHOLDER_IMAGE } from '@/lib/constants';
import { type BlogType, authorSource, categorySource } from '@/lib/docs/source';
import { formatDate } from '@/lib/formatter'; import { formatDate } from '@/lib/formatter';
import type { Post } from 'content-collections';
import Image from 'next/image'; import Image from 'next/image';
interface BlogCardProps { interface BlogCardProps {
post: Post; locale: string;
post: BlogType;
} }
export default function BlogCard({ post }: BlogCardProps) { export default function BlogCard({ locale, post }: BlogCardProps) {
const publishDate = post.date; const { date, title, description, image, author, categories } = post.data;
const date = formatDate(new Date(publishDate)); const publishDate = formatDate(new Date(date));
const blogAuthor = authorSource.getPage([author], locale);
const blogCategories = categorySource
.getPages(locale)
.filter((category) => categories.includes(category.slugs[0] ?? ''));
// Extract the slug parts for the Link component // Extract the slug parts for the Link component
const slugParts = post.slugAsParams.split('/'); const slugParts = post.slugs[0].split('/');
// console.log('BlogCard, slugParts', slugParts); // console.log('BlogCard, slugParts', slugParts);
return ( return (
@ -22,27 +27,27 @@ export default function BlogCard({ post }: BlogCardProps) {
<div className="group flex flex-col border rounded-lg overflow-hidden h-full"> <div className="group flex flex-col border rounded-lg overflow-hidden h-full">
{/* Image container - fixed aspect ratio */} {/* Image container - fixed aspect ratio */}
<div className="group overflow-hidden relative aspect-16/9 w-full"> <div className="group overflow-hidden relative aspect-16/9 w-full">
{post.image && ( {image && (
<div className="relative w-full h-full"> <div className="relative w-full h-full">
<Image <Image
src={post.image} src={image}
alt={post.title || 'image for blog post'} alt={title || 'image for blog post'}
title={post.title || 'image for blog post'} title={title || 'image for blog post'}
className="object-cover hover:scale-105 transition-transform duration-300" className="object-cover hover:scale-105 transition-transform duration-300"
placeholder="blur" placeholder="blur"
blurDataURL={PLACEHOLDER_IMAGE} blurDataURL={PLACEHOLDER_IMAGE}
fill fill
/> />
{post.categories && post.categories.length > 0 && ( {blogCategories && blogCategories.length > 0 && (
<div className="absolute left-2 bottom-2 opacity-100 transition-opacity duration-300"> <div className="absolute left-2 bottom-2 opacity-100 transition-opacity duration-300">
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{post.categories.map((category, index) => ( {blogCategories.map((category, index) => (
<span <span
key={`${category?.slug}-${index}`} key={`${category?.slugs[0]}-${index}`}
className="text-xs font-medium text-white bg-black bg-opacity-50 px-2 py-1 rounded-md" className="text-xs font-medium text-white bg-black bg-opacity-50 px-2 py-1 rounded-md"
> >
{category?.name} {category?.data.name}
</span> </span>
))} ))}
</div> </div>
@ -58,7 +63,7 @@ export default function BlogCard({ post }: BlogCardProps) {
{/* Post title */} {/* Post title */}
<h3 className="text-lg line-clamp-2 font-medium"> <h3 className="text-lg line-clamp-2 font-medium">
<span <span
className="bg-linear-to-r from-green-200 to-green-100 className="bg-linear-to-r from-green-200 to-green-100
bg-[length:0px_10px] bg-left-bottom bg-no-repeat bg-[length:0px_10px] bg-left-bottom bg-no-repeat
transition-[background-size] transition-[background-size]
duration-500 duration-500
@ -66,15 +71,15 @@ export default function BlogCard({ post }: BlogCardProps) {
group-hover:bg-[length:100%_10px] group-hover:bg-[length:100%_10px]
dark:from-purple-800 dark:to-purple-900" dark:from-purple-800 dark:to-purple-900"
> >
{post.title} {title}
</span> </span>
</h3> </h3>
{/* Post excerpt */} {/* Post excerpt */}
<div className="mt-2"> <div className="mt-2">
{post.description && ( {description && (
<p className="line-clamp-2 text-sm text-muted-foreground"> <p className="line-clamp-2 text-sm text-muted-foreground">
{post.description} {description}
</p> </p>
)} )}
</div> </div>
@ -84,20 +89,20 @@ export default function BlogCard({ post }: BlogCardProps) {
<div className="mt-4 pt-4 border-t flex items-center justify-between space-x-4 text-muted-foreground"> <div className="mt-4 pt-4 border-t flex items-center justify-between space-x-4 text-muted-foreground">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative h-8 w-8 shrink-0"> <div className="relative h-8 w-8 shrink-0">
{post?.author?.avatar && ( {blogAuthor?.data.avatar && (
<Image <Image
src={post?.author?.avatar} src={blogAuthor?.data.avatar}
alt={`avatar for ${post?.author?.name}`} alt={`avatar for ${blogAuthor?.data.name}`}
className="rounded-full object-cover border" className="rounded-full object-cover border"
fill fill
/> />
)} )}
</div> </div>
<span className="truncate text-sm">{post?.author?.name}</span> <span className="truncate text-sm">{blogAuthor?.data.name}</span>
</div> </div>
<time className="truncate text-sm" dateTime={date}> <time className="truncate text-sm" dateTime={date}>
{date} {publishDate}
</time> </time>
</div> </div>
</div> </div>

View File

@ -1,10 +1,10 @@
import Container from '@/components/layout/container'; import Container from '@/components/layout/container';
import type { Category } from 'content-collections'; import type { BlogCategory } from '@/types/blog-types';
import { BlogCategoryListDesktop } from './blog-category-list-desktop'; import { BlogCategoryListDesktop } from './blog-category-list-desktop';
import { BlogCategoryListMobile } from './blog-category-list-mobile'; import { BlogCategoryListMobile } from './blog-category-list-mobile';
interface BlogCategoryFilterProps { interface BlogCategoryFilterProps {
categoryList: Category[]; categoryList: BlogCategory[];
} }
export function BlogCategoryFilter({ categoryList }: BlogCategoryFilterProps) { export function BlogCategoryFilter({ categoryList }: BlogCategoryFilterProps) {

View File

@ -3,12 +3,12 @@
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'; import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { LocaleLink } from '@/i18n/navigation'; import { LocaleLink } from '@/i18n/navigation';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import type { Category } from 'content-collections'; import type { BlogCategory } from '@/types/blog-types';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
export type BlogCategoryListDesktopProps = { export type BlogCategoryListDesktopProps = {
categoryList: Category[]; categoryList: BlogCategory[];
}; };
export function BlogCategoryListDesktop({ export function BlogCategoryListDesktop({

View File

@ -9,14 +9,14 @@ import {
DrawerTitle, DrawerTitle,
DrawerTrigger, DrawerTrigger,
} from '@/components/ui/drawer'; } from '@/components/ui/drawer';
import type { Category } from 'content-collections'; import type { BlogCategory } from '@/types/blog-types';
import { LayoutListIcon } from 'lucide-react'; import { LayoutListIcon } from 'lucide-react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { useState } from 'react'; import { useState } from 'react';
export type BlogCategoryListMobileProps = { export type BlogCategoryListMobileProps = {
categoryList: Category[]; categoryList: BlogCategory[];
}; };
export function BlogCategoryListMobile({ export function BlogCategoryListMobile({

View File

@ -1,15 +1,17 @@
import type { Post } from 'content-collections'; import type { BlogType } from '@/lib/docs/source';
import EmptyGrid from '../shared/empty-grid'; import EmptyGrid from '../shared/empty-grid';
import CustomPagination from '../shared/pagination'; import CustomPagination from '../shared/pagination';
import BlogGrid from './blog-grid'; import BlogGrid from './blog-grid';
interface BlogGridWithPaginationProps { interface BlogGridWithPaginationProps {
posts: Post[]; locale: string;
posts: BlogType[];
totalPages: number; totalPages: number;
routePrefix: string; routePrefix: string;
} }
export default function BlogGridWithPagination({ export default function BlogGridWithPagination({
locale,
posts, posts,
totalPages, totalPages,
routePrefix, routePrefix,
@ -19,7 +21,7 @@ export default function BlogGridWithPagination({
{posts.length === 0 && <EmptyGrid />} {posts.length === 0 && <EmptyGrid />}
{posts.length > 0 && ( {posts.length > 0 && (
<div> <div>
<BlogGrid posts={posts} /> <BlogGrid locale={locale} posts={posts} />
<div className="mt-8 flex items-center justify-center"> <div className="mt-8 flex items-center justify-center">
<CustomPagination <CustomPagination
routePrefix={routePrefix} routePrefix={routePrefix}

View File

@ -1,19 +1,20 @@
import BlogCard, { BlogCardSkeleton } from '@/components/blog/blog-card'; import BlogCard, { BlogCardSkeleton } from '@/components/blog/blog-card';
import { websiteConfig } from '@/config/website'; import { websiteConfig } from '@/config/website';
import type { Post } from 'content-collections'; import type { BlogType } from '@/lib/docs/source';
interface BlogGridProps { interface BlogGridProps {
posts: Post[]; locale: string;
posts: BlogType[];
} }
export default function BlogGrid({ posts }: BlogGridProps) { export default function BlogGrid({ locale, posts }: BlogGridProps) {
// console.log('BlogGrid, posts', posts); // console.log('BlogGrid, posts', posts);
return ( return (
<div> <div>
{posts?.length > 0 && ( {posts?.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{posts.map((post) => ( {posts.map((post) => (
<BlogCard key={post.slug} post={post} /> <BlogCard key={post.slugs[0]} locale={locale} post={post} />
))} ))}
</div> </div>
)} )}

25
src/types/blog-types.ts Normal file
View File

@ -0,0 +1,25 @@
/**
* Blog Category
*
* we can not pass CategoryType from server component to client component
* so we need to define a new type, and use it in the client component
*/
export type BlogCategory = {
slug: string;
name: string;
description: string;
};
export type BlogAuthor = {
slug: string;
name: string;
description: string;
avatar: string;
};
export type BlogPost = {
slug: string;
title: string;
description: string;
date: string;
};