Files
jiang13-forum/frontend/src/layouts/MainLayout.tsx

975 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback, useRef, useMemo, Suspense, startTransition } from 'react';
import { Outlet, useNavigate, useSearchParams, useLocation } from 'react-router-dom';
import { Menu, Moon, Sun, Search, Plus, PanelRight, X, Mail, SlidersHorizontal } from 'lucide-react';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { useAuth } from '../hooks/useAuth';
import { useTheme, useMediaQuery } from '../hooks/useTheme';
import { useOverlayA11y, moveTabIndex } from '../hooks/useOverlayA11y';
import { api } from '../api/client';
import type { Board, RecentComment, RecentUser, ForumStats, TagCount, User } from '../api/types';
import type { PostHeading } from '../utils/postHeadings';
import { getCachedBoards, getCachedStats, getCachedRecentComments, getCachedRecentUsers, getCachedTags, hasCachedAside, setCachedBoards, setCachedStats, setCachedRecentComments, setCachedRecentUsers, setCachedTags } from '../utils/layoutCache';
import Sidebar, { isNeutralSidebarRoute } from '../components/Sidebar';
import RightPanel from '../components/RightPanel';
import BackToTop from '../components/BackToTop';
import { useForumLimits } from '../hooks/useForumLimits';
import { resolveAsideWidgets } from '../utils/asideWidgets';
import { buildHomeUrl, parseFeedSort } from '../components/FeedSortBar';
import { navigateFeed, PAGE_FORCE_REFRESH_EVENT } from '../utils/feedCache';
import { PAGE_SOFT_REFRESH_COMMIT_EVENT } from '../utils/softRefresh';
import { prefetchRoute, wasColdBootEnsured } from '../utils/prefetchRoute';
import {
transitionTo,
} from '../utils/spaTransition';
import PostSearchPanel from '../components/search/PostSearchPanel';
import {
POST_SEARCH_OPEN_EVENT,
usePostSearch,
} from '../hooks/usePostSearch';
import { cn } from '@/lib/utils';
import { getBoardThemeIndex } from '../utils/boardTheme';
import { loginPath } from '../utils/authRedirect';
import { openForumPost } from '../utils/openPost';
import { refetchSiteBranding, useSiteBranding } from '../hooks/useSiteBranding';
import { useMonitorPageview } from '../hooks/useMonitorPageview';
import SiteBrandMark from '../components/SiteBrandMark';
import SiteFooter from '../components/SiteFooter';
import { userPath } from '../utils/userPath';
import { parsePermalinkID } from '../utils/permalink';
import { ensureSitePagesLoaded } from '../hooks/useSitePages';
import { endHomeHydrate, isHomeHydrating } from '../utils/homeHydrate';
import { getBootUnread } from '../utils/authBoot';
export default function MainLayout() {
const { user, loading: authLoading, logout } = useAuth();
const { theme, toggle } = useTheme();
const { branding } = useSiteBranding();
useMonitorPageview();
const mqMobile = useMediaQuery('(max-width: 768px)');
const hideAside = useMediaQuery('(max-width: 1100px)');
/** hydrate 首帧强制桌面布局SSR 为桌面三栏),随后再跟 matchMedia */
const [forceDesktop, setForceDesktop] = useState(() => isHomeHydrating());
const isMobile = forceDesktop ? false : mqMobile;
const nav = useNavigate();
const loc = useLocation();
const [params] = useSearchParams();
const isCompose = loc.pathname.startsWith('/compose') || /\/post\/\d+\/edit$/.test(loc.pathname);
useEffect(() => {
if (!forceDesktop) return;
endHomeHydrate();
startTransition(() => setForceDesktop(false));
}, [forceDesktop]);
const [boards, setBoards] = useState<Board[]>(() => getCachedBoards());
const [stats, setStats] = useState<ForumStats | null>(() => getCachedStats());
const [recentComments, setRecentComments] = useState<RecentComment[]>(() => getCachedRecentComments());
const [recentUsers, setRecentUsers] = useState<RecentUser[]>(() => getCachedRecentUsers());
const [unreadMessages, setUnreadMessages] = useState(() => getBootUnread());
const [tags, setTags] = useState<TagCount[]>(() => getCachedTags());
const [tagsLoading, setTagsLoading] = useState(() => getCachedTags().length === 0);
const [postOutline, setPostOutline] = useState<{
headings: PostHeading[];
scrollRoot: HTMLElement | null;
title?: string;
author?: User | null;
publishedAt?: string;
viewCount?: number;
} | null>(null);
const [asideOpen, setAsideOpen] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [searchPanelOpen, setSearchPanelOpen] = useState(false);
const searchInputRef = useRef<HTMLInputElement>(null);
const [asideLoading, setAsideLoading] = useState(() => !hasCachedAside());
const [boardsLoading, setBoardsLoading] = useState(() => getCachedBoards().length === 0);
const [layoutRefreshTick, setLayoutRefreshTick] = useState(0);
const asideEverLoaded = useRef(false);
/** 冷启动门闩main.tsx 已预热则可同步放行;否则等齐再呈现 */
const [shellReady, setShellReady] = useState(() => isCompose || wasColdBootEnsured());
const coldBootDone = useRef(isCompose || wasColdBootEnsured());
const bootGen = useRef(0);
const [boardId, setBoardId] = useState(() => {
const m = loc.pathname.match(/^\/board\/(\d+(?:\.[A-Za-z0-9]{1,16})?)$/);
if (m) return parsePermalinkID(m[1]) || 0;
return Number(params.get('board')) || 0;
});
const [keywordDraft, setKeywordDraft] = useState(params.get('keyword') || '');
const { limits: forumLimits } = useForumLimits();
const feedSort = parseFeedSort(params.get('sort'), forumLimits.feed_sort_tabs);
const postSearch = usePostSearch(forumLimits);
const asideWidgets = useMemo(() => resolveAsideWidgets(forumLimits), [forumLimits]);
const showTagCloud = asideWidgets.some(w => w.id === 'tag_cloud' && w.enabled);
const showRecentComments = asideWidgets.some(w => w.id === 'recent_comments' && w.enabled);
const showRecentUsers = asideWidgets.some(w => w.id === 'recent_users' && w.enabled);
const asideDrawerRef = useRef<HTMLElement>(null);
const asideCloseRef = useRef<HTMLButtonElement>(null);
const sidebarDrawerRef = useRef<HTMLElement>(null);
const sidebarCloseRef = useRef<HTMLButtonElement>(null);
const boardBarRef = useRef<HTMLDivElement>(null);
const closeAside = useCallback(() => setAsideOpen(false), []);
const closeSidebar = useCallback(() => setSidebarOpen(false), []);
const openAside = useCallback(() => {
setSidebarOpen(false);
setAsideOpen(true);
}, []);
const openSidebar = useCallback(() => {
setAsideOpen(false);
setSidebarOpen(true);
}, []);
useOverlayA11y(asideOpen && hideAside && !isCompose, closeAside, asideDrawerRef, {
initialFocusRef: asideCloseRef,
});
useOverlayA11y(sidebarOpen && isMobile && !isCompose, closeSidebar, sidebarDrawerRef, {
initialFocusRef: sidebarCloseRef,
});
useEffect(() => {
const m = loc.pathname.match(/^\/board\/(\d+(?:\.[A-Za-z0-9]{1,16})?)$/);
if (m) {
setBoardId(parsePermalinkID(m[1]) || 0);
return;
}
setBoardId(Number(params.get('board')) || 0);
}, [loc.pathname, params]);
useEffect(() => {
setKeywordDraft(params.get('keyword') || '');
}, [params]);
useEffect(() => {
setAsideOpen(false);
setSidebarOpen(false);
}, [loc.pathname, loc.search]);
useEffect(() => {
if (!/^\/post\/\d+/.test(loc.pathname)) setPostOutline(null);
}, [loc.pathname]);
useEffect(() => {
if (!hideAside) setAsideOpen(false);
}, [hideAside]);
useEffect(() => {
if (!isMobile) setSidebarOpen(false);
}, [isMobile]);
const openSearchPanel = useCallback(() => setSearchPanelOpen(true), []);
useEffect(() => {
const onOpen = () => setSearchPanelOpen(true);
window.addEventListener(POST_SEARCH_OPEN_EVENT, onOpen);
return () => window.removeEventListener(POST_SEARCH_OPEN_EVENT, onOpen);
}, []);
useEffect(() => {
if (isCompose) return;
const onKey = (e: KeyboardEvent) => {
if (!(e.ctrlKey || e.metaKey) || e.key.toLowerCase() !== 'k') return;
const tag = (e.target as HTMLElement | null)?.tagName;
const editable = (e.target as HTMLElement | null)?.isContentEditable;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || editable) return;
e.preventDefault();
if (isMobile) {
setSearchPanelOpen(true);
} else {
searchInputRef.current?.focus();
searchInputRef.current?.select();
}
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [isCompose, isMobile]);
useEffect(() => {
if (!asideOpen && !sidebarOpen) return;
const prev = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = prev; };
}, [asideOpen, sidebarOpen]);
const refreshBoards = useCallback(() => {
return Promise.all([
api.boards().then(d => {
const next = d.boards ?? [];
setBoards(next);
setCachedBoards(next);
return next;
}).catch(() => [] as Board[]),
api.stats().then(next => {
setStats(next);
setCachedStats(next);
return next;
}).catch(() => null),
]).finally(() => {
setBoardsLoading(false);
});
}, []);
useEffect(() => {
refreshBoards();
const onRefresh = () => refreshBoards();
window.addEventListener('boards-refresh', onRefresh);
return () => window.removeEventListener('boards-refresh', onRefresh);
}, [refreshBoards]);
// 冷启动兜底main 未预热时静默等齐(不打进度条);站内已预热则保持打开
useEffect(() => {
if (isCompose) {
coldBootDone.current = true;
setShellReady(true);
return;
}
if (coldBootDone.current || wasColdBootEnsured()) {
coldBootDone.current = true;
setShellReady(true);
setBoardsLoading(false);
setAsideLoading(false);
setTagsLoading(false);
asideEverLoaded.current = true;
return;
}
const gen = ++bootGen.current;
let cancelled = false;
(async () => {
try {
const path = `${loc.pathname}${loc.search}`;
const tasks: Promise<unknown>[] = [
prefetchRoute(path, { force: false }),
refreshBoards(),
ensureSitePagesLoaded(),
];
if (!hideAside) {
if (showRecentComments) {
tasks.push(
api.recentComments().then((d) => {
const next = Array.isArray(d.comments) ? d.comments : [];
setRecentComments(next);
setCachedRecentComments(next);
}).catch(() => undefined),
);
}
if (showRecentUsers) {
tasks.push(
api.recentUsers().then((d) => {
const next = Array.isArray(d.users) ? d.users : [];
setRecentUsers(next);
setCachedRecentUsers(next);
}).catch(() => undefined),
);
}
if (showTagCloud) {
tasks.push(
api.tags(40).then((d) => {
const next = Array.isArray(d.tags) ? d.tags : [];
setTags(next);
setCachedTags(next);
}).catch(() => undefined),
);
}
}
await Promise.all(tasks);
} catch {
// 失败也放行
} finally {
if (!cancelled && bootGen.current === gen) {
asideEverLoaded.current = true;
setAsideLoading(false);
setTagsLoading(false);
setBoardsLoading(false);
coldBootDone.current = true;
setShellReady(true);
}
}
})();
return () => {
cancelled = true;
};
}, [
isCompose,
loc.pathname,
loc.search,
hideAside,
showRecentComments,
showRecentUsers,
showTagCloud,
refreshBoards,
]);
const refreshUnreadMessages = useCallback(() => {
if (!user) {
setUnreadMessages(0);
return;
}
api.messageUnreadCount()
.then((r) => setUnreadMessages(r.count || 0))
.catch(() => setUnreadMessages(0));
}, [user]);
useEffect(() => {
refreshUnreadMessages();
const onRefresh = () => refreshUnreadMessages();
window.addEventListener('messages-unread-refresh', onRefresh);
const timer = window.setInterval(refreshUnreadMessages, 60_000);
return () => {
window.removeEventListener('messages-unread-refresh', onRefresh);
window.clearInterval(timer);
};
}, [refreshUnreadMessages]);
useEffect(() => {
const syncFromCache = () => {
// 仅当 cache 有内容时覆盖,避免空数组盖住已渲染的非空 UI
const nextBoards = getCachedBoards();
if (nextBoards.length > 0) setBoards(nextBoards);
setBoardsLoading(false);
const nextStats = getCachedStats();
if (nextStats) setStats(nextStats);
const nextComments = getCachedRecentComments();
if (nextComments.length > 0 || hasCachedAside()) setRecentComments(nextComments);
const nextUsers = getCachedRecentUsers();
if (nextUsers.length > 0 || hasCachedAside()) setRecentUsers(nextUsers);
const nextTags = getCachedTags();
if (nextTags.length > 0) setTags(nextTags);
setTagsLoading(false);
setAsideLoading(false);
asideEverLoaded.current = true;
refreshUnreadMessages();
};
const onForce = () => {
// 旧路径:仍可能被别处派发;尽量静默刷新壳层
refreshBoards();
refreshUnreadMessages();
refetchSiteBranding();
setLayoutRefreshTick(n => n + 1);
};
const onCommit = () => {
// 软刷新:预热已写入 cache同一拍同步进 state不触发分批 loading
syncFromCache();
};
window.addEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
window.addEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit);
return () => {
window.removeEventListener(PAGE_FORCE_REFRESH_EVENT, onForce);
window.removeEventListener(PAGE_SOFT_REFRESH_COMMIT_EVENT, onCommit);
};
}, [refreshBoards, refreshUnreadMessages]);
// 标签云:进页/离开发帖页时拉取;不跟 posts-refresh 联动(置顶/推荐等不改标签)
useEffect(() => {
if (isCompose || !showTagCloud) return;
let cancelled = false;
// 有 session 缓存则静默刷新(软刷新不卸空白)
if (getCachedTags().length === 0) setTagsLoading(true);
api.tags(40).then(d => {
if (cancelled) return;
const next = Array.isArray(d.tags) ? d.tags : [];
setTags(next);
setCachedTags(next);
}).catch(() => {}).finally(() => {
if (!cancelled) setTagsLoading(false);
});
return () => {
cancelled = true;
};
}, [isCompose, showTagCloud, layoutRefreshTick]);
const needAsideData = !isCompose && (!hideAside || asideOpen);
const needRecentComments = needAsideData && showRecentComments;
useEffect(() => {
if (!needRecentComments) return;
let cancelled = false;
// 无缓存时才显示加载态,有缓存则静默刷新,避免抽屉高度跳动
if (!asideEverLoaded.current && !hasCachedAside()) {
setAsideLoading(true);
}
api.recentComments().then(d => {
if (cancelled) return;
const next = Array.isArray(d.comments) ? d.comments : [];
setRecentComments(next);
setCachedRecentComments(next);
}).catch(() => {}).finally(() => {
if (!cancelled) {
asideEverLoaded.current = true;
setAsideLoading(false);
}
});
return () => {
cancelled = true;
};
}, [needRecentComments, layoutRefreshTick]);
const needRecentUsers = needAsideData && showRecentUsers;
useEffect(() => {
if (!needRecentUsers) return;
let cancelled = false;
if (!asideEverLoaded.current && !hasCachedAside()) {
setAsideLoading(true);
}
api.recentUsers().then(d => {
if (cancelled) return;
const next = Array.isArray(d.users) ? d.users : [];
setRecentUsers(next);
setCachedRecentUsers(next);
}).catch(() => {}).finally(() => {
if (!cancelled) {
asideEverLoaded.current = true;
setAsideLoading(false);
}
});
return () => {
cancelled = true;
};
}, [needRecentUsers, layoutRefreshTick]);
const doQuickSearch = () => {
const { author, titleOnly, scopeBoardId } = postSearch.filters;
postSearch.submitSearch({
keyword: keywordDraft,
author,
titleOnly,
scopeBoardId,
}, { refreshIfSame: true });
};
const handleHeaderClear = () => {
const hasUrlSearch = postSearch.filters.isFiltered;
setKeywordDraft('');
if (hasUrlSearch) postSearch.clearSearch();
};
const contextBoard = boards.find((b) => b.id === boardId);
const searchPanelDraft = {
keyword: keywordDraft,
author: postSearch.filters.author,
titleOnly: postSearch.filters.titleOnly,
scopeBoardId: postSearch.filters.scopeBoardId,
};
const openPost = useCallback((id: number, opts?: { floor?: number }) => {
setAsideOpen(false);
openForumPost(nav, id, forumLimits.open_posts_in_new_tab, opts);
}, [nav, forumLimits.open_posts_in_new_tab]);
const userInitial = user?.nickname?.charAt(0) || '?';
const isFeedHome = loc.pathname === '/' || /^\/board\/\d+/.test(loc.pathname);
const outletKeyword = params.get('keyword') || '';
const outletTag = params.get('tag') || '';
const outletAuthor = params.get('author') || '';
// 搜索/标签结果页不选中任何板块芯片(避免看起来仍停在「全部」)
const mobileActiveBoard =
isNeutralSidebarRoute(loc.pathname) || !!outletKeyword || !!outletTag || !!outletAuthor
? -1
: boardId;
const boardChipIds = useMemo(() => [0, ...boards.map(b => b.id)], [boards]);
const activeChipIndex = Math.max(0, boardChipIds.indexOf(mobileActiveBoard === -1 ? 0 : mobileActiveBoard));
const isPostDetail = /^\/post\/\d+/.test(loc.pathname) && !/\/edit$/.test(loc.pathname);
const setPostOutlineSafe = useCallback((outline: {
headings: PostHeading[];
scrollRoot: HTMLElement | null;
title?: string;
author?: User | null;
publishedAt?: string;
viewCount?: number;
} | null) => {
setPostOutline(outline);
}, []);
const layoutCtx = useMemo<LayoutCtx>(() => ({
boardId,
keyword: outletKeyword,
setBoardId,
boards,
boardsLoading,
stats,
refreshBoards,
isMobile,
setPostOutline: setPostOutlineSafe,
}), [boardId, outletKeyword, boards, boardsLoading, stats, refreshBoards, isMobile, setPostOutlineSafe]);
const selectBoardChip = (id: number) => {
setBoardId(id);
navigateFeed(nav, buildHomeUrl(id, feedSort, { permalink: forumLimits }));
};
const onBoardBarKeyDown = (e: React.KeyboardEvent) => {
const next = moveTabIndex(e.key, activeChipIndex, boardChipIds.length);
if (next == null) return;
e.preventDefault();
selectBoardChip(boardChipIds[next]);
requestAnimationFrame(() => {
const tabs = boardBarRef.current?.querySelectorAll<HTMLElement>('[role="tab"]');
tabs?.[next]?.focus();
});
};
return (
<div className={cn('app-shell', forceDesktop && 'ssr-home')}>
<div className="app-frame">
<header className="app-header">
<div className="header-inner">
{!isCompose && (
<button
type="button"
className="header-icon-btn header-menu-btn"
onClick={openSidebar}
aria-label="打开导航菜单"
aria-expanded={sidebarOpen}
aria-controls="sidebar-drawer"
title="导航"
>
<Menu size={18} aria-hidden />
</button>
)}
{/* 任意页点 Logo回首页并强制刷新不展示会话缓存 */}
<button type="button" className="header-brand" onClick={() => navigateFeed(nav, '/', { refresh: true })}>
<SiteBrandMark branding={branding} className="header-logo-mark" />
<span className="header-logo-text">{branding.name}</span>
</button>
{!isCompose && isMobile && (
<button
type="button"
className={cn(
'header-search-mobile-pill',
(keywordDraft || postSearch.filters.isFiltered) && 'header-search-mobile-pill--active',
)}
onClick={openSearchPanel}
aria-label="搜索帖子"
>
<Search size={15} aria-hidden className="header-search-mobile-pill__icon" />
<span className="header-search-mobile-pill__text">
{keywordDraft
|| postSearch.filters.keyword
|| postSearch.filters.author
|| '搜索帖子…'}
</span>
{postSearch.filters.hasAdvancedFilters && (
<span className="header-search-mobile-pill__dot" aria-hidden />
)}
</button>
)}
{!isCompose && !isMobile && (
<form
className="header-search-wrap"
role="search"
onSubmit={e => {
e.preventDefault();
doQuickSearch();
}}
>
<div className="header-search-row">
<Search className="header-search-icon" size={16} aria-hidden />
<input
ref={searchInputRef}
className="header-search-input"
type="search"
placeholder="搜索帖子…"
aria-label="搜索帖子"
value={keywordDraft}
onChange={e => setKeywordDraft(e.target.value)}
maxLength={forumLimits.search_keyword_max > 0 ? forumLimits.search_keyword_max : undefined}
enterKeyHint="search"
/>
<button
type="button"
className={cn(
'header-search-filter-btn',
postSearch.filters.hasAdvancedFilters && 'header-search-filter-btn--active',
)}
onClick={openSearchPanel}
aria-label="搜索筛选"
title="筛选"
>
<SlidersHorizontal size={15} aria-hidden />
{postSearch.filters.hasAdvancedFilters && (
<span className="header-search-filter-btn__dot" aria-hidden />
)}
</button>
{(keywordDraft || postSearch.filters.isFiltered) && (
<button
type="button"
className="header-search-clear"
onClick={handleHeaderClear}
aria-label="清除搜索"
>×</button>
)}
<kbd className="header-search-kbd" aria-hidden>Ctrl K</kbd>
</div>
</form>
)}
<div className="header-actions">
{!isCompose && (
<button
type="button"
className="header-compose-btn"
onClick={() => user ? void transitionTo(nav, '/compose') : nav(loginPath('/compose'))}
aria-label="发帖"
>
<Plus size={16} aria-hidden />
<span className="header-compose-btn__label"></span>
</button>
)}
<div className="header-action-group">
{/* 平板:侧栏收起时用按钮打开社区动态;手机改由导航抽屉入口 */}
{!isCompose && hideAside && !isMobile && (
<button
type="button"
className="header-icon-btn"
onClick={openAside}
aria-label={isPostDetail ? '打开作者与目录' : '打开社区动态'}
aria-expanded={asideOpen}
aria-controls="aside-drawer"
title={isPostDetail ? '作者与目录' : '社区动态'}
>
<PanelRight size={18} aria-hidden />
</button>
)}
<button
type="button"
className="header-icon-btn header-theme-btn"
onClick={toggle}
aria-label={theme === 'light' ? '切换暗色模式' : '切换亮色模式'}
title={theme === 'light' ? '切换暗色模式' : '切换亮色模式'}
>
{theme === 'light' ? <Moon size={18} aria-hidden /> : <Sun size={18} aria-hidden />}
</button>
{authLoading ? (
<span className="header-auth-slot header-auth-slot--loading" aria-hidden />
) : user ? (
<>
<button
type="button"
className="header-icon-btn header-msg-btn"
title={unreadMessages > 0 ? `${unreadMessages} 条未读消息` : '站内消息'}
aria-label={unreadMessages > 0 ? `站内消息,${unreadMessages} 条未读` : '站内消息'}
onClick={() => void transitionTo(nav, '/messages')}
>
<Mail size={18} aria-hidden />
{unreadMessages > 0 && (
<span className="header-msg-badge">{unreadMessages > 99 ? '99+' : unreadMessages}</span>
)}
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className="header-user-btn" title={user.nickname} aria-label={`用户菜单:${user.nickname}`}>
{user.avatar
? <img src={user.avatar} alt="" className="header-user-avatar" loading="lazy" decoding="async" />
: <span className="header-user-initial">{userInitial}</span>}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-40"
onCloseAutoFocus={(e) => e.preventDefault()}
>
<DropdownMenuItem onClick={() => void transitionTo(nav, userPath(user.id))}></DropdownMenuItem>
<DropdownMenuItem onClick={() => void transitionTo(nav, '/profile')}>
{typeof user.points === 'number' ? ` · ${user.points} 积分` : ''}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void transitionTo(nav, '/messages')}>
{unreadMessages > 0 ? ` (${unreadMessages})` : ''}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => void transitionTo(nav, '/favorites')}></DropdownMenuItem>
{isMobile && (
<DropdownMenuItem onClick={toggle}>
{theme === 'light' ? '切换暗色模式' : '切换亮色模式'}
</DropdownMenuItem>
)}
{user.role === 'admin' && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => nav('/admin/dashboard')}></DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => logout().then(() => nav('/login'))}>
退
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
) : (
<button type="button" className="header-login-btn" onClick={() => nav(loginPath())}>
</button>
)}
</div>
</div>
</div>
</header>
{!isCompose && (
<PostSearchPanel
open={searchPanelOpen}
onOpenChange={setSearchPanelOpen}
draft={searchPanelDraft}
contextBoardId={boardId}
contextBoardName={contextBoard?.name}
onSubmit={(input) => {
const ok = postSearch.submitSearch(input);
if (ok) setKeywordDraft((input.keyword ?? '').trim());
return ok;
}}
onClear={postSearch.clearSearch}
/>
)}
<div className={`app-body${isCompose ? ' app-body--compose' : ''}`}>
{!isCompose && shellReady && (
<Sidebar
boards={boards}
activeBoard={boardId}
onSelectBoard={setBoardId}
boardsLoading={boardsLoading}
/>
)}
<div className={cn(
'content-workspace',
isCompose && 'content-workspace--compose',
hideAside && !isCompose && 'content-workspace--aside-hidden',
)}>
<main
className={cn(
'main-content',
isCompose && 'main-content--compose',
// 手机 Feed整栏滚动板块条 / 排序栏可滚出视口,多露出帖子列表
isMobile && !isCompose && isFeedHome && 'main-content--feed-mobile-scroll',
)}
aria-busy={!shellReady}
>
{shellReady && isMobile && !isCompose && isFeedHome && (
<div
ref={boardBarRef}
className="mobile-board-bar"
role="tablist"
aria-label="板块"
onKeyDown={onBoardBarKeyDown}
>
<button
type="button"
role="tab"
tabIndex={activeChipIndex === 0 ? 0 : -1}
aria-selected={mobileActiveBoard === 0}
className={`board-chip ${mobileActiveBoard === 0 ? 'active' : ''}`}
onClick={() => selectBoardChip(0)}
></button>
{boards.map((b, i) => {
const themeIdx = getBoardThemeIndex(b);
const isActive = mobileActiveBoard === b.id;
const idx = i + 1;
return (
<button
key={b.id}
type="button"
role="tab"
tabIndex={activeChipIndex === idx ? 0 : -1}
aria-selected={isActive}
className={cn(
'board-chip',
isActive && 'active',
isActive && `board-chip--${themeIdx}`,
)}
onClick={() => selectBoardChip(b.id)}
>{b.name}</button>
);
})}
</div>
)}
{shellReady ? (
<Suspense fallback={null}>
<Outlet context={layoutCtx} />
</Suspense>
) : null}
</main>
{!isCompose && (
<aside className="aside-panel" aria-busy={!shellReady}>
{shellReady && (
<RightPanel
recentComments={recentComments}
recentUsers={recentUsers}
tags={tags}
tagsLoading={tagsLoading}
stats={stats}
loading={asideLoading}
asideWidgets={asideWidgets}
onPostClick={openPost}
postDetail={isPostDetail ? {
author: postOutline?.author ?? null,
publishedAt: postOutline?.publishedAt,
viewCount: postOutline?.viewCount,
headings: postOutline?.headings ?? [],
scrollRoot: postOutline?.scrollRoot ?? null,
outlineTitle: postOutline?.title,
} : null}
/>
)}
</aside>
)}
</div>
</div>
{/* 桌面壳层贴底;手机端由各页 InFlowSiteFooter 随内容滚动 */}
{!isCompose && !isMobile && <SiteFooter />}
</div>
{sidebarOpen && isMobile && !isCompose && (
<div className="sidebar-drawer-root">
<button
type="button"
className="aside-drawer-backdrop"
aria-label="关闭导航菜单"
tabIndex={-1}
onClick={closeSidebar}
/>
<aside
id="sidebar-drawer"
ref={sidebarDrawerRef}
className="sidebar-drawer"
role="dialog"
aria-modal="true"
aria-label="导航菜单"
>
<div className="aside-drawer-head">
<span></span>
<button
ref={sidebarCloseRef}
type="button"
className="header-icon-btn"
aria-label="关闭"
onClick={closeSidebar}
>
<X size={18} aria-hidden />
</button>
</div>
<div className="aside-drawer-body sidebar-drawer-body">
{shellReady && (
<Sidebar
boards={boards}
activeBoard={boardId}
onSelectBoard={setBoardId}
boardsLoading={boardsLoading}
/>
)}
<div className="sidebar-drawer-extras">
<button
type="button"
className="sidebar-drawer-extra-btn"
onClick={() => { closeSidebar(); openAside(); }}
>
<PanelRight size={16} aria-hidden />
{isPostDetail ? '作者与目录' : '社区动态'}
</button>
<button
type="button"
className="sidebar-drawer-extra-btn"
onClick={toggle}
>
{theme === 'light' ? <Moon size={16} aria-hidden /> : <Sun size={16} aria-hidden />}
{theme === 'light' ? '切换暗色模式' : '切换亮色模式'}
</button>
</div>
</div>
</aside>
</div>
)}
{asideOpen && hideAside && !isCompose && (
<div className="aside-drawer-root">
<button
type="button"
className="aside-drawer-backdrop"
aria-label="关闭社区动态"
tabIndex={-1}
onClick={closeAside}
/>
<aside
id="aside-drawer"
ref={asideDrawerRef}
className="aside-drawer"
role="dialog"
aria-modal="true"
aria-label={isPostDetail ? '作者与目录' : '社区动态'}
>
<div className="aside-drawer-head">
<span>{isPostDetail ? '作者与目录' : '社区动态'}</span>
<button
ref={asideCloseRef}
type="button"
className="header-icon-btn"
aria-label="关闭"
onClick={closeAside}
>
<X size={18} aria-hidden />
</button>
</div>
<div className="aside-drawer-body">
<RightPanel
recentComments={recentComments}
recentUsers={recentUsers}
tags={tags}
tagsLoading={tagsLoading}
stats={stats}
loading={asideLoading}
asideWidgets={asideWidgets}
onPostClick={openPost}
postDetail={isPostDetail ? {
author: postOutline?.author ?? null,
publishedAt: postOutline?.publishedAt,
viewCount: postOutline?.viewCount,
headings: postOutline?.headings ?? [],
scrollRoot: postOutline?.scrollRoot ?? null,
outlineTitle: postOutline?.title,
} : null}
/>
</div>
</aside>
</div>
)}
<BackToTop />
</div>
);
}
export type LayoutCtx = {
boardId: number;
keyword: string;
setBoardId: (id: number) => void;
boards: Board[];
boardsLoading: boolean;
stats: ForumStats | null;
refreshBoards: () => void;
isMobile: boolean;
/** 详情页上报作者与目录,供右侧栏展示 */
setPostOutline: (outline: {
headings: PostHeading[];
scrollRoot: HTMLElement | null;
title?: string;
author?: User | null;
publishedAt?: string;
viewCount?: number;
} | null) => void;
};