This commit is contained in:
z.ai committed 2026-10-08 11:09:37 +08:00
1 parent c1541b80df
commit b4c7908029
97 files changed
+12403 -154

No files matched your search

+111
View File
@@ -0,0 +1,111 @@
import { Link } from "@/i18n/navigation";
import { ArrowRight, Sparkles } from "lucide-react";
import { Container } from "@/components/ui/layout";
import { buttonClassName } from "@/components/ui/button-variants";
/**
* 首页 Hero 区。
*
* 对应 DESIGN.md 的 hero-band-sky:大气渐变(天空蓝 → 柔奶油)作为背景,
* 居中标题、副标题与按钮行。品牌薄荷绿仅用于单个强调 CTA。
*
* 这里用 CSS 渐变 + 径向光晕替代插画资源,避免引入额外图片依赖,
* 同时保持规范要求的「氛围感渐变」基调。
*/
export interface HomeHeroProps {
eyebrow: string;
title: string;
subtitle: string;
primaryLabel: string;
secondaryLabel: string;
stats: {
posts: number;
tags: number;
since: string;
postsLabel: string;
tagsLabel: string;
sinceLabel: string;
};
}
export function HomeHero({
eyebrow,
title,
subtitle,
primaryLabel,
secondaryLabel,
stats,
}: HomeHeroProps) {
return (
<section
aria-labelledby="hero-heading"
className="relative overflow-hidden border-b border-hairline-soft"
>
{/* 氛围渐变背景(装饰性,对辅助技术隐藏) */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 -z-10 bg-gradient-to-b from-hero-sky-from via-[#d9dbe0] to-hero-sky-to"
/>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 -z-10 opacity-70 [background:radial-gradient(60%_50%_at_50%_0%,rgba(255,255,255,0.85),transparent_70%)]"
/>
<Container className="flex flex-col items-center py-section text-center sm:py-section-lg lg:py-hero">
{/* 大写微标签 */}
<p className="inline-flex items-center gap-xs rounded-full border border-white/60 bg-white/70 px-md py-[6px] text-micro-uppercase font-semibold uppercase tracking-[0.5px] text-steel backdrop-blur-sm">
<Sparkles className="size-3.5 text-brand-green-deep" aria-hidden="true" />
{eyebrow}
</p>
<h1
id="hero-heading"
className="mt-lg w-full max-w-[880px] text-heading-2 font-semibold text-ink text-balance-pretty sm:text-heading-1 lg:text-hero-display"
>
{title}
</h1>
<p className="mt-lg w-full max-w-[640px] text-body-md text-slate text-balance-pretty sm:text-subtitle">
{subtitle}
</p>
{/* 按钮行:品牌绿强调 CTA + 描边次级按钮 */}
<div className="mt-xxl flex flex-col items-center gap-sm sm:flex-row">
<Link
href="/#posts"
className={buttonClassName({ variant: "accent", size: "lg" })}
>
{primaryLabel}
<ArrowRight className="size-4" aria-hidden="true" />
</Link>
<Link
href="/about"
className={buttonClassName({ variant: "secondary", size: "lg" })}
>
{secondaryLabel}
</Link>
</div>
{/* 站点统计:窄屏下压缩间距,避免三个卡片把布局撑出横向滚动 */}
<dl className="mt-section grid w-full min-w-0 max-w-[560px] grid-cols-3 gap-xs sm:mt-hero sm:gap-md">
<StatItem value={String(stats.posts)} label={stats.postsLabel} />
<StatItem value={String(stats.tags)} label={stats.tagsLabel} />
<StatItem value={stats.since} label={stats.sinceLabel} />
</dl>
</Container>
</section>
);
}
function StatItem({ value, label }: { value: string; label: string }) {
return (
<div className="flex min-w-0 flex-col items-center gap-[2px] rounded-lg border border-white/60 bg-white/55 px-xs py-sm backdrop-blur-sm sm:px-md">
<dt className="sr-only">{label}</dt>
<dd className="font-mono text-heading-5 font-semibold text-ink sm:text-heading-4">
{value}
</dd>
<dd className="text-caption text-steel">{label}</dd>
</div>
);
}
+82
View File
@@ -0,0 +1,82 @@
"use client";
import { useState } from "react";
import { usePathname, useRouter } from "@/i18n/navigation";
import { useLocale, useTranslations } from "next-intl";
import { Globe } from "lucide-react";
import { routing } from "@/i18n/routing";
import { cn } from "@/lib/utils";
/**
* 语言切换器。
*
* 使用 next-intl 的 usePathname / useRouter,切换语言时保留当前路径,
* 而不是跳回首页。通过 useParams 需要传参的场景在这里不适用,
* 因为本项目的动态段只有 slug,路径本身已经能表达。
*/
export function LocaleSwitcher({ className }: { className?: string }) {
const t = useTranslations("LocaleSwitcher");
const locale = useLocale();
const router = useRouter();
const pathname = usePathname();
const [open, setOpen] = useState(false);
const handleSelect = (nextLocale: string) => {
setOpen(false);
router.replace(pathname, { locale: nextLocale });
};
return (
<div className={cn("relative", className)}>
<button
type="button"
onClick={() => setOpen((value) => !value)}
aria-haspopup="listbox"
aria-expanded={open}
aria-label={t("label")}
className="inline-flex h-9 items-center gap-[6px] rounded-full px-sm text-body-sm text-steel transition-colors duration-150 active:bg-surface"
>
<Globe className="size-4" aria-hidden="true" />
<span className="hidden sm:inline">{t(locale)}</span>
</button>
{open ? (
<>
{/* 点击遮罩关闭菜单 */}
<button
type="button"
tabIndex={-1}
aria-hidden="true"
onClick={() => setOpen(false)}
className="fixed inset-0 z-40 cursor-default"
/>
<ul
role="listbox"
aria-label={t("label")}
className="absolute right-0 z-50 mt-xs min-w-[140px] overflow-hidden rounded-md border border-hairline bg-canvas py-xxs shadow-card"
>
{routing.locales.map((item) => (
<li key={item}>
<button
type="button"
role="option"
aria-selected={item === locale}
onClick={() => handleSelect(item)}
className={cn(
"flex w-full items-center px-md py-xs text-left text-body-sm transition-colors duration-150",
item === locale
? "bg-surface font-medium text-ink"
: "text-steel active:bg-surface",
)}
>
{t(item)}
</button>
</li>
))}
</ul>
</>
) : null}
</div>
);
}
+107
View File
@@ -0,0 +1,107 @@
import { Link } from "@/i18n/navigation";
import { getTranslations } from "next-intl/server";
import { Mail, Rss, SquareArrowOutUpRight } from "lucide-react";
import { siteConfig } from "@/lib/site-config";
/**
* 社交链接图标。
*
* 注意:lucide-react v1.x 已移除品牌图标(Github / Twitter 等),
* 因此这里使用通用图标,由 `label` 文本承载具体含义——
* 图标仅作装饰(aria-hidden),可访问性不依赖它。
*/
const SOCIAL_ICONS = {
github: SquareArrowOutUpRight,
twitter: Rss,
mail: Mail,
} as const;
/**
* 站点页脚。
*
* DESIGN.md 的 footer-region:白底上一道 hairline 顶边,多列分组,
* 分栏标题用 body-sm-medium 深色,链接用 body-sm 灰色。
*/
export async function SiteFooter() {
const t = await getTranslations("Footer");
const tNav = await getTranslations("Nav");
const year = new Date().getFullYear();
return (
<footer className="border-t border-hairline bg-canvas">
<div className="mx-auto w-full max-w-[1280px] px-lg py-section sm:px-xxl">
<div className="grid grid-cols-1 gap-xxl sm:grid-cols-2 lg:grid-cols-4">
{/* 品牌列 */}
<div className="flex flex-col gap-sm lg:col-span-2">
<div className="flex items-center gap-xs text-body-md font-semibold text-ink">
<span
aria-hidden="true"
className="grid size-7 place-items-center rounded-md bg-primary font-mono text-caption font-semibold text-brand-green"
>
&gt;_
</span>
{siteConfig.name}
</div>
<p className="max-w-[360px] text-body-sm text-steel">
{siteConfig.description}
</p>
</div>
{/* 导航列 */}
<nav aria-label={t("navigation")} className="flex flex-col gap-xs">
<h2 className="mb-xs text-body-sm font-medium text-ink">
{t("navigation")}
</h2>
<Link
href="/"
className="py-xxs text-body-sm text-steel transition-colors duration-150 hover:text-ink"
>
{tNav("home")}
</Link>
<Link
href="/about"
className="py-xxs text-body-sm text-steel transition-colors duration-150 hover:text-ink"
>
{tNav("about")}
</Link>
</nav>
{/* 社交列 */}
<div className="flex flex-col gap-xs">
<h2 className="mb-xs text-body-sm font-medium text-ink">
{t("elsewhere")}
</h2>
<ul className="flex flex-col gap-xxs">
{siteConfig.social.map((item) => {
const Icon = SOCIAL_ICONS[item.icon];
return (
<li key={item.label}>
<a
href={item.href}
target={item.href.startsWith("mailto:") ? undefined : "_blank"}
rel="noopener noreferrer"
className="inline-flex items-center gap-xs py-xxs text-body-sm text-steel transition-colors duration-150 hover:text-ink"
>
<Icon className="size-4" aria-hidden="true" />
{item.label}
</a>
</li>
);
})}
</ul>
</div>
</div>
<div className="mt-xxl flex flex-col gap-xs border-t border-hairline-soft pt-lg text-caption text-steel sm:flex-row sm:items-center sm:justify-between">
<p>
© {year} {siteConfig.name}. {t("rights")}
</p>
<p className="font-mono">{t("builtWith")}</p>
</div>
</div>
</footer>
);
}
+154
View File
@@ -0,0 +1,154 @@
"use client";
import { useEffect, useState } from "react";
import { Link, usePathname } from "@/i18n/navigation";
import { useTranslations } from "next-intl";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Menu, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { LocaleSwitcher } from "./locale-switcher";
const NAV_ITEMS = [
{ href: "/", key: "home" },
{ href: "/about", key: "about" },
] as const;
/**
* 站点头部导航。
*
* DESIGN.md:粘性白色导航条,高度约 64px,底部分割线 1px hairline-soft。
* 桌面端横向展示链接与 CTA;移动端(<1024px)折叠为抽屉菜单。
*/
export function SiteHeader() {
const t = useTranslations("Nav");
const pathname = usePathname();
const [menuOpen, setMenuOpen] = useState(false);
const shouldReduceMotion = useReducedMotion();
// 菜单打开时锁定页面滚动(与外部系统 document.body 同步)
useEffect(() => {
if (!menuOpen) {
return;
}
const original = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = original;
};
}, [menuOpen]);
const isActive = (href: string) =>
href === "/" ? pathname === "/" : pathname.startsWith(href);
return (
<header className="sticky top-0 z-40 border-b border-hairline-soft bg-canvas/85 backdrop-blur-md">
<div className="mx-auto flex h-16 w-full max-w-[1280px] items-center justify-between gap-md px-lg sm:px-xxl">
{/* 品牌标识 */}
<Link
href="/"
className="flex items-center gap-xs text-body-md font-semibold text-ink"
aria-label={t("home")}
>
<span
aria-hidden="true"
className="grid size-7 place-items-center rounded-md bg-primary font-mono text-caption font-semibold text-brand-green"
>
&gt;_
</span>
<span className="hidden sm:inline">个人博客</span>
</Link>
{/* 桌面端导航 */}
<nav
aria-label={t("home")}
className="hidden items-center gap-xxs md:flex"
>
{NAV_ITEMS.map((item) => (
<Link
key={item.href}
href={item.href}
aria-current={isActive(item.href) ? "page" : undefined}
className={cn(
"rounded-full px-md py-xs text-body-sm transition-colors duration-150",
isActive(item.href)
? "font-medium text-ink"
: "text-steel active:bg-surface",
)}
>
{t(item.key)}
</Link>
))}
</nav>
<div className="flex items-center gap-xxs">
<LocaleSwitcher />
{/* GitHub 入口(桌面端) */}
<a
href="https://github.com"
target="_blank"
rel="noopener noreferrer"
className="hidden h-9 items-center rounded-full bg-primary px-lg text-button-md text-on-primary transition-colors duration-150 active:bg-charcoal md:inline-flex"
>
GitHub
</a>
{/* 移动端菜单开关 */}
<button
type="button"
onClick={() => setMenuOpen((value) => !value)}
aria-expanded={menuOpen}
aria-controls="mobile-navigation"
aria-label={menuOpen ? t("closeMenu") : t("openMenu")}
className="grid size-10 place-items-center rounded-full text-ink transition-colors duration-150 active:bg-surface md:hidden"
>
{menuOpen ? (
<X className="size-5" aria-hidden="true" />
) : (
<Menu className="size-5" aria-hidden="true" />
)}
</button>
</div>
</div>
{/* 移动端抽屉 */}
<AnimatePresence>
{menuOpen ? (
<motion.nav
id="mobile-navigation"
aria-label={t("home")}
initial={shouldReduceMotion ? false : { opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={shouldReduceMotion ? { opacity: 0 } : { opacity: 0, height: 0 }}
transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
className="overflow-hidden border-t border-hairline-soft bg-canvas md:hidden"
>
<ul className="flex flex-col px-lg py-sm sm:px-xxl">
{NAV_ITEMS.map((item) => (
<li key={item.href}>
<Link
href={item.href}
// 点击后立即收起抽屉,避免依赖路由变化的副作用
onClick={() => setMenuOpen(false)}
aria-current={isActive(item.href) ? "page" : undefined}
className={cn(
// 触控目标 ≥ 44px
"flex min-h-11 items-center rounded-md px-sm text-body-md transition-colors duration-150",
isActive(item.href)
? "font-medium text-ink"
: "text-steel active:bg-surface",
)}
>
{t(item.key)}
</Link>
</li>
))}
</ul>
</motion.nav>
) : null}
</AnimatePresence>
</header>
);
}
+67
View File
@@ -0,0 +1,67 @@
import type { ReactNode } from "react";
import { Info, Lightbulb, TriangleAlert, CircleAlert } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* 提示块:可在 MDX 正文中直接使用。
*
* 颜色取自 DESIGN.md 的语义色:
* - info → brand-tag(信息蓝)
* - tip → brand-green(品牌绿)
* - warning → brand-warn(警告橙)
* - danger → brand-error(错误红)
*/
const CALLOUT_VARIANTS = {
info: {
icon: Info,
label: "说明",
className: "border-brand-tag/30 bg-brand-tag/[0.06] text-brand-tag",
},
tip: {
icon: Lightbulb,
label: "提示",
className:
"border-brand-green/40 bg-brand-green/[0.07] text-brand-green-deep",
},
warning: {
icon: TriangleAlert,
label: "注意",
className: "border-brand-warn/30 bg-brand-warn/[0.07] text-brand-warn",
},
danger: {
icon: CircleAlert,
label: "警告",
className: "border-brand-error/30 bg-brand-error/[0.06] text-brand-error",
},
} as const;
export type CalloutType = keyof typeof CALLOUT_VARIANTS;
export interface CalloutProps {
type?: CalloutType;
title?: string;
children: ReactNode;
}
export function Callout({ type = "info", title, children }: CalloutProps) {
const variant = CALLOUT_VARIANTS[type] ?? CALLOUT_VARIANTS.info;
const Icon = variant.icon;
return (
<aside
className={cn(
"my-xl flex gap-sm rounded-md border p-md",
variant.className,
)}
>
<Icon className="mt-[2px] size-4 shrink-0" aria-hidden="true" />
<div className="flex-1 text-body-sm">
<p className="mb-xxs font-semibold">
{title ?? variant.label}
</p>
<div className="text-slate [&>p]:text-slate [&>p+p]:mt-xs">{children}</div>
</div>
</aside>
);
}
+119
View File
@@ -0,0 +1,119 @@
"use client";
import { useState, type ReactNode } from "react";
import { Check, Copy } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* 代码块容器:对应 DESIGN.md 的 code-block + code-block-header + copy-code-button。
*
* Shiki 已在构建期把高亮结果编译成 <pre><code>…</code></pre>,
* 这里只负责套上头部(语言标签 + 复制按钮),不重新解析代码。
*
* 复制逻辑优先使用 Clipboard API,失败时回退到 execCommand,
* 以便在非 HTTPS 环境下依然可用。
*/
export interface CodeBlockProps {
children?: ReactNode;
className?: string;
/** Shiki 输出的 data-language(由 rehype-pretty-code 注入到 figure 上) */
"data-language"?: string;
raw?: string;
}
async function copyText(text: string): Promise<boolean> {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// 落回下面的兼容实现
}
try {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
const succeeded = document.execCommand("copy");
document.body.removeChild(textarea);
return succeeded;
} catch {
return false;
}
}
/** 从 <pre> 的 React 子节点中抽取纯文本,作为复制内容 */
function extractText(node: ReactNode): string {
if (node === null || node === undefined || typeof node === "boolean") {
return "";
}
if (typeof node === "string" || typeof node === "number") {
return String(node);
}
if (Array.isArray(node)) {
return node.map(extractText).join("");
}
if (typeof node === "object" && "props" in node) {
const props = (node as { props?: { children?: ReactNode } }).props;
return extractText(props?.children);
}
return "";
}
export function CodeBlock({ children, className, ...props }: CodeBlockProps) {
const [copied, setCopied] = useState(false);
const language =
props["data-language"] ?? detectLanguage(className) ?? "text";
const handleCopy = async () => {
const text = extractText(children).replace(/\n$/, "");
const succeeded = await copyText(text);
if (succeeded) {
setCopied(true);
window.setTimeout(() => setCopied(false), 2000);
}
};
return (
<div className="group relative">
<div className="absolute right-xs top-xs z-10 flex items-center gap-xs">
<span className="font-mono text-caption text-on-dark-muted">
{language}
</span>
<button
type="button"
onClick={handleCopy}
aria-label={copied ? "已复制代码" : "复制代码"}
className="inline-flex items-center gap-[4px] rounded-sm border border-hairline-dark bg-surface-code/90 px-xs py-[4px] font-mono text-caption text-on-dark-muted transition-colors duration-150 active:text-on-dark"
>
{copied ? (
<Check className="size-3 text-brand-green" aria-hidden="true" />
) : (
<Copy className="size-3" aria-hidden="true" />
)}
<span className={cn(copied && "text-brand-green")}>
{copied ? "已复制" : "复制"}
</span>
</button>
</div>
<pre className={className} {...props}>
{children}
</pre>
</div>
);
}
/** 从 className 中提取语言,如 "language-ts" → "ts" */
function detectLanguage(className?: string): string | null {
const match = /language-([\w-]+)/.exec(className ?? "");
return match ? match[1] : null;
}
+74
View File
@@ -0,0 +1,74 @@
import Image from "next/image";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
import { Callout } from "./callout";
import { CodeBlock } from "./code-block";
/**
* MDX 组件映射。
*
* 通过替换原生元素,把 DESIGN.md 的排版规则与可访问性要求统一注入正文:
* - img → next/image(图片优化 + 保留 alt)
* - pre → 带语言标签与复制按钮的代码块
* - a → 外链自动补 rel/target
*
* 类型说明:这里不使用 `mdx/types` 的 MDXComponents,因为它依赖可选安装的
* @types/mdx。改为按组件签名手写映射类型,避免引入额外类型包。
*/
type MdxImageProps = ComponentPropsWithoutRef<"img">;
type MdxAnchorProps = ComponentPropsWithoutRef<"a"> & { children?: ReactNode };
/**
* MDX 组件映射类型。
*
* `next-mdx-remote` 内部使用 `MDXComponents`(带字符串索引签名),
* 因此这里显式声明索引签名,保证类型兼容的同时仍然约束了我们实际用到的键。
*/
export interface MdxComponentMap {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
img: (props: MdxImageProps) => ReactNode;
a: (props: MdxAnchorProps) => ReactNode;
pre: typeof CodeBlock;
Callout: typeof Callout;
}
export const mdxComponents: MdxComponentMap = {
// 文章内图片改用 next/image,alt 缺失时降级为空字符串(装饰性图片)
img: ({ src, alt, width, height }) => {
if (typeof src !== "string") {
return null;
}
return (
<Image
src={src}
alt={alt ?? ""}
width={typeof width === "number" ? width : 1200}
height={typeof height === "number" ? height : 630}
className="rounded-lg border border-hairline"
sizes="(max-width: 768px) 100vw, 720px"
/>
);
},
// 外链统一加上安全属性;站内锚点(# 开头)与相对路径保持原样
a: ({ href, children, ...props }) => {
const isExternal = typeof href === "string" && /^https?:\/\//.test(href);
return (
<a
href={href}
{...(isExternal
? { target: "_blank", rel: "noopener noreferrer" }
: {})}
{...props}
>
{children}
</a>
);
},
pre: CodeBlock,
Callout,
};
+78
View File
@@ -0,0 +1,78 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
/**
* 入场动画包装器。
*
* ## 设计取舍(重要)
*
* 这里**没有**使用 motion 的 `whileInView`。原因是它在服务端会输出
* `opacity: 0`,只有 JS 执行且元素滚入视口后才显示。对博客来说这不可接受:
* JS 失败、被拦截或加载缓慢时,**整份文章列表会永久不可见**,同时
* 搜索引擎抓到的也是空内容。
*
* 改为「渐进增强」策略:
* 1. 服务端输出完全可见的 HTML —— 无 JS 也能正常阅读,SEO 无风险;
* 2. 客户端挂载后,仅当元素**尚未进入视口**时,才加上入场动画的起始类,
* 并在下一帧移除,从而播放一次淡入上移。
*
* 换句话说:可读性是默认状态,动画是额外叠加的效果。
*/
export interface MotionItemProps {
children: ReactNode;
delay?: number;
className?: string;
}
export function MotionItem({ children, delay = 0, className }: MotionItemProps) {
const ref = useRef<HTMLDivElement>(null);
const [animationClass, setAnimationClass] = useState<string>("");
useEffect(() => {
const element = ref.current;
if (!element) {
return;
}
// 尊重系统的「减弱动态效果」偏好
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
if (prefersReducedMotion) {
return;
}
// 已经出现在首屏内的元素不播放动画,避免可见内容的闪烁
const rect = element.getBoundingClientRect();
const isBelowFold = rect.top > window.innerHeight;
if (!isBelowFold) {
return;
}
// 仅在元素位于视口下方时才设置动画起始态
setAnimationClass("opacity-0 translate-y-3");
// 下一帧移除起始类,触发 CSS transition 过渡到最终状态
const frame = requestAnimationFrame(() => {
requestAnimationFrame(() => setAnimationClass(""));
});
return () => cancelAnimationFrame(frame);
}, []);
return (
<div
ref={ref}
className={cn(
"motion-safe:transition-[opacity,transform] motion-safe:duration-500 motion-safe:ease-brand",
animationClass,
className,
)}
style={delay > 0 ? { transitionDelay: `${delay}s` } : undefined}
>
{children}
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
"use client";
import { motion, useReducedMotion, useScroll, useSpring } from "motion/react";
/**
* 页面顶部阅读进度条。
*
* 使用 motion 的 `useScroll` + `useSpring`:滚动位置映射为宽度百分比,
* 经弹簧平滑后呈现,避免直接跟随时产生的抖动。
*/
export function ReadingProgress() {
const shouldReduceMotion = useReducedMotion();
const { scrollYProgress } = useScroll();
const scaleX = useSpring(scrollYProgress, {
stiffness: 220,
damping: 40,
restDelta: 0.001,
});
if (shouldReduceMotion) {
return null;
}
return (
<motion.div
aria-hidden="true"
style={{ scaleX }}
className="fixed inset-x-0 top-0 z-50 h-[2px] origin-left bg-brand-green"
/>
);
}
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
/**
* 通用入场动画包装器(渐进增强)。
*
* 与 MotionItem 采用同一策略:服务端输出的 HTML 默认可见,
* 仅在客户端确认元素位于视口下方时,才叠加一次淡入上移动画。
*
* 这样即使 JS 完全不可用,内容依然完整可读——动画纯粹是增强项。
*/
export interface RevealProps {
children: ReactNode;
className?: string;
/** 延迟秒数,用于列表的错位入场 */
delay?: number;
/** 位移距离(Tailwind 间距类,如 "translate-y-3") */
offsetClassName?: string;
}
export function Reveal({
children,
className,
delay = 0,
offsetClassName = "translate-y-4",
}: RevealProps) {
const ref = useRef<HTMLDivElement>(null);
const [animationClass, setAnimationClass] = useState<string>("");
useEffect(() => {
const element = ref.current;
if (!element) {
return;
}
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
if (prefersReducedMotion) {
return;
}
// 首屏内可见的元素不做动画,避免内容闪烁
const rect = element.getBoundingClientRect();
if (rect.top <= window.innerHeight) {
return;
}
setAnimationClass(cn("opacity-0", offsetClassName));
const frame = requestAnimationFrame(() => {
requestAnimationFrame(() => setAnimationClass(""));
});
return () => cancelAnimationFrame(frame);
}, [offsetClassName]);
return (
<div
ref={ref}
className={cn(
"motion-safe:transition-[opacity,transform] motion-safe:duration-500 motion-safe:ease-brand",
animationClass,
className,
)}
style={delay > 0 ? { transitionDelay: `${delay}s` } : undefined}
>
{children}
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
import { Link } from "@/i18n/navigation";
import { getLocale, getTranslations } from "next-intl/server";
import { CalendarDays, Clock } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { formatDate, toDateTimeAttribute } from "@/lib/utils";
import type { PostMeta } from "@/lib/posts/types";
/**
* 文章卡片(服务端组件)。
*
* DESIGN.md 的 card-base:白底 + 1px hairline 描边 + rounded-lg,
* 内部留白 {spacing.xl}。展示标题、日期、阅读时长、摘要与标签。
*/
export interface PostCardProps {
post: PostMeta;
/** 是否展示摘要;紧凑列表可关闭 */
showSummary?: boolean;
className?: string;
}
export async function PostCard({
post,
showSummary = true,
className,
}: PostCardProps) {
const t = await getTranslations("Post");
const locale = await getLocale();
return (
<article className={className}>
<Link
href={`/posts/${post.slug}`}
className="group flex h-full flex-col gap-sm rounded-lg border border-hairline bg-canvas p-xl transition-colors duration-150 hover:border-hairline-soft hover:bg-surface-soft active:bg-surface"
>
{/* 元信息:日期 + 阅读时长 */}
<div className="flex flex-wrap items-center gap-sm text-caption text-steel">
<span className="inline-flex items-center gap-[6px]">
<CalendarDays className="size-3.5" aria-hidden="true" />
<time dateTime={toDateTimeAttribute(post.date)}>
{formatDate(post.date, locale)}
</time>
</span>
<span aria-hidden="true" className="text-muted">
·
</span>
<span className="inline-flex items-center gap-[6px]">
<Clock className="size-3.5" aria-hidden="true" />
{t("readingTime", { minutes: post.readingMinutes })}
</span>
</div>
{/* 标题 */}
<h3 className="text-heading-4 font-semibold text-ink transition-colors duration-150 group-hover:text-brand-green-deep">
{post.title}
</h3>
{/* 摘要 */}
{showSummary ? (
<p className="flex-1 text-body-sm text-steel text-balance-pretty">
{post.summary}
</p>
) : null}
{/* 标签 */}
{post.tags && post.tags.length > 0 ? (
<ul className="mt-xs flex flex-wrap gap-xs">
{post.tags.map((tag) => (
<li key={tag}>
<Badge variant="tag">{tag}</Badge>
</li>
))}
</ul>
) : null}
</Link>
</article>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { cn } from "@/lib/utils";
/**
* 文章骨架屏组件。
*
* ## 为什么本项目没有使用路由级 `loading.tsx`
*
* 路由级 `loading.tsx` 会创建 Suspense 边界,Next.js 会**立即以 200 状态**
* 流式输出骨架屏;之后页面内的 `notFound()` 只能替换内容,无法再改写已经
* 发出的状态码。
*
* 实测结果(Next.js 16.3.8):
* - 存在 `app/[locale]/loading.tsx` 时,`/zh/posts/<草稿>` 与
* `/zh/posts/<不存在>` 均返回 **200**(内容却是 404 页面);
* - 移除后,二者正确返回 **404**。
*
* 对博客而言,让不存在的 URL 返回 200 会被搜索引擎当作有效页面收录,
* 属于必须避免的 SEO 缺陷。因此这里**牺牲路由级骨架屏**,
* 保证状态码正确;骨架屏仅作为组件按需使用(例如包裹已知存在的
* 慢速数据区块)。
*/
export function PostListSkeleton({ count = 6 }: { count?: number }) {
return (
<div
className="grid grid-cols-1 gap-lg sm:grid-cols-2 lg:grid-cols-3"
aria-hidden="true"
>
{Array.from({ length: count }).map((_, index) => (
<div
// 骨架屏为静态占位,使用索引作 key 不会引起状态错位
key={index}
className={cn(
"flex flex-col gap-sm rounded-lg border border-hairline bg-canvas p-xl",
)}
>
{/* 日期 + 阅读时长 */}
<div className="h-4 w-40 animate-pulse rounded-sm bg-hairline-soft" />
{/* 标题 */}
<div className="h-6 w-full animate-pulse rounded-md bg-hairline" />
{/* 摘要两行 */}
<div className="h-4 w-full animate-pulse rounded-sm bg-hairline-soft" />
<div className="h-4 w-3/4 animate-pulse rounded-sm bg-hairline-soft" />
{/* 标签 */}
<div className="mt-xs flex gap-xs">
<div className="h-5 w-14 animate-pulse rounded-sm bg-hairline-soft" />
<div className="h-5 w-16 animate-pulse rounded-sm bg-hairline-soft" />
</div>
</div>
))}
</div>
);
}
/** 文章详情页正文骨架 */
export function PostBodySkeleton() {
return (
<div className="mt-xl flex flex-col gap-md" aria-hidden="true">
{Array.from({ length: 10 }).map((_, index) => (
<div
key={index}
className="h-4 animate-pulse rounded-sm bg-hairline-soft"
style={{ width: `${[100, 96, 88, 100, 92, 100, 84][index % 7]}%` }}
/>
))}
<div className="mt-md h-40 w-full animate-pulse rounded-md bg-surface-code/20" />
</div>
);
}
+99
View File
@@ -0,0 +1,99 @@
"use client";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { cn } from "@/lib/utils";
import type { TocItem } from "@/lib/toc";
/**
* 文章目录(右侧栏)。
*
* 用 IntersectionObserver 做滚动高亮——比监听 scroll 事件性能更好,
* 且不需要手动计算元素偏移。根边距设置为顶部 96px(避开粘性导航)
* 到底部 -70%,让"当前阅读位置"落在视口上部三分之一处。
*/
export function TableOfContents({ items }: { items: TocItem[] }) {
const t = useTranslations("Post");
const [activeId, setActiveId] = useState<string>(items[0]?.id ?? "");
useEffect(() => {
if (items.length === 0) {
return;
}
const headings = items
.map((item) => document.getElementById(item.id))
.filter((element): element is HTMLElement => element !== null);
if (headings.length === 0) {
return;
}
const observer = new IntersectionObserver(
(entries) => {
// 取当前可见且位置最靠上的标题作为激活项
const visible = entries
.filter((entry) => entry.isIntersecting)
.sort(
(a, b) =>
a.boundingClientRect.top - b.boundingClientRect.top,
);
if (visible.length > 0) {
setActiveId(visible[0].target.id);
}
},
{
rootMargin: "-96px 0px -70% 0px",
threshold: 0,
},
);
for (const heading of headings) {
observer.observe(heading);
}
return () => observer.disconnect();
}, [items]);
if (items.length === 0) {
return null;
}
return (
<nav aria-labelledby="toc-heading" className="flex flex-col gap-xs">
<h2
id="toc-heading"
className="text-micro-uppercase font-semibold uppercase tracking-[0.5px] text-steel"
>
{t("tableOfContents")}
</h2>
<ul className="flex flex-col border-l border-hairline">
{items.map((item) => {
const isActive = item.id === activeId;
return (
<li key={item.id}>
<a
href={`#${item.id}`}
aria-current={isActive ? "location" : undefined}
className={cn(
"block border-l-2 py-xxs text-body-sm transition-colors duration-150",
item.level === 3 ? "pl-lg" : "pl-sm",
// 用 -ml-px 让边框与父级 border-l 重合
"-ml-px",
isActive
? "border-brand-green font-medium text-ink"
: "border-transparent text-steel hover:text-ink",
)}
>
{item.title}
</a>
</li>
);
})}
</ul>
</nav>
);
}
+40
View File
@@ -0,0 +1,40 @@
import type { HTMLAttributes } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
/**
* 徽章变体,对应 DESIGN.md 的 badge-* 组件:
* - tag → badge-tag(文章标签,蓝色 + 半透明底)
* - type → badge-type(代码风格的类型标记)
* - discount → badge-discount(品牌绿强调)
* - required → badge-required(错误红,大写微标签)
* - neutral → 中性灰,用于日期 / 阅读时长等元信息
*/
export const badgeVariants = cva(
"inline-flex items-center gap-[4px] whitespace-nowrap rounded-sm leading-none",
{
variants: {
variant: {
tag: "bg-brand-tag/15 text-brand-tag text-caption font-semibold px-xs py-[3px]",
type: "bg-surface text-steel font-mono text-code-sm px-xs py-[3px]",
discount: "bg-brand-green text-primary text-caption font-semibold rounded-full px-xs py-[3px]",
required:
"bg-brand-error text-on-dark text-micro-uppercase font-semibold px-[6px] py-[3px]",
neutral: "bg-surface text-steel text-caption px-xs py-[3px]",
},
},
defaultVariants: {
variant: "neutral",
},
},
);
export type BadgeProps = HTMLAttributes<HTMLSpanElement> &
VariantProps<typeof badgeVariants>;
/** 徽章 / 标签 */
export function Badge({ className, variant, ...props }: BadgeProps) {
return (
<span className={cn(badgeVariants({ variant }), className)} {...props} />
);
}
+30
View File
@@ -0,0 +1,30 @@
import type { AnchorHTMLAttributes } from "react";
import {
buttonClassName,
type ButtonStyleOptions,
} from "./button-variants";
export type ButtonLinkProps = AnchorHTMLAttributes<HTMLAnchorElement> &
ButtonStyleOptions;
/**
* 外链按钮:用于邮件、社交链接等外部地址。
* 站内跳转请使用 next-intl 的 <Link> 并传入 buttonClassName()。
*/
export function ButtonLink({
className,
variant,
size,
rel = "noopener noreferrer",
target = "_blank",
...props
}: ButtonLinkProps) {
return (
<a
rel={rel}
target={target}
className={buttonClassName({ className, variant, size })}
{...props}
/>
);
}
+57
View File
@@ -0,0 +1,57 @@
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
/**
* 按钮样式变体。
*
* 对应 DESIGN.md 的组件定义:
* - primary → button-primary(黑底胶囊,主 CTA)
* - secondary → button-secondary(描边胶囊)
* - accent → button-accent-green(品牌薄荷绿,谨慎使用)
* - ghost → button-ghost(无背景矩形)
* - link → button-link(内联文字链接)
*
* 规范要求:所有按钮一律 `{rounded.full}` 胶囊形,不软化圆角。
*/
export const buttonVariants = cva(
"inline-flex items-center justify-center gap-xs font-medium whitespace-nowrap rounded-full transition-colors duration-150 ease-out disabled:pointer-events-none disabled:bg-hairline disabled:text-muted",
{
variants: {
variant: {
primary: "bg-primary text-on-primary active:bg-charcoal",
secondary:
"bg-transparent text-ink border border-hairline active:bg-surface",
accent: "bg-brand-green text-primary active:bg-brand-green-deep",
onDark: "bg-on-dark text-primary active:bg-on-dark-muted",
ghost: "rounded-md bg-transparent text-ink active:bg-surface",
link: "rounded-none bg-transparent text-ink underline-offset-4 active:underline p-0 h-auto",
},
size: {
sm: "h-9 px-md text-body-sm",
// DESIGN.md: padding 10px 20px,字号 14px / 行高 1.3
md: "h-10 px-lg text-button-md",
// 移动端触控目标 ≥ 44px
lg: "h-11 px-xl text-body-md",
},
},
defaultVariants: {
variant: "primary",
size: "md",
},
},
);
export type ButtonVariantProps = VariantProps<typeof buttonVariants>;
export interface ButtonStyleOptions extends ButtonVariantProps {
className?: string;
}
/** 生成按钮 className,供 <button> 与 <Link> 复用同一套样式 */
export function buttonClassName({
className,
variant,
size,
}: ButtonStyleOptions = {}): string {
return cn(buttonVariants({ variant, size }), className);
}
+25
View File
@@ -0,0 +1,25 @@
import type { ButtonHTMLAttributes } from "react";
import {
buttonClassName,
type ButtonStyleOptions,
} from "./button-variants";
export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
ButtonStyleOptions;
/** 按钮组件:渲染原生 <button>,样式由 buttonVariants 统一管理 */
export function Button({
className,
variant,
size,
type = "button",
...props
}: ButtonProps) {
return (
<button
type={type}
className={buttonClassName({ className, variant, size })}
{...props}
/>
);
}
+42
View File
@@ -0,0 +1,42 @@
import type { HTMLAttributes } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
/**
* 卡片变体,对应 DESIGN.md:
* - base → card-base(白底 + hairline 描边)
* - feature → card-feature(surface 底、无描边)
* - mockup → hero-product-mockup(含大扩散阴影,仅 Hero 使用)
*/
export const cardVariants = cva("", {
variants: {
variant: {
base: "bg-canvas border border-hairline rounded-lg",
feature: "bg-surface rounded-lg",
mockup: "bg-canvas border border-hairline-soft rounded-lg shadow-mockup",
},
padding: {
none: "p-0",
sm: "p-md",
md: "p-xl",
lg: "p-xxl",
},
},
defaultVariants: {
variant: "base",
padding: "md",
},
});
export type CardProps = HTMLAttributes<HTMLDivElement> &
VariantProps<typeof cardVariants>;
/** 通用卡片容器 */
export function Card({ className, variant, padding, ...props }: CardProps) {
return (
<div
className={cn(cardVariants({ variant, padding }), className)}
{...props}
/>
);
}
+84
View File
@@ -0,0 +1,84 @@
import type { HTMLAttributes, ElementType } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
/**
* 容器:统一站点内容宽度与左右留白。
* DESIGN.md:营销页面 1280px 最大宽度 + 32px 沟槽。
*/
const containerVariants = cva("mx-auto w-full", {
variants: {
size: {
// 文章正文最舒适的阅读宽度
prose: "max-w-[720px]",
default: "max-w-[1280px]",
narrow: "max-w-[960px]",
},
gutter: {
default: "px-lg sm:px-xxl",
none: "px-0",
},
},
defaultVariants: {
size: "default",
gutter: "default",
},
});
export type ContainerProps = HTMLAttributes<HTMLElement> &
VariantProps<typeof containerVariants> & {
/** 渲染的语义化标签,默认 div */
as?: ElementType;
};
export function Container({
className,
size,
gutter,
as: Component = "div",
...props
}: ContainerProps) {
return (
<Component
className={cn(containerVariants({ size, gutter }), className)}
{...props}
/>
);
}
/**
* 区块:负责垂直节奏。
* DESIGN.md:营销页区块之间 96px,文档面 32–64px。
*/
const sectionVariants = cva("w-full", {
variants: {
spacing: {
none: "py-0",
sm: "py-section-sm",
default: "py-section",
lg: "py-section-lg",
},
},
defaultVariants: {
spacing: "default",
},
});
export type SectionProps = HTMLAttributes<HTMLElement> &
VariantProps<typeof sectionVariants> & {
as?: ElementType;
};
export function Section({
className,
spacing,
as: Component = "section",
...props
}: SectionProps) {
return (
<Component
className={cn(sectionVariants({ spacing }), className)}
{...props}
/>
);
}
+52
View File
@@ -0,0 +1,52 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
/**
* 区块标题:DESIGN.md 中反复出现的「大写微标签 + 标题 + 描述」结构。
* 微标签使用 {typography.micro-uppercase}(11px / 600 / +0.5px 字距)。
*/
export interface SectionHeadingProps {
/** 标签上方的大写微标签 */
eyebrow?: string;
title: ReactNode;
description?: ReactNode;
className?: string;
/** 对齐方式,默认居中(营销区块);文档面可用 left */
align?: "center" | "left";
as?: "h1" | "h2" | "h3";
}
export function SectionHeading({
eyebrow,
title,
description,
className,
align = "center",
as: Heading = "h2",
}: SectionHeadingProps) {
return (
<div
className={cn(
"flex flex-col gap-sm",
align === "center" ? "items-center text-center" : "items-start text-left",
className,
)}
>
{eyebrow ? (
<p className="text-micro-uppercase font-semibold uppercase tracking-[0.5px] text-steel">
{eyebrow}
</p>
) : null}
<Heading className="text-heading-2 font-semibold text-ink text-balance-pretty">
{title}
</Heading>
{description ? (
<p className="max-w-[640px] text-subtitle text-steel text-balance-pretty">
{description}
</p>
) : null}
</div>
);
}