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