Files
2026-10-08 11:09:37 +08:00

136 lines
4.3 KiB
TypeScript

import type { Metadata } from "next";
import { Link } from "@/i18n/navigation";
import { getTranslations, setRequestLocale } from "next-intl/server";
import { ArrowRight } from "lucide-react";
import { Container, Section } from "@/components/ui/layout";
import { SectionHeading } from "@/components/ui/section-heading";
import { buttonClassName } from "@/components/ui/button-variants";
import { PostCard } from "@/components/post/post-card";
import { MotionItem } from "@/components/motion/motion-item";
import { HomeHero } from "@/components/home/hero";
import { getAllPosts, getAllTags } from "@/lib/posts";
import { siteConfig } from "@/lib/site-config";
/**
* 首页渲染策略:ISR 缓存 60 秒。
*
* 内容来自数据库,因此不在构建期固化;访问时生成并缓存,
* 更新文章后最多 60 秒自动生效。
*
* 注意:Next.js 会静态分析路由段配置,这里必须写成**字面量**,
* 不能引用从其他模块导入的常量,否则会报
* "Invalid segment configuration export detected"。
*/
export const revalidate = 60;
type HomePageProps = {
params: Promise<{ locale: string }>;
};
export async function generateMetadata({
params,
}: HomePageProps): Promise<Metadata> {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: "Site" });
return {
title: `${siteConfig.name} · ${t("tagline")}`,
description: siteConfig.description,
alternates: { canonical: `/${locale}` },
};
}
export default async function HomePage({ params }: HomePageProps) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getTranslations("Home");
// Hero 需要的统计数据量很小,直接在此等待;
// 文章列表区块用 Suspense 包裹,先出骨架再填充内容。
const [posts, tags] = await Promise.all([getAllPosts(), getAllTags()]);
const earliestPost = posts.at(-1);
return (
<>
<HomeHero
eyebrow={t("eyebrow")}
title={t("heroTitle")}
subtitle={t("heroSubtitle")}
primaryLabel={t("ctaPrimary")}
secondaryLabel={t("ctaSecondary")}
stats={{
posts: posts.length,
tags: tags.length,
since: earliestPost ? earliestPost.date.slice(0, 4) : "—",
postsLabel: t("statsPosts"),
tagsLabel: t("statsTags"),
sinceLabel: t("statsSince"),
}}
/>
<Section id="posts" spacing="lg" aria-labelledby="latest-posts-heading">
<Container>
<SectionHeading
eyebrow={t("latestEyebrow")}
title={<span id="latest-posts-heading">{t("latestTitle")}</span>}
description={t("latestDescription")}
className="mb-xxl"
/>
{posts.length === 0 ? (
<EmptyState />
) : (
<div className="grid grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-3">
{posts.map((post, index) => (
<MotionItem
key={post.slug}
delay={Math.min(index, 6) * 0.05}
className="h-full"
>
<PostCard post={post} className="h-full" />
</MotionItem>
))}
</div>
)}
<div className="mt-xxl flex justify-center">
<Link
href="/about"
className={buttonClassName({ variant: "secondary" })}
>
{t("ctaSecondary")}
<ArrowRight className="size-4" aria-hidden="true" />
</Link>
</div>
</Container>
</Section>
</>
);
}
/** 无文章时的空状态(skill 要求前端具备加载态 / 空态 / 错误态) */
function EmptyState() {
return (
<div className="flex flex-col items-center gap-sm rounded-lg border border-dashed border-hairline bg-surface-soft px-xxl py-section text-center">
<p className="text-heading-5 font-semibold text-ink">还没有已发布的文章</p>
<p className="max-w-[520px] text-body-sm text-steel">
调用{" "}
<code className="rounded-xs border border-hairline bg-surface px-[6px] py-[2px] font-mono text-code-inline">
POST /api/posts
</code>{" "}
创建文章(或运行{" "}
<code className="rounded-xs border border-hairline bg-surface px-[6px] py-[2px] font-mono text-code-inline">
pnpm seed
</code>{" "}
导入示例内容),状态为{" "}
<code className="rounded-xs border border-hairline bg-surface px-[6px] py-[2px] font-mono text-code-inline">
published
</code>{" "}
的文章就会出现在这里。
</p>
</div>
);
}