feat: add ads/sponsor/captcha modules and polish home & chat UI
This commit is contained in:
73
frontend/components/AdText.tsx
Normal file
73
frontend/components/AdText.tsx
Normal 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;
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user