新增模块: - 友链(friendlink)后端处理 + 前端管理页与友链板 - 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot - 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉 - 排行榜 service 测试、站点页面测试、时间线发布(timeline_release) 其它改动: - 后端 handlers/services 全量小幅调整 - 前端组件、库函数、URL/品牌/站点 URL 工具更新 - Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
198 lines
7.1 KiB
TypeScript
198 lines
7.1 KiB
TypeScript
import { ChevronRight } from "lucide-react";
|
||
import type { ReactNode } from "react";
|
||
import { renderMarkdownToReact } from "@/components/markdown/renderMarkdown";
|
||
import { ENTRY_TITLE_NUM_RE, type EntryItem } from "@/lib/entryBlocks";
|
||
|
||
/** 标签 chip:"主:次" 或 "主=次" 形式渲染主次两段,否则单段;不做语义配色,保持字段通用 */
|
||
function EntryTag({ tag }: { tag: string }) {
|
||
const idx = tag.search(/[::=]/);
|
||
const main = idx >= 0 ? tag.slice(0, idx) : tag;
|
||
const sub = idx >= 0 ? tag.slice(idx + 1) : "";
|
||
return (
|
||
<span
|
||
className="inline-flex items-baseline gap-1 rounded-full px-2.5 py-0.5 text-[11.5px] leading-5"
|
||
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
|
||
>
|
||
<span className="font-semibold" style={{ color: "var(--ink-2)" }}>
|
||
{main}
|
||
</span>
|
||
{sub ? (
|
||
<span style={{ color: "var(--ink-3)" }}>{sub}</span>
|
||
) : null}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 条目卡纯展示视图:编号标题 + 标签 chips + 高亮摘要 + 通用字段行 + 折叠来源。
|
||
* 字段名不限定(成本/收益/备注只是书写约定),任何书籍可定义自己的字段;
|
||
* markdown 片段由调用方注入(服务端 renderMarkdownToReact / 客户端 ReactMarkdown 预览)。
|
||
*/
|
||
export function MdEntriesView({
|
||
items,
|
||
renderMd,
|
||
titleIds,
|
||
}: {
|
||
items: EntryItem[];
|
||
renderMd: (text: string) => ReactNode;
|
||
/** 与目录共享的标题锚点 id(按条目顺序;缺省不挂 id) */
|
||
titleIds?: (string | undefined)[];
|
||
}) {
|
||
return (
|
||
<div className="my-4 -mx-2 flex flex-col gap-4 sm:-mx-3">
|
||
{items.map((it, i) => (
|
||
<article
|
||
key={i}
|
||
className="j13-entry-card rounded-2xl px-3 py-4 sm:px-4 sm:py-5"
|
||
style={{
|
||
border: "1px solid var(--line)",
|
||
// 极浅灰卡底:panel-2 半量混入 panel,深浅色模式各自成立
|
||
background: "color-mix(in srgb, var(--panel-2) 50%, var(--panel))",
|
||
}}
|
||
>
|
||
{!it.plain ? (
|
||
<h2
|
||
id={titleIds?.[i]}
|
||
className="flex items-baseline gap-2.5"
|
||
style={{
|
||
color: "var(--ink)",
|
||
scrollMarginTop: 90,
|
||
// 二级标题字号(移动端经 --entry-title-size 收小,见 globals.css);
|
||
// 覆盖 .prose-content h2 的外边距与底边线(卡内不需要)
|
||
fontSize: "var(--entry-title-size, 1.4rem)",
|
||
fontWeight: 700,
|
||
lineHeight: 1.35,
|
||
margin: 0,
|
||
padding: 0,
|
||
border: "none",
|
||
}}
|
||
>
|
||
{ENTRY_TITLE_NUM_RE.test(it.title) ? null : (
|
||
<span
|
||
className="tabular-nums text-[12.5px] font-semibold shrink-0"
|
||
style={{ color: "var(--ink-3)" }}
|
||
>
|
||
{i + 1}
|
||
</span>
|
||
)}
|
||
<span className="min-w-0">{it.title}</span>
|
||
</h2>
|
||
) : null}
|
||
|
||
{it.tags.length > 0 ? (
|
||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||
{it.tags.map((tag, ti) => (
|
||
<EntryTag key={ti} tag={tag} />
|
||
))}
|
||
</div>
|
||
) : null}
|
||
|
||
{it.summary?.trim() ? (
|
||
<div
|
||
className="j13-entry-summary mt-3 rounded-xl px-3.5 py-3 text-[14px] leading-relaxed [&>p]:my-0"
|
||
style={{
|
||
background: "var(--accent-soft)",
|
||
borderLeft: "3px solid var(--accent)",
|
||
color: "var(--ink)",
|
||
}}
|
||
>
|
||
{renderMd(it.summary)}
|
||
</div>
|
||
) : null}
|
||
|
||
{it.fields.length > 0 ? (
|
||
<dl className="mt-3 flex flex-col gap-2.5">
|
||
{it.fields.map((f, fi) => (
|
||
<div
|
||
key={fi}
|
||
className="grid grid-cols-1 gap-y-0.5 sm:grid-cols-[76px_minmax(0,1fr)] sm:gap-x-3 sm:gap-y-0"
|
||
>
|
||
<dt className="text-[12.5px] leading-[22px]" style={{ color: "var(--ink-3)" }}>
|
||
{f.label}
|
||
</dt>
|
||
<dd
|
||
className="min-w-0 text-[14px] leading-relaxed [&>p]:my-0 [&>ul]:my-1 [&>ol]:my-1"
|
||
style={{
|
||
// 正文用正常墨色;备注/来源类说明性字段保持浅灰
|
||
color:
|
||
f.label === "备注" || f.label === "来源"
|
||
? "var(--ink-2)"
|
||
: "var(--ink)",
|
||
}}
|
||
>
|
||
{f.value.trim() ? renderMd(f.value) : null}
|
||
</dd>
|
||
</div>
|
||
))}
|
||
</dl>
|
||
) : null}
|
||
|
||
{it.sources.length > 0 ? (
|
||
<details className="group mt-3">
|
||
<summary
|
||
className="inline-flex cursor-pointer select-none list-none items-center gap-1 text-[12.5px] font-medium [&::-webkit-details-marker]:hidden"
|
||
style={{ color: "var(--ink-2)" }}
|
||
>
|
||
<ChevronRight
|
||
size={13}
|
||
className="transition-transform group-open:rotate-90"
|
||
aria-hidden
|
||
/>
|
||
来源 · {it.sources.length} 条
|
||
</summary>
|
||
<div className="mt-2 flex flex-col gap-2">
|
||
{it.sources.map((s, si) => (
|
||
<div
|
||
key={si}
|
||
className="flex gap-2 text-[12.5px] leading-relaxed"
|
||
style={{ color: "var(--ink-2)" }}
|
||
>
|
||
<span className="tabular-nums shrink-0" style={{ color: "var(--ink-3)" }}>
|
||
{si + 1}.
|
||
</span>
|
||
<div className="min-w-0">
|
||
<span>{s.text}</span>
|
||
{s.url ? (
|
||
<a
|
||
href={s.url}
|
||
target="_blank"
|
||
rel="noopener noreferrer nofollow"
|
||
className="block break-all hover:text-[var(--accent)]"
|
||
style={{ color: "var(--ink-3)" }}
|
||
>
|
||
{s.url}
|
||
</a>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</details>
|
||
) : null}
|
||
</article>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** 服务端条目卡:markdown 片段走站点统一渲染管线(Shiki 等) */
|
||
export default async function MdEntries({
|
||
items,
|
||
titleIds,
|
||
}: {
|
||
items: EntryItem[];
|
||
titleIds?: (string | undefined)[];
|
||
}) {
|
||
const cache = new Map<string, ReactNode>();
|
||
await Promise.all(
|
||
items.flatMap((it) => {
|
||
const put = async (text?: string) => {
|
||
if (!text?.trim() || cache.has(text)) return;
|
||
cache.set(text, await renderMarkdownToReact(text));
|
||
};
|
||
return [put(it.summary), ...it.fields.map((f) => put(f.value))];
|
||
})
|
||
);
|
||
return <MdEntriesView items={items} titleIds={titleIds} renderMd={(t) => cache.get(t) ?? null} />;
|
||
}
|