新增模块: - 友链(friendlink)后端处理 + 前端管理页与友链板 - 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot - 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉 - 排行榜 service 测试、站点页面测试、时间线发布(timeline_release) 其它改动: - 后端 handlers/services 全量小幅调整 - 前端组件、库函数、URL/品牌/站点 URL 工具更新 - Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
213 lines
6.4 KiB
TypeScript
213 lines
6.4 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { usePathname } from "next/navigation";
|
||
import { Fragment, useEffect, useState } from "react";
|
||
import {
|
||
Home,
|
||
BookOpen,
|
||
PenSquare,
|
||
MessageSquare,
|
||
User as UserIcon,
|
||
} from "lucide-react";
|
||
import { apiChatUnreadSummary, type User } from "@/lib/api";
|
||
import { useAllowMessages } from "@/components/MessagesPolicyProvider";
|
||
import { useSiteBrand } from "@/components/SiteBrandProvider";
|
||
import { onDocumentVisible } from "@/lib/visibilityReconcile";
|
||
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 以下显示):
|
||
* 按 SiteBrand.mobile_tabs 配置顺序渲染(消息在未登录/未开放时不显示)。
|
||
* SiteChrome 只在启用 tab 的根路径渲染本组件,
|
||
* 避免与聊天 / 发帖工作台的锁视口布局冲突。
|
||
*/
|
||
export default function MobileTabBar({
|
||
user,
|
||
initialChatUnread = 0,
|
||
}: {
|
||
user: User | null;
|
||
initialChatUnread?: number;
|
||
}) {
|
||
const pathname = usePathname();
|
||
const allowMessages = useAllowMessages();
|
||
const { url_style: urlStyle, mobile_tabs: mobileTabs } = useSiteBrand();
|
||
const libHref = libraryPath(urlStyle);
|
||
const [chatUnread, setChatUnread] = useState(initialChatUnread);
|
||
|
||
// 登录态经 router.refresh() 下发新 props 时校正徽标
|
||
useEffect(() => {
|
||
setChatUnread(initialChatUnread);
|
||
}, [initialChatUnread]);
|
||
|
||
// 实时未读:与 Header 同源(chat:unread 对全体成员推送;在当前会话页则忽略)
|
||
useEffect(() => {
|
||
if (!user || !allowMessages) return;
|
||
const off = realtime.on<RtChatUnreadData>(RT_CHAT_UNREAD, (data) => {
|
||
if (!data || typeof data.room_id !== "number") return;
|
||
if (pathname === `/chat/${data.room_id}`) return;
|
||
const delta = typeof data.delta === "number" ? data.delta : 1;
|
||
setChatUnread((c) => Math.max(0, c + delta));
|
||
});
|
||
return off;
|
||
}, [user, pathname, allowMessages]);
|
||
|
||
// 会话内已读 / 回前台:合并对账(短时间多次同步只打一次 summary)
|
||
useEffect(() => {
|
||
if (!user || !allowMessages) return;
|
||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||
const pullSummary = () => {
|
||
apiChatUnreadSummary()
|
||
.then((res) => setChatUnread(res.total || 0))
|
||
.catch(() => {});
|
||
};
|
||
const onSync = () => {
|
||
if (debounceTimer) clearTimeout(debounceTimer);
|
||
debounceTimer = setTimeout(pullSummary, 250);
|
||
};
|
||
window.addEventListener("j13-chat-unread-sync", onSync);
|
||
const offVisible = onDocumentVisible(pullSummary);
|
||
return () => {
|
||
if (debounceTimer) clearTimeout(debounceTimer);
|
||
window.removeEventListener("j13-chat-unread-sync", onSync);
|
||
offVisible();
|
||
};
|
||
}, [user, allowMessages]);
|
||
|
||
const showMessages = !!user && allowMessages;
|
||
|
||
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={{
|
||
borderColor: "var(--line)",
|
||
background: "color-mix(in srgb, var(--panel) 92%, transparent)",
|
||
backdropFilter: "blur(14px)",
|
||
WebkitBackdropFilter: "blur(14px)",
|
||
paddingBottom: "env(safe-area-inset-bottom)",
|
||
}}
|
||
>
|
||
<div className="flex items-stretch">
|
||
{mobileTabs.map((key) =>
|
||
tabNodes[key] ? <Fragment key={key}>{tabNodes[key]}</Fragment> : null,
|
||
)}
|
||
</div>
|
||
</nav>
|
||
);
|
||
}
|
||
|
||
function TabLink({
|
||
href,
|
||
icon,
|
||
label,
|
||
active,
|
||
prominent = false,
|
||
badge = 0,
|
||
}: {
|
||
href: string;
|
||
icon: React.ReactNode;
|
||
label: string;
|
||
active: boolean;
|
||
prominent?: boolean;
|
||
badge?: number | string;
|
||
}) {
|
||
const ink = active ? "var(--accent)" : "var(--ink-2)";
|
||
return (
|
||
<Link
|
||
href={href}
|
||
prefetch={false}
|
||
aria-label={label}
|
||
aria-current={active ? "page" : undefined}
|
||
className="flex-1 min-w-0 flex flex-col items-center justify-center gap-0.5 pt-2 pb-1.5 transition-colors"
|
||
style={{ color: ink }}
|
||
>
|
||
{prominent ? (
|
||
<span
|
||
className="w-9 h-9 -mt-1 flex items-center justify-center rounded-full"
|
||
style={{ background: "var(--accent)", color: "var(--accent-on)" }}
|
||
aria-hidden="true"
|
||
>
|
||
{icon}
|
||
</span>
|
||
) : (
|
||
<span className="relative" aria-hidden="true">
|
||
{icon}
|
||
{badge ? (
|
||
<span
|
||
className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] px-1 text-[9.5px] font-medium flex items-center justify-center rounded-full"
|
||
style={{
|
||
background: "var(--clay)",
|
||
color: "#fff",
|
||
border: "2px solid var(--panel)",
|
||
}}
|
||
>
|
||
{badge}
|
||
</span>
|
||
) : null}
|
||
</span>
|
||
)}
|
||
<span
|
||
className={`text-[10px] leading-none ${
|
||
prominent ? "mt-0.5" : ""
|
||
} ${active ? "font-semibold" : "font-medium"}`}
|
||
>
|
||
{label}
|
||
</span>
|
||
</Link>
|
||
);
|
||
}
|