Files
freefire 4f3f3a265c feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块:
- 友链(friendlink)后端处理 + 前端管理页与友链板
- 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot
- 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉
- 排行榜 service 测试、站点页面测试、时间线发布(timeline_release)

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
2026-09-30 16:30:07 +08:00

113 lines
4.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { ReactNode } from "react";
import { ExternalLink } from "lucide-react";
import {
groupTimelineByDate,
type TimelineItem,
} from "@/lib/timelineBlocks";
import { timelineDayId } from "@/lib/toc";
type MdTimelineProps = {
items: TimelineItem[];
/** 每项正文已渲染的 React 节点(与 items 等长);缺省则只显示纯文本 */
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,
};
}
/**
* 按日分组的垂直时间线(更新树)。
* 日分组 id 与 extractToc 的 tl-YYYY-MM-DD 对齐,供右侧目录跳转。
* 条目用普通段落而非 h3,避免被 .prose-content 标题样式放大。
*/
export default function MdTimeline({ items, renderBody }: MdTimelineProps) {
if (!items.length) {
return <p className="meta px-1 py-2">暂无时间线条目</p>;
}
const groups = groupTimelineByDate(items);
let flat = 0;
return (
<div className="j13-timeline" role="list" aria-label="时间线">
{groups.map((g) => (
<section
key={g.date}
id={timelineDayId(g.date)}
className="j13-timeline-day"
style={{ scrollMarginTop: 90 }}
aria-labelledby={`${timelineDayId(g.date)}-label`}
>
<ul className="j13-timeline-items">
{g.items.map((it, idx) => {
const flatIdx = flat++;
const { kind, scope, rest } = splitTitlePrefix(it.title);
const bodyNode = renderBody
? renderBody(it, flatIdx)
: it.body.trim()
? <span className="whitespace-pre-wrap">{it.body}</span>
: null;
const isFirstInDay = idx === 0;
return (
<li
key={`${g.date}-${flatIdx}-${it.title}`}
className={`j13-timeline-item${isFirstInDay ? " is-first-in-day" : ""}`}
role="listitem"
>
<span className="j13-timeline-dot" aria-hidden />
<div className="j13-timeline-card">
<div className="j13-timeline-head">
{isFirstInDay ? (
<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>
) : null}
<p className="j13-timeline-summary">{rest}</p>
{kind ? (
<span className={`j13-timeline-chip is-${kind}`}>
{kind}
{scope ? <span className="j13-timeline-chip-scope">({scope})</span> : null}
</span>
) : null}
{it.sourceUrl ? (
<a
href={it.sourceUrl}
className="j13-timeline-src"
target="_blank"
rel="noopener noreferrer"
data-tip="查看提交"
aria-label="查看提交"
>
<ExternalLink size={12} />
</a>
) : null}
</div>
{bodyNode ? <div className="j13-timeline-body">{bodyNode}</div> : null}
</div>
</li>
);
})}
</ul>
</section>
))}
</div>
);
}