初始提交:姜十三论坛 Jiang13 Forum
轻量自用论坛,Go 单二进制 + React SPA 内嵌 + SQLite。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
251
frontend/src/layouts/MainLayout.tsx
Normal file
251
frontend/src/layouts/MainLayout.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import { useState, useEffect, useCallback, Suspense } from 'react';
|
||||
import PageLoader from '../components/PageLoader';
|
||||
import { Outlet, useNavigate, useSearchParams, useLocation } from 'react-router-dom';
|
||||
import { Moon, Sun, Search, Plus } from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { openAdminDashboard } from '../utils/admin';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { useTheme, useMediaQuery } from '../hooks/useTheme';
|
||||
import { api } from '../api/client';
|
||||
import type { Board, PostItem, Notification, OnlineStats, ForumStats } from '../api/types';
|
||||
import { getCachedBoards, getCachedStats, setCachedBoards, setCachedStats } from '../utils/layoutCache';
|
||||
import Sidebar, { isNeutralSidebarRoute } from '../components/Sidebar';
|
||||
import RightPanel from '../components/RightPanel';
|
||||
|
||||
export default function MainLayout() {
|
||||
const { user, loading: authLoading, logout } = useAuth();
|
||||
const { theme, toggle } = useTheme();
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
const nav = useNavigate();
|
||||
const loc = useLocation();
|
||||
const [params] = useSearchParams();
|
||||
const isCompose = loc.pathname.startsWith('/compose') || /\/post\/\d+\/edit$/.test(loc.pathname);
|
||||
|
||||
const [boards, setBoards] = useState<Board[]>(() => getCachedBoards());
|
||||
const [stats, setStats] = useState<ForumStats | null>(() => getCachedStats());
|
||||
const [layoutReady, setLayoutReady] = useState(() => getCachedBoards().length > 0 || !!getCachedStats());
|
||||
const [hot, setHot] = useState<PostItem[]>([]);
|
||||
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||
const [online, setOnline] = useState<OnlineStats | null>(null);
|
||||
const [boardId, setBoardId] = useState(Number(params.get('board')) || 0);
|
||||
const [keyword, setKeyword] = useState(params.get('keyword') || '');
|
||||
|
||||
useEffect(() => { setBoardId(Number(params.get('board')) || 0); }, [params]);
|
||||
useEffect(() => { setKeyword(params.get('keyword') || ''); }, [params]);
|
||||
|
||||
const refreshBoards = useCallback(() => {
|
||||
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(() => setLayoutReady(true));
|
||||
}, []);
|
||||
|
||||
const refreshOnline = useCallback(() => {
|
||||
api.online().then(d => {
|
||||
setOnline({
|
||||
count: d.count ?? 0,
|
||||
members: d.members ?? 0,
|
||||
guests: d.guests ?? 0,
|
||||
users: Array.isArray(d.users) ? d.users : [],
|
||||
});
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refreshBoards();
|
||||
api.hotPosts().then(d => setHot(Array.isArray(d.posts) ? d.posts : [])).catch(() => {});
|
||||
api.notifications().then(d => setNotifications(Array.isArray(d.notifications) ? d.notifications : [])).catch(() => {});
|
||||
refreshOnline();
|
||||
api.presence().catch(() => {});
|
||||
const onlineTimer = setInterval(refreshOnline, 30000);
|
||||
const presenceTimer = setInterval(() => api.presence().catch(() => {}), 60000);
|
||||
const onRefresh = () => refreshBoards();
|
||||
window.addEventListener('boards-refresh', onRefresh);
|
||||
return () => {
|
||||
clearInterval(onlineTimer);
|
||||
clearInterval(presenceTimer);
|
||||
window.removeEventListener('boards-refresh', onRefresh);
|
||||
};
|
||||
}, [refreshBoards, refreshOnline]);
|
||||
|
||||
const doSearch = () => {
|
||||
if (keyword.trim()) nav(`/?keyword=${encodeURIComponent(keyword.trim())}`);
|
||||
else nav('/');
|
||||
};
|
||||
|
||||
const userInitial = user?.nickname?.charAt(0) || '?';
|
||||
const mobileActiveBoard = isNeutralSidebarRoute(loc.pathname) ? -1 : boardId;
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<div className="app-frame">
|
||||
<header className="app-header">
|
||||
<div className="header-inner">
|
||||
<button type="button" className="header-brand" onClick={() => nav('/')}>
|
||||
<span className="header-logo-mark">姜</span>
|
||||
{!isMobile && <span className="header-logo-text">姜十三论坛</span>}
|
||||
</button>
|
||||
|
||||
{!isCompose && (
|
||||
<div className="header-search-wrap">
|
||||
<Search className="header-search-icon" size={16} />
|
||||
<input
|
||||
className="header-search-input"
|
||||
type="search"
|
||||
placeholder="搜索帖子..."
|
||||
value={keyword}
|
||||
onChange={e => setKeyword(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && doSearch()}
|
||||
/>
|
||||
{keyword && (
|
||||
<button
|
||||
type="button"
|
||||
className="header-search-clear"
|
||||
onClick={() => { setKeyword(''); nav('/'); }}
|
||||
aria-label="清除搜索"
|
||||
>×</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="header-actions">
|
||||
{!isCompose && (
|
||||
<button
|
||||
type="button"
|
||||
className="header-compose-btn"
|
||||
onClick={() => user ? nav('/compose') : nav('/login')}
|
||||
>
|
||||
<Plus size={16} />
|
||||
{!isMobile && <span>发帖</span>}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="header-action-group">
|
||||
<button
|
||||
type="button"
|
||||
className="header-icon-btn"
|
||||
onClick={toggle}
|
||||
title={theme === 'light' ? '切换暗色模式' : '切换亮色模式'}
|
||||
>
|
||||
{theme === 'light' ? <Moon size={18} /> : <Sun size={18} />}
|
||||
</button>
|
||||
|
||||
{authLoading ? (
|
||||
<span className="header-auth-slot header-auth-slot--loading" aria-hidden />
|
||||
) : user ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button type="button" className="header-user-btn" title={user.nickname}>
|
||||
{user.avatar
|
||||
? <img src={user.avatar} alt="" className="header-user-avatar" />
|
||||
: <span className="header-user-initial">{userInitial}</span>}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuItem onClick={() => nav('/profile')}>个人中心</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => nav('/favorites')}>我的收藏</DropdownMenuItem>
|
||||
{user.role === 'admin' && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => nav('/boards')}>管理板块</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={openAdminDashboard}>系统后台</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => logout().then(() => nav('/login'))}>
|
||||
退出登录
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<button type="button" className="header-login-btn" onClick={() => nav('/login')}>
|
||||
登录
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className={`app-body${isCompose ? ' app-body--compose' : ''}`}>
|
||||
{!isCompose && (
|
||||
<Sidebar
|
||||
boards={boards}
|
||||
activeBoard={boardId}
|
||||
onSelectBoard={setBoardId}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`content-workspace${isCompose ? ' content-workspace--compose' : ''}`}>
|
||||
<main className={`main-content${isCompose ? ' main-content--compose' : ''}`}>
|
||||
{isMobile && !isCompose && (
|
||||
<div className="mobile-board-bar">
|
||||
<span
|
||||
className={`board-chip ${mobileActiveBoard === 0 ? 'active' : ''}`}
|
||||
onClick={() => { setBoardId(0); nav('/'); }}
|
||||
>全部</span>
|
||||
{boards.map(b => (
|
||||
<span
|
||||
key={b.id}
|
||||
className={`board-chip ${mobileActiveBoard === b.id ? 'active' : ''}`}
|
||||
onClick={() => { setBoardId(b.id); nav(`/?board=${b.id}`); }}
|
||||
>{b.name}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<Outlet context={{
|
||||
boardId,
|
||||
keyword: params.get('keyword') || '',
|
||||
setBoardId,
|
||||
boards,
|
||||
stats,
|
||||
layoutReady,
|
||||
refreshBoards,
|
||||
isMobile,
|
||||
} satisfies LayoutCtx} />
|
||||
</Suspense>
|
||||
</main>
|
||||
|
||||
{!isCompose && (
|
||||
<aside className="aside-panel">
|
||||
<RightPanel
|
||||
hot={hot}
|
||||
notifications={notifications}
|
||||
online={online}
|
||||
onPostClick={(id) => nav(`/post/${id}`)}
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type LayoutCtx = {
|
||||
boardId: number;
|
||||
keyword: string;
|
||||
setBoardId: (id: number) => void;
|
||||
boards: Board[];
|
||||
stats: ForumStats | null;
|
||||
layoutReady: boolean;
|
||||
refreshBoards: () => void;
|
||||
isMobile: boolean;
|
||||
};
|
||||
Reference in New Issue
Block a user