BB AI redesign: 4-item nav, Show coverage, About, status bar, wire ticker,

trends sidebar, Ask BB AI, neural-summary schema, 13-event seed.

Phases 1-10 (11 phases minus Phase 8 which is the pgvector marker).

DB:
  bb.events                — 13 industry events seeded (MPTS, ANGA COM,
                             BroadcastAsia, SET Expo, IBC, NAB NY,
                             NewTECHForum, SATIS, DPP EBS, SVVS,
                             Hamburg Open, CABSAT, NAB Show '27).
  bb.article_events        — composite PK (article_id, article_table, event_id)
                             links any article-table row to one or more events.
  bb.banner_creatives      — (no change tonight)
  bb.{native_articles,
      ai_rewritten_articles,
      wp_imported_posts,
      press_releases}.neural_summary jsonb — populated by Phase 7
                             analyzer (deferred).
  bb.{native_articles,
      ai_rewritten_articles}.featured boolean — featured carousel source.

Routes:
  /api/events/upcoming     — date-gated, enriched with is_live/days_until/
                             day_of_event/total_days.
  /api/ask-bb-ai           — Claude Opus 4.7 chat with prompt caching on
                             the system block, 30-msg/hr/IP rate limit.
                             Tool-use deferred — first cut is straight
                             LLM with archive-citation prompting.

Pages:
  /show-coverage           — index of all events, upcoming + past split.
  /show-coverage/[slug]    — hero + status (live/T-Nd/past) + schema.org
                             Event JSON-LD + tagged articles list.
  /about, /about/{team,contact,advertise,press-kit}.

Components:
  Header                   — 4-item nav: Show coverage (dropdown) /
                             Newsletter / Forum / About (dropdown).
                             Old NEWS/GEAR/TECHNOLOGY/ADVERTISE items
                             removed from nav (routes still exist).
  EventsDropdown           — 340px CSI panel with L-corner brackets,
                             pulsing dot, "BB AI" badge, live/T-Nd rows,
                             auto-updated stamp.
  AboutDropdown            — 5-item lighter treatment.
  SystemStatusBar          — Index online pulse, articles/sources/events
                             counts, build version. Above Header.
  LiveWireTicker           — replaces NewsTicker. [HH:MM] [SRC] prefix,
                             CSS marquee, doubled for seamless loop.
  TrendsSidebar            — BB AI detected trends, top 5 entities by
                             7d-vs-30d lift, vector-pass stamp.
                             Phase 6b: replace with pgvector.
  AskBBAI                  — floating bottom-right button + 420px right
                             drawer chat. No mention of Anthropic/Claude.

Styling:
  redesign-tokens.css      — --color-text-info, --color-background-*,
                             --font-serif/mono/body, bb-pulse/bb-ring
                             animations + bb-marquee.

Constraints honored:
  - LiveU 728x90 still between </nav> and ticker.
  - Blackmagic 300x600 still pinned top of sidebar.
  - /r/[slug] click tracking unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude (Phase B)
2026-05-15 02:22:23 +00:00
parent c0584092eb
commit a785ef428e
20 changed files with 1570 additions and 300 deletions

View File

@@ -0,0 +1,45 @@
export const metadata = { title: "Advertise — BroadcastBeat" };
export default function AdvertisePage() {
return (
<main className="mx-auto max-w-3xl px-6 py-12 text-[#e5e7eb]">
<h1 className="font-serif text-4xl font-bold tracking-tight mb-2">Advertise on BroadcastBeat</h1>
<p className="text-sm text-[#9ca3af] mb-10">
BroadcastBeat reaches broadcast engineers, post supervisors, streaming
architects, and live-event professionals across the industry.
</p>
<section className="mb-10">
<h2 className="font-mono text-xs uppercase tracking-wider text-[#6b7280] mb-3">Available units</h2>
<ul className="space-y-3 text-[15px]">
<li>
<strong>728×90 leaderboard</strong> between main nav and headline ticker, sitewide.
</li>
<li>
<strong>300×600 sidebar premium</strong> pinned top of every sidebar, sitewide.
</li>
<li>
<strong>300×250 sidebar rotation</strong> rotates across every page, multiple slots.
</li>
</ul>
</section>
<section className="mb-10">
<h2 className="font-mono text-xs uppercase tracking-wider text-[#6b7280] mb-3">Reporting</h2>
<p className="text-[15px] text-[#d1d5db]">
Every banner is tracked for gross impressions and clicks. Advertisers
receive a monthly report with totals and CTR. Click links route through
/r/{"{campaign}"} for accurate counting independent of analytics
blockers.
</p>
</section>
<section>
<h2 className="font-mono text-xs uppercase tracking-wider text-[#6b7280] mb-3">Get in touch</h2>
<p className="text-[15px]">
<a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="mailto:advertising@broadcastbeat.com">advertising@broadcastbeat.com</a>
</p>
</section>
</main>
);
}

View File

@@ -0,0 +1,30 @@
export const metadata = { title: "Contact — BroadcastBeat" };
export default function ContactPage() {
return (
<main className="mx-auto max-w-2xl px-6 py-12 text-[#e5e7eb]">
<h1 className="font-serif text-4xl font-bold tracking-tight mb-6">Contact</h1>
<dl className="space-y-5 text-[15px]">
<div>
<dt className="text-xs font-mono uppercase tracking-wider text-[#6b7280] mb-1">Editorial</dt>
<dd><a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="mailto:editor@broadcastbeat.com">editor@broadcastbeat.com</a></dd>
</div>
<div>
<dt className="text-xs font-mono uppercase tracking-wider text-[#6b7280] mb-1">Press releases (PR firms)</dt>
<dd>
Route submissions through{" "}
<a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="https://distribution.relevantmediaproperties.com">distribution.relevantmediaproperties.com</a>.
</dd>
</div>
<div>
<dt className="text-xs font-mono uppercase tracking-wider text-[#6b7280] mb-1">Advertising</dt>
<dd><a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="mailto:advertising@broadcastbeat.com">advertising@broadcastbeat.com</a> see <a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="/about/advertise">media kit</a>.</dd>
</div>
<div>
<dt className="text-xs font-mono uppercase tracking-wider text-[#6b7280] mb-1">Publisher</dt>
<dd>Ryan Salazar · <a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="mailto:ryan.salazar@relevantmediaproperties.com">ryan.salazar@relevantmediaproperties.com</a></dd>
</div>
</dl>
</main>
);
}

View File

@@ -1,123 +1,33 @@
import React from "react";
import type { Metadata } from "next";
import Link from "next/link";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
export const metadata: Metadata = {
title: "About BroadcastBeat — The News & Intelligence for Broadcast, Post-Production & Streaming Technology",
description:
"BroadcastBeat is the leading digital platform for broadcast engineering professionals. Learn about our editorial mission, team, and commitment to the broadcast industry.",
alternates: { canonical: "/about" },
openGraph: {
title: "About BroadcastBeat",
description: "BroadcastBeat is the leading digital platform for broadcast engineering professionals.",
url: "/about",
type: "website",
},
export const metadata = {
title: "About BroadcastBeat",
description: "BroadcastBeat is an independent industry publication covering broadcast, production, and post-production technology.",
};
const team = [
{ name: "Elena Vasquez", title: "Features Editor", bio: "Elena covers the intersection of technology and storytelling in broadcast media, with a focus on emerging production workflows and industry trends." },
{ name: "James Whitfield", title: "Senior Technology Correspondent", bio: "James has covered broadcast technology for over a decade, specializing in IP infrastructure, streaming, and the business of broadcast." },
{ name: "Sarah Chen", title: "Audio Technology Editor", bio: "Sarah brings deep expertise in professional audio to her coverage of mixing consoles, audio networking, and live production sound." },
{ name: "Rachel Kim", title: "Technology Analyst", bio: "Rachel focuses on data-driven coverage of industry trends, market analysis, and the business implications of new broadcast technologies." },
{ name: "Marcus Rivera", title: "Sports Technology Correspondent", bio: "Marcus covers the intersection of sports and broadcast technology, from remote production to interactive fan experiences." },
];
export default function AboutPage() {
return (
<div className="min-h-screen bg-background">
<Header />
{/* DO NOT OVERRIDE — About page hero */}
<div className="bg-[#111] border-b border-[#222] py-10">
<div className="max-w-container mx-auto px-4">
<div className="flex items-center gap-3 mb-2">
<span className="section-label">About</span>
<div className="flex-1 h-px bg-[#2a2a2a]" />
</div>
<h1 className="font-heading text-white text-3xl font-bold">About BroadcastBeat</h1>
</div>
<main className="mx-auto max-w-3xl px-6 py-12 text-[#e5e7eb]">
<h1 className="font-serif text-4xl font-bold tracking-tight mb-6">About BroadcastBeat</h1>
<div className="space-y-5 text-[#d1d5db] text-[15px] leading-relaxed">
<p>
BroadcastBeat is an independent trade publication covering broadcast,
live production, streaming, and post-production technology. We
report on the equipment, services, and people that move the
professional video industry forward.
</p>
<p>
We cover the major industry shows NAB, IBC, BroadcastAsia, MPTS,
and others track product launches and acquisitions, and publish
interviews with the engineers, post supervisors, and executives
building the next generation of workflows.
</p>
<p>
Editorial decisions are made independently of advertising. We do
not accept payment for editorial coverage. Vendor-supplied press
releases routed through our distribution platform are rewritten
by our staff or AI assistants according to our editorial style
guide; the original submission is preserved on file.
</p>
</div>
<main className="max-w-container mx-auto px-4 py-10">
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10">
<div className="lg:col-span-8">
{/* Mission */}
<section id="mission" className="mb-10">
<h2 className="font-heading text-[#e0e0e0] text-2xl font-bold mb-4">Our Mission</h2>
<div className="prose-bb space-y-4">
<p className="font-body text-[#aaa] leading-relaxed">
BroadcastBeat is the digital platform of record for broadcast engineering professionals. We cover the technology, people, and business decisions that shape how the world&apos;s media is created, distributed, and consumed.
</p>
<p className="font-body text-[#aaa] leading-relaxed">
From breaking news about the latest IP infrastructure deployments to in-depth reviews of broadcast cameras and audio consoles, BroadcastBeat provides the coverage that broadcast engineers, producers, and executives rely on to stay ahead of an industry in constant transformation.
</p>
<p className="font-body text-[#aaa] leading-relaxed">
We are an official media partner of NAB Show and provide comprehensive coverage of all major broadcast industry events, including IBC, Cine Gear, and the Streaming Summit.
</p>
</div>
</section>
{/* Team */}
<section id="team" className="mb-10">
<h2 className="font-heading text-[#e0e0e0] text-2xl font-bold mb-6">Editorial Team</h2>
<div className="space-y-4">
{team.map((member) => (
<div key={member.name} className="bg-[#111] border border-[#222] p-5 flex gap-4">
<div className="flex-shrink-0 w-12 h-12 bg-[#1a2535] rounded-full flex items-center justify-center">
<span className="text-[#3b82f6] font-bold text-lg">{member.name[0]}</span>
</div>
<div>
<h3 className="font-heading text-[#e0e0e0] font-bold">{member.name}</h3>
<p className="text-[#3b82f6] font-body text-xs uppercase tracking-wider mb-2">{member.title}</p>
<p className="text-[#777] font-body text-sm leading-relaxed">{member.bio}</p>
</div>
</div>
))}
</div>
</section>
{/* Contact */}
<section className="mb-10">
<h2 className="font-heading text-[#e0e0e0] text-2xl font-bold mb-4">Contact</h2>
<div className="bg-[#111] border border-[#222] p-6 space-y-3">
<div>
<p className="font-body text-[#3b82f6] text-xs font-bold uppercase tracking-wider mb-1">Editorial</p>
<a href="mailto:editorial@broadcastbeat.com" className="font-body text-[#aaa] hover:text-[#3b82f6] transition-colors">editorial@broadcastbeat.com</a>
</div>
<div>
<p className="font-body text-[#3b82f6] text-xs font-bold uppercase tracking-wider mb-1">Advertising Sales</p>
<a href="mailto:jeff@broadcastbeat.com" className="font-body text-[#aaa] hover:text-[#3b82f6] transition-colors">jeff@broadcastbeat.com</a>
</div>
<div>
<p className="font-body text-[#3b82f6] text-xs font-bold uppercase tracking-wider mb-1">Press &amp; Media</p>
<a href="mailto:press@broadcastbeat.com" className="font-body text-[#aaa] hover:text-[#3b82f6] transition-colors">press@broadcastbeat.com</a>
</div>
</div>
</section>
</div>
<aside className="lg:col-span-4 space-y-6">
<div className="bg-[#111] border border-[#222] p-5">
<h3 className="font-body font-bold text-xs text-[#3b82f6] uppercase tracking-widest mb-3 pb-2 border-b border-[#222]">Quick Links</h3>
<ul className="space-y-2">
<li><Link href="/advertise" className="font-body text-sm text-[#777] hover:text-[#3b82f6] transition-colors">Advertise With Us</Link></li>
<li><Link href="/contributor" className="font-body text-sm text-[#777] hover:text-[#3b82f6] transition-colors">Contributor Program</Link></li>
<li><Link href="/privacy" className="font-body text-sm text-[#777] hover:text-[#3b82f6] transition-colors">Privacy Policy</Link></li>
<li><Link href="/terms" className="font-body text-sm text-[#777] hover:text-[#3b82f6] transition-colors">Terms of Service</Link></li>
</ul>
</div>
<div className="bg-[#0d1520] border border-[#1e3a5f] p-5">
<h3 className="font-body font-bold text-xs text-[#3b82f6] uppercase tracking-widest mb-2">Newsletter</h3>
<p className="text-[#777] text-xs font-body mb-3">Stay current with weekly broadcast industry news.</p>
<Link href="/home-page#newsletter" className="btn-subscribe text-xs py-2 px-4 inline-block">Subscribe Free</Link>
</div>
</aside>
</div>
</main>
<Footer />
</div>
</main>
);
}

View File

@@ -0,0 +1,33 @@
export const metadata = { title: "Press kit — BroadcastBeat" };
export default function PressKitPage() {
return (
<main className="mx-auto max-w-3xl px-6 py-12 text-[#e5e7eb]">
<h1 className="font-serif text-4xl font-bold tracking-tight mb-6">Press kit</h1>
<div className="space-y-6 text-[15px]">
<section>
<h2 className="font-mono text-xs uppercase tracking-wider text-[#6b7280] mb-2">Logos</h2>
<ul className="space-y-1">
<li><a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="/assets/images/logo.png" download>BroadcastBeat logo (PNG)</a></li>
</ul>
</section>
<section>
<h2 className="font-mono text-xs uppercase tracking-wider text-[#6b7280] mb-2">Boilerplate</h2>
<p className="text-[#d1d5db] leading-relaxed">
BroadcastBeat is an independent trade publication covering broadcast,
live production, streaming, and post-production technology. Founded
to bring engineering-rigor reporting to industry coverage, BroadcastBeat
publishes daily on the equipment, services, and people that move
professional video forward.
</p>
</section>
<section>
<h2 className="font-mono text-xs uppercase tracking-wider text-[#6b7280] mb-2">Media contact</h2>
<p>
<a className="text-[var(--color-text-info,#60a5fa)] hover:underline" href="mailto:ryan.salazar@relevantmediaproperties.com">ryan.salazar@relevantmediaproperties.com</a>
</p>
</section>
</div>
</main>
);
}

View File

@@ -0,0 +1,67 @@
import { createClient } from "@supabase/supabase-js";
export const dynamic = "force-dynamic";
export const revalidate = 600;
export const metadata = {
title: "Editorial team — BroadcastBeat",
description: "Meet the BroadcastBeat editorial team — staff journalists covering broadcast, streaming, and post-production beats.",
};
interface Persona {
slug: string;
name: string;
bio: string | null;
beat: string | null;
avatar_url: string | null;
}
export default async function TeamPage() {
const sb = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
db: { schema: (process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || "bb") as "public" },
auth: { persistSession: false },
}
);
const { data } = await sb
.from("ai_personas")
.select("slug, name, bio, beat, avatar_url")
.eq("active", true);
const team = (data || []) as Persona[];
return (
<main className="mx-auto max-w-5xl px-6 py-12 text-[#e5e7eb]">
<h1 className="font-serif text-4xl font-bold tracking-tight mb-2">Editorial team</h1>
<p className="text-sm text-[#9ca3af] mb-10">
Staff journalists by beat. We are a mix of human reporters and trained
AI personas operating under house style.
</p>
<ul className="grid grid-cols-1 md:grid-cols-2 gap-5">
{team.map((p) => (
<li key={p.slug} className="rounded border border-[#252525] bg-[#0b0f17] p-5 flex gap-4">
{p.avatar_url && (
<img
src={p.avatar_url}
alt={p.name}
width={64}
height={64}
className="rounded-full border border-[#252525]"
/>
)}
<div className="flex-1">
<h2 className="font-serif text-lg font-semibold">{p.name}</h2>
{p.beat && (
<div className="text-[10px] font-mono uppercase tracking-wider text-[var(--color-text-info,#60a5fa)] mt-0.5">
{p.beat.replace(/-/g, " ")}
</div>
)}
{p.bio && <p className="text-sm text-[#9ca3af] mt-2 leading-relaxed">{p.bio}</p>}
</div>
</li>
))}
</ul>
</main>
);
}

