- {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.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() {
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 + "…";
+}