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