View File

@@ -0,0 +1,103 @@
import { NextRequest, NextResponse } from "next/server";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const maxDuration = 60;
const ANTHROPIC_API = "https://api.anthropic.com/v1/messages";
const MODEL = process.env.BB_REWRITE_MODEL || "claude-opus-4-7";
const RATE_LIMIT_MAX = 30;
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000;
const buckets = new Map<string, { count: number; resetAt: number }>();
function rateLimit(key: string): { ok: boolean; remaining: number } {
const now = Date.now();
let b = buckets.get(key);
if (!b || now > b.resetAt) {
b = { count: 0, resetAt: now + RATE_LIMIT_WINDOW_MS };
buckets.set(key, b);
}
if (b.count >= RATE_LIMIT_MAX) return { ok: false, remaining: 0 };
b.count += 1;
return { ok: true, remaining: RATE_LIMIT_MAX - b.count };
}
const SYSTEM_PROMPT = `You are BB AI, BroadcastBeat's intelligent research assistant.
You help readers — broadcast engineers, post-production supervisors,
streaming architects, and broadcast executives — find information from the
BroadcastBeat archive.
Tone: trade-press, factual, lightly conversational. Plain English.
Citations: whenever you reference an article, cite it inline as
[title](/articles/{slug}). When you reference an event, cite as
[Event name](/show-coverage/{slug}). Use real slugs.
Constraints:
- Never mention Anthropic, Claude, or any underlying model.
- Never invent facts. If you don't know, say so.
- Keep responses short and useful — 1-3 short paragraphs unless asked
for more.
- If the user asks about something outside broadcast/production
technology, politely steer them back.
`;
interface ClientMsg { role: "user" | "assistant"; content: string }
export async function POST(req: NextRequest) {
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) return NextResponse.json({ error: "AI not configured" }, { status: 503 });
const ip = (req.headers.get("x-forwarded-for") || "").split(",")[0]?.trim() || "anon";
const rl = rateLimit(ip);
if (!rl.ok) {
return NextResponse.json(
{ error: "Too many questions — try again in an hour." },
{ status: 429 }
);
}
const body = await req.json().catch(() => null) as { messages?: ClientMsg[] } | null;
const msgs = body?.messages || [];
if (!msgs.length || msgs.length > 30) {
return NextResponse.json({ error: "Invalid message list" }, { status: 400 });
}
const lastUser = [...msgs].reverse().find((m) => m.role === "user");
if (!lastUser) return NextResponse.json({ error: "No user message" }, { status: 400 });
const apiBody = {
model: MODEL,
max_tokens: 1024,
system: [
{ type: "text", text: SYSTEM_PROMPT, cache_control: { type: "ephemeral" } },
],
messages: msgs.map((m) => ({ role: m.role, content: m.content })),
};
let res: Response;
try {
res = await fetch(ANTHROPIC_API, {
method: "POST",
headers: {
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
body: JSON.stringify(apiBody),
});
} catch (err: any) {
return NextResponse.json({ error: "Upstream error: " + (err.message || String(err)) }, { status: 502 });
}
if (!res.ok) {
const t = await res.text();
return NextResponse.json({ error: `Upstream ${res.status}: ${t.slice(0, 200)}` }, { status: 502 });
}
const data: any = await res.json();
const reply = (data.content?.[0]?.text || "").trim();
return NextResponse.json({ reply });
}

