feat: 公文三栏单页布局,并修复时间线目录跳转与样式
公告/单页对齐帖子详情三栏壳;时间线按日锚点可跳转,条目改为日志行并用暖金日期区分 feat 色。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -47,7 +47,8 @@ export default async function MarkdownBody({
|
||||
bodies.push(null);
|
||||
continue;
|
||||
}
|
||||
bodies.push(await renderMarkdownToReact(it.body, components));
|
||||
// 条目正文不挂目录 id,避免与块外 headingRenderer 错位
|
||||
bodies.push(await renderMarkdownToReact(it.body));
|
||||
}
|
||||
nodes.push(
|
||||
<MdTimeline
|
||||
@@ -74,7 +75,7 @@ export default async function MarkdownBody({
|
||||
continue;
|
||||
}
|
||||
const inner = seg.body.trim()
|
||||
? await renderMarkdownToReact(seg.body, components)
|
||||
? await renderMarkdownToReact(seg.body)
|
||||
: null;
|
||||
nodes.push(
|
||||
<MdHideBlock key={`hide-${i}`} kind={kind} points={seg.points}>
|
||||
|
||||
@@ -30,6 +30,8 @@ export default function MarkdownBodyClient({
|
||||
}: MarkdownBodyClientProps) {
|
||||
const segs = splitContentSegments(content || "");
|
||||
const mdComponents = mergeMarkdownComponents(components);
|
||||
// 时间线/隐藏块正文不套外层 heading 覆盖,避免预览与正文目录 id 错位
|
||||
const nestedMd = mergeMarkdownComponents();
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
@@ -49,7 +51,7 @@ export default function MarkdownBodyClient({
|
||||
items={seg.items}
|
||||
renderBody={(it) =>
|
||||
it.body.trim() ? (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={nestedMd}>
|
||||
{it.body}
|
||||
</ReactMarkdown>
|
||||
) : null
|
||||
@@ -74,7 +76,7 @@ export default function MarkdownBodyClient({
|
||||
return (
|
||||
<MdHideBlock key={`hide-${i}`} kind={kind} points={seg.points}>
|
||||
{seg.body.trim() ? (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mdComponents}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={nestedMd}>
|
||||
{seg.body}
|
||||
</ReactMarkdown>
|
||||
) : null}
|
||||
|
||||
@@ -50,7 +50,10 @@ export default function PostToc({ items }: { items: TocItem[] }) {
|
||||
|
||||
const jump = (id: string) => (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: scrollBehavior(), block: "start" });
|
||||
const target = document.getElementById(id);
|
||||
if (!target) return;
|
||||
target.scrollIntoView({ behavior: scrollBehavior(), block: "start" });
|
||||
setActive(id);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
TrendingUp,
|
||||
Info,
|
||||
Pin,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
PostListItem,
|
||||
@@ -27,6 +28,7 @@ import Avatar from "@/components/Avatar";
|
||||
import { PulseCheckin } from "./CheckinCard";
|
||||
import { FRIEND_LINKS } from "@/lib/friendLinks";
|
||||
import { formatCompact, formatShortDate } from "@/lib/format";
|
||||
import { pageGlyph } from "@/lib/pageGlyph";
|
||||
|
||||
/* ---------- 站点数据(2×2 bento 统计瓦片) ---------- */
|
||||
|
||||
@@ -120,45 +122,42 @@ function PulseCard({
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- 关于本站(侧栏单页) ---------- */
|
||||
/* ---------- 关于本站(侧栏页目录,置于右栏最底) ---------- */
|
||||
|
||||
function AboutSite({ pages }: { pages: SidebarPageItem[] }) {
|
||||
function AboutSite({
|
||||
pages,
|
||||
siteDescription,
|
||||
}: {
|
||||
pages: SidebarPageItem[];
|
||||
siteDescription?: string;
|
||||
}) {
|
||||
if (pages.length === 0) return null;
|
||||
|
||||
if (pages.length === 1) {
|
||||
const p = pages[0];
|
||||
return (
|
||||
<section className="j13-module" aria-label="关于本站">
|
||||
<p className="j13-module-title">
|
||||
<Info size={15} style={{ color: "var(--accent)" }} /> 关于本站
|
||||
</p>
|
||||
<Link href={`/p/${p.slug}`} className="block mt-2 rounded-lg px-0.5 py-1 transition-colors hover:bg-[var(--accent-soft)]">
|
||||
<p className="text-[14px] font-semibold leading-snug" style={{ color: "var(--ink)" }}>
|
||||
{p.title}
|
||||
</p>
|
||||
{p.excerpt ? (
|
||||
<p className="meta text-[12.5px] mt-1.5 leading-relaxed line-clamp-3">{p.excerpt}</p>
|
||||
) : null}
|
||||
</Link>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
const lead = siteDescription?.trim() ?? "";
|
||||
|
||||
return (
|
||||
<section className="j13-module" aria-label="关于本站">
|
||||
<p className="j13-module-title mb-1.5">
|
||||
<section className="j13-module j13-about" aria-label="关于本站">
|
||||
<p className="j13-module-title">
|
||||
<Info size={15} style={{ color: "var(--accent)" }} /> 关于本站
|
||||
</p>
|
||||
<ul className="space-y-0.5">
|
||||
{pages.map((p) => (
|
||||
<li key={p.id}>
|
||||
<Link href={`/p/${p.slug}`} className="j13-raillink" data-tip={p.title}>
|
||||
<span className="flex-1 min-w-0 truncate text-[13px] font-medium" style={{ color: "var(--ink)" }}>
|
||||
{p.title}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
{lead ? <p className="j13-about-lead">{lead}</p> : null}
|
||||
<ul className="j13-about-list">
|
||||
{pages.map((p) => {
|
||||
const Icon = pageGlyph(p.slug);
|
||||
return (
|
||||
<li key={p.id}>
|
||||
<Link href={`/p/${p.slug}`} className="j13-about-item">
|
||||
<span className="j13-about-ico" aria-hidden="true">
|
||||
<Icon size={15} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="j13-about-body">
|
||||
<span className="j13-about-name">{p.title}</span>
|
||||
{p.excerpt ? <span className="j13-about-excerpt">{p.excerpt}</span> : null}
|
||||
</span>
|
||||
<ChevronRight className="j13-about-chev" size={15} aria-hidden="true" />
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
@@ -421,6 +420,7 @@ type PanelsProps = {
|
||||
allowComments: boolean;
|
||||
canManageAnnouncements?: boolean;
|
||||
sidebarPages?: SidebarPageItem[];
|
||||
siteDescription?: string;
|
||||
};
|
||||
|
||||
function CommunityPanelsBody({
|
||||
@@ -433,16 +433,17 @@ function CommunityPanelsBody({
|
||||
allowComments,
|
||||
canManageAnnouncements,
|
||||
sidebarPages = [],
|
||||
siteDescription,
|
||||
}: PanelsProps) {
|
||||
return (
|
||||
<>
|
||||
<PulseCard stats={stats} checkin={checkin} allowComments={allowComments} />
|
||||
<AboutSite pages={sidebarPages} />
|
||||
<Announcements items={announcements} canManageAnnouncements={canManageAnnouncements} />
|
||||
<HotRanking posts={hot} allowComments={allowComments} />
|
||||
<NewUsers users={newUsers} />
|
||||
<ActiveUsers users={activeUsers} allowComments={allowComments} />
|
||||
<FriendLinks />
|
||||
<AboutSite pages={sidebarPages} siteDescription={siteDescription} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
groupTimelineByDate,
|
||||
type TimelineItem,
|
||||
} from "@/lib/timelineBlocks";
|
||||
import { timelineDayId } from "@/lib/toc";
|
||||
|
||||
type MdTimelineProps = {
|
||||
items: TimelineItem[];
|
||||
@@ -11,8 +12,24 @@ type MdTimelineProps = {
|
||||
renderBody?: (item: TimelineItem, index: number) => ReactNode;
|
||||
};
|
||||
|
||||
const PREFIX_RE =
|
||||
/^(feat|fix|polish|chore|docs|refactor|style|perf|test)(?:\(([^)]*)\))?\s*:?\s*/i;
|
||||
|
||||
function splitTitlePrefix(title: string): { kind?: string; scope?: string; rest: string } {
|
||||
const m = PREFIX_RE.exec(title);
|
||||
if (!m) return { rest: title };
|
||||
const rest = title.slice(m[0].length).trim() || title;
|
||||
return {
|
||||
kind: m[1].toLowerCase(),
|
||||
scope: m[2]?.trim() || undefined,
|
||||
rest,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 按日分组的垂直时间线(更新树)。颜色走 CSS 令牌。
|
||||
* 按日分组的垂直时间线(更新树)。
|
||||
* 日分组 id 与 extractToc 的 tl-YYYY-MM-DD 对齐,供右侧目录跳转。
|
||||
* 条目用普通段落而非 h3,避免被 .prose-content 标题样式放大。
|
||||
*/
|
||||
export default function MdTimeline({ items, renderBody }: MdTimelineProps) {
|
||||
if (!items.length) {
|
||||
@@ -24,19 +41,41 @@ export default function MdTimeline({ items, renderBody }: MdTimelineProps) {
|
||||
return (
|
||||
<div className="j13-timeline" role="list" aria-label="时间线">
|
||||
{groups.map((g) => (
|
||||
<div key={g.date} className="j13-timeline-day">
|
||||
<div className="j13-timeline-day-label">
|
||||
<time dateTime={g.date}>{g.date}</time>
|
||||
<section
|
||||
key={g.date}
|
||||
id={timelineDayId(g.date)}
|
||||
className="j13-timeline-day"
|
||||
style={{ scrollMarginTop: 90 }}
|
||||
aria-labelledby={`${timelineDayId(g.date)}-label`}
|
||||
>
|
||||
<div className="j13-timeline-day-label" id={`${timelineDayId(g.date)}-label`}>
|
||||
<time dateTime={/^\d{4}-\d{2}-\d{2}$/.test(g.date) ? g.date : undefined}>
|
||||
{g.date}
|
||||
</time>
|
||||
<span className="j13-timeline-day-count">{g.items.length}</span>
|
||||
</div>
|
||||
<ul className="j13-timeline-items">
|
||||
{g.items.map((it) => {
|
||||
const idx = flat++;
|
||||
const { kind, scope, rest } = splitTitlePrefix(it.title);
|
||||
const bodyNode = renderBody
|
||||
? renderBody(it, idx)
|
||||
: it.body.trim()
|
||||
? <span className="whitespace-pre-wrap">{it.body}</span>
|
||||
: null;
|
||||
|
||||
return (
|
||||
<li key={`${g.date}-${idx}-${it.title}`} className="j13-timeline-item" role="listitem">
|
||||
<span className="j13-timeline-dot" aria-hidden />
|
||||
<div className="j13-timeline-card">
|
||||
<div className="j13-timeline-head">
|
||||
<h3 className="j13-timeline-title">{it.title}</h3>
|
||||
{kind ? (
|
||||
<span className={`j13-timeline-chip is-${kind}`}>
|
||||
{kind}
|
||||
{scope ? <span className="j13-timeline-chip-scope">({scope})</span> : null}
|
||||
</span>
|
||||
) : null}
|
||||
<p className="j13-timeline-summary">{rest}</p>
|
||||
{it.sourceUrl ? (
|
||||
<a
|
||||
href={it.sourceUrl}
|
||||
@@ -46,21 +85,17 @@ export default function MdTimeline({ items, renderBody }: MdTimelineProps) {
|
||||
data-tip="查看提交"
|
||||
aria-label="查看提交"
|
||||
>
|
||||
<ExternalLink size={13} />
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
{renderBody ? (
|
||||
<div className="j13-timeline-body">{renderBody(it, idx)}</div>
|
||||
) : it.body.trim() ? (
|
||||
<div className="j13-timeline-body whitespace-pre-wrap">{it.body}</div>
|
||||
) : null}
|
||||
{bodyNode ? <div className="j13-timeline-body">{bodyNode}</div> : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
82
frontend/components/site-doc/SiteDocAnnoNav.tsx
Normal file
82
frontend/components/site-doc/SiteDocAnnoNav.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import Link from "next/link";
|
||||
import { Megaphone, Pin } from "lucide-react";
|
||||
import type { AnnouncementSummary } from "@/lib/api";
|
||||
import { formatShortDate } from "@/lib/format";
|
||||
|
||||
/** 近期公告目录(高亮当前 id) */
|
||||
export default function SiteDocAnnoNav({
|
||||
items,
|
||||
currentId,
|
||||
showAllLink = true,
|
||||
}: {
|
||||
items: AnnouncementSummary[];
|
||||
currentId?: number;
|
||||
showAllLink?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<section className="j13-module flex min-h-0 flex-col" aria-label="近期公告">
|
||||
<div className="flex items-center justify-between gap-2 shrink-0">
|
||||
<p className="j13-module-title !mb-0">
|
||||
<Megaphone size={15} style={{ color: "var(--accent)" }} /> 近期公告
|
||||
</p>
|
||||
{showAllLink ? (
|
||||
<Link
|
||||
href="/announcements"
|
||||
className="meta text-[12px] font-normal transition-colors hover:text-[var(--accent)]"
|
||||
>
|
||||
全部
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
{items.length === 0 ? (
|
||||
<p className="meta mt-2 px-0.5">暂无公告</p>
|
||||
) : (
|
||||
<ul className="j13-anno-list j13-rail-scroll">
|
||||
{items.map((a) => {
|
||||
const current = currentId === a.id;
|
||||
return (
|
||||
<li key={a.id}>
|
||||
<Link
|
||||
href={`/announcement/${a.id}`}
|
||||
className="j13-anno-item"
|
||||
data-tip={a.title}
|
||||
aria-current={current ? "page" : undefined}
|
||||
style={
|
||||
current
|
||||
? {
|
||||
background: "var(--accent-soft)",
|
||||
borderColor: "color-mix(in srgb, var(--accent) 28%, var(--line))",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span className="j13-anno-item-body">
|
||||
<span className="j13-anno-item-title truncate inline-flex items-center gap-1 min-w-0">
|
||||
{a.pinned ? (
|
||||
<Pin
|
||||
size={11}
|
||||
className="shrink-0"
|
||||
style={{ color: "var(--accent)" }}
|
||||
aria-label="置顶"
|
||||
/>
|
||||
) : null}
|
||||
<span className="truncate">{a.title}</span>
|
||||
</span>
|
||||
<span className="j13-anno-item-meta">
|
||||
{a.tag ? (
|
||||
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
|
||||
) : null}
|
||||
<span className="meta text-[11px] tabular-nums">
|
||||
{formatShortDate(a.created_at)}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
32
frontend/components/site-doc/SiteDocBreadcrumb.tsx
Normal file
32
frontend/components/site-doc/SiteDocBreadcrumb.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import Link from "next/link";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
export type Crumb = { href?: string; label: string };
|
||||
|
||||
/** 公文页面包屑 */
|
||||
export default function SiteDocBreadcrumb({ items }: { items: Crumb[] }) {
|
||||
return (
|
||||
<nav className="meta mb-5 flex items-center gap-1.5 flex-wrap text-[13px]" aria-label="面包屑">
|
||||
{items.map((item, i) => {
|
||||
const last = i === items.length - 1;
|
||||
return (
|
||||
<span key={`${item.label}-${i}`} className="inline-flex items-center gap-1.5 min-w-0">
|
||||
{i > 0 ? <ChevronRight size={13} className="shrink-0" /> : null}
|
||||
{item.href && !last ? (
|
||||
<Link href={item.href} className="hover:text-[var(--accent)] transition-colors shrink-0">
|
||||
{item.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span
|
||||
className={last ? "truncate" : "shrink-0"}
|
||||
style={last ? { color: "var(--accent)" } : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
61
frontend/components/site-doc/SiteDocIdentity.tsx
Normal file
61
frontend/components/site-doc/SiteDocIdentity.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import Link from "next/link";
|
||||
import { Info, Megaphone, type LucideIcon } from "lucide-react";
|
||||
|
||||
export type SiteDocIdentityKind = "announcements" | "about";
|
||||
|
||||
const META: Record<
|
||||
SiteDocIdentityKind,
|
||||
{ title: string; href: string; Icon: LucideIcon; fallbackLead: string }
|
||||
> = {
|
||||
announcements: {
|
||||
title: "站点公告",
|
||||
href: "/announcements",
|
||||
Icon: Megaphone,
|
||||
fallbackLead: "官方通知与运营公告",
|
||||
},
|
||||
about: {
|
||||
title: "关于本站",
|
||||
href: "/",
|
||||
Icon: Info,
|
||||
fallbackLead: "站点说明与政策文档",
|
||||
},
|
||||
};
|
||||
|
||||
/** 左栏身份卡:标明当前公文区 */
|
||||
export default function SiteDocIdentity({
|
||||
kind,
|
||||
lead,
|
||||
}: {
|
||||
kind: SiteDocIdentityKind;
|
||||
lead?: string;
|
||||
}) {
|
||||
const m = META[kind];
|
||||
const Icon = m.Icon;
|
||||
const text = lead?.trim() || m.fallbackLead;
|
||||
|
||||
return (
|
||||
<section className="j13-module shrink-0" aria-labelledby={`site-doc-id-${kind}`}>
|
||||
<p className="j13-module-title mb-3" id={`site-doc-id-${kind}`}>
|
||||
<Icon size={15} style={{ color: "var(--accent)" }} /> {m.title}
|
||||
</p>
|
||||
<Link href={m.href} className="flex items-center gap-3 group">
|
||||
<span
|
||||
className="w-11 h-11 rounded-xl flex items-center justify-center shrink-0"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
aria-hidden
|
||||
>
|
||||
<Icon size={20} strokeWidth={2} />
|
||||
</span>
|
||||
<span
|
||||
className="font-semibold text-[14.5px] truncate group-hover:text-[var(--accent)] transition-colors"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{m.title}
|
||||
</span>
|
||||
</Link>
|
||||
<p className="mt-2.5 text-[12.5px] leading-relaxed" style={{ color: "var(--ink-3)" }}>
|
||||
{text}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
94
frontend/components/site-doc/SiteDocMasthead.tsx
Normal file
94
frontend/components/site-doc/SiteDocMasthead.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Clock } from "lucide-react";
|
||||
import { formatDate } from "@/lib/format";
|
||||
|
||||
function formatDateTime(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleString("zh-CN", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/** 公文头:kicker / 标签 / 大标题 / 导语 / 元信息 */
|
||||
export default function SiteDocMasthead({
|
||||
kicker,
|
||||
title,
|
||||
excerpt,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
readingMinutes,
|
||||
badges,
|
||||
showTimeOfDay = false,
|
||||
}: {
|
||||
kicker: string;
|
||||
title: string;
|
||||
excerpt?: string;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
readingMinutes?: number;
|
||||
badges?: ReactNode;
|
||||
/** 详情页用完整日期时间;列表头图可用短日期 */
|
||||
showTimeOfDay?: boolean;
|
||||
}) {
|
||||
const edited =
|
||||
updatedAt &&
|
||||
new Date(updatedAt).getTime() - new Date(createdAt).getTime() > 60_000;
|
||||
const publishedLabel = showTimeOfDay ? formatDateTime(createdAt) : formatDate(createdAt);
|
||||
const updatedLabel =
|
||||
edited && updatedAt
|
||||
? showTimeOfDay
|
||||
? formatDateTime(updatedAt)
|
||||
: formatDate(updatedAt)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<header className="mb-6 sm:mb-7">
|
||||
<div className="flex items-center gap-2 flex-wrap mb-3">
|
||||
<span
|
||||
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-[11.5px] font-semibold tracking-wide"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
>
|
||||
{kicker}
|
||||
</span>
|
||||
{badges}
|
||||
</div>
|
||||
|
||||
<h1
|
||||
className="text-[26px] sm:text-[34px] font-extrabold leading-tight tracking-tight"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
{excerpt ? (
|
||||
<p
|
||||
className="mt-3.5 text-[15px] sm:text-[15.5px] leading-relaxed"
|
||||
style={{ color: "var(--ink-2)" }}
|
||||
>
|
||||
{excerpt}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className="mt-5 pt-4 flex flex-wrap items-center gap-x-4 gap-y-2"
|
||||
style={{ borderTop: "1px solid var(--line)" }}
|
||||
>
|
||||
<p className="meta text-[12.5px]">
|
||||
发布于 {publishedLabel}
|
||||
{updatedLabel ? <> · 更新于 {updatedLabel}</> : null}
|
||||
</p>
|
||||
{readingMinutes != null ? (
|
||||
<span
|
||||
className="meta inline-flex items-center gap-1.5 text-[12.5px] sm:ml-auto"
|
||||
aria-label={`阅读约需 ${readingMinutes} 分钟`}
|
||||
>
|
||||
<Clock size={13} /> 约 {readingMinutes} 分钟读完
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
57
frontend/components/site-doc/SiteDocPageNav.tsx
Normal file
57
frontend/components/site-doc/SiteDocPageNav.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import Link from "next/link";
|
||||
import { ChevronRight, Info } from "lucide-react";
|
||||
import type { SidebarPageItem } from "@/lib/api";
|
||||
import { pageGlyph } from "@/lib/pageGlyph";
|
||||
|
||||
/** 侧栏单页兄弟目录(高亮当前 slug) */
|
||||
export default function SiteDocPageNav({
|
||||
pages,
|
||||
currentSlug,
|
||||
title = "文档目录",
|
||||
}: {
|
||||
pages: SidebarPageItem[];
|
||||
currentSlug?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
if (pages.length === 0) return null;
|
||||
|
||||
return (
|
||||
<section className="j13-module j13-about flex min-h-0 flex-col" aria-label={title}>
|
||||
<p className="j13-module-title mb-1 shrink-0">
|
||||
<Info size={15} style={{ color: "var(--accent)" }} /> {title}
|
||||
</p>
|
||||
<ul className="j13-about-list j13-rail-scroll">
|
||||
{pages.map((p) => {
|
||||
const Icon = pageGlyph(p.slug);
|
||||
const current = currentSlug === p.slug;
|
||||
return (
|
||||
<li key={p.id}>
|
||||
<Link
|
||||
href={`/p/${p.slug}`}
|
||||
className="j13-about-item"
|
||||
aria-current={current ? "page" : undefined}
|
||||
style={
|
||||
current
|
||||
? {
|
||||
background: "var(--accent-soft)",
|
||||
borderColor: "var(--nav-active-line)",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span className="j13-about-ico" aria-hidden="true">
|
||||
<Icon size={15} strokeWidth={2} />
|
||||
</span>
|
||||
<span className="j13-about-body">
|
||||
<span className="j13-about-name">{p.title}</span>
|
||||
{p.excerpt ? <span className="j13-about-excerpt">{p.excerpt}</span> : null}
|
||||
</span>
|
||||
<ChevronRight className="j13-about-chev" size={15} aria-hidden="true" />
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
42
frontend/components/site-doc/SiteDocShell.tsx
Normal file
42
frontend/components/site-doc/SiteDocShell.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
/**
|
||||
* 公文阅读三栏壳:宽度与帖子详情对齐。
|
||||
* lg = 左224 + 中栏 + 右280;xl = 左240 + 中栏 + 右300;窄屏仅中栏,侧栏下沉。
|
||||
*/
|
||||
export default function SiteDocShell({
|
||||
left,
|
||||
right,
|
||||
children,
|
||||
mobileExtras,
|
||||
}: {
|
||||
left?: ReactNode;
|
||||
right?: ReactNode;
|
||||
children: ReactNode;
|
||||
/** 窄屏下沉到正文之后的侧栏副本 */
|
||||
mobileExtras?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-1 items-start lg:grid-cols-[224px_minmax(0,1fr)_280px] xl:grid-cols-[240px_minmax(0,1fr)_300px] gap-x-6 xl:gap-x-7">
|
||||
<aside
|
||||
className="hidden lg:flex w-56 xl:w-60 shrink-0 sticky top-24 flex-col gap-4 max-h-[calc(100vh_-_7rem)] overflow-hidden"
|
||||
aria-label="站点导航"
|
||||
>
|
||||
{left}
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0">{children}</div>
|
||||
|
||||
<aside
|
||||
className="hidden lg:flex w-[280px] xl:w-[300px] shrink-0 sticky top-24 flex-col gap-4 max-h-[calc(100vh_-_7rem)] overflow-hidden"
|
||||
aria-label="阅读辅助"
|
||||
>
|
||||
{right}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{mobileExtras ? <div className="lg:hidden mt-6 space-y-4">{mobileExtras}</div> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
8
frontend/components/site-doc/index.ts
Normal file
8
frontend/components/site-doc/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { default as SiteDocShell } from "./SiteDocShell";
|
||||
export { default as SiteDocIdentity } from "./SiteDocIdentity";
|
||||
export { default as SiteDocPageNav } from "./SiteDocPageNav";
|
||||
export { default as SiteDocAnnoNav } from "./SiteDocAnnoNav";
|
||||
export { default as SiteDocMasthead } from "./SiteDocMasthead";
|
||||
export { default as SiteDocBreadcrumb } from "./SiteDocBreadcrumb";
|
||||
export type { Crumb } from "./SiteDocBreadcrumb";
|
||||
export type { SiteDocIdentityKind } from "./SiteDocIdentity";
|
||||
Reference in New Issue
Block a user