homepage: bento 3-up + 300x600 inline; word-safe excerpt; bigger sponsor logos

- FeaturedBentoFromDb: row below the hero is now a 3-up rail on the left
  with the Blackmagic 300x600 on the right, aligned with the hero's right
  edge. SidebarAdStack skips that 300x600 so the article-feed sidebar
  starts with the 300x250 stack instead.
- cleanExcerpt: render-time word-snap for excerpts that were hard-cut at a
  byte count (Matrox 50-years showed "Since 1976, the co" mid-word). Used
  on the bento hero subhead and the article-feed cards.
- SponsorLogoStrip: now also pulls every tracked_companies.featured=true
  brand (Rode, TBC Consoles, Plura, Autocue, Autoscript, Filmcraft,
  Lectrosonics, Prompter People, SanDisk, Sennheiser), and logos go from
  h-10/h-12 max-w-140 → h-14/h-16 max-w-180 with thicker padding.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ryan Salazar
2026-05-29 10:56:38 +00:00
parent 0541103c89
commit 3b74f610a2
5 changed files with 110 additions and 48 deletions

View File

@@ -4,6 +4,7 @@ import AppImage from "@/components/ui/AppImage";
import SidebarAdStack from "@/components/SidebarAdStack"; import SidebarAdStack from "@/components/SidebarAdStack";
import AdImage from "@/components/AdImage"; import AdImage from "@/components/AdImage";
import { rotateAll, type Ad } from "@/lib/ads"; import { rotateAll, type Ad } from "@/lib/ads";
import { cleanExcerpt } from "@/lib/articles/excerpt";
import { PersonIcon, ChevronLeftIcon, ChevronRightIcon, SearchIcon, CloseIcon } from "@/components/ui/Icons"; import { PersonIcon, ChevronLeftIcon, ChevronRightIcon, SearchIcon, CloseIcon } from "@/components/ui/Icons";
import StarRating from "@/components/StarRating"; import StarRating from "@/components/StarRating";
import Link from "next/link"; import Link from "next/link";
@@ -632,7 +633,7 @@ export default function ArticleFeed() {
</Link> </Link>
</h3> </h3>
<p className="font-body text-[#777] text-sm leading-relaxed line-clamp-2 mb-2 md:mb-2.5 hidden sm:block"> <p className="font-body text-[#777] text-sm leading-relaxed line-clamp-2 mb-2 md:mb-2.5 hidden sm:block">
{article?.excerpt} {cleanExcerpt(article?.excerpt)}
</p> </p>
<div className="mb-2"> <div className="mb-2">
<StarRating seed={article?.slug || article?.title || ""} publishedAt={article?.date} size="sm" /> <StarRating seed={article?.slug || article?.title || ""} publishedAt={article?.date} size="sm" />
@@ -807,10 +808,11 @@ export default function ArticleFeed() {
)} )}
</div> </div>
{/* Sidebar — Blackmagic 300x600 fixed at top + every 300x250 banner stacked */} {/* Sidebar — 300x250 banners stacked. The 300x600 is rendered up in
FeaturedBento alongside the 3-up rail, so it's skipped here. */}
<aside aria-label="Advertisements"> <aside aria-label="Advertisements">
<div className="lg:sticky lg:top-24"> <div className="lg:sticky lg:top-24">
<SidebarAdStack /> <SidebarAdStack skipTop600 />
</div> </div>
</aside> </aside>
</div> </div>

View File