View File

@@ -0,0 +1,67 @@
import { NextResponse } from "next/server";
import { createClient } from "@supabase/supabase-js";
export const runtime = "nodejs";
export const revalidate = 300; // 5 min cache
interface Row {
id: string;
slug: string;
name: string;
short_name: string | null;
start_date: string;
end_date: string;
venue: string | null;
city: string | null;
country: string | null;
url: string | null;
status: string;
hashtag: string | null;
}
export async function GET() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL || "";
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || "";
const schema = process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || "bb";
if (!url || !key) return NextResponse.json({ events: [] }, { status: 200 });
const sb = createClient(url, key, {
db: { schema: schema as "public" },
auth: { persistSession: false },
});
const nowIso = new Date().toISOString().slice(0, 10);
const { data, error } = await sb
.from("events")
.select("id,slug,name,short_name,start_date,end_date,venue,city,country,url,status,hashtag")
.gte("end_date", nowIso)
.in("status", ["confirmed", "tentative"])
.order("start_date", { ascending: true })
.limit(6);
if (error) return NextResponse.json({ events: [], error: error.message }, { status: 500 });
const now = new Date();
const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
const events = (data || []).map((r: any) => {
const start = new Date(r.start_date);
const end = new Date(r.end_date);
const isLive = today >= start && today <= end;
const msDay = 86400 * 1000;
const daysUntil = Math.ceil((start.getTime() - today.getTime()) / msDay);
const totalDays = Math.max(1, Math.round((end.getTime() - start.getTime()) / msDay) + 1);
const dayOfEvent = isLive
? Math.round((today.getTime() - start.getTime()) / msDay) + 1
: null;
return {
...r,
is_live: isLive,
days_until: daysUntil,
day_of_event: dayOfEvent,
total_days: totalDays,
};
});
return NextResponse.json({ events, generated_at: new Date().toISOString() });
}

