feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块: - 友链(friendlink)后端处理 + 前端管理页与友链板 - 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot - 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉 - 排行榜 service 测试、站点页面测试、时间线发布(timeline_release) 其它改动: - 后端 handlers/services 全量小幅调整 - 前端组件、库函数、URL/品牌/站点 URL 工具更新 - Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
This commit is contained in:
197
frontend/components/markdown/MdEntries.tsx
Normal file
197
frontend/components/markdown/MdEntries.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
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} />;
|
||||
}
|
||||
Reference in New Issue
Block a user