feat: 友链/书库/移动端导航/排行榜等多模块功能

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

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
This commit is contained in:
2026-09-30 16:30:07 +08:00
parent e487bc2e92
commit 4f3f3a265c
105 changed files with 12371 additions and 852 deletions

View File

@@ -0,0 +1,53 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
/** 顶部横版槽位的最小门槛:自然宽度低于该值的横版图视为过小,不占顶部布局、归入左栏 */
const MIN_TOP_WIDTH = 600;
/**
* 封面自适应槽位:同一封面在两个位置各放一个槽位(左栏=竖版、顶部全宽=横版),
* 组件用隐藏探测图读取自然宽高,只渲染与自身方向匹配的槽位。
* 竖版槽默认显示(书封多为竖版),探测为横版时两槽自动换位,避免闪空。
* 横版但过小(自然宽度 < MIN_TOP_WIDTH)的图同样只渲染在左栏槽位。
*/
export default function AdaptiveCoverSlot({
src,
orientation,
children,
}: {
src: string;
orientation: "portrait" | "landscape";
children: ReactNode;
}) {
const ref = useRef<HTMLImageElement>(null);
const [match, setMatch] = useState(orientation === "portrait");
const measure = (img: HTMLImageElement) => {
if (!img.naturalWidth || !img.naturalHeight) return;
const landscape = img.naturalWidth > img.naturalHeight;
// 仅“横版且足够大”才配得上顶部槽位;过小的横版图随竖版走左栏
const topWorthy = landscape && img.naturalWidth >= MIN_TOP_WIDTH;
setMatch(orientation === "landscape" ? topWorthy : !topWorthy);
};
useEffect(() => {
// 缓存图可能在 hydration 前就完成加载而错过 onLoad
if (ref.current?.complete) measure(ref.current);
}, []);
return (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
ref={ref}
src={src}
alt=""
aria-hidden
className="hidden"
onLoad={(e) => measure(e.currentTarget)}
/>
{match ? children : null}
</>
);
}

View File

