Files
jiang13-bbs/frontend/components/home/CommunityPanels.tsx
freefire d7c6b3a961 feat: 帖子收藏与用户关注
- 新增收藏功能:favorite 服务与 handler、FavoriteButton 组件
- 新增关注功能:follow 服务与 handler、FollowButton 组件
- 用户主页适配关注/粉丝与收藏展示
- flash 消息工具、广告与侧边栏组件微调
2026-09-26 03:02:40 +08:00

651 lines
20 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 Link from "next/link";
import {
Flame,
Users,
MessageCircle,
Heart,
UserPlus,
Link2,
Megaphone,
BarChart3,
PenLine,
Activity,
TrendingUp,
Pin,
ChevronRight,
CircleDollarSign,
Handshake,
} from "lucide-react";
import type {
PostListItem,
ActiveUser,
OverviewStats,
AnnouncementSummary,
NewUser,
CheckinStatus,
SidebarPageItem,
PublicAdItem,
PublicSponsorItem,
} from "@/lib/api";
import Avatar from "@/components/Avatar";
import { AdText } from "@/components/AdText";
import { PulseCheckin } from "./CheckinCard";
import { FRIEND_LINKS } from "@/lib/friendLinks";
import { formatCompact, formatShortDate } from "@/lib/format";
import { pageGlyph } from "@/lib/pageGlyph";
import { AboutSiteHeader } from "@/components/site-doc";
import {
AD_IMAGE_HEIGHT,
AD_IMAGE_SIZE_LABEL,
AD_IMAGE_WIDTH,
AD_TIP_DELAY_MS,
adHoverTip,
} from "@/lib/ads";
/* ---------- 站点数据(2×2 bento 统计瓦片) ---------- */
export function PulseCard({
stats,
checkin,
allowComments,
}: {
stats: OverviewStats;
checkin?: CheckinStatus | null;
allowComments: boolean;
}) {
const cells = [
{
icon: <Users size={12.5} />,
label: "注册用户",
value: formatCompact(stats.users),
foot: `+${formatCompact(stats.today_users)} 今日`,
up: true,
},
{
icon: <PenLine size={12.5} />,
label: "话题总数",
value: formatCompact(stats.posts),
foot: `+${formatCompact(stats.today_posts)} 今日`,
up: true,
},
...(allowComments
? [
{
icon: <MessageCircle size={12.5} />,
label: "回复总数",
value: formatCompact(stats.comments),
foot: `+${formatCompact(stats.today_comments)} 今日`,
up: true,
},
]
: [
{
icon: <TrendingUp size={12.5} />,
label: "今日新帖",
value: formatCompact(stats.today_posts),
foot: "本日新增",
up: stats.today_posts > 0,
},
]),
{
icon: <Activity size={12.5} />,
label: "在线用户",
value: formatCompact(stats.online),
foot: `峰值 ${formatCompact(stats.peak_online)}`,
up: false,
},
];
return (
<section className="j13-module" aria-label="站点数据">
<div className="flex items-center justify-between">
<p className="j13-module-title">
<BarChart3 size={15} style={{ color: "var(--accent)" }} /> 站点数据
</p>
<span
className="inline-flex items-center gap-1.5 text-[11px] font-normal normal-case tracking-normal"
style={{ color: "var(--ink-3)" }}
data-tip="近 5 分钟在线"
>
<span className="j13-pulse-dot" aria-hidden="true" />
实时
</span>
</div>
<div className="j13-pulse">
{cells.map((c) => (
<div key={c.label} className="j13-pulse-cell">
<p className="j13-pulse-lab">
<span style={{ color: "var(--accent)" }} aria-hidden="true">
{c.icon}
</span>
{c.label}
</p>
<p className="j13-pulse-num">{c.value}</p>
<p className={`j13-pulse-foot${c.up ? " is-up" : ""}`}>
{c.up && <TrendingUp size={10.5} aria-hidden="true" />}
{c.foot}
</p>
</div>
))}
</div>
<div className="j13-pulse-checkin">
<PulseCheckin initial={checkin} />
</div>
</section>
);
}
/* ---------- 关于本站(侧栏页目录,置于右栏最底) ---------- */
export function AboutSite({
pages,
siteDescription,
canManagePages,
}: {
pages: SidebarPageItem[];
siteDescription?: string;
canManagePages?: boolean;
}) {
if (pages.length === 0 && !canManagePages) return null;
const lead = siteDescription?.trim() ?? "";
return (
<section className="j13-module j13-about" aria-label="关于本站">
<AboutSiteHeader canManage={canManagePages} />
{lead ? <p className="j13-about-lead">{lead}</p> : null}
{pages.length === 0 ? (
<p className="meta mt-2 px-0.5">暂无介绍</p>
) : (
<ul className="j13-about-list">
{pages.map((p) => {
const Icon = pageGlyph(p.slug);
return (
<li key={p.id}>
<Link href={`/p/${p.slug}`} className="j13-about-item">
<span className="j13-about-ico" aria-hidden="true">
<Icon size={15} strokeWidth={2} />
</span>
<span className="j13-about-body">
<span className="j13-about-name">{p.title}</span>
{p.excerpt ? <span className="j13-about-excerpt">{p.excerpt}</span> : null}
</span>
<ChevronRight className="j13-about-chev" size={15} aria-hidden="true" />
</Link>
</li>
);
})}
</ul>
)}
</section>
);
}
/* ---------- 站点公告 ---------- */
export function Announcements({
items,
canManageAnnouncements,
}: {
items: AnnouncementSummary[];
canManageAnnouncements?: boolean;
}) {
return (
<section className="j13-module" aria-label="站点公告">
<div className="flex items-center justify-between gap-2">
<p className="j13-module-title !mb-0">
<Megaphone size={15} style={{ color: "var(--accent)" }} /> 站点公告
</p>
<span className="inline-flex items-center gap-2 shrink-0">
<Link
href="/announcements"
className="meta text-[12px] font-normal transition-colors hover:text-[var(--accent)]"
>
全部
</Link>
{canManageAnnouncements ? (
<Link
href="/admin/announcements"
className="meta text-[12px] font-normal transition-colors hover:text-[var(--accent)]"
>
管理
</Link>
) : null}
</span>
</div>
{items.length === 0 ? (
<p className="meta mt-2 px-0.5">暂无公告</p>
) : (
<ul className="j13-anno-list">
{items.map((a) => (
<li key={a.id}>
<Link
href={`/announcement/${a.id}`}
className="j13-anno-item"
data-tip={a.title}
>
<span className="j13-anno-item-body">
<span className="j13-anno-item-title truncate inline-flex items-center gap-1 min-w-0">
{a.pinned ? (
<Pin
size={11}
className="shrink-0"
style={{ color: "var(--accent)" }}
aria-label="置顶"
/>
) : null}
<span className="truncate">{a.title}</span>
</span>
<span className="j13-anno-item-meta">
{a.tag ? (
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
) : null}
<span className="meta text-[11px] tabular-nums">
{formatShortDate(a.created_at)}
</span>
</span>
</span>
</Link>
</li>
))}
</ul>
)}
</section>
);
}
/* ---------- 热门榜 ---------- */
export function HotRanking({ posts, allowComments }: { posts: PostListItem[]; allowComments: boolean }) {
return (
<section className="j13-module" aria-label="热门讨论">
<p className="j13-module-title mb-1.5">
<Flame size={15} style={{ color: "var(--clay)" }} /> 热门讨论
</p>
{posts.length === 0 ? (
<p className="meta px-1.5 py-3">还没有热门讨论</p>
) : (
<ol className="j13-ranklist">
{posts.map((p, i) => (
<li key={p.id}>
<Link href={`/post/${p.id}`} className="j13-rank" data-tip={p.title}>
<span className="j13-rank-no">{i + 1}</span>
<span className="min-w-0 flex-1">
<span className="j13-rank-title truncate">
{p.title}
</span>
<span className="mt-1 flex items-center gap-2.5 meta text-[11.5px]">
{allowComments ? (
<span className="inline-flex items-center gap-1">
<MessageCircle size={11.5} /> {p.comment_count}
</span>
) : null}
<span className="inline-flex items-center gap-1">
<Heart size={11.5} /> {p.like_count}
</span>
</span>
</span>
</Link>
</li>
))}
</ol>
)}
</section>
);
}
/* ---------- 最新注册 ---------- */
export function NewUsers({ users }: { users: NewUser[] }) {
if (users.length === 0) return null;
return (
<section className="j13-module" aria-label="最新注册">
<p className="j13-module-title mb-3">
<UserPlus size={15} style={{ color: "var(--accent)" }} /> 最新注册
</p>
<ul className="grid grid-cols-4 gap-y-3">
{users.map((u) => {
const name = u.nickname || u.username;
return (
<li key={u.id}>
<Link
href={`/u/${u.id}`}
className="flex flex-col items-center gap-1.5 rounded-lg px-1 py-1 transition-colors hover:bg-[var(--item-hover-bg)] focus-visible:bg-[var(--item-hover-bg)]"
>
<Avatar name={name} src={u.avatar} size={38} />
<span
className="w-full truncate text-center text-[11px]"
style={{ color: "var(--ink-2)" }}
data-tip={name}
>
{name}
</span>
</Link>
</li>
);
})}
</ul>
</section>
);
}
/* ---------- 活跃成员(本周发帖 / 回复) ---------- */
export function ActiveUsers({ users, allowComments }: { users: ActiveUser[]; allowComments: boolean }) {
return (
<section className="j13-module" aria-label="本周活跃成员">
<p className="j13-module-title mb-2">
<Users size={15} style={{ color: "var(--accent)" }} /> 活跃成员
<span className="ml-auto font-normal normal-case tracking-normal text-[11px]" style={{ color: "var(--ink-3)" }}>
本周
</span>
</p>
{users.length === 0 ? (
<p className="meta px-1.5 py-2">本周还没有人互动</p>
) : (
<ul className="space-y-0.5">
{users.map((u) => {
const name = u.nickname || u.username;
return (
<li key={u.id}>
<Link href={`/u/${u.id}`} className="j13-raillink" data-tip={name}>
<Avatar name={name} src={u.avatar} size={30} />
<span className="flex-1 min-w-0">
<span
className="block truncate text-[13px] font-medium leading-tight"
style={{ color: "var(--ink)" }}
>
{name}
</span>
<span className="mt-px flex items-center gap-2.5 text-[11px] meta">
<span className="inline-flex items-center gap-1">
<PenLine size={10.5} /> 发帖 {u.post_count}
</span>
{allowComments ? (
<span className="inline-flex items-center gap-1">
<MessageCircle size={10.5} /> 回复 {u.comment_count}
</span>
) : null}
</span>
</span>
<span
className="shrink-0 text-[13px] tabular-nums"
style={{ color: "var(--accent)" }}
aria-label={
allowComments
? `本周共 ${u.post_count + u.comment_count} 次互动`
: `本周发帖 ${u.post_count}`
}
>
{allowComments ? u.post_count + u.comment_count : u.post_count}
</span>
</Link>
</li>
);
})}
</ul>
)}
</section>
);
}
/* ---------- 友情链接 ---------- */
export function FriendLinks() {
if (FRIEND_LINKS.length === 0) return null;
return (
<section className="j13-module" aria-label="友情链接">
<p className="j13-module-title mb-3">
<Link2 size={15} style={{ color: "var(--accent)" }} /> 友情链接
<Link
href="/compose"
className="meta ml-auto text-[12px] font-normal transition-colors hover:text-[var(--accent)]"
>
申请
</Link>
</p>
<div className="flex flex-wrap gap-2">
{FRIEND_LINKS.map((l) =>
l.href.startsWith("http") ? (
<a
key={l.name}
href={l.href}
target="_blank"
rel="noopener noreferrer"
className="j13-chip"
>
{l.name}
</a>
) : (
<span key={l.name} className="j13-chip" data-tip="友链待配置">
{l.name}
</span>
)
)}
</div>
</section>
);
}
/* ---------- 桌面 / 移动两种装配 ---------- */
type PanelsProps = {
stats: OverviewStats;
hot: PostListItem[];
activeUsers: ActiveUser[];
announcements: AnnouncementSummary[];
newUsers: NewUser[];
checkin?: CheckinStatus | null;
allowComments: boolean;
canManageAnnouncements?: boolean;
canManagePages?: boolean;
sidebarPages?: SidebarPageItem[];
siteDescription?: string;
ads?: PublicAdItem[];
adsPanelTitle?: string;
adsEnabled?: boolean;
sponsors?: PublicSponsorItem[];
sponsorsPanelTitle?: string;
sponsorsEnabled?: boolean;
};
export function AdsPanel({
items,
title,
enabled,
}: {
items: PublicAdItem[];
title: string;
enabled: boolean;
}) {
if (!enabled) return null;
const IMAGE_SLOTS = 5;
const TEXT_SLOTS = 6;
const images = items.filter((a) => a.kind === "image").slice(0, IMAGE_SLOTS);
const texts = items.filter((a) => a.kind === "text").slice(0, TEXT_SLOTS);
const imageSlots = Array.from({ length: IMAGE_SLOTS }, (_, i) => images[i] ?? null);
const textSlots = Array.from({ length: TEXT_SLOTS }, (_, i) => texts[i] ?? null);
return (
<section className="j13-module" aria-label={title || "自助推广"}>
<p className="j13-module-title mb-3">
<CircleDollarSign size={15} style={{ color: "var(--accent)" }} />
{title || "自助推广"}
<Link href="/ads/buy" className="j13-ad-buy-btn ml-auto">
购买广告位
</Link>
</p>
<div className="j13-ad-promo">
<ul className="space-y-2" aria-label="图片广告位">
{imageSlots.map((ad, i) =>
ad ? (
<li key={`img-${ad.id}`}>
<a
href={ad.link_url}
target="_blank"
rel="noopener noreferrer sponsored"
className="j13-ad-image-link"
data-tip={adHoverTip(ad.ends_at)}
data-tip-delay={AD_TIP_DELAY_MS}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={ad.image_url}
alt="推广"
className="j13-ad-image"
width={AD_IMAGE_WIDTH}
height={AD_IMAGE_HEIGHT}
loading="lazy"
/>
</a>
</li>
) : (
<li key={`img-slot-${i}`}>
<Link href="/ads/buy" className="j13-ad-slot j13-ad-slot-image">
<span>图片广告位 {AD_IMAGE_SIZE_LABEL}</span>
</Link>
</li>
)
)}
</ul>
<ul className="j13-ad-text-grid" aria-label="文字广告位">
{textSlots.map((ad, i) =>
ad ? (
<li key={`txt-${ad.id}`}>
<AdText
title={ad.title ?? ""}
textColor={ad.text_color || "#1E293B"}
bgColor={ad.bg_color || "#F1F5F9"}
href={ad.link_url}
endsAt={ad.ends_at}
/>
</li>
) : (
<li key={`txt-slot-${i}`}>
<Link href="/ads/buy" className="j13-ad-slot j13-ad-slot-text">
文字广告位
</Link>
</li>
)
)}
</ul>
</div>
</section>
);
}
export function SponsorsPanel({
items,
title,
enabled,
}: {
items: PublicSponsorItem[];
title: string;
enabled: boolean;
}) {
if (!enabled || items.length === 0) return null;
return (
<section className="j13-module" aria-label={title || "赞助商"}>
<p className="j13-module-title mb-3">
<Handshake size={15} style={{ color: "var(--accent)" }} />
{title || "赞助商"}
</p>
<ul className="j13-sponsors-grid">
{items.map((sp) => {
const tip = (sp.hover_text || sp.name || "").trim();
const href = sp.link_url?.trim() || undefined;
const inner = (
// eslint-disable-next-line @next/next/no-img-element
<img src={sp.logo_url} alt={sp.name} className="j13-sponsor-logo" loading="lazy" />
);
return (
<li key={sp.id} className="j13-sponsor">
{href ? (
<a
href={href}
target="_blank"
rel="noopener noreferrer sponsored"
className="j13-sponsor-hit"
aria-label={sp.name}
data-tip={tip || undefined}
>
{inner}
</a>
) : (
<span
className="j13-sponsor-hit"
tabIndex={0}
aria-label={sp.name}
data-tip={tip || undefined}
>
{inner}
</span>
)}
</li>
);
})}
</ul>
</section>
);
}
function CommunityPanelsBody({
stats,
hot,
activeUsers,
announcements,
newUsers,
checkin,
allowComments,
canManageAnnouncements,
canManagePages,
sidebarPages = [],
siteDescription,
ads = [],
adsPanelTitle = "自助推广",
adsEnabled = true,
sponsors = [],
sponsorsPanelTitle = "赞助商",
sponsorsEnabled = true,
}: PanelsProps) {
return (
<>
<PulseCard stats={stats} checkin={checkin} allowComments={allowComments} />
<Announcements items={announcements} canManageAnnouncements={canManageAnnouncements} />
<AdsPanel items={ads} title={adsPanelTitle} enabled={adsEnabled} />
<SponsorsPanel items={sponsors} title={sponsorsPanelTitle} enabled={sponsorsEnabled} />
<HotRanking posts={hot} allowComments={allowComments} />
<NewUsers users={newUsers} />
<ActiveUsers users={activeUsers} allowComments={allowComments} />
<FriendLinks />
<AboutSite
pages={sidebarPages}
siteDescription={siteDescription}
canManagePages={canManagePages}
/>
</>
);
}
export function CommunityPanels(props: PanelsProps) {
return (
<aside
className="hidden w-[280px] shrink-0 lg:block xl:w-[300px]"
aria-label="社区面板"
>
<div className="space-y-4">
<CommunityPanelsBody {...props} />
</div>
</aside>
);
}
export function CommunityPanelsMobile(props: PanelsProps) {
return (
<div className="space-y-4 lg:hidden" aria-label="社区面板">
<CommunityPanelsBody {...props} />
</div>
);
}