Files
jiang13-bbs/frontend/app/announcements/page.tsx

192 lines
6.5 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 { Megaphone } from "lucide-react";
import { fetchAnnouncementsPage, fetchOverview, type Announcement } from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import { formatDate } from "@/lib/format";
import Pagination from "@/components/Pagination";
import {
SiteDocShell,
SiteDocIdentity,
SiteDocPageNav,
SiteDocBreadcrumb,
} from "@/components/site-doc";
export const metadata: Metadata = {
title: "站点公告",
description: "站点公告与通知列表",
};
interface PageProps {
searchParams: Promise<{ page?: string }>;
}
/** 粗略去掉 Markdown,取摘要 */
function plainSummary(md: string, max = 120): string {
const text = md
.replace(/```[\s\S]*?```/g, " ")
.replace(/!\[[^\]]*\]\([^)]+\)/g, " ")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/[#>*_`~\-|=]/g, "")
.replace(/\s+/g, " ")
.trim();
return text.length > max ? `${text.slice(0, max)}…` : text;
}
function FeaturedAnnouncement({ a }: { a: Announcement }) {
const summary = plainSummary(a.content, 180);
return (
<Link
href={`/announcement/${a.id}`}
className="block px-5 sm:px-6 pt-5 sm:pt-6 pb-5 transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
>
<div className="flex items-center gap-2 flex-wrap mb-3">
<span
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-[11.5px] font-semibold tracking-wide"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
置顶公告
</span>
{a.pinned ? <span className="badge-pin">置顶</span> : null}
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
<time className="meta text-[12px] tabular-nums ml-auto" dateTime={a.created_at}>
{formatDate(a.created_at)}
</time>
</div>
<h2
className="text-[22px] sm:text-[26px] font-extrabold leading-snug tracking-tight"
style={{ color: "var(--ink)" }}
>
{a.title}
</h2>
{summary ? (
<p className="mt-3 text-[14.5px] leading-relaxed line-clamp-3" style={{ color: "var(--ink-2)" }}>
{summary}
</p>
) : null}
</Link>
);
}
function AnnouncementRow({ a, showDivider }: { a: Announcement; showDivider: boolean }) {
const summary = plainSummary(a.content);
return (
<Link
href={`/announcement/${a.id}`}
className="block px-5 sm:px-6 py-4 sm:py-5 transition-colors hover:bg-[var(--accent-soft)] focus-visible:bg-[var(--accent-soft)]"
style={showDivider ? { borderTop: "1px solid var(--line)" } : undefined}
>
<div className="flex items-center gap-2 flex-wrap">
{a.pinned ? <span className="badge-pin">置顶</span> : null}
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
<time className="meta text-[12px] tabular-nums ml-auto" dateTime={a.created_at}>
{formatDate(a.created_at)}
</time>
</div>
<h2
className="mt-2 text-[16px] sm:text-[17px] font-semibold leading-snug tracking-tight"
style={{ color: "var(--ink)" }}
>
{a.title}
</h2>
{summary ? (
<p className="mt-1.5 text-[13.5px] leading-relaxed line-clamp-2" style={{ color: "var(--ink-2)" }}>
{summary}
</p>
) : null}
</Link>
);
}
export default async function AnnouncementsListPage({ searchParams }: PageProps) {
const sp = await searchParams;
const page = Math.max(1, parseInt(sp.page || "1", 10) || 1);
const size = 20;
let announcements: Announcement[] = [];
let total = 0;
let error = "";
try {
const res = await fetchAnnouncementsPage(page, size);
announcements = res.announcements ?? [];
total = res.total ?? 0;
} catch (e) {
error = e instanceof Error ? e.message : "加载公告失败";
}
const cookie = authCookieHeader(await cookies());
const [overview, settings, me] = await Promise.all([
fetchOverview().catch(() => null),
getPublicSettingsCached(),
getMeCached(cookie || undefined),
]);
const sidebarPages = overview?.sidebar_pages ?? [];
const siteLead = settings.site_description?.trim() || undefined;
const canManagePages = !!me.user && isAdminOrAbove(me.user.role);
// 首页置顶条目作头图;其余走列表行(置顶已在头图时不再重复)
const featured = page === 1 ? announcements.find((a) => a.pinned) : undefined;
const rest = featured ? announcements.filter((a) => a.id !== featured.id) : announcements;
const left = <SiteDocIdentity kind="announcements" lead={siteLead} />;
const right = (
<SiteDocPageNav pages={sidebarPages} title="关于本站" canManage={canManagePages} />
);
return (
<SiteDocShell
left={left}
right={right}
breadcrumb={
<SiteDocBreadcrumb
items={[
{ href: "/", label: "首页" },
{ label: "站点公告" },
]}
/>
}
mobileExtras={
<SiteDocPageNav pages={sidebarPages} title="关于本站" canManage={canManagePages} />
}
>
<header className="mb-5">
<h1
className="text-[24px] sm:text-[28px] font-extrabold tracking-tight flex items-center gap-2"
style={{ color: "var(--ink)" }}
>
<Megaphone size={26} style={{ color: "var(--accent)" }} aria-hidden />
站点公告
</h1>
<p className="meta mt-2">
{error ? "加载失败" : total > 0 ? `共 ${total} 条官方通知` : "站点通知与运营公告"}
</p>
</header>
{error ? (
<p className="alert-error">{error}</p>
) : announcements.length === 0 ? (
<div className="panel p-10 text-center">
<p className="font-medium" style={{ color: "var(--ink)" }}>
暂无公告
</p>
<p className="meta mt-1.5">有新消息时会显示在这里</p>
</div>
) : (
<div className="panel overflow-hidden">
{featured ? <FeaturedAnnouncement a={featured} /> : null}
{rest.map((a, i) => (
<AnnouncementRow key={a.id} a={a} showDivider={!!featured || i > 0} />
))}
</div>
)}
{!error ? (
<Pagination total={total} page={page} size={size} basePath="/announcements" />
) : null}
</SiteDocShell>
);
}