@@ -14,6 +14,7 @@ export default function ConfirmDialog({
cancelLabel = "取消",
danger = false,
busy = false,
zIndexClass = "z-[1100]",
onConfirm,
onCancel,
}: {
@@ -24,6 +25,8 @@ export default function ConfirmDialog({
cancelLabel?: string;
danger?: boolean;
busy?: boolean;
/** 叠层高度;默认 1100,被更高弹窗遮挡时上调(如编辑器主弹窗 1200 时传 1300) */
zIndexClass?: string;
onConfirm: () => void;
onCancel: () => void;
}) {
@@ -33,7 +36,7 @@ export default function ConfirmDialog({
title={title}
onClose={onCancel}
busy={busy}
zIndexClass="z-[1100]"
zIndexClass={zIndexClass}
maxWidthClass="max-w-sm"
panelClassName="p-5"
footer={

View File

@@ -13,11 +13,13 @@ import {
Bell,
Home,
Trophy,
BookOpen,
User as UserIcon,
ChevronDown,
ShieldCheck,
MessageSquare,
X,
ExternalLink,
} from "lucide-react";
import { apiLogout, apiMe, apiChatUnreadSummary, type User } from "@/lib/api";
import { hasAuthCookieHint } from "@/lib/cookies";
@@ -34,8 +36,13 @@ import LevelBadge from "./LevelBadge";
import BadgeChip from "./BadgeChip";
import BrandLockup from "./BrandLockup";
import { useAllowMessages } from "./MessagesPolicyProvider";
import { useUrlStyle } from "./SiteBrandProvider";
import { leaderboardPath } from "@/lib/urlStyle";
import { useSiteBrand, useUrlStyle } from "./SiteBrandProvider";
import { leaderboardPath, libraryPath } from "@/lib/urlStyle";
import {
HEADER_NAV_BUILTIN_LABELS,
isHeaderNavExternal,
type HeaderNavBuiltinKey,
} from "@/lib/headerNav";
import type { BrandLogoFit, BrandLogoSize, BrandMark } from "@/lib/brand";
// 搜索关键词长度上限(与后端 handler maxSearchKeywordRunes 一致);
@@ -72,6 +79,33 @@ export default function Header({
const pathname = usePathname();
const urlStyle = useUrlStyle();
const lbHref = leaderboardPath(urlStyle);
const libHref = libraryPath(urlStyle);
// 顶部主导航由后台「基本信息 → 顶部导航」配置(顺序/显隐/自定义链接),
// 配置为空时整个 nav 不渲染(含左侧分隔线);移动端导航走底部公共菜单
const headerNav = useSiteBrand().header_nav;
const builtinNavIcon: Record<HeaderNavBuiltinKey, React.ReactNode> = {
home: <Home size={15} />,
leaderboard: <Trophy size={15} />,
library: <BookOpen size={15} />,
};
const builtinNavHref: Record<HeaderNavBuiltinKey, string> = {
home: "/",
leaderboard: lbHref,
library: libHref,
};
const isBuiltinNavActive = (key: HeaderNavBuiltinKey): boolean => {
if (!pathname) return false;
if (key === "home") return pathname === "/";
if (key === "leaderboard") return pathname === lbHref || pathname.startsWith(`${lbHref}/`);
// 书库详情/阅读页标准路径与伪静态(/library-*.html)统一按 /library 前缀高亮
return pathname.startsWith("/library");
};
// 自定义站内菜单高亮:精确或子路径;"/" 仅精确,避免高亮全站
const isCustomNavActive = (url: string): boolean => {
if (!pathname || !url.startsWith("/") || url.startsWith("//")) return false;
if (url === "/") return pathname === "/";
return pathname === url || pathname.startsWith(`${url}/`);
};
const allowMessages = useAllowMessages();
const hideComposeNav =
pathname === "/compose" ||
@@ -244,18 +278,35 @@ export default function Header({
logoDark={logoDark}
/>
</Link>
<nav className="hidden lg:flex items-center gap-1.5 pl-3" style={{ borderLeft: "1px solid var(--line)" }}>
<NavPill href="/" active={pathname === "/"} icon={<Home size={15} />}>
首页
</NavPill>
<NavPill
href={lbHref}
active={!!pathname && (pathname === lbHref || pathname.startsWith(`${lbHref}/`))}
icon={<Trophy size={15} />}
>
排行榜
</NavPill>
</nav>
{headerNav.length > 0 && (
<nav className="hidden lg:flex items-center gap-1.5 pl-3" style={{ borderLeft: "1px solid var(--line)" }}>
{headerNav.map((item, idx) =>
item.type === "builtin" ? (
<NavPill
key={`builtin-${item.key}`}
href={builtinNavHref[item.key]}
active={isBuiltinNavActive(item.key)}
icon={builtinNavIcon[item.key]}
>
{HEADER_NAV_BUILTIN_LABELS[item.key]}
</NavPill>
) : (
<NavPill
key={`custom-${idx}`}
href={item.url}
external={isHeaderNavExternal(item.url)}
newTab={item.new_tab}
active={!isHeaderNavExternal(item.url) && isCustomNavActive(item.url)}
>
{item.label}
{isHeaderNavExternal(item.url) && item.new_tab ? (
<ExternalLink size={12} aria-hidden style={{ color: "var(--ink-3)" }} />
) : null}
</NavPill>
),
)}
</nav>
)}
</div>
{/* 中部弹性留白,把搜索与操作区推到右侧 */}
@@ -415,23 +466,36 @@ function NavPill({
href,
active,
icon,
external,
newTab,
children,
}: {
href: string;
active?: boolean;
icon?: React.ReactNode;
external?: boolean;
newTab?: boolean;
children: React.ReactNode;
}) {
const cls =
"inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[15px] font-semibold transition-colors";
const style =
active
? { background: "transparent", color: "var(--accent)" }
: { color: "var(--ink)" };
const target = newTab ? "_blank" : undefined;
const rel = newTab ? "noopener noreferrer" : undefined;
// 自定义外链渲染原生 <a>;站内链接(含勾选新标签的)走 Next Link 保留软导航
if (external) {
return (
<a href={href} target={target} rel={rel} className={cls} style={style}>
{icon}
{children}
</a>
);
}
return (
<Link
href={href}
className="inline-flex items-center gap-1.5 px-3.5 py-2 rounded-full text-[15px] font-semibold transition-colors"
style={
active
? { background: "transparent", color: "var(--accent)" }
: { color: "var(--ink)" }
}
>
<Link href={href} target={target} rel={rel} className={cls} style={style}>
{icon}
{children}
</Link>

View File

@@ -0,0 +1,48 @@
"use client";
import { useState } from "react";
import ImageLightbox from "@/components/markdown/ImageLightbox";
/**
* 静态图片图箱入口(区别于 markdown 管线的 MdImage):
* 封面等非 markdown 图片点击进全屏图箱(单图,无多图切换)。
* 在 Link 内使用时 preventDefault + stopPropagation 阻断外层导航——
* 点图片本身是看图,点卡片其余区域仍走原链接。
*/
export default function LightboxImage({
src,
alt,
className,
}: {
src: string;
alt: string;
className?: string;
}) {
const [open, setOpen] = useState(false);
return (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={alt}
className={`cursor-zoom-in${className ? ` ${className}` : ""}`}
loading="lazy"
decoding="async"
draggable={false}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setOpen(true);
}}
/>
{open && (
<ImageLightbox
items={[{ src, alt }]}
index={0}
onClose={() => setOpen(false)}
onIndexChange={() => {}}
/>
)}
</>
);
}

View File

@@ -6,6 +6,7 @@ import { expandDyEmojiShortcodes } from "@/lib/dyEmoji";
import type { HideKind } from "@/lib/hideBlocks";
import MdHideBlock from "@/components/markdown/MdHideBlock";
import MdTimeline from "@/components/markdown/MdTimeline";
import MdEntries from "@/components/markdown/MdEntries";
type MarkdownBodyProps = {
content: string;
@@ -16,10 +17,14 @@ type MarkdownBodyProps = {
/** 登录跳转(公告等无 postId 时) */
loginHref?: string;
loggedIn?: boolean;
/** 书级「全章节条目卡模式」:章节内容免写 [entries] 壳 */
entriesAuto?: boolean;
/** 按分段索引的条目标题锚点 id(与章内目录共享;阅读页传入,其他场景缺省) */
entriesTitleIds?: (string[] | undefined)[];
};
/**
* 站点统一 Markdown 渲染(服务端):GFM + Shiki;支持 [hide] / [timeline]。
* 站点统一 Markdown 渲染(服务端):GFM + Shiki;支持 [hide] / [timeline] / [entries]。
*/
export default async function MarkdownBody({
content,
@@ -28,8 +33,12 @@ export default async function MarkdownBody({
postId,
loginHref,
loggedIn = false,
entriesAuto = false,
entriesTitleIds,
}: MarkdownBodyProps) {
const segs = splitContentSegments(expandDyEmojiShortcodes(content || ""));
const segs = splitContentSegments(expandDyEmojiShortcodes(content || ""), {
entriesAuto,
});
const nodes = [];
for (let i = 0; i < segs.length; i++) {
const seg = segs[i];
@@ -60,6 +69,12 @@ export default async function MarkdownBody({
);
continue;
}
if (seg.type === "entries") {
nodes.push(
<MdEntries key={`entries-${i}`} items={seg.items} titleIds={entriesTitleIds?.[i]} />
);
continue;
}
const kind = seg.kind as HideKind;
if (seg.locked) {
nodes.push(

View File

@@ -8,6 +8,7 @@ import { expandDyEmojiShortcodes } from "@/lib/dyEmoji";
import type { HideKind } from "@/lib/hideBlocks";
import MdHideBlock from "@/components/markdown/MdHideBlock";
import MdTimeline from "@/components/markdown/MdTimeline";
import { MdEntriesView } from "@/components/markdown/MdEntries";
type MarkdownBodyClientProps = {
content: string;
@@ -16,10 +17,14 @@ type MarkdownBodyClientProps = {
postId?: number;
loginHref?: string;
loggedIn?: boolean;
/** 书级「全章节条目卡模式」:章节内容免写 [entries] 壳 */
entriesAuto?: boolean;
/** 按分段索引的条目标题锚点 id(与章内目录共享;阅读页传入,其他场景缺省) */
entriesTitleIds?: (string[] | undefined)[];
};
/**
* 客户端 Markdown(发帖预览 / 评论):GFM + 隐藏块 / 时间线预览。
* 客户端 Markdown(发帖预览 / 评论):GFM + 隐藏块 / 时间线 / 条目卡预览。
*/
export default function MarkdownBodyClient({
content,
@@ -28,8 +33,12 @@ export default function MarkdownBodyClient({
postId,
loginHref,
loggedIn = false,
entriesAuto = false,
entriesTitleIds,
}: MarkdownBodyClientProps) {
const segs = splitContentSegments(expandDyEmojiShortcodes(content || ""));
const segs = splitContentSegments(expandDyEmojiShortcodes(content || ""), {
entriesAuto,
});
const mdComponents = mergeMarkdownComponents(components);
// 时间线/隐藏块正文不套外层 heading 覆盖,避免预览与正文目录 id 错位
const nestedMd = mergeMarkdownComponents();
@@ -60,6 +69,20 @@ export default function MarkdownBodyClient({
/>
);
}
if (seg.type === "entries") {
return (
<MdEntriesView
key={`entries-${i}`}
items={seg.items}
titleIds={entriesTitleIds?.[i]}
renderMd={(t) => (
<ReactMarkdown remarkPlugins={[remarkGfm]} components={nestedMd}>
{expandDyEmojiShortcodes(t)}
</ReactMarkdown>
)}
/>
);
}
const kind = seg.kind as HideKind;
if (seg.locked) {
return (

View File

@@ -33,12 +33,14 @@ import {
History,
Ellipsis,
Smile,
LayoutList,
} from "lucide-react";
import { apiSearchPosts, apiUploadPostImage, type PostListItem } from "@/lib/api";
import { toast } from "@/lib/toast";
import MarkdownBodyClient from "@/components/MarkdownBodyClient";
import ImageInsertModal from "@/components/ImageInsertModal";
import TimelineEditor from "@/components/TimelineEditor";
import { ENTRIES_TEMPLATE, ENTRIES_TEMPLATE_AUTO } from "@/lib/entryBlocks";
import EmojiPicker from "@/components/EmojiPicker";
import { resolveTimelineBlockForEdit } from "@/lib/directiveBlocks";
import { SHIKI_LANG_IDS } from "@/components/markdown/shikiHighlighter";
@@ -79,6 +81,8 @@ type MarkdownEditorProps = {
/** 提交失败等原因,显示在编辑器内 */
error?: string;
onUploadingChange?: (uploading: boolean) => void;
/** 书级「全章节条目卡模式」:插入免壳模板、预览按隐式条目渲染 */
entriesAuto?: boolean;
};
type EditorMode = "write" | "preview";
@@ -159,6 +163,7 @@ export default function MarkdownEditor({
maxChars,
error,
onUploadingChange,
entriesAuto = false,
}: MarkdownEditorProps) {
const taRef = useRef<HTMLTextAreaElement>(null);
const urlStyle = useUrlStyle();
@@ -212,10 +217,9 @@ export default function MarkdownEditor({
}, [uploading]);
useEffect(() => {
if (!isComment) return;
const el = toolbarRef.current;
if (!el) return;
const apply = () => setToolNarrow(el.clientWidth < 520);
const apply = () => setToolNarrow(el.clientWidth < (isComment ? 520 : 640));
apply();
const ro = new ResizeObserver(apply);
ro.observe(el);
@@ -742,6 +746,180 @@ export default function MarkdownEditor({
l.toLowerCase().includes(codeQuery.trim().toLowerCase())
);
// 代码块语言弹层:常规时挂在行内按钮下,窄屏收进「更多」后挂在其锚点下(居中防溢出)
const codePopover =
popover === "code" ? (
<div
className={`j13-md-popover j13-md-popover-wide${toolNarrow ? " j13-md-popover-center" : ""}`}
role="dialog"
aria-label="选择语言"
>
<input
autoFocus
className="j13-md-popover-input"
placeholder="搜索语言…"
value={codeQuery}
onChange={(e) => setCodeQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && filteredLangs[0]) {
e.preventDefault();
insertCodeBlock(filteredLangs[0]);
}
}}
/>
<div className="j13-md-popover-list">
{filteredLangs.map((lang) => (
<button key={lang} type="button" onClick={() => insertCodeBlock(lang)}>
{lang}
</button>
))}
{filteredLangs.length === 0 && (
<p className="meta px-2 py-3 text-center">无匹配语言</p>
)}
</div>
</div>
) : null;
// 隐藏内容弹层:同上,窄屏时改挂在「更多」锚点下
const hidePopover =
allowHide && popover === "hide" ? (
<div
className={`j13-md-popover j13-md-popover-form${toolNarrow ? " j13-md-popover-center" : ""}`}
role="dialog"
aria-label="插入隐藏内容"
>
{hideStep === "menu" ? (
<>
<button type="button" className="j13-md-menu-item" onClick={() => insertHide("login")}>
登录可见
</button>
<button
type="button"
className="j13-md-menu-item"
disabled={!allowReplyHide}
data-tip={allowReplyHide ? undefined : "当前内容不可评论,无法使用回复可见"}
aria-disabled={!allowReplyHide}
onClick={() => {
if (!allowReplyHide) return;
insertHide("reply");
}}
>
回复可见
{!allowReplyHide ? (
<span className="j13-md-menu-item-hint">不可用</span>
) : null}
</button>
<button
type="button"
className="j13-md-menu-item"
onClick={() => setHideStep("points")}
>
积分可见…
</button>
<button
type="button"
className="j13-md-menu-item"
onClick={() => setHideStep("password")}
>
密码可见…
</button>
</>
) : hideStep === "points" ? (
<>
<label className="j13-md-field">
<span>所需积分</span>
<input
autoFocus
type="number"
min={1}
max={100000}
value={hidePoints}
onChange={(e) => setHidePoints(Number(e.target.value) || 1)}
className="j13-md-popover-input"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (hidePoints >= 1 && hidePoints <= 100000) {
insertHide("points", hidePoints);
}
}
}}
/>
</label>
<div className="j13-md-popover-footer">
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => setHideStep("menu")}
>
返回
</button>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => {
if (hidePoints < 1 || hidePoints > 100000) {
toast("积分须在 1–100000", "error");
return;
}
insertHide("points", hidePoints);
}}
>
插入
</button>
</div>
</>
) : (
<>
<label className="j13-md-field">
<span>查看密码(不含空格,1–64 字)</span>
<input
autoFocus
type="text"
value={hidePassword}
maxLength={64}
placeholder="例如:secret"
onChange={(e) => setHidePassword(e.target.value)}
className="j13-md-popover-input"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const p = hidePassword.trim();
if (p && !/\s/.test(p)) {
insertHide("password", undefined, p);
}
}
}}
/>
</label>
<div className="j13-md-popover-footer">
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => setHideStep("menu")}
>
返回
</button>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => {
const p = hidePassword.trim();
if (!p || /\s/.test(p) || p.length > 64) {
toast("密码须为 1–64 字符且不含空格", "error");
return;
}
insertHide("password", undefined, p);
}}
>
插入
</button>
</div>
</>
)}
</div>
) : null;
const ToolBtn = ({
label,
icon,
@@ -774,7 +952,11 @@ export default function MarkdownEditor({
const Sep = () => <span className="j13-md-sep" aria-hidden />;
return (
<div className={`j13-md-editor${fullscreen ? " is-fullscreen" : ""}${isComment ? " is-comment" : ""}`}>
<div
className={`j13-md-editor${fullscreen ? " is-fullscreen" : ""}${
isComment ? " is-comment" : ""
}${toolNarrow ? " is-narrow" : ""}`}
>
<div className="j13-md-toolbar" ref={toolbarRef}>
<div className="j13-md-tool-groups flex items-center gap-0.5 flex-1 flex-wrap min-w-0">
<ToolBtn
@@ -782,21 +964,21 @@ export default function MarkdownEditor({
icon={<Bold size={15} />}
onClick={() => runWrap({ before: "**", after: "**", placeholder: "粗体" })}
/>
{!isComment && (
{!isComment && !toolNarrow && (
<ToolBtn
label="斜体 Ctrl+I"
icon={<Italic size={15} />}
onClick={() => runWrap({ before: "*", after: "*", placeholder: "斜体" })}
/>
)}
{!isComment && (
{!isComment && !toolNarrow && (
<ToolBtn
label="删除线"
icon={<Strikethrough size={15} />}
onClick={() => runWrap({ before: "~~", after: "~~", placeholder: "删除线" })}
/>
)}
{!isComment && <Sep />}
{!isComment && !toolNarrow && <Sep />}
{!isComment && (
<div className="j13-md-popover-anchor relative">
<ToolBtn
@@ -838,8 +1020,8 @@ export default function MarkdownEditor({
)}
</div>
)}
{(!isComment || !toolNarrow) && <Sep />}
{(!isComment || !toolNarrow) && (
{!toolNarrow && <Sep />}
{!toolNarrow && (
<ToolBtn
label="引用"
icon={<Quote size={15} />}
@@ -848,7 +1030,7 @@ export default function MarkdownEditor({
}
/>
)}
{(!isComment || !toolNarrow) && (
{!toolNarrow && (
<ToolBtn
label="无序列表"
icon={<List size={15} />}
@@ -857,15 +1039,15 @@ export default function MarkdownEditor({
}
/>
)}
{!isComment && (
{!isComment && !toolNarrow && (
<ToolBtn
label="有序列表"
icon={<ListOrdered size={15} />}
onClick={insertOrderedList}
/>
)}
{!isComment && <ToolBtn label="表格" icon={<Table size={15} />} onClick={insertTable} />}
{!isComment && (
{!isComment && !toolNarrow && <ToolBtn label="表格" icon={<Table size={15} />} onClick={insertTable} />}
{!isComment && !toolNarrow && (
<ToolBtn
label="分割线"
icon={<Minus size={15} />}
@@ -878,7 +1060,7 @@ export default function MarkdownEditor({
icon={<Code size={15} />}
onClick={() => runWrap({ before: "`", after: "`", placeholder: "code" })}
/>
{(!isComment || !toolNarrow) && (
{!toolNarrow && (
<div className="j13-md-popover-anchor relative">
<ToolBtn
label="代码块 Ctrl+Shift+C"
@@ -886,37 +1068,7 @@ export default function MarkdownEditor({
active={popover === "code"}
onClick={() => openPopover("code")}
/>
{popover === "code" && (
<div className="j13-md-popover j13-md-popover-wide" role="dialog" aria-label="选择语言">
<input
autoFocus
className="j13-md-popover-input"
placeholder="搜索语言…"
value={codeQuery}
onChange={(e) => setCodeQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && filteredLangs[0]) {
e.preventDefault();
insertCodeBlock(filteredLangs[0]);
}
}}
/>
<div className="j13-md-popover-list">
{filteredLangs.map((lang) => (
<button
key={lang}
type="button"
onClick={() => insertCodeBlock(lang)}
>
{lang}
</button>
))}
{filteredLangs.length === 0 && (
<p className="meta px-2 py-3 text-center">无匹配语言</p>
)}
</div>
</div>
)}
{codePopover}
</div>
)}
<Sep />
@@ -1008,6 +1160,7 @@ export default function MarkdownEditor({
</div>
)}
</div>
{(isComment || !toolNarrow) && (
<div className="j13-md-popover-anchor relative">
<ToolBtn
label="表情"
@@ -1021,6 +1174,7 @@ export default function MarkdownEditor({
</div>
)}
</div>
)}
<div className="j13-md-popover-anchor relative">
<ToolBtn
label={uploading ? "上传中…" : "图片"}
@@ -1030,12 +1184,12 @@ export default function MarkdownEditor({
onClick={openImageModal}
/>
</div>
{isComment && (
{(isComment || toolNarrow) && (
<div className="j13-md-popover-anchor relative">
<button
ref={moreBtnRef}
type="button"
className={`j13-md-tool${popover === "more" || (toolNarrow && popover === "code") ? " is-active" : ""}`}
className={`j13-md-tool${popover === "more" || (toolNarrow && (popover === "code" || popover === "emoji" || popover === "hide")) ? " is-active" : ""}`}
data-tip="更多工具"
aria-label="更多工具"
aria-haspopup="menu"
@@ -1060,55 +1214,49 @@ export default function MarkdownEditor({
)}
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "*", after: "*", placeholder: "斜体" }); setPopover(null); }}>斜体</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "~~", after: "~~", placeholder: "删除线" }); setPopover(null); }}>删除线</button>
{!isComment && (
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => openPopover("emoji")}>表情</button>
)}
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "## ", after: "", placeholder: "二级标题" }); setPopover(null); }}>二级标题</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { runWrap({ before: "### ", after: "", placeholder: "三级标题" }); setPopover(null); }}>三级标题</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { insertOrderedList(); setPopover(null); }}>有序列表</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { insertTable(); setPopover(null); }}>表格</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { insertAtCursor("---"); setPopover(null); }}>分隔线</button>
{!isComment && allowHide && (
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => openPopover("hide")}>隐藏内容</button>
)}
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { setPopover(null); openTimelineEditor(); }}>时间线</button>
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { insertAtCursor(entriesAuto ? ENTRIES_TEMPLATE_AUTO : ENTRIES_TEMPLATE); setPopover(null); }}>条目卡</button>
{toolNarrow && (
<button type="button" role="menuitem" className="j13-md-menu-item" onMouseDown={(e) => e.preventDefault()} onClick={() => { setPopover(null); toggleFullscreen(); }}>{fullscreen ? "退出全屏" : "全屏写作"}</button>
)}
</div>
)}
{toolNarrow && popover === "code" && (
<div className="j13-md-popover j13-md-popover-wide" role="dialog" aria-label="选择语言">
<input
autoFocus
className="j13-md-popover-input"
placeholder="搜索语言…"
value={codeQuery}
onChange={(e) => setCodeQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && filteredLangs[0]) {
e.preventDefault();
insertCodeBlock(filteredLangs[0]);
}
}}
/>
<div className="j13-md-popover-list">
{filteredLangs.map((lang) => (
<button key={lang} type="button" onClick={() => insertCodeBlock(lang)}>
{lang}
</button>
))}
{filteredLangs.length === 0 && (
<p className="meta px-2 py-3 text-center">无匹配语言</p>
)}
</div>
{toolNarrow && codePopover}
{toolNarrow && popover === "emoji" && !isComment && (
<div className="j13-md-popover j13-md-emoji-popover j13-md-popover-center" role="dialog" aria-label="插入表情">
<EmojiPicker onPick={pickEmoji} />
</div>
)}
{toolNarrow && hidePopover}
</div>
)}
{!isComment && <Sep />}
{!isComment && (
{!isComment && !toolNarrow && <Sep />}
{!isComment && !toolNarrow && (
<ToolBtn
label="时间线"
icon={<History size={15} />}
onClick={openTimelineEditor}
/>
)}
{allowHide ? (
{!isComment && !toolNarrow && (
<ToolBtn
label="条目卡"
icon={<LayoutList size={15} />}
onClick={() => insertAtCursor(entriesAuto ? ENTRIES_TEMPLATE_AUTO : ENTRIES_TEMPLATE)}
/>
)}
{allowHide && !toolNarrow ? (
<div className="j13-md-popover-anchor relative">
<ToolBtn
label="隐藏内容"
@@ -1116,139 +1264,7 @@ export default function MarkdownEditor({
active={popover === "hide"}
onClick={() => openPopover("hide")}
/>
{popover === "hide" && (
<div className="j13-md-popover j13-md-popover-form" role="dialog" aria-label="插入隐藏内容">
{hideStep === "menu" ? (
<>
<button type="button" className="j13-md-menu-item" onClick={() => insertHide("login")}>
登录可见
</button>
<button
type="button"
className="j13-md-menu-item"
disabled={!allowReplyHide}
data-tip={allowReplyHide ? undefined : "当前内容不可评论,无法使用回复可见"}
aria-disabled={!allowReplyHide}
onClick={() => {
if (!allowReplyHide) return;
insertHide("reply");
}}
>
回复可见
{!allowReplyHide ? (
<span className="j13-md-menu-item-hint">不可用</span>
) : null}
</button>
<button
type="button"
className="j13-md-menu-item"
onClick={() => setHideStep("points")}
>
积分可见…
</button>
<button
type="button"
className="j13-md-menu-item"
onClick={() => setHideStep("password")}
>
密码可见…
</button>
</>
) : hideStep === "points" ? (
<>
<label className="j13-md-field">
<span>所需积分</span>
<input
autoFocus
type="number"
min={1}
max={100000}
value={hidePoints}
onChange={(e) => setHidePoints(Number(e.target.value) || 1)}
className="j13-md-popover-input"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (hidePoints >= 1 && hidePoints <= 100000) {
insertHide("points", hidePoints);
}
}
}}
/>
</label>
<div className="j13-md-popover-footer">
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => setHideStep("menu")}
>
返回
</button>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => {
if (hidePoints < 1 || hidePoints > 100000) {
toast("积分须在 1–100000", "error");
return;
}
insertHide("points", hidePoints);
}}
>
插入
</button>
</div>
</>
) : (
<>
<label className="j13-md-field">
<span>查看密码(不含空格,1–64 字)</span>
<input
autoFocus
type="text"
value={hidePassword}
maxLength={64}
placeholder="例如:secret"
onChange={(e) => setHidePassword(e.target.value)}
className="j13-md-popover-input"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const p = hidePassword.trim();
if (p && !/\s/.test(p)) {
insertHide("password", undefined, p);
}
}
}}
/>
</label>
<div className="j13-md-popover-footer">
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => setHideStep("menu")}
>
返回
</button>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => {
const p = hidePassword.trim();
if (!p || /\s/.test(p) || p.length > 64) {
toast("密码须为 1–64 字符且不含空格", "error");
return;
}
insertHide("password", undefined, p);
}}
>
插入
</button>
</div>
</>
)}
</div>
)}
{hidePopover}
</div>
) : null}
</div>
@@ -1281,7 +1297,7 @@ export default function MarkdownEditor({
<Eye size={12} /> 预览
</button>
</div>
{(!isComment || !toolNarrow) && (
{!toolNarrow && (
<button
type="button"
className="j13-md-tool"
@@ -1354,7 +1370,7 @@ export default function MarkdownEditor({
预览
</div>
{deferredValue.trim() ? (
<MarkdownBodyClient content={deferredValue} />
<MarkdownBodyClient content={deferredValue} entriesAuto={entriesAuto} />
) : (
<p className="meta text-center py-16">暂无内容可预览</p>
)}

View File

@@ -0,0 +1,206 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Hash, ListTree, X } from "lucide-react";
type DrawerSide = "left" | "right";
export interface RailDrawerSide {
/** 触发按钮与抽屉标题 */
label: string;
/** 抽屉内容(服务端渲染节点) */
content: ReactNode;
/** 按钮图标(缺省:左 ListTree / 右 Hash) */
icon?: ReactNode;
}
/**
* 抽屉开合副作用:ESC 关闭 + 锁定背景滚动。
* onClose 经 ref 引用,避免回调身份变化导致反复装卸监听与滚动锁泄漏。
*/
export function useDrawerChrome(open: boolean, onClose: () => void) {
const closeRef = useRef(onClose);
closeRef.current = onClose;
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") closeRef.current();
};
window.addEventListener("keydown", onKey);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", onKey);
document.body.style.overflow = prev;
};
}, [open]);
}
/**
* 单侧抽屉面板(lg 以下生效):遮罩 + 侧滑面板。
* - 关闭时保留 DOM 以获得滑出过渡,invisible 延迟切换保证动画完整;
* - 抽屉内点击链接(锚点 / 跳转)后自动收起。
*/
export function RailDrawerPanel({
side,
title,
open,
onClose,
children,
}: {
side: DrawerSide;
title: string;
open: boolean;
onClose: () => void;
children: ReactNode;
}) {
return (
<div
className="lg:hidden"
style={{ visibility: open ? "visible" : "hidden", transition: "visibility .2s" }}
aria-hidden={!open}
>
<div
className={`fixed inset-0 z-[1090] bg-black/40 transition-opacity duration-200 ${
open ? "opacity-100" : "pointer-events-none opacity-0"
}`}
onClick={onClose}
/>
<aside
role="dialog"
aria-modal="true"
aria-label={title}
className={`fixed bottom-0 z-[1100] flex w-[min(82vw,320px)] flex-col ${
side === "left" ? "left-0" : "right-0"
} transition-transform duration-200 ease-out ${
open ? "translate-x-0" : side === "left" ? "-translate-x-full" : "translate-x-full"
}`}
style={{
top: 0,
background: "var(--panel)",
boxShadow: "0 0 40px rgba(0,0,0,0.18)",
borderLeft: side === "right" ? "1px solid var(--line)" : undefined,
borderRight: side === "left" ? "1px solid var(--line)" : undefined,
}}
onClick={(e) => {
if ((e.target as HTMLElement).closest("a")) onClose();
}}
>
<div
className="flex shrink-0 items-center gap-2 px-4 py-3"
style={{ borderBottom: "1px solid var(--line)" }}
>
<span className="text-[13px] font-bold" style={{ color: "var(--ink)" }}>
{title}
</span>
<button
type="button"
className="ml-auto rounded-lg p-1.5 transition-colors hover:bg-[var(--panel-2)]"
onClick={onClose}
aria-label="关闭"
>
<X size={16} style={{ color: "var(--ink-2)" }} aria-hidden />
</button>
</div>
<div className="j13-rail-scroll flex flex-1 flex-col gap-3 overflow-y-auto p-3">
{children}
</div>
</aside>
</div>
);
}
/**
* 通用移动端侧栏抽屉(lg 以下生效,桌面仍用常驻侧栏):
* - 吸顶工具栏贴在全局 Header 下方,左右各一个抽屉触发按钮,中间可放页面上文;
* - 左右两侧内容均可选,两侧皆空时整体不渲染;
* - 渐进增强:ESC 关闭、背景滚动锁定、点链接自动收起。
*/
export default function MobileRailDrawers({
left,
right,
center,
}: {
/** 左抽屉(对应桌面左栏) */
left?: RailDrawerSide;
/** 右抽屉(对应桌面右栏) */
right?: RailDrawerSide;
/** 工具栏中间内容(如当前文章标题),可省略 */
center?: ReactNode;
}) {
const [open, setOpen] = useState<DrawerSide | null>(null);
useDrawerChrome(open !== null, () => setOpen(null));
if (!left && !right) return null;
const btn =
"relative inline-flex items-center gap-1 rounded-lg px-2.5 py-1.5 text-[12.5px] font-medium shrink-0 transition-colors hover:bg-[var(--panel-2)] active:scale-[0.97] " +
// 触控热区上下外扩 8px(视觉不变),移动端更易点中
"before:absolute before:inset-x-0 before:-inset-y-2 before:content-['']";
const sides: { side: DrawerSide; cfg?: RailDrawerSide; fallbackIcon: ReactNode }[] = [
{ side: "left", cfg: left, fallbackIcon: <ListTree size={15} style={{ color: "var(--accent)" }} aria-hidden /> },
{ side: "right", cfg: right, fallbackIcon: <Hash size={15} style={{ color: "var(--accent)" }} aria-hidden /> },
];
return (
<>
{/* 吸顶工具栏:与书籍阅读页一致的三段布局——左钮贴左、中间文字居中、右钮贴右;
有中间文字而缺某侧按钮时以等宽占位保持文字不偏心 */}
<header
className="lg:hidden sticky z-30 mb-3 flex items-center gap-1 rounded-xl px-1 py-1"
style={{
top: "var(--header-h)",
background: "color-mix(in srgb, var(--panel) 88%, transparent)",
backdropFilter: "blur(10px)",
WebkitBackdropFilter: "blur(10px)",
border: "1px solid var(--line)",
}}
>
{left ? (
<button
type="button"
className={btn}
onClick={() => setOpen("left")}
aria-haspopup="dialog"
>
{left.icon ?? sides[0].fallbackIcon}
{left.label}
</button>
) : center ? (
<span className="w-[52px] shrink-0" aria-hidden />
) : null}
{center ? <div className="min-w-0 flex-1 px-1 text-center">{center}</div> : null}
{right ? (
<button
type="button"
className={`${btn} ml-auto`}
onClick={() => setOpen("right")}
aria-haspopup="dialog"
>
{right.icon ?? sides[1].fallbackIcon}
{right.label}
</button>
) : center ? (
<span className="w-[52px] shrink-0" aria-hidden />
) : null}
</header>
{sides.map(({ side, cfg }) =>
cfg ? (
<RailDrawerPanel
key={side}
side={side}
title={cfg.label}
open={open === side}
onClose={() => setOpen(null)}
>
{cfg.content}
</RailDrawerPanel>
) : null
)}
</>
);
}

View File

@@ -2,26 +2,28 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { Fragment, useEffect, useState } from "react";
import {
Home,
Trophy,
BookOpen,
PenSquare,
MessageSquare,
User as UserIcon,
} from "lucide-react";
import { apiChatUnreadSummary, type User } from "@/lib/api";
import { useAllowMessages } from "@/components/MessagesPolicyProvider";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { useSiteBrand } from "@/components/SiteBrandProvider";
import { onDocumentVisible } from "@/lib/visibilityReconcile";
import { parseEntityPath, userPath, leaderboardPath } from "@/lib/urlStyle";
import { userPath, libraryPath } from "@/lib/urlStyle";
import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime";
import type { RtChatUnreadData } from "@/lib/realtime";
import type { MobileTabKey } from "@/lib/mobileTabs";
/**
* 移动端全局底部公共菜单(lg 以下显示):
* 首页 / 排行榜 / 发帖 / 消息(未登录不显示)/ 我的。
* 聊天页、发帖工作台、管理后台由 SiteChrome 控制不渲染,避免与锁视口布局冲突。
* 按 SiteBrand.mobile_tabs 配置顺序渲染(消息在未登录/未开放时不显示)。
* SiteChrome 只在启用 tab 的根路径渲染本组件,
* 避免与聊天 / 发帖工作台的锁视口布局冲突。
*/
export default function MobileTabBar({
user,
@@ -32,8 +34,8 @@ export default function MobileTabBar({
}) {
const pathname = usePathname();
const allowMessages = useAllowMessages();
const urlStyle = useUrlStyle();
const lbHref = leaderboardPath(urlStyle);
const { url_style: urlStyle, mobile_tabs: mobileTabs } = useSiteBrand();
const libHref = libraryPath(urlStyle);
const [chatUnread, setChatUnread] = useState(initialChatUnread);
// 登录态经 router.refresh() 下发新 props 时校正徽标
@@ -80,8 +82,55 @@ export default function MobileTabBar({
const isActive = (match: (p: string) => boolean) =>
!!pathname && match(pathname);
// 配置已由 SiteBrandProvider normalize(白名单内子集,空回落全量);数组顺序即展示顺序
const tabNodes: Record<MobileTabKey, React.ReactNode> = {
home: (
<TabLink
href="/"
icon={<Home size={20} />}
label="首页"
active={isActive((p) => p === "/")}
/>
),
library: (
<TabLink
href={libHref}
icon={<BookOpen size={20} />}
label="书库"
active={isActive((p) => p === libHref)}
/>
),
compose: (
<TabLink
href={user ? "/compose" : "/login"}
icon={<PenSquare size={18} />}
label="发帖"
prominent
active={false}
/>
),
messages: showMessages ? (
<TabLink
href="/chat"
icon={<MessageSquare size={20} />}
label="消息"
active={isActive((p) => p === "/chat")}
badge={chatUnread > 0 ? (chatUnread > 99 ? "99+" : chatUnread) : 0}
/>
) : null,
me: (
<TabLink
href={user ? userPath(urlStyle, user.id) : "/login"}
icon={<UserIcon size={20} />}
label="我的"
active={!!user && isActive((p) => p === userPath(urlStyle, user.id))}
/>
),
};
return (
<nav
data-j13-tabbar
className="lg:hidden fixed bottom-0 inset-x-0 z-50 border-t"
aria-label="底部导航"
style={{
@@ -93,49 +142,9 @@ export default function MobileTabBar({
}}
>
<div className="flex items-stretch">
<TabLink
href="/"
icon={<Home size={20} />}
label="首页"
active={isActive((p) => p === "/")}
/>
<TabLink
href={lbHref}
icon={<Trophy size={20} />}
label="排行榜"
active={!!pathname && (pathname === lbHref || pathname.startsWith(`${lbHref}/`))}
/>
<TabLink
href={user ? "/compose" : "/login"}
icon={<PenSquare size={18} />}
label="发帖"
prominent
active={false}
/>
{showMessages && (
<TabLink
href="/chat"
icon={<MessageSquare size={20} />}
label="消息"
active={isActive((p) => p.startsWith("/chat"))}
badge={chatUnread > 0 ? (chatUnread > 99 ? "99+" : chatUnread) : 0}
/>
{mobileTabs.map((key) =>
tabNodes[key] ? <Fragment key={key}>{tabNodes[key]}</Fragment> : null,
)}
<TabLink
href={user ? userPath(urlStyle, user.id) : "/login"}
icon={<UserIcon size={20} />}
label="我的"
active={
!!user &&
isActive((p) => {
const parsed = parseEntityPath(p);
return (
(parsed?.kind === "user" && parsed.id === user.id) ||
p.startsWith(`${userPath(urlStyle, user.id)}/`)
);
})
}
/>
</div>
</nav>
);

View File

@@ -213,7 +213,7 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
aria-hidden="true"
/>
<div
className="absolute right-0 mt-2.5 w-[340px] max-w-[calc(100vw-2rem)] overflow-hidden z-50 j13-toast-in flex flex-col
className="j13-notif-sheet absolute right-0 mt-2.5 w-[340px] max-w-[calc(100vw-2rem)] overflow-hidden z-50 j13-toast-in flex flex-col
max-lg:fixed max-lg:inset-x-0 max-lg:bottom-0 max-lg:top-auto max-lg:mt-0 max-lg:w-full max-lg:max-w-none max-lg:max-h-[75dvh] max-lg:rounded-t-2xl!"
style={{
background: "var(--panel)",

View File

@@ -50,7 +50,7 @@ export default function Pagination({ total, page, size, basePath, query = {}, on
"pager-btn min-w-[38px] h-9 px-1 flex items-center justify-center font-semibold text-[13px] rounded-full border transition-colors duration-150";
return (
<nav className="flex items-center justify-center gap-1.5 mt-10 flex-wrap" aria-label="分页">
<nav className="flex items-center justify-center gap-1.5 mt-6 flex-wrap" aria-label="分页">
{page > 1 && (
<Link
href={buildHref(page - 1)}

View File

@@ -16,7 +16,15 @@ export interface TocItem {
* - IntersectionObserver 高亮当前小节;
* - 当前项被滚动到目录可视区外时,列表自动最小化跟随(首屏不滚动、尊重 reduced-motion)。
*/
export default function PostToc({ items }: { items: TocItem[] }) {
export default function PostToc({
items,
title = "目录",
bare = false,
}: {
items: TocItem[];
title?: string;
bare?: boolean;
}) {
const [active, setActive] = useState<string>(items[0]?.id ?? "");
const navRef = useRef<HTMLElement>(null);
// 首次挂载时 active 取首项,不触发跟随滚动,避免进页跳动
@@ -38,15 +46,25 @@ export default function PostToc({ items }: { items: TocItem[] }) {
return () => obs.disconnect();
}, [items]);
// active 变化时把当前目录项滚入列表可视区(block: nearest 仅在贴边时最小移动)
// active 变化时把当前目录项滚入列表可视区(仅在列表容器内最小滚动)。
// 不用 scrollIntoView:移动端目录下沉到文末(普通文档流、容器本身不可滚),
// scrollIntoView(nearest) 会连带滚动窗口,把正在阅读的页面拽到底部目录,
// 表现为"进页自动下滚 / 无法向上滑动"。
useEffect(() => {
if (skipFollow.current) {
skipFollow.current = false;
return;
}
const el = navRef.current?.querySelector<HTMLElement>(`[data-toc-id="${active}"]`);
if (!el) return;
el.scrollIntoView({ block: "nearest", behavior: scrollBehavior() });
const list = navRef.current?.querySelector<HTMLElement>(".j13-rail-scroll");
const el = list?.querySelector<HTMLElement>(`[data-toc-id="${active}"]`);
if (!list || !el) return;
const listRect = list.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
if (elRect.top < listRect.top) {
list.scrollBy({ top: elRect.top - listRect.top, behavior: scrollBehavior() });
} else if (elRect.bottom > listRect.bottom) {
list.scrollBy({ top: elRect.bottom - listRect.bottom, behavior: scrollBehavior() });
}
}, [active]);
const jump = (id: string) => (e: React.MouseEvent) => {
@@ -59,16 +77,29 @@ export default function PostToc({ items }: { items: TocItem[] }) {
};
return (
<nav ref={navRef} aria-label="文章目录" className="panel p-4 flex min-h-0 flex-col">
<nav
ref={navRef}
aria-label="文章目录"
className={`${bare ? "py-4" : "panel p-4"} flex min-h-0 flex-col`}
style={
bare
? {
background: "var(--panel)",
borderTop: "1px solid var(--line)",
borderBottom: "1px solid var(--line)",
}
: undefined
}
>
<p
className="text-[11.5px] font-bold tracking-[0.08em] uppercase mb-2.5 px-1 shrink-0"
style={{ color: "var(--ink-3)" }}
>
目录
{title}
</p>
<ul
className="j13-rail-scroll space-y-0.5 pr-0.5"
style={{ borderLeft: "1px solid var(--line)" }}
className="j13-rail-scroll min-h-0 flex-1 space-y-0.5 pr-0.5"
style={bare ? undefined : { borderLeft: "1px solid var(--line)" }}
>
{items.map((it) => {
const isActive = it.id === active;

View File

@@ -4,6 +4,8 @@ import { createContext, useContext, useEffect, useMemo, useRef, useState, type R
import { apiGetSettings, type PublicSettings } from "@/lib/api";
import { siteDocumentTitle, normalizeBrandLogoFit, normalizeBrandLogoSize, normalizeBrandMark, normalizeFooterLinks } from "@/lib/brand";
import { normalizeUrlStyle, type UrlStyle } from "@/lib/urlStyle";
import { DEFAULT_MOBILE_TABS, normalizeMobileTabs, type MobileTabKey } from "@/lib/mobileTabs";
import { DEFAULT_HEADER_NAV, normalizeHeaderNav, type HeaderNavItem } from "@/lib/headerNav";
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
import { onDocumentVisible, VISIBLE_RECONCILE_GAP_MS } from "@/lib/visibilityReconcile";
@@ -19,6 +21,8 @@ export type SiteBrand = {
brand_logo_fit: PublicSettings["brand_logo_fit"];
footer_links: PublicSettings["footer_links"];
url_style: UrlStyle;
mobile_tabs: MobileTabKey[];
header_nav: HeaderNavItem[];
};
const SiteBrandContext = createContext<SiteBrand | null>(null);
@@ -36,6 +40,8 @@ function fromSettings(s: PublicSettings): SiteBrand {
brand_logo_fit: s.brand_logo_fit,
footer_links: s.footer_links,
url_style: s.url_style,
mobile_tabs: s.mobile_tabs,
header_nav: s.header_nav,
};
}
@@ -51,7 +57,9 @@ function sameBrand(a: SiteBrand, b: SiteBrand): boolean {
a.brand_logo_size === b.brand_logo_size &&
a.brand_logo_fit === b.brand_logo_fit &&
a.url_style === b.url_style &&
JSON.stringify(a.footer_links) === JSON.stringify(b.footer_links)
JSON.stringify(a.footer_links) === JSON.stringify(b.footer_links) &&
JSON.stringify(a.mobile_tabs) === JSON.stringify(b.mobile_tabs) &&
JSON.stringify(a.header_nav) === JSON.stringify(b.header_nav)
);
}
@@ -102,6 +110,8 @@ export function useSiteBrand(): SiteBrand {
brand_logo_fit: "contain",
footer_links: [],
url_style: "default",
mobile_tabs: [...DEFAULT_MOBILE_TABS],
header_nav: DEFAULT_HEADER_NAV.map((item) => ({ ...item })),
};
}
return ctx;
@@ -153,6 +163,8 @@ export default function SiteBrandProvider({
? normalizeFooterLinks(raw.footer_links)
: prev.footer_links,
url_style: raw.url_style !== undefined ? normalizeUrlStyle(raw.url_style) : prev.url_style,
mobile_tabs: raw.mobile_tabs !== undefined ? normalizeMobileTabs(raw.mobile_tabs) : prev.mobile_tabs,
header_nav: raw.header_nav !== undefined ? normalizeHeaderNav(raw.header_nav) : prev.header_nav,
};
if (sameBrand(prev, next)) return;
retitle(prev, next);

View File

@@ -7,6 +7,8 @@ import Footer from "@/components/Footer";
import BackToTop from "@/components/BackToTop";
import MobileTabBar from "@/components/MobileTabBar";
import type { User } from "@/lib/api";
import { isWideShellPath } from "@/lib/siteUrl";
import { libraryPath, userPath } from "@/lib/urlStyle";
import { useSiteBrand } from "@/components/SiteBrandProvider";
function pathIsAdmin(pathname: string | null) {
@@ -24,11 +26,6 @@ function pathIsComposeStudio(pathname: string | null) {
return pathname === "/compose" || /^\/post\/[^/]+\/edit\/?$/.test(pathname);
}
/** 底部公共菜单避让:聊天 / 发帖工作台锁视口,不渲染底栏 */
function pathHasNoTabBar(pathname: string | null) {
return pathIsChat(pathname) || pathIsComposeStudio(pathname);
}
/**
* 公开站 chrome 闸门:/admin 不渲染 Header/Footer/1440 main。
* 首屏用 middleware 的 x-pathname 初值保证 hydration 一致;
@@ -85,7 +82,19 @@ export default function SiteChrome({
// 聊天 / 发帖工作台锁视口,窗口回顶无意义
const showBackToTop = !pathIsChat(pathname) && !pathIsComposeStudio(pathname);
const showTabBar = showBackToTop;
// 底栏只在已启用 tab 的根路径显示(个人页子 tab 走 ?tab= 查询参数,不影响 pathname);
// 发帖无「首页」概念不产生可见路径,发帖工作台 / 帖子编辑页继续排除
const enabledTabs = new Set(brand.mobile_tabs);
const meHref = user ? userPath(brand.url_style, user.id) : "";
const tabBarRoots: string[] = [];
if (enabledTabs.has("home")) tabBarRoots.push("/");
if (enabledTabs.has("library")) tabBarRoots.push(libraryPath(brand.url_style));
if (enabledTabs.has("messages")) tabBarRoots.push("/chat");
if (enabledTabs.has("me") && meHref) tabBarRoots.push(meHref);
const showTabBar =
!!pathname && !pathIsComposeStudio(pathname) && tabBarRoots.includes(pathname);
// 书库页正文区放宽(Header/Footer 保持 1440,避免顶栏菜单位置随页面跳动)
const wideShell = isWideShellPath(pathname);
return (
<>
@@ -105,7 +114,7 @@ export default function SiteChrome({
<main
id="main"
tabIndex={-1}
className="flex-1 w-full max-w-[1440px] mx-auto px-4 sm:px-6 pt-4 sm:pt-[var(--main-pad-y)] pb-[calc(4.75rem+env(safe-area-inset-bottom))] lg:pb-[var(--main-pad-y)] outline-none"
className={`flex-1 w-full ${wideShell ? "max-w-[1680px]" : "max-w-[1440px]"} mx-auto px-4 sm:px-6 pt-4 sm:pt-[var(--main-pad-y)] pb-[var(--main-pad-y)] outline-none`}
>
{children}
</main>

View File

@@ -7,6 +7,7 @@ import {
GitBranch,
Loader2,
Plus,
Tag,
Trash2,
} from "lucide-react";
import Modal from "@/components/Modal";
@@ -19,7 +20,7 @@ import {
serializeTimelineBlock,
type TimelineItem,
} from "@/lib/timelineBlocks";
import { apiTimelineFromGit, type TimelineGitItem } from "@/lib/api";
import { apiTimelineFromGit, apiTimelineFromReleases, type TimelineGitItem } from "@/lib/api";
import { toast } from "@/lib/toast";
type ImportMode = "replace" | "append";
@@ -108,11 +109,34 @@ export default function TimelineEditor({
);
};
const runImport = async () => {
const urls = urlsText
const collectURLs = () =>
urlsText
.split(/\n/)
.map((l) => l.trim())
.filter(Boolean);
const applyImported = (mapped: TimelineItem[], errorHint: string) => {
if (mapped.length === 0) {
setImportError(errorHint);
return false;
}
setItems((prev) => {
if (importMode === "append") {
const seen = new Set(
prev.map((p) => p.sourceUrl || `${p.date}|${p.title}`).filter(Boolean)
);
const extra = mapped.filter(
(m) => !m.sourceUrl || !seen.has(m.sourceUrl)
);
return [...prev.filter((p) => p.title.trim() || p.body.trim()), ...extra];
}
return mapped;
});
return true;
};
const runImportCommits = async () => {
const urls = collectURLs();
if (urls.length === 0) {
setImportError("请粘贴至少一条提交记录页面 URL");
return;
@@ -122,23 +146,10 @@ export default function TimelineEditor({
try {
const res = await apiTimelineFromGit({ urls, follow_pages: followPages });
const mapped = (res.items || []).map(fromGitItem);
if (mapped.length === 0) {
setImportError(res.error || "未能解析出提交;请检查 URL 或改为手填");
if (!applyImported(mapped, res.error || "未能解析出提交;请检查 URL 或改为手填")) {
if (res.warning) toast(res.warning);
return;
}
setItems((prev) => {
if (importMode === "append") {
const seen = new Set(
prev.map((p) => p.sourceUrl || `${p.date}|${p.title}`).filter(Boolean)
);
const extra = mapped.filter(
(m) => !m.sourceUrl || !seen.has(m.sourceUrl)
);
return [...prev.filter((p) => p.title.trim() || p.body.trim()), ...extra];
}
return mapped;
});
if (res.warning) toast(res.warning, "ok");
else toast(`已导入 ${mapped.length} 条,请点「插入/更新时间线」写入正文`, "ok");
if (res.error) setImportError(res.error);
@@ -149,6 +160,31 @@ export default function TimelineEditor({
}
};
const runImportReleases = async () => {
const urls = collectURLs();
if (urls.length === 0) {
setImportError("请粘贴至少一条 Release 页面 URL");
return;
}
setImporting(true);
setImportError(null);
try {
const res = await apiTimelineFromReleases({ urls });
const mapped = (res.items || []).map(fromGitItem);
if (!applyImported(mapped, res.error || "未能解析出 Release;请检查 URL 或改为手填")) {
if (res.warning) toast(res.warning);
return;
}
if (res.warning) toast(res.warning, "ok");
else toast(`已从 Release 导入 ${mapped.length} 条,请点「插入/更新时间线」写入正文`, "ok");
if (res.error) setImportError(res.error);
} catch (e) {
setImportError(e instanceof Error ? e.message : "导入失败,请手填时间线");
} finally {
setImporting(false);
}
};
const handleInsert = () => {
const kept = items.filter((it) => it.selected !== false);
if (kept.length === 0) {
@@ -222,7 +258,7 @@ export default function TimelineEditor({
</p>
</div>
<p className="meta text-[12px] mb-2 leading-relaxed">
粘贴 Gitea / GitHub 的 commits 列表页 URL,每行一条。默认自动拉取后续页,也可只贴某几页。
粘贴 Gitea / GitHub 的 commits 或 releases 列表页 URL,每行一条。点「导入提交」拉取提交记录,点「导入 Release」拉取发布日志。
</p>
<textarea
value={urlsText}
@@ -231,7 +267,7 @@ export default function TimelineEditor({
setImportError(null);
}}
rows={3}
placeholder={"https://github.com/org/repo/commits/main\nhttps://git.example.com/user/repo/commits/branch/main"}
placeholder={"https://github.com/org/repo/commits/main\nhttps://git.iioio.com/freefire/jiang13-bbs/releases"}
className="j13-tl-urls"
disabled={importing}
/>
@@ -260,20 +296,40 @@ export default function TimelineEditor({
<option value="append">追加</option>
</select>
</div>
<button
type="button"
className="btn btn-line btn-sm ml-auto"
disabled={importing}
onClick={() => void runImport()}
>
{importing ? (
<>
<Loader2 size={14} className="animate-spin" /> 导入中…
</>
) : (
"导入"
)}
</button>
<div className="ml-auto flex items-center gap-1.5">
<button
type="button"
className="btn btn-line btn-sm"
disabled={importing}
onClick={() => void runImportCommits()}
>
{importing ? (
<>
<Loader2 size={14} className="animate-spin" /> 导入中…
</>
) : (
<>
<GitBranch size={14} /> 导入提交
</>
)}
</button>
<button
type="button"
className="btn btn-line btn-sm"
disabled={importing}
onClick={() => void runImportReleases()}
>
{importing ? (
<>
<Loader2 size={14} className="animate-spin" /> 导入中…
</>
) : (
<>
<Tag size={14} /> 导入 Release
</>
)}
</button>
</div>
</div>
{showReplaceWarning ? (
<p className="j13-tl-banner is-warn">
@@ -300,7 +356,7 @@ export default function TimelineEditor({
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => setItems((prev) => [...prev, emptyItem()])}
onClick={() => setItems((prev) => [emptyItem(), ...prev])}
>
<Plus size={14} /> 添加
</button>
@@ -408,6 +464,7 @@ export default function TimelineEditor({
title="删除条目"
message="确定删除这条时间线项目?"
danger
zIndexClass="z-[1300]"
confirmLabel="删除"
onCancel={() => setDeleteIdx(null)}
onConfirm={() => {

View File

@@ -22,6 +22,7 @@ import {
Settings,
ChevronDown,
FileText,
BookOpen,
CircleDollarSign,
Columns3,
PanelLeft,
@@ -29,6 +30,7 @@ import {
Award,
Layers,
Images,
Link2,
} from "lucide-react";
import type { User } from "@/lib/api";
import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
@@ -108,6 +110,18 @@ const NAV_GROUPS: NavGroup[] = [
icon: FileText,
visible: (u) => isAdminOrAbove(u.role),
},
{
href: "/admin/library",
label: "书库管理",
icon: BookOpen,
visible: (u) => isAdminOrAbove(u.role),
},
{
href: "/admin/friend-links",
label: "友情链接",
icon: Link2,
visible: (u) => isSuperOrOwner(u.role),
},
],
},
{

View File

@@ -66,10 +66,14 @@ export function AdminChartCard({
</div>
{actions}
</div>
{/* 捕获阶段拦截 mousedown:冒泡阶段太晚,浏览器已开始 SVG 多柱选区黑框 */}
{/* 捕获阶段拦截 SVG 内 mousedown:冒泡阶段太晚,浏览器已开始选区黑框;非 SVG(输入框/表格)不拦截,否则无法聚焦输入 */}
{/* overflow-hidden 会沿自身 padding box 裁剪:px/p- 相同值负 margin 外扩裁剪边界 3px(内容宽度不变),
避免贴边子元素(如明细工具条首个搜索框)的 2px 聚焦光环被裁掉 */}
<div
className="w-full overflow-hidden select-none [&_*]:select-none [&_svg]:outline-none [&_.recharts-surface]:outline-none [&_.recharts-tooltip-cursor]:hidden"
onMouseDownCapture={(e) => e.preventDefault()}
className="overflow-hidden px-[3px] -mx-[3px] [&_svg]:select-none [&_svg]:outline-none [&_.recharts-surface]:outline-none [&_.recharts-tooltip-cursor]:hidden"
onMouseDownCapture={(e) => {
if ((e.target as Element).closest("svg")) e.preventDefault();
}}
>
{children}
</div>

View File

@@ -26,13 +26,13 @@ import type {
SidebarPageItem,
PublicAdItem,
PublicSponsorItem,
FriendLink,
} from "@/lib/api";
import Avatar from "@/components/Avatar";
import { AdText } from "@/components/AdText";
import { getPublicSettingsCached } from "@/lib/serverData";
import { announcementPath, postPath, userPath, pagePath } from "@/lib/urlStyle";
import { PulseCheckin } from "./CheckinCard";
import { FRIEND_LINKS } from "@/lib/friendLinks";
import { formatCompact, formatShortDate } from "@/lib/format";
import { pageGlyph } from "@/lib/pageGlyph";
import { AboutSiteHeader } from "@/components/site-doc";
@@ -396,38 +396,53 @@ export async function ActiveUsers({ users, allowComments }: { users: ActiveUser[
/* ---------- 友情链接 ---------- */
export function FriendLinks() {
if (FRIEND_LINKS.length === 0) return null;
export function FriendLinks({ links }: { links: FriendLink[] }) {
// 块显隐由侧栏布局配置控制;列表为空时仍渲染(保留「申请」入口),仅展示占位提示
return (
<section className="j13-module" aria-label="友情链接">
<p className="j13-module-title mb-3">
<Link2 size={15} style={{ color: "var(--accent)" }} /> 友情链接
<Link
href="/compose"
className="meta ml-auto text-[12px] font-normal transition-colors hover:text-[var(--accent)]"
>
申请
</Link>
<span className="ml-auto inline-flex items-center gap-3 text-[12px] font-normal">
<Link href="/links" className="meta transition-colors hover:text-[var(--accent)]">
全部
</Link>
<Link
href="/links#apply"
className="meta transition-colors hover:text-[var(--accent)]"
>
申请
</Link>
</span>
</p>
<div className="flex flex-wrap gap-2">
{FRIEND_LINKS.map((l) =>
l.href.startsWith("http") ? (
{links.length === 0 ? (
<p className="meta text-[12.5px]">暂无友链,欢迎申请</p>
) : (
<div className="flex flex-wrap gap-2">
{links.map((l) => (
<a
key={l.name}
href={l.href}
key={l.id}
href={l.url}
target="_blank"
rel="noopener noreferrer"
className="j13-chip"
className="j13-chip inline-flex items-center gap-1.5"
title={l.description || l.name}
>
{l.logo_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={l.logo_url}
alt=""
width={14}
height={14}
className="object-contain"
style={{ width: 14, height: 14 }}
/>
) : null}
{l.name}
</a>
) : (
<span key={l.name} className="j13-chip" data-tip="友链待配置">
{l.name}
</span>
)
)}
</div>
))}
</div>
)}
</section>
);
}
@@ -452,6 +467,7 @@ type PanelsProps = {
sponsors?: PublicSponsorItem[];
sponsorsPanelTitle?: string;
sponsorsEnabled?: boolean;
friendLinks?: FriendLink[];
};
export function AdsPanel({
@@ -615,6 +631,7 @@ function CommunityPanelsBody({
sponsors = [],
sponsorsPanelTitle = "赞助商",
sponsorsEnabled = true,
friendLinks = [],
}: PanelsProps) {
return (
<>
@@ -625,7 +642,7 @@ function CommunityPanelsBody({
<HotRanking posts={hot} allowComments={allowComments} />
<NewUsers users={newUsers} />
<ActiveUsers users={activeUsers} allowComments={allowComments} />
<FriendLinks />
<FriendLinks links={friendLinks} />
<AboutSite
pages={sidebarPages}
siteDescription={siteDescription}

View File

@@ -2,13 +2,14 @@
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { Bookmark, Home, ShieldCheck } from "lucide-react";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { Bookmark, Home, LayoutGrid, Menu, ShieldCheck } from "lucide-react";
import type { BoardWithCount, User } from "@/lib/api";
import { isStaff } from "@/lib/roles";
import BoardIcon, { boardColor } from "@/components/BoardIcon";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { userPath } from "@/lib/urlStyle";
import { RailDrawerPanel, useDrawerChrome } from "@/components/MobileRailDrawers";
// 首页壳内板块切换:仅替换中栏帖子流,不离开工作台
function boardHref(id: number) {
@@ -116,13 +117,27 @@ export function WorkspaceNav({ boards, user }: { boards: BoardWithCount[]; user?
);
}
// 移动端:横向滑动板块条(两端渐隐指示「可左右滑动」);发帖入口由底部公共菜单承载
export function WorkspaceNavMobile({ boards }: { boards: BoardWithCount[] }) {
// 移动端:横向滑动板块条(两端渐隐指示「可左右滑动」);发帖入口由底部公共菜单承载。
// 板块条两端嵌左右抽屉入口(☰ 导航 / ▦ 面板),与书籍阅读页的左右工具栏同构:
// 左抽屉 = 桌面左栏(板块目录 + 工作台),右抽屉 = 桌面右栏面板;内容为空则对应入口不渲染
export function WorkspaceNavMobile({
boards,
left,
right,
}: {
boards: BoardWithCount[];
/** 左抽屉内容(桌面左栏,服务端渲染节点) */
left?: ReactNode;
/** 右抽屉内容(桌面右栏,服务端渲染节点) */
right?: ReactNode;
}) {
const params = useSearchParams();
const activeBoard = Number(params.get("board")) || 0;
const scrollRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(false);
const [drawer, setDrawer] = useState<"left" | "right" | null>(null);
useDrawerChrome(drawer !== null, () => setDrawer(null));
const updateScrollState = useCallback(() => {
const el = scrollRef.current;
@@ -151,43 +166,88 @@ export function WorkspaceNavMobile({ boards }: { boards: BoardWithCount[] }) {
});
}, [activeBoard, boards]);
const triggerBtn =
"relative inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-full border transition-colors hover:bg-[var(--panel-2)] active:scale-95 " +
// 视觉 28px、触控热区扩到 44px(HIG 最小触控目标),仅扩大命中范围不改变外观
"before:absolute before:-inset-2 before:rounded-full before:content-['']";
return (
<div className="lg:hidden -mx-4 sm:-mx-6 px-4 sm:px-6 bg-[var(--main-bg)] border-b border-[var(--line)] py-2.5">
<div className="relative">
<div
ref={scrollRef}
className="j13-chips"
onScroll={updateScrollState}
>
<Link
href="/"
className="j13-chip"
aria-current={activeBoard === 0 ? "page" : undefined}
<div className="relative flex items-center gap-1.5">
{left ? (
<button
type="button"
className={triggerBtn}
style={{ borderColor: "var(--line)", color: "var(--ink-2)" }}
onClick={() => setDrawer("left")}
aria-haspopup="dialog"
aria-label="打开导航抽屉"
>
<Menu size={15} aria-hidden />
</button>
) : null}
<div className="relative min-w-0 flex-1">
<div
ref={scrollRef}
className="j13-chips"
onScroll={updateScrollState}
>
<Home size={13} /> 全部
</Link>
{boards.map((b) => (
<Link
key={b.id}
href={boardHref(b.id)}
href="/"
className="j13-chip"
aria-current={activeBoard === b.id ? "page" : undefined}
aria-current={activeBoard === 0 ? "page" : undefined}
>
<BoardIcon icon={b.icon} name={b.name} size={13} />
{b.name}
<Home size={13} /> 全部
</Link>
))}
{boards.map((b) => (
<Link
key={b.id}
href={boardHref(b.id)}
className="j13-chip"
aria-current={activeBoard === b.id ? "page" : undefined}
>
<BoardIcon icon={b.icon} name={b.name} size={13} />
{b.name}
</Link>
))}
</div>
{/* 渐隐滑动指示:遮罩与背景同色,pointer-events-none 不挡触控 */}
<div
className={`j13-chip-fade j13-chip-fade-left ${canScrollLeft ? "is-on" : ""}`}
aria-hidden="true"
/>
<div
className={`j13-chip-fade j13-chip-fade-right ${canScrollRight ? "is-on" : ""}`}
aria-hidden="true"
/>
</div>
{/* 渐隐滑动指示:遮罩与背景同色,pointer-events-none 不挡触控 */}
<div
className={`j13-chip-fade j13-chip-fade-left ${canScrollLeft ? "is-on" : ""}`}
aria-hidden="true"
/>
<div
className={`j13-chip-fade j13-chip-fade-right ${canScrollRight ? "is-on" : ""}`}
aria-hidden="true"
/>
{right ? (
<button
type="button"
className={triggerBtn}
style={{ borderColor: "var(--line)", color: "var(--ink-2)" }}
onClick={() => setDrawer("right")}
aria-haspopup="dialog"
aria-label="打开面板抽屉"
>
<LayoutGrid size={15} aria-hidden />
</button>
) : null}
</div>
{/* 左右抽屉:内容由服务端渲染后传入;lg 起隐藏(桌面用常驻侧栏) */}
{left ? (
<RailDrawerPanel side="left" title="导航" open={drawer === "left"} onClose={() => setDrawer(null)}>
{left}
</RailDrawerPanel>
) : null}
{right ? (
<RailDrawerPanel side="right" title="面板" open={drawer === "right"} onClose={() => setDrawer(null)}>
{right}
</RailDrawerPanel>
) : null}
</div>
);
}

View 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} />;
}

View File

@@ -48,34 +48,44 @@ export default function MdTimeline({ items, renderBody }: MdTimelineProps) {
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++;
{g.items.map((it, idx) => {
const flatIdx = flat++;
const { kind, scope, rest } = splitTitlePrefix(it.title);
const bodyNode = renderBody
? renderBody(it, idx)
? renderBody(it, flatIdx)
: it.body.trim()
? <span className="whitespace-pre-wrap">{it.body}</span>
: null;
const isFirstInDay = idx === 0;
return (
<li key={`${g.date}-${idx}-${it.title}`} className="j13-timeline-item" role="listitem">
<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}
<p className="j13-timeline-summary">{rest}</p>
{it.sourceUrl ? (
<a
href={it.sourceUrl}

View File

@@ -6,6 +6,7 @@ import type {
BoardSidebarStats,
BoardWithCount,
CheckinStatus,
FriendLink,
NewUser,
OverviewStats,
PostListItem,
@@ -62,6 +63,7 @@ export interface SidebarBlockProps {
hot?: PostListItem[];
newUsers?: NewUser[];
activeUsers?: ActiveUser[];
friendLinks?: FriendLink[];
sidebarPages?: SidebarPageItem[];
siteDescription?: string;
canManagePages?: boolean;
@@ -117,7 +119,8 @@ export function renderSidebarBlock(key: string, p: SidebarBlockProps) {
<ActiveUsers users={p.activeUsers} allowComments={p.allowComments} />
) : null;
case "friend_links":
return <FriendLinks />;
// 空列表也渲染(保留申请入口);显隐由侧栏布局配置控制
return <FriendLinks links={p.friendLinks ?? []} />;
case "about_site":
return p.sidebarPages || p.canManagePages ? (
<AboutSite

View File

@@ -1,23 +1,28 @@
import type { ReactNode } from "react";
import MobileRailDrawers from "@/components/MobileRailDrawers";
/**
* 公文阅读三栏壳:宽度与帖子详情对齐。
* lg = 左224 + 中栏 + 右280;xl = 左240 + 中栏 + 右300;窄屏仅中栏,侧栏下沉。
* lg = 左224 + 中栏 + 右280;xl = 左240 + 中栏 + 右300;
* 窄屏仅中栏,左右两栏收进移动端抽屉(工具栏按钮:导航 / 目录)。
*/
export default function SiteDocShell({
left,
right,
children,
mobileExtras,
}: {
left?: ReactNode;
right?: ReactNode;
children: ReactNode;
/** 窄屏下沉到正文之后的侧栏副本 */
mobileExtras?: ReactNode;
}) {
return (
<>
{/* 移动端:左右抽屉(导航 / 目录),lg 起隐藏;两侧皆空时组件自身不渲染 */}
<MobileRailDrawers
left={left ? { label: "导航", content: left } : undefined}
right={right ? { label: "目录", content: right } : undefined}
/>
<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-[var(--rail-top)] flex-col gap-4 max-h-[var(--rail-max-h)] overflow-hidden"
@@ -26,7 +31,7 @@ export default function SiteDocShell({
{left}
</aside>
<div className="min-w-0">
<div className="j13-doc-bleed min-w-0">
{children}
</div>
@@ -37,8 +42,6 @@ export default function SiteDocShell({
{right}
</aside>
</div>
{mobileExtras ? <div className="lg:hidden mt-6 space-y-4">{mobileExtras}</div> : null}
</>
);
}