Files
jiang13-bbs/frontend/app/page.tsx
freefire 40bbad2c82 公开页补齐自引用 Canonical 与按类型的结构化数据,图片补上说明文字。
后台设置和内容编辑改成可导航布局;部署用访客 origin 生成 Canonical,避免落到容器地址。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 00:11:24 +08:00

321 lines
11 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,
type OverviewResponse,
type BoardSidebarResponse,
} from "@/lib/api";
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 { WorkspaceNav, WorkspaceNavMobile } from "@/components/home/WorkspaceNav";
import { SiteDocBreadcrumb } from "@/components/site-doc";
import { CommunityPanels, CommunityPanelsMobile } from "@/components/home/CommunityPanels";
import {
BoardPanels,
BoardPanelsMobile,
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 [me, postsData, overview, boardSidebar] = await Promise.all([
mePromise,
postsPromise,
overviewPromise,
boardSidebarPromise,
]);
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;
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,
});
// 搜索态:单栏占满,无 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">
{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>
<JsonLd data={listingLd} />
<WorkspaceNavMobile boards={boards} user={user} />
<div className={`flex items-start gap-6 xl:gap-7 ${activeBoard ? "" : "mt-5 lg:mt-0"}`}>
<WorkspaceNav boards={boards} user={user} />
{/* 中栏:tab 栏与帖子流为同一个整体卡片(左栏切板块只替换这一列) */}
<div className="flex-1 min-w-0">
<h1 className="sr-only">{pageName}</h1>
<div className="panel overflow-hidden">
{activeBoard ? (
<div className="px-3 sm:px-4 pt-3">
<SiteDocBreadcrumb
className="is-ruled !mb-0"
items={[
{ href: "/", label: "首页" },
{ label: activeBoard.name },
]}
/>
</div>
) : null}
<div className="flex items-center justify-between gap-2 px-3 sm:px-4 pt-3 pb-2.5">
<FeedTabs current={sort} boardId={boardId} />
<span className="meta 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 ? (
<BoardPanels
board={boardSidebar.board}
moderators={boardSidebar.moderators}
stats={boardSidebar.stats}
hot={boardSidebar.hot}
activeUsers={boardSidebar.active_users}
checkin={boardSidebar.checkin ?? undefined}
allowComments={allowComments}
/>
) : boardPanelsMissing ? (
<BoardMissingPanel />
) : null
) : (
overview && (
<CommunityPanels
stats={overview.stats}
hot={overview.hot}
activeUsers={overview.active_users}
announcements={overview.announcements}
newUsers={overview.new_users}
checkin={overview.checkin ?? undefined}
allowComments={allowComments}
canManageAnnouncements={canManageAnnouncements}
canManagePages={canManagePages}
sidebarPages={sidebarPages}
siteDescription={publicSettings.site_description}
/>
)
)}
</div>
{/* 窄屏:面板下沉到帖子流之后 */}
{showBoardPanels ? (
boardSidebar ? (
<div className="mt-6">
<BoardPanelsMobile
board={boardSidebar.board}
moderators={boardSidebar.moderators}
stats={boardSidebar.stats}
hot={boardSidebar.hot}
activeUsers={boardSidebar.active_users}
checkin={boardSidebar.checkin ?? undefined}
allowComments={allowComments}
/>
</div>
) : null
) : (
overview && (
<div className="mt-6">
<CommunityPanelsMobile
stats={overview.stats}
hot={overview.hot}
activeUsers={overview.active_users}
announcements={overview.announcements}
newUsers={overview.new_users}
checkin={overview.checkin ?? undefined}
allowComments={allowComments}
canManageAnnouncements={canManageAnnouncements}
canManagePages={canManagePages}
sidebarPages={sidebarPages}
siteDescription={publicSettings.site_description}
/>
</div>
)
)}
</div>
);
}