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

@@ -0,0 +1,73 @@
"use client";
import { useEffect, useRef, useState, type CSSProperties } from "react";
type AdTextProps = {
title: string;
textColor?: string;
bgColor?: string;
href?: string;
className?: string;
};
/**
* 文字广告:仅当文字宽度超出容器时才启用跑马灯滚动,
* 能完整显示时居中静止。结构与样式复用侧栏 .j13-ad-text。
*/
export function AdText({ title, textColor, bgColor, href, className = "" }: AdTextProps) {
const trackRef = useRef<HTMLSpanElement>(null);
const [overflow, setOverflow] = useState(false);
useEffect(() => {
const track = trackRef.current;
if (!track) return;
const measure = () => {
const run = track.querySelector<HTMLElement>(".j13-ad-text-run");
const container = track.parentElement;
if (!run || !container) return;
setOverflow(run.scrollWidth > container.clientWidth);
};
measure();
window.addEventListener("resize", measure);
return () => window.removeEventListener("resize", measure);
}, [title]);
const cls = `j13-ad-text${overflow ? " is-overflow" : ""}${className ? ` ${className}` : ""}`;
const style: CSSProperties = { color: textColor, background: bgColor };
const inner = (
<span
className={`j13-ad-text-track${overflow ? " is-overflow" : ""}`}
ref={trackRef}
>
<span className="j13-ad-text-run">{title}</span>
{overflow ? (
<span className="j13-ad-text-run" aria-hidden>
{title}
</span>
) : null}
</span>
);
if (href) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer sponsored"
className={cls}
style={style}
data-tip={title}
>
{inner}
</a>
);
}
return (
<span className={cls} style={style} data-tip={title}>
{inner}
</span>
);
}
export default AdText;

View File

@@ -1,4 +1,4 @@
"use client";
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";

View File

@@ -263,12 +263,7 @@ export default function Header({
onKeyDown={onSearchKeyDown}
placeholder="搜索帖子..."
aria-label="搜索帖子"
className="w-full pl-10 pr-10 py-2.5 text-sm rounded-full"
style={{
background: "var(--panel)",
color: "var(--ink)",
border: "1px solid var(--line)",
}}
className="j13-search-field w-full pl-10 pr-10 py-2.5 text-sm rounded-full"
/>
<kbd
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-[11px] w-5 h-5 flex items-center justify-center rounded-md pointer-events-none"
@@ -364,8 +359,7 @@ export default function Header({
onKeyDown={onSearchKeyDown}
aria-label="搜索帖子"
placeholder="搜索帖子...(Esc 清除)"
className="w-full pl-10 pr-4 py-3 rounded-full text-sm"
style={{ background: "var(--panel)", color: "var(--ink)", border: "1px solid var(--line-2)" }}
className="j13-search-field w-full pl-10 pr-4 py-3 rounded-full text-sm"
/>
</form>
<div className="flex flex-col">

View File

@@ -1,4 +1,4 @@
"use client";
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
@@ -22,6 +22,7 @@ import {
Settings,
ChevronDown,
FileText,
CircleDollarSign,
} from "lucide-react";
import type { User } from "@/lib/api";
import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
@@ -87,10 +88,16 @@ const NAV_GROUPS: NavGroup[] = [
},
{
href: "/admin/pages",
label: "单页",
label: "单页管理",
icon: FileText,
visible: (u) => isAdminOrAbove(u.role),
},
{
href: "/admin/ads",
label: "推广与赞助",
icon: CircleDollarSign,
visible: (u) => isAdminOrAbove(u.role),
},
],
},
{

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} />