feat: 站点媒体库与移动端底部导航,服务测试补强
- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面 - 移动端底部导航 MobileTabBar 替换 MobilePostBar - 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强 - site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
This commit is contained in:
@@ -15,8 +15,8 @@ import Modal from "@/components/Modal";
|
||||
*
|
||||
* 两类告知:
|
||||
* - 账号被封禁(reason=banned):管理员封禁后由 HTTP 拦截链路派发;
|
||||
* - 登录已失效(reason=session_replaced):其他浏览器登录顶掉旧会话、
|
||||
* 安全设置剔除本设备等场景,旧浏览器当场弹窗提示,而非等硬刷新才发现。
|
||||
* - 登录已失效(reason=session_replaced):安全设置剔除本设备、
|
||||
* 会话数超上限被淘汰等场景,旧浏览器当场弹窗提示,而非等硬刷新才发现。
|
||||
*
|
||||
* 登录态被顶的感知入口(探测统一收敛在 lib/api 的 apiMe 判定):
|
||||
* 1) 后端 WS 即时事件 session:replaced(旧设备 WS 在线时秒级感知);
|
||||
@@ -152,7 +152,7 @@ export default function AccountGuard() {
|
||||
id="session-ended-dialog-desc"
|
||||
className="meta text-[13px] leading-relaxed text-center"
|
||||
>
|
||||
你的账号已在其他浏览器登录,或登录会话已被移除,当前设备的登录状态已失效。
|
||||
你的登录会话已被移除(在安全设置中剔除,或会话数超出上限被淘汰),当前设备的登录状态已失效。
|
||||
请重新登录后继续使用。
|
||||
</p>
|
||||
</Modal>
|
||||
|
||||
@@ -8,13 +8,9 @@ const SHOW_AFTER = 400;
|
||||
|
||||
/**
|
||||
* 公开站长页回顶:滚过阈值后右下角浮现。
|
||||
* 短页自然不出现;帖子移动端底栏时抬高,避免叠住操作条。
|
||||
* 短页自然不出现;移动端位置已为底部公共菜单预留避让(见 globals.css)。
|
||||
*/
|
||||
export default function BackToTop({
|
||||
liftForMobileBar = false,
|
||||
}: {
|
||||
liftForMobileBar?: boolean;
|
||||
}) {
|
||||
export default function BackToTop() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -29,7 +25,7 @@ export default function BackToTop({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`j13-back-top j13-toast-in${liftForMobileBar ? " is-lifted" : ""}`}
|
||||
className="j13-back-top j13-toast-in"
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: scrollBehavior() })}
|
||||
aria-label="回到顶部"
|
||||
>
|
||||
|
||||
@@ -1306,7 +1306,11 @@ export default function CommentSection({
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="comments" className="panel p-6 sm:p-8 mt-6 scroll-mt-24" aria-label="评论区">
|
||||
<section
|
||||
id="comments"
|
||||
className="panel p-4 sm:p-6 lg:p-8 mt-4 sm:mt-6 scroll-mt-24 max-sm:-mx-4 max-sm:rounded-none max-sm:border-x-0 max-sm:shadow-none"
|
||||
aria-label="评论区"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 mb-6">
|
||||
<h2 className="text-lg font-semibold" style={{ color: "var(--ink)" }}>
|
||||
评论
|
||||
|
||||
@@ -4,8 +4,6 @@ import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
Menu,
|
||||
X,
|
||||
LogIn,
|
||||
UserPlus,
|
||||
LogOut,
|
||||
@@ -19,6 +17,7 @@ import {
|
||||
ChevronDown,
|
||||
ShieldCheck,
|
||||
MessageSquare,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { apiLogout, apiMe, apiChatUnreadSummary, type User } from "@/lib/api";
|
||||
import { hasAuthCookieHint } from "@/lib/cookies";
|
||||
@@ -43,7 +42,6 @@ export default function Header({
|
||||
initialTheme = "light",
|
||||
siteName = "姜十三",
|
||||
wordmark = "",
|
||||
slogan = "",
|
||||
logoLight = "",
|
||||
logoDark = "",
|
||||
brandMark = "image_text",
|
||||
@@ -56,7 +54,6 @@ export default function Header({
|
||||
initialTheme?: "light" | "dark";
|
||||
siteName?: string;
|
||||
wordmark?: string;
|
||||
slogan?: string;
|
||||
logoLight?: string;
|
||||
logoDark?: string;
|
||||
brandMark?: BrandMark;
|
||||
@@ -70,13 +67,14 @@ export default function Header({
|
||||
pathname === "/compose" ||
|
||||
(pathname?.startsWith("/compose/") ?? false) ||
|
||||
/^\/post\/[^/]+\/edit\/?$/.test(pathname || "");
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
// 登录态以 SSR 直出为初始值,F5 无切换闪动;router.refresh() 后随 props 校正
|
||||
const [user, setUser] = useState<User | null>(initialUser);
|
||||
const [unread, setUnread] = useState(initialUnread);
|
||||
const [chatUnread, setChatUnread] = useState(initialChatUnread);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
// 移动端展开式搜索行(导航已交给底部公共菜单,顶部只留搜索/主题/铃铛/头像)
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const mobileSearchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 登录/登出触发 router.refresh() 后,服务端 layout 重新取态并下发新 props
|
||||
useEffect(() => {
|
||||
@@ -101,7 +99,6 @@ export default function Header({
|
||||
// 有效登录态),本地立即游客化并 router.refresh 校正 SSR 区域,
|
||||
// 封禁告知弹窗由 AccountGuard 负责,避免状态与 UI 清理分散
|
||||
useEffect(() => onForceLogout(() => {
|
||||
setMenuOpen(false);
|
||||
apiLogout()
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
@@ -164,8 +161,9 @@ export default function Header({
|
||||
e.preventDefault();
|
||||
const q = searchQuery.trim();
|
||||
router.push(q ? `/?q=${encodeURIComponent(q)}` : "/");
|
||||
setMenuOpen(false);
|
||||
setSearchOpen(false);
|
||||
searchInputRef.current?.blur();
|
||||
mobileSearchRef.current?.blur();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -203,7 +201,6 @@ export default function Header({
|
||||
/* ignore */
|
||||
}
|
||||
setUser(null);
|
||||
setMenuOpen(false);
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
@@ -233,7 +230,6 @@ export default function Header({
|
||||
fit={brandLogoFit}
|
||||
name={siteName}
|
||||
wordmark={wordmark}
|
||||
slogan={slogan}
|
||||
logoLight={logoLight}
|
||||
logoDark={logoDark}
|
||||
/>
|
||||
@@ -255,9 +251,19 @@ export default function Header({
|
||||
{/* 中部弹性留白,把搜索与操作区推到右侧 */}
|
||||
<div className="hidden lg:block flex-1" aria-hidden />
|
||||
|
||||
{/* 右侧操作区:搜索靠右停靠 + 桌面/移动共用铃铛(仅挂载一次,避免重复轮询),
|
||||
桌面额外显示主题/发帖/用户菜单,移动端搜索收进汉堡抽屉 */}
|
||||
<div className="flex items-center gap-2 lg:gap-3">
|
||||
{/* 右侧操作区:搜索靠右停靠 + 桌面/移动共用铃铛(仅挂载一次,避免重复轮询)。
|
||||
移动端导航交给底部公共菜单,顶部只保留 搜索/主题/铃铛/头像 */}
|
||||
<div className="flex items-center gap-2 lg:gap-3 ml-auto lg:ml-0">
|
||||
{/* 移动端搜索开关 */}
|
||||
<button
|
||||
onClick={() => setSearchOpen((v) => !v)}
|
||||
aria-label="搜索"
|
||||
aria-expanded={searchOpen}
|
||||
className={`${iconBtn} lg:hidden`}
|
||||
style={iconBtnStyle}
|
||||
>
|
||||
{searchOpen ? <X size={17} /> : <Search size={17} />}
|
||||
</button>
|
||||
<form onSubmit={handleSearch} className="hidden lg:block">
|
||||
<div className="relative w-[220px] xl:w-[260px] focus-within:w-[268px] xl:focus-within:w-[300px] transition-[width] duration-200">
|
||||
<Search
|
||||
@@ -283,18 +289,14 @@ export default function Header({
|
||||
</kbd>
|
||||
</div>
|
||||
</form>
|
||||
<ThemeToggle
|
||||
initialTheme={initialTheme}
|
||||
className={`${iconBtn} hidden lg:inline-flex`}
|
||||
style={iconBtnStyle}
|
||||
/>
|
||||
<ThemeToggle initialTheme={initialTheme} className={iconBtn} style={iconBtnStyle} />
|
||||
{user && allowMessages && (
|
||||
<Link
|
||||
href="/chat"
|
||||
prefetch={false}
|
||||
aria-label="消息"
|
||||
data-tip="消息"
|
||||
className="relative inline-flex items-center justify-center w-10 h-10 rounded-full transition-colors"
|
||||
className="relative hidden lg:inline-flex items-center justify-center w-10 h-10 rounded-full transition-colors"
|
||||
style={{
|
||||
color: pathname?.startsWith("/chat")
|
||||
? "var(--accent)"
|
||||
@@ -333,95 +335,43 @@ export default function Header({
|
||||
<UserMenu user={user} onLogout={handleLogout} />
|
||||
</>
|
||||
) : (
|
||||
<div className="hidden lg:flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/login" className="btn btn-line btn-sm">
|
||||
<LogIn size={14} /> 登录
|
||||
</Link>
|
||||
<Link href="/register" className="btn btn-primary btn-sm">
|
||||
<Link href="/register" className="btn btn-primary btn-sm hidden sm:inline-flex">
|
||||
<UserPlus size={14} /> 注册
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-full lg:hidden"
|
||||
style={{ border: "1px solid var(--line)", background: "var(--panel)", color: "var(--ink)" }}
|
||||
aria-label="菜单"
|
||||
aria-expanded={menuOpen}
|
||||
>
|
||||
{menuOpen ? <X size={19} /> : <Menu size={19} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 移动端抽屉 */}
|
||||
{menuOpen && (
|
||||
{/* 移动端展开式搜索行 */}
|
||||
{searchOpen && (
|
||||
<div
|
||||
className="lg:hidden border-t px-4 py-4"
|
||||
className="lg:hidden border-t px-4 py-2.5"
|
||||
style={{ borderColor: "var(--line)", background: "var(--bg)" }}
|
||||
>
|
||||
<form onSubmit={handleSearch} className="relative mb-3">
|
||||
<form onSubmit={handleSearch} className="relative">
|
||||
<Search size={16} className="absolute left-4 top-1/2 -translate-y-1/2" style={{ color: "var(--ink-3)" }} />
|
||||
<input
|
||||
ref={mobileSearchRef}
|
||||
autoFocus
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
setSearchOpen(false);
|
||||
if (new URLSearchParams(window.location.search).get("q")) router.push("/");
|
||||
}
|
||||
}}
|
||||
aria-label="搜索帖子"
|
||||
placeholder="搜索帖子...(Esc 清除)"
|
||||
placeholder="搜索帖子..."
|
||||
className="j13-search-field w-full pl-10 pr-4 py-3 rounded-full text-sm"
|
||||
/>
|
||||
</form>
|
||||
<div className="flex flex-col">
|
||||
<MobileLink href="/" active={pathname === "/"}>首页</MobileLink>
|
||||
<MobileLink href="/leaderboard" active={pathname?.startsWith("/leaderboard") ?? false}>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Trophy size={15} /> 排行榜
|
||||
</span>
|
||||
</MobileLink>
|
||||
{user && !hideComposeNav && <MobileLink href="/compose">发帖</MobileLink>}
|
||||
{user && allowMessages && (
|
||||
<MobileLink href="/chat" active={pathname?.startsWith("/chat") ?? false}>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<MessageSquare size={15} /> 消息
|
||||
{chatUnread > 0 && (
|
||||
<span
|
||||
className="inline-flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-medium rounded-full"
|
||||
style={{ background: "var(--clay)", color: "#fff" }}
|
||||
>
|
||||
{chatUnread > 99 ? "99+" : chatUnread}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</MobileLink>
|
||||
)}
|
||||
{user && (
|
||||
<MobileLink href="/notifications">
|
||||
<span className="inline-flex items-center gap-2"><Bell size={15} /> 通知中心</span>
|
||||
</MobileLink>
|
||||
)}
|
||||
{user && <MobileLink href="/settings">账号设置</MobileLink>}
|
||||
{user && (isStaff(user.role) || canAccessAdminMessages(user)) && (
|
||||
<MobileLink href="/admin">
|
||||
<span className="inline-flex items-center gap-2"><ShieldCheck size={15} /> 管理面板</span>
|
||||
</MobileLink>
|
||||
)}
|
||||
{!user && <MobileLink href="/login">登录</MobileLink>}
|
||||
{!user && <MobileLink href="/register">注册</MobileLink>}
|
||||
</div>
|
||||
<div className="pt-3 mt-2 border-t flex items-center justify-between" style={{ borderColor: "var(--line)" }}>
|
||||
<ThemeToggle
|
||||
initialTheme={initialTheme}
|
||||
labelMode="labeled"
|
||||
iconSize={14}
|
||||
className="btn btn-line btn-sm"
|
||||
/>
|
||||
{user && (
|
||||
<button onClick={handleLogout} className="btn btn-danger btn-sm">
|
||||
<LogOut size={14} /> 退出
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
@@ -455,32 +405,9 @@ function NavPill({
|
||||
);
|
||||
}
|
||||
|
||||
function MobileLink({
|
||||
href,
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
active?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="px-3 py-3 rounded-2xl text-[16px] font-semibold transition-colors"
|
||||
style={{
|
||||
color: active ? "var(--accent)" : "var(--ink)",
|
||||
background: "transparent",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 桌面端用户菜单:头像 + 昵称触发器,下拉承载个人主页/设置/登出,
|
||||
* 把零散的图标按钮收拢为一个语义明确的控件
|
||||
* 用户菜单(两端可用):头像触发器,下拉承载通知中心/个人主页/设置/登出,
|
||||
* 把零散的图标按钮收拢为一个语义明确的控件;移动端只显示头像
|
||||
*/
|
||||
function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -506,12 +433,13 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
|
||||
"w-full flex items-center gap-2.5 px-4 py-2.5 text-[13.5px] transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]";
|
||||
|
||||
return (
|
||||
<div className="relative hidden lg:block" ref={menuRef}>
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
className="flex items-center gap-2 rounded-full pl-1 pr-2.5 py-1 transition-colors"
|
||||
aria-label="用户菜单"
|
||||
className="flex items-center gap-2 rounded-full p-1 lg:pl-1 lg:pr-2.5 transition-colors"
|
||||
style={{
|
||||
border: "1px solid " + (open ? "var(--accent)" : "var(--line)"),
|
||||
background: open ? "var(--accent-soft)" : "var(--panel)",
|
||||
@@ -519,14 +447,14 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
|
||||
>
|
||||
<Avatar name={user.nickname || user.username} src={user.avatar || undefined} size={28} />
|
||||
<span
|
||||
className="text-[13px] max-w-[90px] truncate"
|
||||
className="hidden lg:inline text-[13px] max-w-[90px] truncate"
|
||||
style={{ color: open ? "var(--accent)" : "var(--ink)" }}
|
||||
>
|
||||
{user.nickname}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className="transition-transform duration-200"
|
||||
className="hidden lg:block transition-transform duration-200"
|
||||
style={{
|
||||
color: "var(--ink-3)",
|
||||
transform: open ? "rotate(180deg)" : "none",
|
||||
@@ -562,6 +490,9 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-1.5">
|
||||
<Link href="/notifications" onClick={() => setOpen(false)} className={itemCls} role="menuitem">
|
||||
<Bell size={15} style={{ color: "var(--ink-3)" }} /> 通知中心
|
||||
</Link>
|
||||
<Link href={`/u/${user.id}`} onClick={() => setOpen(false)} className={itemCls} role="menuitem">
|
||||
<UserIcon size={15} style={{ color: "var(--ink-3)" }} /> 个人主页
|
||||
</Link>
|
||||
|
||||
@@ -134,7 +134,9 @@ export default function ImageInsertModal({
|
||||
<span className="j13-img-dropzone-title">
|
||||
{uploading ? "图片上传中…" : "拖拽图片到此处,或点击选择文件"}
|
||||
</span>
|
||||
<span className="j13-img-dropzone-hint">支持 JPEG / PNG / WebP</span>
|
||||
<span className="j13-img-dropzone-hint">
|
||||
支持 JPEG / PNG / WebP / GIF,自动转存 WebP(GIF 除外)
|
||||
</span>
|
||||
</button>
|
||||
{uploadError && !uploading ? (
|
||||
<p className="j13-img-modal-error" role="alert">
|
||||
|
||||
@@ -413,9 +413,9 @@ export default function MarkdownEditor({
|
||||
if (!file || disabled) return;
|
||||
retryFile.current = file;
|
||||
setUploadError("");
|
||||
if (!/^image\/(jpeg|png|webp)$/i.test(file.type)) {
|
||||
setUploadError("仅支持 JPEG / PNG / WebP");
|
||||
toast("仅支持 JPEG / PNG / WebP", "error");
|
||||
if (!/^image\/(jpeg|png|webp|gif)$/i.test(file.type)) {
|
||||
setUploadError("仅支持 JPEG / PNG / WebP / GIF");
|
||||
toast("仅支持 JPEG / PNG / WebP / GIF", "error");
|
||||
return;
|
||||
}
|
||||
const maxBytes = Math.max(1, imageMaxMB) * 1024 * 1024;
|
||||
@@ -1254,7 +1254,7 @@ export default function MarkdownEditor({
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
className="hidden"
|
||||
onChange={(e) => void uploadImageFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import LikeButton from "./LikeButton";
|
||||
import FavoriteButton from "./FavoriteButton";
|
||||
import { scrollBehavior } from "@/lib/motion";
|
||||
|
||||
/**
|
||||
* 帖子详情移动端浮动操作条(lg 以下显示):
|
||||
* 长文读到中途也能点赞 / 收藏 / 跳评论框;回顶由全局 BackToTop 承担。
|
||||
*/
|
||||
export default function MobilePostBar({
|
||||
postId,
|
||||
liked,
|
||||
likeCount,
|
||||
favorited = false,
|
||||
allowComments = true,
|
||||
}: {
|
||||
postId: string;
|
||||
liked: boolean;
|
||||
likeCount: number;
|
||||
favorited?: boolean;
|
||||
allowComments?: boolean;
|
||||
}) {
|
||||
const scrollToComments = () => {
|
||||
document.getElementById("comment-input")?.scrollIntoView({
|
||||
behavior: scrollBehavior(),
|
||||
block: "center",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="lg:hidden fixed bottom-4 left-1/2 -translate-x-1/2 z-40" role="navigation" aria-label="阅读操作">
|
||||
<div
|
||||
className="flex items-center gap-0.5 rounded-full border px-1.5 py-1.5"
|
||||
style={{
|
||||
background: "color-mix(in srgb, var(--panel) 90%, transparent)",
|
||||
borderColor: "var(--line-2)",
|
||||
boxShadow: "var(--shadow-lg)",
|
||||
backdropFilter: "blur(14px)",
|
||||
WebkitBackdropFilter: "blur(14px)",
|
||||
}}
|
||||
>
|
||||
{allowComments ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToComments}
|
||||
className="w-11 h-11 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
|
||||
style={{ color: "var(--ink-2)" }}
|
||||
aria-label="跳到评论框"
|
||||
>
|
||||
<MessageSquare size={18} />
|
||||
</button>
|
||||
) : null}
|
||||
<div className="j13-bar-like">
|
||||
<LikeButton postId={postId} liked={liked} likeCount={likeCount} variant="compact" />
|
||||
</div>
|
||||
<FavoriteButton postId={postId} favorited={favorited} variant="compact" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
192
frontend/components/MobileTabBar.tsx
Normal file
192
frontend/components/MobileTabBar.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Home,
|
||||
Trophy,
|
||||
PenSquare,
|
||||
MessageSquare,
|
||||
User as UserIcon,
|
||||
} from "lucide-react";
|
||||
import { apiChatUnreadSummary, type User } from "@/lib/api";
|
||||
import { useAllowMessages } from "@/components/MessagesPolicyProvider";
|
||||
import { onDocumentVisible } from "@/lib/visibilityReconcile";
|
||||
import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime";
|
||||
import type { RtChatUnreadData } from "@/lib/realtime";
|
||||
|
||||
/**
|
||||
* 移动端全局底部公共菜单(lg 以下显示):
|
||||
* 首页 / 排行榜 / 发帖 / 消息(未登录不显示)/ 我的。
|
||||
* 聊天页、发帖工作台、管理后台由 SiteChrome 控制不渲染,避免与锁视口布局冲突。
|
||||
*/
|
||||
export default function MobileTabBar({
|
||||
user,
|
||||
initialChatUnread = 0,
|
||||
}: {
|
||||
user: User | null;
|
||||
initialChatUnread?: number;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const allowMessages = useAllowMessages();
|
||||
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);
|
||||
|
||||
return (
|
||||
<nav
|
||||
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">
|
||||
<TabLink
|
||||
href="/"
|
||||
icon={<Home size={20} />}
|
||||
label="首页"
|
||||
active={isActive((p) => p === "/")}
|
||||
/>
|
||||
<TabLink
|
||||
href="/leaderboard"
|
||||
icon={<Trophy size={20} />}
|
||||
label="排行榜"
|
||||
active={isActive((p) => p.startsWith("/leaderboard"))}
|
||||
/>
|
||||
<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}
|
||||
/>
|
||||
)}
|
||||
<TabLink
|
||||
href={user ? `/u/${user.id}` : "/login"}
|
||||
icon={<UserIcon size={20} />}
|
||||
label="我的"
|
||||
active={
|
||||
!!user && isActive((p) => p === `/u/${user.id}` || p.startsWith(`/u/${user.id}/`))
|
||||
}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -201,31 +201,46 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="absolute right-0 mt-2.5 w-[340px] max-w-[calc(100vw-2rem)] overflow-hidden z-50 j13-toast-in"
|
||||
style={{
|
||||
background: "var(--panel)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r)",
|
||||
boxShadow: "var(--shadow-lg)",
|
||||
}}
|
||||
>
|
||||
<>
|
||||
{/* 移动端遮罩:点击关闭(桌面不渲染) */}
|
||||
<div
|
||||
className="flex items-center justify-between px-5 py-3.5"
|
||||
style={{ borderBottom: "1px solid var(--line)" }}
|
||||
className="lg:hidden fixed inset-0 z-40"
|
||||
style={{ background: "rgba(0,0,0,0.35)" }}
|
||||
onClick={() => setOpen(false)}
|
||||
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
|
||||
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)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r)",
|
||||
boxShadow: "var(--shadow-lg)",
|
||||
}}
|
||||
>
|
||||
<span className="font-semibold text-[15px]" style={{ color: "var(--ink)" }}>通知</span>
|
||||
{unread > 0 && (
|
||||
<button
|
||||
onClick={handleMarkAllRead}
|
||||
className="flex items-center gap-1 text-xs meta hover:text-[var(--accent)] transition-colors"
|
||||
>
|
||||
<CheckCheck size={13} /> 全部已读
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* 移动端底部面板抓手 */}
|
||||
<div
|
||||
className="lg:hidden mx-auto mt-2 mb-[-6px] w-10 h-1 rounded-full shrink-0"
|
||||
style={{ background: "var(--line-2)" }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
className="flex items-center justify-between px-5 py-3.5 shrink-0"
|
||||
style={{ borderBottom: "1px solid var(--line)" }}
|
||||
>
|
||||
<span className="font-semibold text-[15px]" style={{ color: "var(--ink)" }}>通知</span>
|
||||
{unread > 0 && (
|
||||
<button
|
||||
onClick={handleMarkAllRead}
|
||||
className="flex items-center gap-1 text-xs meta hover:text-[var(--accent)] transition-colors"
|
||||
>
|
||||
<CheckCheck size={13} /> 全部已读
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="max-h-[24rem] overflow-y-auto">
|
||||
<div className="flex-1 min-h-0 max-h-[24rem] max-lg:max-h-none overflow-y-auto">
|
||||
{loading ? (
|
||||
<p className="text-center py-10 meta">加载中...</p>
|
||||
) : notifications.length === 0 ? (
|
||||
@@ -303,7 +318,7 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="px-4 py-2.5 text-center"
|
||||
className="px-4 py-2.5 text-center shrink-0"
|
||||
style={{ borderTop: "1px solid var(--line)" }}
|
||||
>
|
||||
<Link
|
||||
@@ -314,7 +329,8 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
|
||||
查看全部通知 →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { usePathname } from "next/navigation";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import BackToTop from "@/components/BackToTop";
|
||||
import MobileTabBar from "@/components/MobileTabBar";
|
||||
import type { User } from "@/lib/api";
|
||||
import { useSiteBrand } from "@/components/SiteBrandProvider";
|
||||
|
||||
@@ -23,10 +24,9 @@ function pathIsComposeStudio(pathname: string | null) {
|
||||
return pathname === "/compose" || /^\/post\/[^/]+\/edit\/?$/.test(pathname);
|
||||
}
|
||||
|
||||
/** 帖子详情(非 edit)移动端有底栏,回顶需抬高 */
|
||||
function pathIsPostDetail(pathname: string | null) {
|
||||
if (!pathname) return false;
|
||||
return /^\/post\/[^/]+$/.test(pathname);
|
||||
/** 底部公共菜单避让:聊天 / 发帖工作台锁视口,不渲染底栏 */
|
||||
function pathHasNoTabBar(pathname: string | null) {
|
||||
return pathIsChat(pathname) || pathIsComposeStudio(pathname);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,6 +85,7 @@ export default function SiteChrome({
|
||||
|
||||
// 聊天 / 发帖工作台锁视口,窗口回顶无意义
|
||||
const showBackToTop = !pathIsChat(pathname) && !pathIsComposeStudio(pathname);
|
||||
const showTabBar = showBackToTop;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -95,7 +96,6 @@ export default function SiteChrome({
|
||||
initialTheme={theme}
|
||||
siteName={brand.site_name}
|
||||
wordmark={brand.site_wordmark}
|
||||
slogan={brand.site_slogan}
|
||||
logoLight={brand.logo_light_url}
|
||||
logoDark={brand.logo_dark_url}
|
||||
brandMark={brand.brand_mark}
|
||||
@@ -105,14 +105,15 @@ export default function SiteChrome({
|
||||
<main
|
||||
id="main"
|
||||
tabIndex={-1}
|
||||
className="flex-1 w-full max-w-[1440px] mx-auto px-4 sm:px-6 py-7 sm:py-[var(--main-pad-y)] outline-none"
|
||||
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"
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
<Footer siteName={brand.site_name} links={brand.footer_links} />
|
||||
{showBackToTop ? (
|
||||
<BackToTop liftForMobileBar={pathIsPostDetail(pathname)} />
|
||||
{showTabBar ? (
|
||||
<MobileTabBar user={user} initialChatUnread={chatUnread} />
|
||||
) : null}
|
||||
{showBackToTop ? <BackToTop /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
BarChart3,
|
||||
Award,
|
||||
Layers,
|
||||
Images,
|
||||
} from "lucide-react";
|
||||
import type { User } from "@/lib/api";
|
||||
import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
|
||||
@@ -62,7 +63,7 @@ interface NavGroup {
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
// 分组导航:概览 → 内容运营 → 成员经济 → 站点配置
|
||||
// 分组导航:概览 → 内容 → 成员与经济 → 外观展示 → 系统
|
||||
const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
title: "概览",
|
||||
@@ -74,6 +75,12 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
title: "内容",
|
||||
items: [
|
||||
{
|
||||
href: "/admin/boards",
|
||||
label: "板块管理",
|
||||
icon: Columns3,
|
||||
visible: (u) => isOwner(u.role),
|
||||
},
|
||||
{
|
||||
href: "/admin/content",
|
||||
label: "内容管理",
|
||||
@@ -99,12 +106,6 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
icon: FileText,
|
||||
visible: (u) => isAdminOrAbove(u.role),
|
||||
},
|
||||
{
|
||||
href: "/admin/ads",
|
||||
label: "推广与赞助",
|
||||
icon: CircleDollarSign,
|
||||
visible: (u) => isAdminOrAbove(u.role),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -134,23 +135,17 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
icon: Award,
|
||||
visible: (u) => isSuperOrOwner(u.role),
|
||||
},
|
||||
{
|
||||
href: "/admin/ads",
|
||||
label: "推广与赞助",
|
||||
icon: CircleDollarSign,
|
||||
visible: (u) => isAdminOrAbove(u.role),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "站点",
|
||||
title: "外观展示",
|
||||
items: [
|
||||
{
|
||||
href: "/admin/boards",
|
||||
label: "板块管理",
|
||||
icon: Columns3,
|
||||
visible: (u) => isOwner(u.role),
|
||||
},
|
||||
{
|
||||
href: "/admin/settings",
|
||||
label: "站点设置",
|
||||
icon: Globe,
|
||||
visible: (u) => isSuperOrOwner(u.role),
|
||||
},
|
||||
{
|
||||
href: "/admin/appearance",
|
||||
label: "外观",
|
||||
@@ -163,6 +158,23 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
icon: PanelLeft,
|
||||
visible: (u) => isSuperOrOwner(u.role),
|
||||
},
|
||||
{
|
||||
href: "/admin/media",
|
||||
label: "媒体库",
|
||||
icon: Images,
|
||||
visible: (u) => isSuperOrOwner(u.role),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "系统",
|
||||
items: [
|
||||
{
|
||||
href: "/admin/settings",
|
||||
label: "站点设置",
|
||||
icon: Globe,
|
||||
visible: (u) => isSuperOrOwner(u.role),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -20,6 +20,8 @@ import type {
|
||||
CheckinStatus,
|
||||
} from "@/lib/api";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import LevelBadge from "@/components/LevelBadge";
|
||||
import BadgeChip from "@/components/BadgeChip";
|
||||
import { PulseCheckin } from "./CheckinCard";
|
||||
import { formatCompact } from "@/lib/format";
|
||||
|
||||
@@ -34,16 +36,21 @@ export function Moderators({ items }: { items: BoardModerator[] }) {
|
||||
{items.length === 0 ? (
|
||||
<p className="meta px-0.5 py-1.5">暂无板块管理员</p>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
<ul className="space-y-1.5">
|
||||
{items.map((u) => {
|
||||
const name = u.nickname || u.username;
|
||||
return (
|
||||
<li key={u.id}>
|
||||
<Link href={`/u/${u.id}`} className="j13-raillink" data-tip={name}>
|
||||
<Avatar name={name} src={u.avatar || undefined} size={30} />
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium" style={{ color: "var(--ink)" }}>
|
||||
<span className="min-w-0 truncate text-[13px] font-medium" style={{ color: "var(--ink)" }}>
|
||||
{name}
|
||||
</span>
|
||||
<LevelBadge level={u.level} className="shrink-0" />
|
||||
{u.badges?.slice(0, 2).map((ub) => (
|
||||
<BadgeChip key={ub.id} badge={ub.badge} className="shrink-0" />
|
||||
))}
|
||||
<span className="flex-1" />
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { PenSquare, Home, LayoutGrid, ShieldCheck } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Bookmark, Home, ShieldCheck } from "lucide-react";
|
||||
import type { BoardWithCount, User } from "@/lib/api";
|
||||
import { isStaff } from "@/lib/roles";
|
||||
import BoardIcon, { boardColor } from "@/components/BoardIcon";
|
||||
@@ -27,17 +28,8 @@ export function WorkspaceNavPanel({
|
||||
|
||||
return (
|
||||
<section className="j13-module flex max-h-[var(--rail-max-h)] flex-col overflow-hidden" aria-label="板块目录">
|
||||
<p className="j13-module-title shrink-0">
|
||||
<LayoutGrid size={15} style={{ color: "var(--accent)" }} aria-hidden />
|
||||
板块目录
|
||||
{boards.length > 0 ? (
|
||||
<span className="ml-auto meta text-[11.5px] font-medium tracking-normal tabular-nums">
|
||||
{boards.length} 个
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
|
||||
<div className="j13-rail-scroll j13-board-list mt-2.5 min-h-0 flex-1">
|
||||
<div className="j13-rail-scroll j13-board-list min-h-0 flex-1">
|
||||
<p className="j13-rail-group">浏览</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="j13-bcard"
|
||||
@@ -53,25 +45,41 @@ export function WorkspaceNavPanel({
|
||||
<span className="j13-bcard-name flex-1 truncate">全部讨论</span>
|
||||
</Link>
|
||||
|
||||
{boards.length > 0 ? <div className="j13-board-split" role="separator" /> : null}
|
||||
|
||||
{boards.map((b) => (
|
||||
<Link
|
||||
key={b.id}
|
||||
href={boardHref(b.id)}
|
||||
className="j13-bcard"
|
||||
aria-current={activeBoard === b.id ? "page" : undefined}
|
||||
aria-label={`${b.name}板块,${b.post_count} 篇讨论`}
|
||||
>
|
||||
<span className={`av-${boardColor(b)} j13-bcard-ico`} aria-hidden="true">
|
||||
<BoardIcon icon={b.icon} name={b.name} size={15} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 flex items-center justify-between gap-2">
|
||||
<span className="j13-bcard-name truncate">{b.name}</span>
|
||||
<span className="meta shrink-0 text-[11px] tabular-nums">{b.post_count} 篇</span>
|
||||
{user ? (
|
||||
<Link href={`/u/${user.id}?tab=favorites`} className="j13-bcard">
|
||||
<span
|
||||
className="j13-bcard-ico"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Bookmark size={15} />
|
||||
</span>
|
||||
<span className="j13-bcard-name flex-1 truncate">我的收藏</span>
|
||||
</Link>
|
||||
))}
|
||||
) : null}
|
||||
|
||||
{boards.length > 0 ? (
|
||||
<>
|
||||
<p className="j13-rail-group">板块</p>
|
||||
{boards.map((b) => (
|
||||
<Link
|
||||
key={b.id}
|
||||
href={boardHref(b.id)}
|
||||
className="j13-bcard"
|
||||
aria-current={activeBoard === b.id ? "page" : undefined}
|
||||
aria-label={`${b.name}板块,${b.post_count} 篇讨论`}
|
||||
>
|
||||
<span className={`av-${boardColor(b)} j13-bcard-ico`} aria-hidden="true">
|
||||
<BoardIcon icon={b.icon} name={b.name} size={15} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 flex items-center justify-between gap-2">
|
||||
<span className="j13-bcard-name truncate">{b.name}</span>
|
||||
<span className="meta shrink-0 text-[11px] tabular-nums">{b.post_count} 篇</span>
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{showAdmin ? (
|
||||
@@ -105,46 +113,77 @@ export function WorkspaceNav({ boards, user }: { boards: BoardWithCount[]; user?
|
||||
);
|
||||
}
|
||||
|
||||
// 移动端:发帖入口 + 横向滑动板块条
|
||||
export function WorkspaceNavMobile({
|
||||
boards,
|
||||
user,
|
||||
}: {
|
||||
boards: BoardWithCount[];
|
||||
user: User | null;
|
||||
}) {
|
||||
// 移动端:横向滑动板块条(两端渐隐指示「可左右滑动」);发帖入口由底部公共菜单承载
|
||||
export function WorkspaceNavMobile({ boards }: { boards: BoardWithCount[] }) {
|
||||
const params = useSearchParams();
|
||||
const activeBoard = Number(params.get("board")) || 0;
|
||||
const composePath =
|
||||
activeBoard > 0 ? `/compose?board=${activeBoard}` : "/compose";
|
||||
const composeHref = user
|
||||
? composePath
|
||||
: `/login?redirect=${encodeURIComponent(composePath)}`;
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
|
||||
const updateScrollState = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const max = el.scrollWidth - el.clientWidth;
|
||||
setCanScrollLeft(el.scrollLeft > 4);
|
||||
setCanScrollRight(max > 4 && el.scrollLeft < max - 4);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
updateScrollState();
|
||||
const el = scrollRef.current;
|
||||
if (!el || typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(updateScrollState);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [boards, updateScrollState]);
|
||||
|
||||
// 进页 / 切板块时把激活 chip 滚到可视区中央
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.querySelector<HTMLElement>('[aria-current="page"]')?.scrollIntoView({
|
||||
block: "nearest",
|
||||
inline: "center",
|
||||
});
|
||||
}, [activeBoard, boards]);
|
||||
|
||||
return (
|
||||
<div className="lg:hidden -mx-4 sm:-mx-6 px-4 sm:px-6">
|
||||
<div className="j13-chips">
|
||||
<Link href={composeHref} className="btn btn-primary btn-sm shrink-0">
|
||||
<PenSquare size={14} /> 发帖
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
className="j13-chip"
|
||||
aria-current={activeBoard === 0 ? "page" : undefined}
|
||||
<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}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
export type Crumb = { href?: string; label: string };
|
||||
|
||||
/** 位置指引:嵌在正文面板顶部,不单独成块 */
|
||||
export default function SiteDocBreadcrumb({
|
||||
items,
|
||||
className,
|
||||
}: {
|
||||
items: Crumb[];
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<nav
|
||||
className={`j13-page-crumb meta flex items-center gap-1 flex-wrap text-[12.5px] leading-none ${className ?? ""}`}
|
||||
aria-label="面包屑"
|
||||
>
|
||||
{items.map((item, i) => {
|
||||
const last = i === items.length - 1;
|
||||
const cls = last ? "truncate" : "shrink-0";
|
||||
return (
|
||||
<span key={`${item.label}-${i}`} className="inline-flex items-center gap-1 min-w-0">
|
||||
{i > 0 ? <ChevronRight size={12} className="shrink-0 opacity-70" aria-hidden /> : null}
|
||||
{item.href ? (
|
||||
<Link
|
||||
href={item.href}
|
||||
className={`hover:text-[var(--accent)] transition-colors ${cls}`}
|
||||
style={last ? { color: "var(--accent)" } : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className={cls} style={last ? { color: "var(--accent)" } : undefined}>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -8,14 +8,11 @@ export default function SiteDocShell({
|
||||
left,
|
||||
right,
|
||||
children,
|
||||
breadcrumb,
|
||||
mobileExtras,
|
||||
}: {
|
||||
left?: ReactNode;
|
||||
right?: ReactNode;
|
||||
children: ReactNode;
|
||||
/** 中栏正文上方的位置指引(流式排布,不再绝对定位) */
|
||||
breadcrumb?: ReactNode;
|
||||
/** 窄屏下沉到正文之后的侧栏副本 */
|
||||
mobileExtras?: ReactNode;
|
||||
}) {
|
||||
@@ -30,7 +27,6 @@ export default function SiteDocShell({
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0">
|
||||
{breadcrumb}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,6 +4,4 @@ export { default as AboutSiteHeader } from "./AboutSiteHeader";
|
||||
export { default as SiteDocPageNav } from "./SiteDocPageNav";
|
||||
export { default as SiteDocAnnoNav } from "./SiteDocAnnoNav";
|
||||
export { default as SiteDocMasthead } from "./SiteDocMasthead";
|
||||
export { default as SiteDocBreadcrumb } from "./SiteDocBreadcrumb";
|
||||
export type { Crumb } from "./SiteDocBreadcrumb";
|
||||
export type { SiteDocIdentityKind } from "./SiteDocIdentity";
|
||||
|
||||
Reference in New Issue
Block a user