feat: add ads/sponsor/captcha modules and polish home & chat UI

This commit is contained in:
2026-09-23 04:39:58 +08:00
parent 96314893bb
commit c1f6a6636b
27 changed files with 4278 additions and 121 deletions

View File

@@ -13,6 +13,8 @@ import {
TrendingUp,
Pin,
ChevronRight,
CircleDollarSign,
Handshake,
} from "lucide-react";
import type {
PostListItem,
@@ -22,13 +24,17 @@ import type {
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 } from "@/lib/ads";
/* ---------- 站点数据(2×2 bento 统计瓦片) ---------- */
@@ -427,8 +433,157 @@ type PanelsProps = {
canManagePages?: boolean;
sidebarPages?: SidebarPageItem[];
siteDescription?: string;
ads?: PublicAdItem[];
adsPanelTitle?: string;
adsEnabled?: boolean;
sponsors?: PublicSponsorItem[];
sponsorsPanelTitle?: string;
sponsorsEnabled?: boolean;
};
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="meta ml-auto text-[12px] font-normal transition-colors hover:text-[var(--accent)]"
>
购买广告位
</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"
>
{/* 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}
/>
</li>
) : (
<li key={`txt-slot-${i}`}>
<Link href="/ads/buy" className="j13-ad-slot j13-ad-slot-text">
文字广告位
</Link>
</li>
)
)}
</ul>
</div>
</section>
);
}
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,
@@ -441,11 +596,19 @@ function CommunityPanelsBody({
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} />