This commit is contained in:
1 parent
c1541b80df
commit
b4c7908029
97 files changed
+12403
-154
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
>_
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
>_
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user