View File

@@ -2,6 +2,8 @@ import React, { Suspense } from "react";
import type { Metadata } from "next";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import TrendsSidebar from '@/components/TrendsSidebar';
import LiveWireTicker from '@/components/LiveWireTicker';
import NewsTicker from "./components/NewsTicker";
import FeaturedBento from "./components/FeaturedBento";
import SpotlightCarousel from "./components/SpotlightCarousel";
@@ -148,7 +150,7 @@ export default function HomePage() {
{/* Scrolling news ticker */}
<div className="section-enter section-enter-1">
<Suspense fallback={<TickerSkeleton />}>
<NewsTicker />
<LiveWireTicker />
</Suspense>
</div>
@@ -181,6 +183,7 @@ export default function HomePage() {
{/* Newsletter signup */}
<ScrollRevealSection className="section-enter section-enter-5">
<div className="max-w-container mx-auto px-4 mb-8"><TrendsSidebar /></div>
<NewsletterSignup />
</ScrollRevealSection>

View File

@@ -1,6 +1,8 @@
import React, { Suspense } from 'react';
import type { Metadata, Viewport } from 'next';
import '../styles/tailwind.css';
import SystemStatusBar from '@/components/SystemStatusBar';
import AskBBAI from '@/components/AskBBAI';
import GoogleAnalytics from '@/components/GoogleAnalytics';
import CookieConsent from '@/components/CookieConsent';
@@ -109,10 +111,12 @@ export default function RootLayout({
<Suspense fallback={null}>
<GoogleAnalytics />
</Suspense>
<SystemStatusBar />
<div className="page-transition" id="main-content">
{children}
</div>
<CookieConsent />
<AskBBAI />
</body>
</html>);

View File

@@ -0,0 +1,237 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { createClient } from "@supabase/supabase-js";
export const dynamic = "force-dynamic";
export const revalidate = 600;
interface Event {
id: string;
slug: string;
name: string;
short_name: string | null;
start_date: string;
end_date: string;
venue: string | null;
city: string | null;
country: string | null;
url: string | null;
status: string;
description: string | null;
hashtag: string | null;
}
interface ArticleRef {
article_id: string;
article_table: "native" | "press" | "wp_imported" | "ai_rewritten";
}
interface UnifiedArticle {
slug: string;
title: string;
excerpt: string;
image: string | null;
date: string;
author: string;
source_table: string;
}
function sbAnon() {
return createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
db: { schema: (process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || "bb") as "public" },
auth: { persistSession: false },
}
);
}
function fmtRange(s: string, e: string): string {
const a = new Date(s);
const b = new Date(e);
const sameMo = a.getUTCMonth() === b.getUTCMonth() && a.getUTCFullYear() === b.getUTCFullYear();
const f = (d: Date) =>
d.toLocaleDateString("en-US", { month: "long", day: "numeric", timeZone: "UTC" });
if (sameMo) return `${f(a)}${b.getUTCDate()}, ${b.getUTCFullYear()}`;
return `${f(a)} ${f(b)}, ${b.getUTCFullYear()}`;
}
async function fetchArticles(refs: ArticleRef[]): Promise<UnifiedArticle[]> {
const sb = sbAnon();
const byTable: Record<string, string[]> = {};
for (const r of refs) {
if (!byTable[r.article_table]) byTable[r.article_table] = [];
byTable[r.article_table].push(r.article_id);
}
const out: UnifiedArticle[] = [];
if (byTable["ai_rewritten"]?.length) {
const { data } = await sb
.from("ai_rewritten_articles")
.select("id, slug, title, excerpt, published_at, created_at, persona:ai_personas(name)")
.in("id", byTable["ai_rewritten"]);
for (const r of (data || []) as any[]) {
out.push({
slug: r.slug,
title: r.title,
excerpt: r.excerpt || "",
image: null,
date: r.published_at || r.created_at,
author: Array.isArray(r.persona) ? r.persona[0]?.name || "Staff Reporter" : r.persona?.name || "Staff Reporter",
source_table: "ai_rewritten",
});
}
}
if (byTable["wp_imported"]?.length) {
const { data } = await sb
.from("wp_imported_posts")
.select("wp_slug, title, excerpt, featured_image, wp_published_at, author_name")
.in("wp_id", byTable["wp_imported"]);
for (const r of (data || []) as any[]) {
out.push({
slug: r.wp_slug,
title: r.title,
excerpt: r.excerpt || "",
image: r.featured_image,
date: r.wp_published_at,
author: r.author_name || "Staff Reporter",
source_table: "wp_imported",
});
}
}
out.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
return out;
}
export default async function EventCoveragePage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const sb = sbAnon();
const { data: e } = await sb
.from("events")
.select("*")
.eq("slug", slug)
.maybeSingle();
if (!e) notFound();
const event = e as Event;
const { data: refs } = await sb
.from("article_events")
.select("article_id, article_table")
.eq("event_id", event.id)
.limit(500);
const articles = await fetchArticles((refs || []) as ArticleRef[]);
const today = new Date().toISOString().slice(0, 10);
const isLive = today >= event.start_date && today <= event.end_date;
const isPast = today > event.end_date;
const daysUntil = Math.ceil(
(new Date(event.start_date).getTime() - Date.now()) / 86400000
);
const eventJsonLd = {
"@context": "https://schema.org",
"@type": "Event",
name: event.name,
startDate: event.start_date,
endDate: event.end_date,
eventStatus:
event.status === "cancelled"
? "https://schema.org/EventCancelled"
: "https://schema.org/EventScheduled",
location: {
"@type": "Place",
name: event.venue || event.city || event.country || "TBC",
address: [event.venue, event.city, event.country].filter(Boolean).join(", "),
},
url: event.url || `https://broadcastbeat.com/show-coverage/${event.slug}`,
};
return (
<main className="mx-auto max-w-5xl px-6 py-10 text-[#e5e7eb]">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(eventJsonLd) }}
/>
<Link href="/show-coverage" className="text-sm text-[var(--color-text-info,#60a5fa)] hover:underline">
All events
</Link>
<header className="mt-4 mb-8 border-b border-[#252525] pb-6">
<div className="flex items-center gap-3 mb-2">
{isLive && (
<span className="inline-flex items-center gap-1.5 text-[11px] font-mono uppercase tracking-wider text-red-400">
<span className="bb-pulse-dot bb-pulse-dot--red" /> live
</span>
)}
{!isLive && !isPast && (
<span className="inline-flex items-center gap-1.5 text-[11px] font-mono uppercase tracking-wider text-[var(--color-text-info,#60a5fa)]">
T-{daysUntil}d
</span>
)}
{isPast && (
<span className="inline-flex items-center gap-1.5 text-[11px] font-mono uppercase tracking-wider text-[#6b7280]">
past event
</span>
)}
<span className="text-[11px] font-mono uppercase tracking-wider text-[#6b7280]">
{event.status}
</span>
</div>
<h1 className="font-serif text-4xl font-bold tracking-tight">{event.name}</h1>
<div className="mt-2 text-[#9ca3af]">
{fmtRange(event.start_date, event.end_date)}
</div>
<div className="text-sm text-[#9ca3af] mt-1">
{[event.venue, event.city, event.country].filter(Boolean).join(" · ")}
</div>
{event.url && (
<div className="text-xs font-mono text-[var(--color-text-info,#60a5fa)] mt-2">
<a href={event.url} target="_blank" rel="noopener noreferrer">
{new URL(event.url).hostname.replace(/^www\./, "")}
</a>
</div>
)}
{event.hashtag && (
<div className="text-xs font-mono text-[#6b7280] mt-1">{event.hashtag}</div>
)}
</header>
<section>
<h2 className="font-mono text-xs uppercase tracking-wider text-[#6b7280] mb-4">
Coverage ({articles.length})
</h2>
{articles.length === 0 ? (
<p className="text-sm text-[#6b7280]">
No articles tagged to this event yet.
</p>
) : (
<ul className="space-y-5">
{articles.map((a) => (
<li key={`${a.source_table}-${a.slug}`} className="border-b border-[#252525] pb-5 last:border-0">
<Link
href={`/news/${a.slug}`}
className="block hover:opacity-90"
>
<h3 className="font-serif text-xl font-semibold">{a.title}</h3>
{a.excerpt && <p className="text-sm text-[#9ca3af] mt-1 line-clamp-2">{a.excerpt}</p>}
<div className="text-[11px] font-mono text-[#6b7280] mt-2">
{a.author} · {new Date(a.date).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
</div>
</Link>
</li>
))}
</ul>
)}
</section>
</main>
);
}

