Files
jiang13-bbs/frontend/app/page.tsx
freefire 4f3f3a265c feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块:
- 友链(friendlink)后端处理 + 前端管理页与友链板
- 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot
- 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉
- 排行榜 service 测试、站点页面测试、时间线发布(timeline_release)

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
2026-09-30 16:30:07 +08:00

360 lines
13 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 type { Metadata } from "next";
import { cookies } from "next/headers";
import Link from "next/link";
import { PenSquare, SearchX, MessagesSquare } from "lucide-react";
import {
fetchPosts,
fetchOverview,
fetchBoardSidebar,
fetchSidebar,
fetchFriendLinks,
type FriendLink,
type OverviewResponse,
type BoardSidebarResponse,
type SidebarPublicPayload,
} from "@/lib/api";
import { defaultSidebarConfig, sidebarScopeLists } from "@/lib/sidebarMeta";
import SidebarRenderer, { type SidebarBlockProps } from "@/components/sidebar/SidebarRenderer";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { siteDocumentTitle } from "@/lib/brand";
import { isAdminOrAbove } from "@/lib/roles";
import Pagination from "@/components/Pagination";
import PostRow from "@/components/PostRow";
import FeedTabs from "@/components/home/FeedTabs";
import FeedNewTip from "@/components/home/FeedNewTip";
import { WorkspaceNavMobile } from "@/components/home/WorkspaceNav";
import { BoardMissingPanel } from "@/components/home/BoardPanels";
import { requestCanonical } from "@/lib/canonicalMeta";
import { defaultFeedSort, homeCanonicalQuery, normalizeFeedSort } from "@/lib/siteUrl";
import { listingJsonLd } from "@/lib/jsonLd";
import JsonLd from "@/components/JsonLd";
interface HomePageProps {
searchParams: Promise<{ q?: string; page?: string; sort?: string; board?: string }>;
}
export async function generateMetadata({ searchParams }: HomePageProps): Promise<Metadata> {
const settings = await getPublicSettingsCached();
const sp = await searchParams;
const allowComments = settings.allow_comments !== false;
return {
title: { absolute: siteDocumentTitle(settings.site_name, settings.site_slogan) },
description: settings.site_description,
...(await requestCanonical("/", homeCanonicalQuery(sp, allowComments))),
};
}
// 空结果面板(无帖 / 无搜索结果)
function EmptyState({ keyword }: { keyword: string }) {
return (
<div className="text-center py-20 px-6">
<span
className="inline-flex w-14 h-14 rounded-full items-center justify-center mb-4"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
{keyword ? <SearchX size={26} /> : <MessagesSquare size={26} />}
</span>
<p className="font-bold text-lg" style={{ color: "var(--ink)" }}>
{keyword ? "没有找到相关帖子" : "还没有帖子"}
</p>
<p className="meta mt-1.5">
{keyword ? `换个关键词试试,或者发起“${keyword}”相关的话题` : "成为第一个发起讨论的人吧"}
</p>
{keyword ? (
<div className="flex items-center justify-center gap-3 mt-6">
<Link href="/" className="btn btn-line">
清除搜索
</Link>
<Link href="/compose" className="btn btn-primary">
<PenSquare size={15} /> 发起这个话题
</Link>
</div>
) : (
<Link href="/compose" className="btn btn-primary mt-6">
<PenSquare size={15} /> 写新帖
</Link>
)}
</div>
);
}
export default async function HomePage({ searchParams }: HomePageProps) {
const sp = await searchParams;
const keyword = sp.q?.trim() || "";
const page = Math.max(1, parseInt(sp.page || "1", 10) || 1);
const boardId = Math.max(0, parseInt(sp.board || "0", 10) || 0);
const isSearch = keyword.length > 0;
const cookie = authCookieHeader(await cookies());
const publicSettings = await getPublicSettingsCached();
const allowComments = publicSettings.allow_comments !== false;
const sort = normalizeFeedSort(sp.sort, allowComments);
// 与 layout 共享同一份 /me 请求(React cache 去重)
const mePromise = getMeCached(cookie || undefined);
const postsPromise = fetchPosts(
page,
20,
isSearch || boardId <= 0 ? undefined : boardId,
sort,
keyword,
sort === "recommended",
cookie
);
// 聚合面板失败不阻塞帖子流
const overviewPromise: Promise<OverviewResponse | null> = fetchOverview(cookie).catch(() => null);
const boardSidebarPromise: Promise<BoardSidebarResponse | null> =
boardId > 0 && !isSearch
? fetchBoardSidebar(boardId, cookie).catch(() => null)
: Promise.resolve(null);
// 侧栏布局配置失败不阻塞页面,回落默认布局
const sidebarPromise: Promise<SidebarPublicPayload | null> = isSearch
? Promise.resolve(null)
: fetchSidebar(cookie).catch(() => null);
// 友情链接列表失败不阻塞页面
const friendLinksPromise: Promise<{ links: FriendLink[] } | null> = isSearch
? Promise.resolve(null)
: fetchFriendLinks(cookie).catch(() => null);
const [me, postsData, overview, boardSidebar, sidebarData, friendLinksData] = await Promise.all([
mePromise,
postsPromise,
overviewPromise,
boardSidebarPromise,
sidebarPromise,
friendLinksPromise,
]);
const sidebar = sidebarData ?? { config: defaultSidebarConfig(), widgets: [] };
const user = me.user;
const boards = overview?.boards ?? [];
const canManageAnnouncements = !!user && isAdminOrAbove(user.role);
const canManagePages = canManageAnnouncements;
const sidebarPages = overview?.sidebar_pages ?? [];
const activeBoard = boards.find((b) => b.id === boardId) || boardSidebar?.board || null;
const showBoardPanels = boardId > 0 && !isSearch;
// boardId 有效但接口失败 → 显示简卡;成功则板级右栏;否则全站右栏
const boardPanelsMissing = showBoardPanels && !boardSidebar;
// 侧栏布局:板块态右栏走 board 配置(左栏跟随首页),其余走 home 配置
const scopeLists = sidebarScopeLists(sidebar.config, showBoardPanels ? "board" : "home");
// 三栏竖向分隔线跟随左右栏显隐(空配置不画悬空线)
const hasLeftRail = scopeLists.left.length > 0;
const hasRightRail = showBoardPanels
? boardSidebar
? scopeLists.right.length > 0
: boardPanelsMissing
: !!(overview && scopeLists.right.length > 0);
const sidebarProps: SidebarBlockProps = {
allowComments,
user,
boards,
checkin: (boardSidebar ?? overview)?.checkin ?? undefined,
// 友链列表独立于 overview:即使聚合接口失败也保留「申请」入口
friendLinks: friendLinksData?.links ?? [],
...(overview
? {
stats: overview.stats,
announcements: overview.announcements,
canManageAnnouncements,
ads: overview.ads ?? [],
adsPanelTitle: overview.ads_panel_title || "自助推广",
adsEnabled: overview.ads_enabled !== false,
sponsors: overview.sponsors ?? [],
sponsorsPanelTitle: overview.sponsors_panel_title || "赞助商",
sponsorsEnabled: overview.sponsors_enabled !== false,
hot: overview.hot,
newUsers: overview.new_users,
activeUsers: overview.active_users,
}
: {}),
canManagePages,
sidebarPages,
siteDescription: publicSettings.site_description,
...(boardSidebar
? {
board: boardSidebar.board,
moderators: boardSidebar.moderators,
boardStats: boardSidebar.stats,
boardHot: boardSidebar.hot,
boardActiveUsers: boardSidebar.active_users,
}
: {}),
};
const pageName = isSearch
? `搜索“${keyword}”`
: activeBoard
? activeBoard.name
: sort === "new"
? "最新发布"
: sort === "recommended"
? "推荐帖"
: "最新讨论";
const listingLd = listingJsonLd({
siteName: publicSettings.site_name || "姜十三论坛",
siteDescription: publicSettings.site_description,
pageName,
path: "/",
query: homeCanonicalQuery(sp, allowComments),
items: postsData.posts.map((p) => ({ id: p.id, name: p.title })),
page,
pageSize: 20,
urlStyle: publicSettings.url_style,
});
// 搜索态:单栏占满,无 Tabs/侧栏
if (isSearch) {
return (
<div className="max-w-3xl mx-auto">
<JsonLd data={listingLd} />
<div className="mb-5">
<h1 className="text-[24px] sm:text-[28px] font-extrabold tracking-tight" style={{ color: "var(--ink)" }}>
搜索“<span style={{ color: "var(--accent)" }}>{keyword}</span>”
</h1>
<p className="meta mt-2">共 {postsData.total} 篇结果 · 按相关度与时间排序</p>
</div>
<div className="panel overflow-hidden max-sm:-mx-4 max-sm:rounded-none max-sm:border-x-0 max-sm:shadow-none">
{postsData.posts.length > 0 ? (
postsData.posts.map((post, i) => (
<PostRow key={post.id} post={post} showBoard index={i} showViews={false} showLike={false} />
))
) : (
<EmptyState keyword={keyword} />
)}
</div>
<Pagination
total={postsData.total}
page={page}
size={20}
basePath="/"
query={{
q: keyword,
...(sort !== defaultFeedSort(allowComments) ? { sort } : {}),
}}
/>
</div>
);
}
// 置顶帖直接按后端 pinned DESC 顺序并入统一信息流;标题仅供屏幕阅读器
const pageQuery: Record<string, string> = {};
if (boardId > 0) pageQuery.board = String(boardId);
if (sort !== defaultFeedSort(allowComments)) pageQuery.sort = sort;
return (
<div className="j13-fluid-home">
<JsonLd data={listingLd} />
{/* 移动端:顶部横向板块条 = 左右抽屉的触发行——
☰ 打开左抽屉(桌面左栏:板块目录 + 工作台),▦ 打开右抽屉(桌面右栏面板) */}
<WorkspaceNavMobile
boards={boards}
left={
scopeLists.left.length > 0 ? (
<SidebarRenderer
blocks={scopeLists.left}
widgets={sidebar.widgets}
props={sidebarProps}
className="space-y-4"
/>
) : undefined
}
right={
scopeLists.right.length > 0 ? (
<SidebarRenderer
blocks={scopeLists.right}
widgets={sidebar.widgets}
props={sidebarProps}
className="space-y-4"
/>
) : undefined
}
/>
<div className={`flex items-start gap-6 xl:gap-7 flex-1 ${activeBoard ? "" : "lg:mt-0"}`}>
{/* 左栏:按配置渲染(缺省 = 板块目录) */}
{scopeLists.left.length > 0 && (
<aside className="j13-rail-left hidden lg:block w-56 xl:w-60 shrink-0 sticky top-[var(--rail-top)] max-h-[var(--rail-max-h)]">
<SidebarRenderer blocks={scopeLists.left} widgets={sidebar.widgets} props={sidebarProps} />
</aside>
)}
{/* 中栏:tab 栏与帖子流为同一个整体(左栏切板块只替换这一列);self-stretch 撑满行高供分隔线贯通 */}
<div
className={`flex-1 min-w-0 self-stretch ${hasLeftRail ? "j13-home-sep-l" : ""} ${
hasRightRail ? "j13-home-sep-r" : ""
}`}
>
<h1 className="sr-only">{pageName}</h1>
<div className="max-sm:-mx-4">
<div className="j13-feed-head flex items-stretch justify-between gap-2 px-3 sm:px-4">
<FeedTabs current={sort} boardId={boardId} />
<span className="meta self-center shrink-0 text-[12px] tabular-nums">
共 {postsData.total} 篇
</span>
</div>
{user && (
<FeedNewTip
sort={sort}
boardId={boardId}
page={page}
userId={user.id}
/>
)}
{postsData.posts.length > 0 ? (
postsData.posts.map((post, i) => (
<PostRow
key={post.id}
post={post}
showBoard={!activeBoard}
compact
index={i}
showViews={false}
showLike={false}
/>
))
) : (
<EmptyState keyword="" />
)}
</div>
<Pagination
total={postsData.total}
page={page}
size={20}
basePath="/"
query={Object.keys(pageQuery).length > 0 ? pageQuery : undefined}
/>
</div>
{/* 右栏:选中板块时切板级面板,否则全站社区面板;均按配置渲染 */}
{showBoardPanels ? (
boardSidebar ? (
scopeLists.right.length > 0 ? (
<aside className="hidden w-[280px] shrink-0 lg:block xl:w-[300px]" aria-label="板块面板">
<SidebarRenderer
blocks={scopeLists.right}
widgets={sidebar.widgets}
props={sidebarProps}
className="space-y-4"
/>
</aside>
) : null
) : boardPanelsMissing ? (
<BoardMissingPanel />
) : null
) : overview && scopeLists.right.length > 0 ? (
<aside className="hidden w-[280px] shrink-0 lg:block xl:w-[300px]" aria-label="社区面板">
<SidebarRenderer
blocks={scopeLists.right}
widgets={sidebar.widgets}
props={sidebarProps}
className="space-y-4"
/>
</aside>
) : null}
</div>
</div>
);
}