初始提交:姜十三论坛 Jiang13 Forum

轻量自用论坛,Go 单二进制 + React SPA 内嵌 + SQLite。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
freefire
2026-06-15 21:08:52 +08:00
commit e1c1708715
140 changed files with 16115 additions and 0 deletions

View 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;
};