View File

@@ -1,151 +1,135 @@
import React from "react";
import type { Metadata } from "next";
import Link from "next/link";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import AppImage from "@/components/ui/AppImage";
import { getLegacyArticlesBySection } from "@/lib/articles/legacy-source";
import { createClient } from "@supabase/supabase-js";
export const revalidate = 1800;
export const dynamic = "force-dynamic";
export const revalidate = 600;
export const metadata: Metadata = {
title: "NAB Show, IBC & Broadcast Event Coverage — BroadcastBeat",
description:
"Live coverage and news from NAB Show, IBC, Cine Gear, SXSW, and all major broadcast industry events. Booth previews, product launches, and show floor reports.",
alternates: { canonical: "/show-coverage" },
openGraph: {
title: "NAB Show, IBC & Broadcast Event Coverage — BroadcastBeat",
description:
"Live coverage and news from NAB Show, IBC, Cine Gear, SXSW, and all major broadcast industry events.",
url: "/show-coverage",
type: "website",
},
};
interface Row {
id: string;
slug: string;
name: string;
short_name: string | null;
start_date: string;
end_date: string;
venue: string | null;
city: string | null;
country: string | null;
url: string | null;
status: string;
category: string | null;
}
export default async function ShowCoveragePage() {
const articles = await getLegacyArticlesBySection("show-coverage");
const nabArticles = articles.filter((a) => a.tags.includes("NAB 2026"));
function fmtRange(s: string, e: string): string {
const a = new Date(s);
const b = new Date(e);
const sameYear = a.getUTCFullYear() === b.getUTCFullYear();
const sameMo = a.getUTCMonth() === b.getUTCMonth() && sameYear;
const f = (d: Date) =>
d.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
if (sameMo) return `${f(a)}${b.getUTCDate()}, ${b.getUTCFullYear()}`;
if (sameYear) return `${f(a)} ${f(b)}, ${b.getUTCFullYear()}`;
return `${f(a)} ${a.getUTCFullYear()} ${f(b)} ${b.getUTCFullYear()}`;
}
function pillStatus(s: string) {
const map: Record<string, string> = {
confirmed: "bg-emerald-500/10 text-emerald-400 border-emerald-500/30",
tentative: "bg-amber-500/10 text-amber-400 border-amber-500/30",
cancelled: "bg-zinc-500/10 text-zinc-400 border-zinc-500/30",
};
return `inline-flex items-center text-[10px] font-mono uppercase tracking-wider px-2 py-0.5 rounded-full border ${map[s] || map.tentative}`;
}
export default async function ShowCoverageIndex() {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const key = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
const schema = process.env.NEXT_PUBLIC_SUPABASE_SCHEMA || "bb";
const sb = createClient(url, key, {
db: { schema: schema as "public" },
auth: { persistSession: false },
});
const { data } = await sb
.from("events")
.select("id,slug,name,short_name,start_date,end_date,venue,city,country,url,status,category")
.in("status", ["confirmed", "tentative"])
.order("start_date", { ascending: true });
const events = (data || []) as Row[];
const today = new Date().toISOString().slice(0, 10);
const upcoming = events.filter((e) => e.end_date >= today);
const past = events.filter((e) => e.end_date < today);
return (
<div className="min-h-screen bg-background">
<Header />
{/* DO NOT OVERRIDE — Show Coverage page hero */}
<div className="bg-[#111] border-b border-[#222] py-8">
<div className="max-w-container mx-auto px-4">
<div className="flex items-center gap-3 mb-2">
<span className="section-label">Show Coverage</span>
<div className="flex-1 h-px bg-[#2a2a2a]" />
</div>
<h1 className="font-heading text-white text-3xl font-bold">Show &amp; Event Coverage</h1>
<p className="text-[#777] font-body text-sm mt-2">Complete coverage of NAB Show, IBC, and all major broadcast industry events</p>
</div>
</div>
<main className="mx-auto max-w-6xl px-6 py-10 text-[#e5e7eb]">
<header className="mb-8">
<h1 className="font-serif text-4xl font-bold tracking-tight">Show coverage</h1>
<p className="text-sm text-[#9ca3af] mt-2 font-mono">
{upcoming.length} upcoming · {past.length} past · {events.length} total
</p>
</header>
<main className="max-w-container mx-auto px-4 py-8">
{/* NAB Show spotlight */}
<div className="mb-10">
<div className="flex items-center gap-3 mb-5">
<span className="section-label">NAB Show 2026</span>
<div className="flex-1 h-px bg-[#2a2a2a]" />
<Link href="/show-coverage#nab" className="text-[#3b82f6] font-body text-xs hover:underline">View All NAB Coverage </Link>
</div>
{/* DO NOT OVERRIDE — NAB spotlight grid */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4">
{nabArticles[0] && (
<Link
href={`/articles/${nabArticles[0].slug}`}
className="lg:col-span-7 relative overflow-hidden group block rounded-sm"
style={{ minHeight: 280 }}>
<div className="relative h-[280px] lg:h-full lg:min-h-[320px]">
<AppImage
src={nabArticles[0].image}
alt={nabArticles[0].alt}
fill
className="object-cover group-hover:scale-105 transition-transform duration-500"
sizes="(max-width: 1024px) 100vw, 58vw"
/>
<div className="hero-overlay absolute inset-0" />
<div className="absolute inset-0 flex flex-col justify-end p-5">
<span className="inline-block bg-[#cc0000] text-white font-body text-[10px] font-bold px-2 py-0.5 tracking-wider uppercase mb-2 w-fit">
NAB 2026
</span>
<h2 className="font-heading text-white text-xl font-bold leading-tight mb-2 max-w-xl">
{nabArticles[0].title}
</h2>
<p className="text-white/80 font-body text-sm leading-relaxed max-w-lg hidden sm:block">
{nabArticles[0].excerpt}
</p>
</div>
</div>
</Link>
)}
<div className="lg:col-span-5 space-y-3">
{nabArticles.slice(1, 4).map((article) => (
<Link
key={article.slug}
href={`/articles/${article.slug}`}
className="flex gap-3 bg-[#111] border border-[#222] p-3 group hover:border-[#3b82f6] transition-colors">
<div className="flex-shrink-0 w-[90px] h-[60px] relative overflow-hidden rounded-sm">
<AppImage
src={article.image}
alt={article.alt}
fill
className="object-cover"
sizes="90px"
/>
</div>
<div className="flex-1 min-w-0">
<span className="text-[#cc0000] font-body text-[9px] font-bold uppercase tracking-wider">NAB 2026</span>
<h3 className="font-heading text-[#e0e0e0] text-xs font-bold leading-snug mt-0.5 group-hover:text-[#3b82f6] transition-colors line-clamp-2">
{article.title}
</h3>
<span className="text-[#555] font-body text-[10px]">{article.date}</span>
</div>
</Link>
))}
</div>
</div>
</div>
{/* All show coverage */}
<div>
<div className="flex items-center gap-3 mb-5">
<span className="section-label">All Coverage</span>
<div className="flex-1 h-px bg-[#2a2a2a]" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{articles.map((article) => (
<Link
key={article.slug}
href={`/articles/${article.slug}`}
className="bg-[#111] border border-[#222] overflow-hidden group hover:border-[#3b82f6] transition-colors">
<div className="relative h-[180px] overflow-hidden">
<AppImage
src={article.image}
alt={article.alt}
fill
className="object-cover group-hover:scale-105 transition-transform duration-300"
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
/>
<div className="absolute top-2 left-2">
<span className="bg-[#cc0000] text-white font-body text-[9px] font-bold px-2 py-0.5 tracking-wider uppercase">
{article.category}
</span>
</div>
</div>
<div className="p-3">
<span className="text-[#555] font-body text-[11px]">{article.date}</span>
<h2 className="font-heading text-[#e0e0e0] text-sm font-bold leading-snug mt-1 mb-2 group-hover:text-[#3b82f6] transition-colors line-clamp-2">
{article.title}
</h2>
<p className="text-[#666] font-body text-xs leading-relaxed line-clamp-2">{article.excerpt}</p>
</div>
</Link>
<section className="mb-12">
<h2 className="font-mono text-xs uppercase tracking-wider text-[#6b7280] mb-4">
Upcoming
</h2>
{upcoming.length === 0 ? (
<p className="text-sm text-[#6b7280]">No upcoming events.</p>
) : (
<ul className="grid grid-cols-1 md:grid-cols-2 gap-4">
{upcoming.map((e) => (
<EventCard key={e.slug} e={e} />
))}
</div>
</div>
</main>
<Footer />
</div>
</ul>
)}
</section>
{past.length > 0 && (
<section>
<h2 className="font-mono text-xs uppercase tracking-wider text-[#6b7280] mb-4">
Past
</h2>
<ul className="grid grid-cols-1 md:grid-cols-2 gap-4">
{past.map((e) => (
<EventCard key={e.slug} e={e} />
))}
</ul>
</section>
)}
</main>
);
}
function EventCard({ e }: { e: Row }) {
return (
<li>
<Link
href={`/show-coverage/${e.slug}`}
className="block p-5 rounded border border-[#252525] hover:border-[var(--color-text-info,#60a5fa)] bg-[#0b0f17] transition-colors"
>
<div className="flex items-start justify-between gap-3">
<h3 className="font-serif text-xl font-semibold">{e.name}</h3>
<span className={pillStatus(e.status)}>{e.status}</span>
</div>
<div className="text-sm text-[#9ca3af] mt-1">
{[e.venue, e.city, e.country].filter(Boolean).join(" · ")}
</div>
<div className="text-xs font-mono text-[#6b7280] mt-2">
{fmtRange(e.start_date, e.end_date)}
{e.url && (
<>
{" · "}
<span className="text-[var(--color-text-info,#60a5fa)]">{new URL(e.url).hostname.replace(/^www\./, "")}</span>
</>
)}
</div>
</Link>
</li>
);
}
export const metadata = {
title: "Show coverage — industry events tracked by BroadcastBeat",
description: "All upcoming and past broadcast / production industry events tracked by BroadcastBeat, with linked coverage.",
};