From 3b74f610a20cf1a59b9f0c7162cc41096eb4945d Mon Sep 17 00:00:00 2001 From: Ryan Salazar Date: Fri, 29 May 2026 10:56:38 +0000 Subject: [PATCH] homepage: bento 3-up + 300x600 inline; word-safe excerpt; bigger sponsor logos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- src/app/home-page/components/ArticleFeed.tsx | 8 ++- src/components/FeaturedBentoFromDb.tsx | 75 +++++++++++++------- src/components/SidebarAdStack.tsx | 9 ++- src/components/SponsorLogoStrip.tsx | 45 +++++++----- src/lib/articles/excerpt.ts | 21 ++++++ 5 files changed, 110 insertions(+), 48 deletions(-) create mode 100644 src/lib/articles/excerpt.ts diff --git a/src/app/home-page/components/ArticleFeed.tsx b/src/app/home-page/components/ArticleFeed.tsx index c1a3356..cba81b4 100644 --- a/src/app/home-page/components/ArticleFeed.tsx +++ b/src/app/home-page/components/ArticleFeed.tsx @@ -4,6 +4,7 @@ import AppImage from "@/components/ui/AppImage"; import SidebarAdStack from "@/components/SidebarAdStack"; import AdImage from "@/components/AdImage"; import { rotateAll, type Ad } from "@/lib/ads"; +import { cleanExcerpt } from "@/lib/articles/excerpt"; import { PersonIcon, ChevronLeftIcon, ChevronRightIcon, SearchIcon, CloseIcon } from "@/components/ui/Icons"; import StarRating from "@/components/StarRating"; import Link from "next/link"; @@ -632,7 +633,7 @@ export default function ArticleFeed() {

- {article?.excerpt} + {cleanExcerpt(article?.excerpt)}

@@ -807,10 +808,11 @@ export default function ArticleFeed() { )}
- {/* 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. */} diff --git a/src/components/FeaturedBentoFromDb.tsx b/src/components/FeaturedBentoFromDb.tsx index a8a52a9..f73658d 100644 --- a/src/components/FeaturedBentoFromDb.tsx +++ b/src/components/FeaturedBentoFromDb.tsx @@ -2,6 +2,9 @@ import Link from "next/link"; import { createClient } from "@supabase/supabase-js"; import { rewriteLegacyImageUrl } from "@/lib/legacy-image"; 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"; @@ -53,7 +56,11 @@ export default async function FeaturedBento() { if (rows.length === 0) return null; 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 { return r.featured_image ? rewriteLegacyImageUrl(r.featured_image) : "/assets/images/article-placeholder.svg"; @@ -102,7 +109,7 @@ export default async function FeaturedBento() { {hero.excerpt && (

- {hero.excerpt} + {cleanExcerpt(hero.excerpt)}

)}
@@ -114,34 +121,48 @@ export default async function FeaturedBento() {
- {/* 4-up rail BELOW the hero, vertical cards */} -
- {rail.map((r) => ( - -
- {r.featured_image_alt -
-
-
- {r.category || "News"} + {/* Row below the hero — 3-up rail (left) + Blackmagic 300x600 (right). + 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. */} +
+
+ {rail.map((r) => ( + +
+ {r.featured_image_alt
-

- {r.title} -

-
- +
+
+ {r.category || "News"} +
+

+ {r.title} +

+
+ +
+ + ))} +
+
); diff --git a/src/components/SidebarAdStack.tsx b/src/components/SidebarAdStack.tsx index c0e7a0d..e91c215 100644 --- a/src/components/SidebarAdStack.tsx +++ b/src/components/SidebarAdStack.tsx @@ -12,13 +12,18 @@ import { pickAds, rotateAll, type Ad } from "@/lib/ads"; export default function SidebarAdStack({ excludeSrcs = [], 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[]; className?: string; + skipTop600?: boolean; }) { const top600 = useMemo( - () => pickAds("300x600", 1)[0] ?? null, - [excludeSrcs.join("|")], + () => (skipTop600 ? null : pickAds("300x600", 1)[0] ?? null), + [excludeSrcs.join("|"), skipTop600], ); const rotating = useMemo( () => rotateAll("300x250", new Set(excludeSrcs)), diff --git a/src/components/SponsorLogoStrip.tsx b/src/components/SponsorLogoStrip.tsx index 4044751..b43c5ea 100644 --- a/src/components/SponsorLogoStrip.tsx +++ b/src/components/SponsorLogoStrip.tsx @@ -15,23 +15,22 @@ async function fetchSponsors(): Promise { .select("company_name_lower") .eq("property", "broadcastbeat"); const names = Array.from(new Set((ads || []).map((r: any) => String(r.company_name_lower)))); - if (names.length === 0) return []; const bb = createAdminClient(); - const { data: dir } = await bb - .from("tracked_companies") - .select("slug, company_name, logo_url") - .eq("directory_visible", true) - .in("company_name", names.map((n) => n)); // case-sensitive — directory should match - // Some directory entries may have slightly different casing — fall back - // to a case-insensitive single-name lookup for anything missed. const bySlug = new Map(); - for (const d of (dir || []) as any[]) { - 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 missing = names.filter((n) => !matchedNames.has(n)); - if (missing.length > 0) { + + // Layer 1 — currently active advertisers. + if (names.length > 0) { + const { data: dir } = await bb + .from("tracked_companies") + .select("slug, company_name, logo_url") + .eq("directory_visible", true) + .in("company_name", names.map((n) => n)); + for (const d of (dir || []) as any[]) { + 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 missing = names.filter((n) => !matchedNames.has(n)); for (const n of missing) { const { data: hit } = await bb .from("tracked_companies") @@ -44,7 +43,21 @@ async function fetchSponsors(): Promise { } } - 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 { return []; } @@ -78,7 +91,7 @@ export default async function SponsorLogoStrip() { {s.name} diff --git a/src/lib/articles/excerpt.ts b/src/lib/articles/excerpt.ts new file mode 100644 index 0000000..94ebfd4 --- /dev/null +++ b/src/lib/articles/excerpt.ts @@ -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(/ /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 + "…"; +}