diff --git a/src/app/api/public/companies/list/route.ts b/src/app/api/public/companies/list/route.ts
new file mode 100644
index 0000000..fafc769
--- /dev/null
+++ b/src/app/api/public/companies/list/route.ts
@@ -0,0 +1,29 @@
+import { NextResponse } from "next/server";
+import { createAdminClient } from "@/lib/supabase/admin";
+
+// Cache aggressively — directory rarely changes, and 1k+ companies is ~50KB.
+export const revalidate = 600;
+
+export async function GET() {
+ try {
+ const svc = createAdminClient();
+ const { data } = await svc
+ .from("tracked_companies")
+ .select("company_name, slug")
+ .eq("directory_visible", true)
+ .not("slug", "is", null)
+ .not("company_name", "is", null);
+
+ const rows = (data || [])
+ .filter((r: any) => r.company_name && r.slug)
+ .map((r: any) => ({ name: String(r.company_name).trim(), slug: String(r.slug) }))
+ .filter((r) => r.name.length >= 3);
+
+ return NextResponse.json(
+ { companies: rows },
+ { headers: { "Cache-Control": "public, s-maxage=600, stale-while-revalidate=1200" } },
+ );
+ } catch (e: any) {
+ return NextResponse.json({ companies: [], error: e?.message || "error" }, { status: 500 });
+ }
+}
diff --git a/src/app/forum/thread/[id]/page.tsx b/src/app/forum/thread/[id]/page.tsx
index bec39c6..32798aa 100644
--- a/src/app/forum/thread/[id]/page.tsx
+++ b/src/app/forum/thread/[id]/page.tsx
@@ -8,6 +8,8 @@ import AdImage from '@/components/AdImage';
import { ADS_300X250, rotateAll } from '@/lib/ads';
import { createClient } from '@/lib/supabase/client';
import SidebarAdStack from "@/components/SidebarAdStack";
+import LinkifyPlainText from "@/components/LinkifyPlainText";
+import CompanyMentionsHover from "@/components/CompanyMentionsHover";
// Inline ad slot inserted between forum replies every N posts.
function InThreadAd({ adIndex }: { adIndex: number }) {
@@ -368,7 +370,9 @@ export default function ForumThreadPage() {
·
{thread.view_count} views
-
{thread.body}
+
+
+
·
{timeAgo(reply.created_at)}
- {reply.body}
+
+
+
+
>
);
}
diff --git a/src/app/home-page/page.tsx b/src/app/home-page/page.tsx
index 3e9f9ec..6023d50 100644
--- a/src/app/home-page/page.tsx
+++ b/src/app/home-page/page.tsx
@@ -9,6 +9,8 @@ import SpotlightCarousel from "./components/SpotlightCarousel";
import ArticleFeed from "./components/ArticleFeed";
import ScrollRevealSection from "@/components/ScrollRevealSection";
import NewsletterSignup from "./components/NewsletterSignup";
+import SponsorLogoStrip from "@/components/SponsorLogoStrip";
+import CompanyMentionsHover from "@/components/CompanyMentionsHover";
export const metadata: Metadata = {
title: 'Broadcast Beat — Broadcast Engineering News & Insights',
@@ -166,8 +168,16 @@ export default function HomePage() {
+ {/* Coverage Partners logo strip — current banner advertisers */}
+
+
+
+
+
+
{/* Footer */}
+
);
}
\ No newline at end of file
diff --git a/src/app/manufacturers/[slug]/page.tsx b/src/app/manufacturers/[slug]/page.tsx
index 7ded85d..45032c0 100644
--- a/src/app/manufacturers/[slug]/page.tsx
+++ b/src/app/manufacturers/[slug]/page.tsx
@@ -41,11 +41,28 @@ async function loadManufacturer(slug: string) {
.order("sort_order")
.order("name");
+ // Recent stories: title contains the company name. The same heuristic the
+ // article auto-linker and the hover card use — keeps the directory in sync
+ // with the editorial coverage even when manufacturer_id isn't backfilled.
+ const needle = String(data.company_name || "").trim();
+ let recentStories: any[] = [];
+ if (needle.length >= 3) {
+ const { data: stories } = await supabase
+ .from("wp_imported_posts")
+ .select("wp_slug, title, excerpt, featured_image, wp_published_at, category")
+ .eq("status", "published")
+ .ilike("title", `%${needle.replace(/[%_]/g, "")}%`)
+ .order("wp_published_at", { ascending: false, nullsFirst: false })
+ .limit(12);
+ recentStories = stories || [];
+ }
+
return {
company: data,
shows: shows || [],
pressReleases: pressReleases || [],
executives: executives || [],
+ recentStories,
};
}
@@ -86,7 +103,7 @@ export default async function ManufacturerProfile({
const { slug } = await params;
const result = await loadManufacturer(slug);
if (!result) notFound();
- const { company, shows, pressReleases, executives } = result;
+ const { company, shows, pressReleases, executives, recentStories } = result;
const jsonLd = {
"@context": "https://schema.org",
@@ -339,9 +356,46 @@ export default async function ManufacturerProfile({
)}
+ {recentStories.length > 0 && (
+
+
+ Recent coverage
+
+ All stories →
+
+
+
+ {recentStories.slice(0, 6).map((r: any) => (
+
+ {r.featured_image ? (
+ /* eslint-disable-next-line @next/next/no-img-element */
+

+ ) : (
+
+ )}
+
+
+ {r.title}
+
+ {r.wp_published_at && (
+
+ {new Date(r.wp_published_at).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
+
+ )}
+
+
+ ))}
+
+
+ )}
+
{pressReleases.length > 0 && (
- Recent news
+ Press releases
{pressReleases.map((p) => (
-
diff --git a/src/app/nab-2026/page.tsx b/src/app/nab-2026/page.tsx
new file mode 100644
index 0000000..d3b3418
--- /dev/null
+++ b/src/app/nab-2026/page.tsx
@@ -0,0 +1,234 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import Header from "@/components/Header";
+import Footer from "@/components/Footer";
+import { createClient } from "@/lib/supabase/server";
+import { createAdminClient } from "@/lib/supabase/admin";
+import CompanyMentionsHover from "@/components/CompanyMentionsHover";
+
+export const revalidate = 600;
+
+export const metadata: Metadata = {
+ title: "NAB Show 2026 — Live Coverage Hub | Broadcast Beat",
+ description:
+ "Broadcast Beat's live coverage of NAB Show 2026: 1,100+ exhibitors, the latest product launches, booth-by-booth news, and editorial pick stories from the show floor.",
+ alternates: { canonical: "/nab-2026" },
+ openGraph: {
+ title: "NAB Show 2026 — Live Coverage Hub",
+ description:
+ "Live coverage, exhibitor list, and breaking product news from NAB Show 2026.",
+ type: "website",
+ url: "https://broadcastbeat.com/nab-2026",
+ },
+};
+
+type ExhibitorRow = {
+ slug: string;
+ company_name: string;
+ logo_url: string | null;
+ featured: boolean;
+ mention_count: number;
+ categories: string[] | null;
+};
+
+type ArticleRow = {
+ wp_slug: string;
+ title: string;
+ excerpt: string | null;
+ featured_image: string | null;
+ wp_published_at: string | null;
+};
+
+export default async function NabHub() {
+ const supabase = await createClient();
+
+ const { data: rawExhibitors } = await supabase
+ .from("tracked_companies")
+ .select("slug, company_name, logo_url, featured, mention_count, categories")
+ .eq("directory_visible", true)
+ .eq("exhibits_nab", true)
+ .not("slug", "is", null)
+ .order("featured", { ascending: false })
+ .order("mention_count", { ascending: false })
+ .order("company_name", { ascending: true })
+ .limit(2000);
+
+ const exhibitors = (rawExhibitors || []) as ExhibitorRow[];
+
+ // Sponsor lookup so the hub can bubble paying advertisers to the top.
+ let sponsors = new Set();
+ try {
+ const adv = createAdminClient("adv");
+ const { data: ads } = await adv
+ .from("active_advertisers_by_property")
+ .select("company_name_lower")
+ .eq("property", "broadcastbeat");
+ sponsors = new Set((ads || []).map((r: any) => String(r.company_name_lower)));
+ } catch { /* tolerate */ }
+
+ // Group exhibitors: sponsors first, then everything else alphabetically.
+ const sponsorRows = exhibitors.filter((e) => sponsors.has(e.company_name.toLowerCase()));
+ const otherRows = exhibitors
+ .filter((e) => !sponsors.has(e.company_name.toLowerCase()))
+ .sort((a, b) => a.company_name.localeCompare(b.company_name));
+
+ // Recent NAB stories — anything published in the last 60 days with NAB in
+ // the title or tags. Cap at 30.
+ const sixtyDaysAgo = new Date(Date.now() - 60 * 24 * 3600 * 1000).toISOString();
+ const { data: rawArticles } = await supabase
+ .from("wp_imported_posts")
+ .select("wp_slug, title, excerpt, featured_image, wp_published_at")
+ .eq("status", "published")
+ .or("title.ilike.%NAB%,title.ilike.%NAB 2026%")
+ .gte("wp_published_at", sixtyDaysAgo)
+ .order("wp_published_at", { ascending: false })
+ .limit(30);
+
+ const articles = (rawArticles || []) as ArticleRow[];
+
+ return (
+
+
+
+
+ {/* Hero */}
+
+
+
+
+ Live coverage hub
+
+ April 11–16, 2026 · Las Vegas
+
+
+ NAB Show 2026
+
+
+ {exhibitors.length.toLocaleString()} exhibitors confirmed.
+ {sponsorRows.length > 0 && <> {sponsorRows.length} are Broadcast Beat coverage partners.>}
+ {" "}Browse the directory, read the latest product news, and find every booth.
+
+
+
+
+
+
+ {/* Featured / Coverage Partners */}
+ {sponsorRows.length > 0 && (
+
+
+
+ Coverage Partners at NAB
+
+ {sponsorRows.length} exhibiting partner{sponsorRows.length === 1 ? "" : "s"}
+
+
+ {sponsorRows.map((e) => (
+
+
+ {e.logo_url ? (
+ /* eslint-disable-next-line @next/next/no-img-element */
+

+ ) : (
+
{e.company_name.charAt(0).toUpperCase()}
+ )}
+
+
{e.company_name}
+
Sponsor
+
+ ))}
+
+
+ )}
+
+ {/* Recent NAB-related stories */}
+ {articles.length > 0 && (
+
+
+
+ Latest from the show floor
+
+
+ See all NAB coverage →
+
+
+
+ {articles.slice(0, 9).map((a) => (
+
+ {a.featured_image && (
+ /* eslint-disable-next-line @next/next/no-img-element */
+

+ )}
+
+
+ {a.title}
+
+ {a.wp_published_at && (
+
+ {new Date(a.wp_published_at).toLocaleDateString("en-US", { month: "short", day: "numeric" })}
+
+ )}
+
+
+ ))}
+
+
+ )}
+
+ {/* Full exhibitor list */}
+
+
+
+ All NAB 2026 exhibitors
+
+
+ Full directory →
+
+
+
+ {otherRows.slice(0, 200).map((e) => (
+
+ {e.logo_url ? (
+ /* eslint-disable-next-line @next/next/no-img-element */
+

+ ) : (
+
+ {e.company_name.charAt(0).toUpperCase()}
+
+ )}
+
{e.company_name}
+
+ ))}
+
+ {otherRows.length > 200 && (
+
+ Showing 200 of {otherRows.length.toLocaleString()} exhibitors.{" "}
+ Browse the full directory →
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/components/LinkifyPlainText.tsx b/src/components/LinkifyPlainText.tsx
new file mode 100644
index 0000000..f40398f
--- /dev/null
+++ b/src/components/LinkifyPlainText.tsx
@@ -0,0 +1,125 @@
+"use client";
+
+import Link from "next/link";
+import { useEffect, useState } from "react";
+
+type Company = { name: string; slug: string; needle: string };
+
+// Module-level cache: fetched once per page load, reused across every
+// LinkifyPlainText instance on the page.
+let companyListPromise: Promise | null = null;
+
+function loadCompanies(): Promise {
+ if (companyListPromise) return companyListPromise;
+ companyListPromise = (async () => {
+ try {
+ const r = await fetch("/api/public/companies/list");
+ if (!r.ok) return [];
+ const j = await r.json();
+ const rows: Company[] = (j.companies || []).map((c: any) => ({
+ name: c.name,
+ slug: c.slug,
+ needle: String(c.name).toLowerCase(),
+ }));
+ // Longest first so "Sony Pictures" beats "Sony"
+ rows.sort((a, b) => b.needle.length - a.needle.length);
+ return rows;
+ } catch {
+ return [];
+ }
+ })();
+ return companyListPromise;
+}
+
+function escapeRegex(s: string): string {
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+}
+
+interface Segment {
+ type: "text" | "link";
+ text: string;
+ slug?: string;
+}
+
+function buildSegments(text: string, companies: Company[], maxLinks = 10): Segment[] {
+ if (!text) return [];
+ if (companies.length === 0) return [{ type: "text", text }];
+
+ const out: Segment[] = [];
+ let cursor = 0;
+ const seen = new Set();
+ let linkCount = 0;
+
+ while (cursor < text.length && linkCount < maxLinks) {
+ // Find earliest match starting at or after cursor
+ let best: { start: number; end: number; company: Company } | null = null;
+ for (const c of companies) {
+ if (seen.has(c.slug)) continue;
+ const re = new RegExp(`(^|[^A-Za-z0-9])(${escapeRegex(c.name)})(?![A-Za-z0-9])`, "i");
+ re.lastIndex = 0;
+ const m = re.exec(text.slice(cursor));
+ if (!m) continue;
+ const absStart = cursor + m.index + m[1].length;
+ const absEnd = absStart + m[2].length;
+ if (!best || absStart < best.start || (absStart === best.start && absEnd - absStart > best.end - best.start)) {
+ best = { start: absStart, end: absEnd, company: c };
+ }
+ }
+ if (!best) break;
+ if (best.start > cursor) {
+ out.push({ type: "text", text: text.slice(cursor, best.start) });
+ }
+ out.push({ type: "link", text: text.slice(best.start, best.end), slug: best.company.slug });
+ seen.add(best.company.slug);
+ linkCount++;
+ cursor = best.end;
+ }
+ if (cursor < text.length) out.push({ type: "text", text: text.slice(cursor) });
+ return out;
+}
+
+export default function LinkifyPlainText({
+ body,
+ className,
+ preserveWhitespace = true,
+}: {
+ body: string;
+ className?: string;
+ preserveWhitespace?: boolean;
+}) {
+ const [companies, setCompanies] = useState(null);
+
+ useEffect(() => {
+ let alive = true;
+ loadCompanies().then((c) => { if (alive) setCompanies(c); });
+ return () => { alive = false; };
+ }, []);
+
+ const segments = companies ? buildSegments(body || "", companies) : null;
+ const wsClass = preserveWhitespace ? "whitespace-pre-wrap" : "";
+ const combined = `${wsClass} ${className || ""}`.trim();
+
+ if (!segments) {
+ // Companies still loading — render raw text so we don't FOUC.
+ return {body};
+ }
+
+ return (
+
+ {segments.map((s, i) =>
+ s.type === "link" && s.slug ? (
+
+ {s.text}
+
+ ) : (
+ {s.text}
+ ),
+ )}
+
+ );
+}
diff --git a/src/components/SponsorLogoStrip.tsx b/src/components/SponsorLogoStrip.tsx
new file mode 100644
index 0000000..4044751
--- /dev/null
+++ b/src/components/SponsorLogoStrip.tsx
@@ -0,0 +1,89 @@
+import Link from "next/link";
+import { createAdminClient } from "@/lib/supabase/admin";
+
+type Sponsor = {
+ slug: string;
+ name: string;
+ logoUrl: string | null;
+};
+
+async function fetchSponsors(): Promise {
+ try {
+ const adv = createAdminClient("adv");
+ const { data: ads } = await adv
+ .from("active_advertisers_by_property")
+ .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) {
+ for (const n of missing) {
+ const { data: hit } = await bb
+ .from("tracked_companies")
+ .select("slug, company_name, logo_url")
+ .eq("directory_visible", true)
+ .ilike("company_name", n)
+ .limit(1)
+ .maybeSingle();
+ if (hit) bySlug.set(hit.slug, { slug: hit.slug, name: hit.company_name, logoUrl: hit.logo_url || null });
+ }
+ }
+
+ return Array.from(bySlug.values()).filter((s) => s.logoUrl); // only show those with a logo
+ } catch {
+ return [];
+ }
+}
+
+export default async function SponsorLogoStrip() {
+ const sponsors = await fetchSponsors();
+ if (sponsors.length === 0) return null;
+
+ return (
+
+
+
+ Coverage Partners
+
+
+
+ Advertise →
+
+
+
+ {sponsors.map((s) => (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+

+
+ ))}
+
+
+ );
+}