@@ -2,6 +2,9 @@ import Link from "next/link";
import { createClient } from "@supabase/supabase-js"; import { createClient } from "@supabase/supabase-js";
import { rewriteLegacyImageUrl } from "@/lib/legacy-image"; import { rewriteLegacyImageUrl } from "@/lib/legacy-image";
import StarRating from "@/components/StarRating"; import StarRating from "@/components/StarRating";
import AdImage from "@/components/AdImage";
import { pickAds } from "@/lib/ads";
import { cleanExcerpt } from "@/lib/articles/excerpt";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -53,7 +56,11 @@ export default async function FeaturedBento() {
if (rows.length === 0) return null; if (rows.length === 0) return null;
const hero = rows[0]; const hero = rows[0];
const rail = rows.slice(1, 5); // 3-up rail: room is reserved on the right of the same row for the
// Blackmagic 300x600 banner — see grid below. Dropped the 4th card so
// the banner can sit baseline-aligned with the hero's right edge.
const rail = rows.slice(1, 4);
const heroAd = pickAds("300x600", 1)[0] || null;
function img(r: Row): string { function img(r: Row): string {
return r.featured_image ? rewriteLegacyImageUrl(r.featured_image) : "/assets/images/article-placeholder.svg"; return r.featured_image ? rewriteLegacyImageUrl(r.featured_image) : "/assets/images/article-placeholder.svg";
@@ -102,7 +109,7 @@ export default async function FeaturedBento() {
</h3> </h3>
{hero.excerpt && ( {hero.excerpt && (
<p className="font-serif text-base md:text-lg text-[#cbd5e1] mt-3 max-w-2xl line-clamp-2"> <p className="font-serif text-base md:text-lg text-[#cbd5e1] mt-3 max-w-2xl line-clamp-2">
{hero.excerpt} {cleanExcerpt(hero.excerpt)}
</p> </p>
)} )}
<div className="mt-4 flex flex-wrap items-center gap-3 text-[11px] font-mono text-[#9ca3af]"> <div className="mt-4 flex flex-wrap items-center gap-3 text-[11px] font-mono text-[#9ca3af]">
@@ -114,8 +121,12 @@ export default async function FeaturedBento() {
</div> </div>
</Link> </Link>
{/* 4-up rail BELOW the hero, vertical cards */} {/* Row below the hero — 3-up rail (left) + Blackmagic 300x600 (right).
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-4"> Right column is locked to 300px so its edge aligns with the right
edge of the 21:9 hero above; SidebarAdStack downstream skips this
banner so it doesn't double-render. */}
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_300px] gap-4 mt-4 items-start">
<div className="grid grid-cols-3 gap-3">
{rail.map((r) => ( {rail.map((r) => (
<Link <Link
key={r.wp_slug} key={r.wp_slug}
@@ -143,6 +154,16 @@ export default async function FeaturedBento() {
</Link> </Link>
))} ))}
</div> </div>
<aside className="hidden lg:block" aria-label="Premium sponsor">
{heroAd ? (
<AdImage ad={heroAd} priority />
) : (
<div className="w-[300px] h-[600px] bg-[#0d1520] border border-[#1e3a5f] rounded flex items-center justify-center text-[#888] text-xs uppercase tracking-widest">
Premium Sponsorship
</div>
)}
</aside>
</div>
</section> </section>
); );
} }

View File

@@ -12,13 +12,18 @@ import { pickAds, rotateAll, type Ad } from "@/lib/ads";
export default function SidebarAdStack({ export default function SidebarAdStack({
excludeSrcs = [], excludeSrcs = [],
className = "", className = "",
// When true, omit the leading 300x600 banner. Used on the homepage where
// the 300x600 is rendered up in the FeaturedBento row instead, to keep
// the article feed sidebar starting with the 300x250 stack.
skipTop600 = false,
}: { }: {
excludeSrcs?: string[]; excludeSrcs?: string[];
className?: string; className?: string;
skipTop600?: boolean;
}) { }) {
const top600 = useMemo<Ad | null>( const top600 = useMemo<Ad | null>(
() => pickAds("300x600", 1)[0] ?? null, () => (skipTop600 ? null : pickAds("300x600", 1)[0] ?? null),
[excludeSrcs.join("|")], [excludeSrcs.join("|"), skipTop600],
); );
const rotating = useMemo<Ad[]>( const rotating = useMemo<Ad[]>(
() => rotateAll("300x250", new Set(excludeSrcs)), () => rotateAll("300x250", new Set(excludeSrcs)),

View File

@@ -15,23 +15,22 @@ async function fetchSponsors(): Promise<Sponsor[]> {
.select("company_name_lower") .select("company_name_lower")
.eq("property", "broadcastbeat"); .eq("property", "broadcastbeat");
const names = Array.from(new Set((ads || []).map((r: any) => String(r.company_name_lower)))); const names = Array.from(new Set((ads || []).map((r: any) => String(r.company_name_lower))));
if (names.length === 0) return [];
const bb = createAdminClient(); const bb = createAdminClient();
const bySlug = new Map<string, Sponsor>();
// Layer 1 — currently active advertisers.
if (names.length > 0) {
const { data: dir } = await bb const { data: dir } = await bb
.from("tracked_companies") .from("tracked_companies")
.select("slug, company_name, logo_url") .select("slug, company_name, logo_url")
.eq("directory_visible", true) .eq("directory_visible", true)
.in("company_name", names.map((n) => n)); // case-sensitive — directory should match .in("company_name", names.map((n) => n));
// Some directory entries may have slightly different casing — fall back
// to a case-insensitive single-name lookup for anything missed.
const bySlug = new Map<string, Sponsor>();
for (const d of (dir || []) as any[]) { for (const d of (dir || []) as any[]) {
bySlug.set(d.slug, { slug: d.slug, name: d.company_name, logoUrl: d.logo_url || null }); bySlug.set(d.slug, { slug: d.slug, name: d.company_name, logoUrl: d.logo_url || null });
} }
const matchedNames = new Set((dir || []).map((d: any) => String(d.company_name).toLowerCase())); const matchedNames = new Set((dir || []).map((d: any) => String(d.company_name).toLowerCase()));
const missing = names.filter((n) => !matchedNames.has(n)); const missing = names.filter((n) => !matchedNames.has(n));
if (missing.length > 0) {
for (const n of missing) { for (const n of missing) {
const { data: hit } = await bb const { data: hit } = await bb
.from("tracked_companies") .from("tracked_companies")
@@ -44,7 +43,21 @@ async function fetchSponsors(): Promise<Sponsor[]> {
} }
} }
return Array.from(bySlug.values()).filter((s) => s.logoUrl); // only show those with a logo // Layer 2 — pinned coverage partners (directory.featured=true). These
// surface here whether or not they have an active ad campaign.
const { data: pinned } = await bb
.from("tracked_companies")
.select("slug, company_name, logo_url")
.eq("directory_visible", true)
.eq("featured", true)
.limit(40);
for (const d of (pinned || []) as any[]) {
if (!bySlug.has(d.slug)) {
bySlug.set(d.slug, { slug: d.slug, name: d.company_name, logoUrl: d.logo_url || null });
}
}
return Array.from(bySlug.values()).filter((s) => s.logoUrl);
} catch { } catch {
return []; return [];
} }
@@ -78,7 +91,7 @@ export default async function SponsorLogoStrip() {
<img <img
src={s.logoUrl || ""} src={s.logoUrl || ""}
alt={s.name} alt={s.name}
className="h-10 md:h-12 w-auto object-contain max-w-[140px] bg-white p-1.5 rounded" className="h-14 md:h-16 w-auto object-contain max-w-[180px] bg-white p-2 rounded"
loading="lazy" loading="lazy"
/> />
</Link> </Link>

View File

@@ -0,0 +1,21 @@
// Render-time excerpt sanitizer. Some imported / AI-generated excerpts were
// hard-truncated at a fixed byte count, leaving sentences clipped mid-word
// (e.g. "Since 1976, the co"). Until the upstream excerpt generator gets the
// same treatment, this helper makes any clipped excerpt visually clean:
// strip trailing junk, snap to the last complete word, and append an
// ellipsis only when the excerpt isn't already a complete sentence.
const SENTENCE_END = /[.!?…]['")\]]?$/;
const TRAILING_JUNK = /[\s,;:—–\-—_]+$/;
export function cleanExcerpt(raw: string | null | undefined): string {
if (!raw) return "";
let s = raw.replace(/&nbsp;/g, " ").trim();
if (!s) return "";
if (SENTENCE_END.test(s)) return s;
// Drop the trailing partial token.
const lastSpace = s.lastIndexOf(" ");
if (lastSpace > 20) s = s.slice(0, lastSpace);
s = s.replace(TRAILING_JUNK, "");
return s + "…";
}