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.",
};

View File

@@ -0,0 +1,68 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
const ITEMS = [
{ label: "About us", href: "/about" },
{ label: "Editorial team", href: "/about/team" },
{ label: "Contact", href: "/about/contact" },
{ label: "Advertise", href: "/about/advertise" },
{ label: "Press kit", href: "/about/press-kit" },
];
export default function AboutDropdown() {
const [open, setOpen] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onClick = (e: MouseEvent) => {
if (!wrapRef.current) return;
if (!wrapRef.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => e.key === "Escape" && setOpen(false);
document.addEventListener("mousedown", onClick);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onClick);
document.removeEventListener("keydown", onKey);
};
}, [open]);
return (
<div ref={wrapRef} className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-haspopup="true"
aria-expanded={open}
className="nav-link-bb h-[60px] inline-flex items-center gap-1 focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6]"
>
<span>About</span>
<span aria-hidden="true" className="text-[10px]"></span>
</button>
{open && (
<div
role="menu"
aria-label="About"
className="absolute left-0 top-full mt-1 min-w-[200px] z-50 bg-[var(--color-background-primary,#0a0a0a)] border border-[#252525] text-[#e5e7eb] shadow-xl py-1"
style={{ borderRadius: "var(--border-radius-md,6px)" }}
>
{ITEMS.map((it) => (
<Link
key={it.href}
href={it.href}
role="menuitem"
onClick={() => setOpen(false)}
className="flex items-center justify-between px-4 py-2 text-sm hover:bg-[#1a1a1a] hover:text-[var(--color-text-info,#60a5fa)] focus:outline-none focus-visible:bg-[#1a1a1a] transition-colors"
>
<span>{it.label}</span>
<span aria-hidden="true" className="text-[10px] text-[#6b7280]"></span>
</Link>
))}
</div>
)}
</div>
);
}

169
src/components/AskBBAI.tsx Normal file
View File

@@ -0,0 +1,169 @@
"use client";
import { useEffect, useRef, useState } from "react";
interface Msg {
role: "user" | "assistant";
content: string;
}
export default function AskBBAI() {
const [open, setOpen] = useState(false);
const [input, setInput] = useState("");
const [busy, setBusy] = useState(false);
const [messages, setMessages] = useState<Msg[]>([
{
role: "assistant",
content:
"Hi — I'm BB AI. Ask me anything about broadcast, production, or post technology. I'll cite sources from the BroadcastBeat archive.",
},
]);
const [err, setErr] = useState<string | null>(null);
const drawerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!open) return;
inputRef.current?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open]);
async function send() {
const q = input.trim();
if (!q || busy) return;
setErr(null);
setInput("");
const next = [...messages, { role: "user" as const, content: q }];
setMessages(next);
setBusy(true);
try {
const r = await fetch("/api/ask-bb-ai", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: next }),
});
if (!r.ok) {
const body = await r.json().catch(() => ({}));
setErr(body.error || `HTTP ${r.status}`);
setBusy(false);
return;
}
const data = await r.json();
setMessages([...next, { role: "assistant", content: data.reply || "(no response)" }]);
} catch (e: any) {
setErr(e.message || String(e));
} finally {
setBusy(false);
}
}
return (
<>
{!open && (
<button
type="button"
onClick={() => setOpen(true)}
className="fixed bottom-6 right-6 z-40 bg-black text-white rounded-full pl-5 pr-5 py-3 shadow-2xl flex items-center gap-3 hover:bg-zinc-900 transition-colors"
aria-label="Ask BB AI"
>
<span className="relative inline-block w-3 h-3">
<span className="absolute inset-0 rounded-full bg-[var(--color-text-info,#60a5fa)]" />
<span className="bb-ring" />
</span>
<span className="font-serif">Ask BB AI</span>
<span className="font-mono text-[10px] text-zinc-400 hidden md:inline">12,847 indexed</span>
</button>
)}
{open && (
<div
ref={drawerRef}
role="dialog"
aria-label="Ask BB AI"
className="fixed right-0 top-0 bottom-0 z-50 w-full sm:w-[420px] bg-[#0a0a0a] border-l border-[#252525] flex flex-col"
>
<header className="flex items-center justify-between px-5 py-4 border-b border-[#252525]">
<div className="flex items-center gap-2">
<span className="relative inline-block w-3 h-3">
<span className="absolute inset-0 rounded-full bg-[var(--color-text-info,#60a5fa)]" />
<span className="bb-ring" />
</span>
<h2 className="font-serif text-lg font-semibold text-white">BB AI</h2>
</div>
<button
onClick={() => setOpen(false)}
aria-label="Close"
className="text-zinc-400 hover:text-white"
>
</button>
</header>
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.map((m, i) => (
<div key={i} className={m.role === "user" ? "text-right" : ""}>
<div
className={
"inline-block max-w-[85%] rounded px-3 py-2 text-sm whitespace-pre-wrap " +
(m.role === "user"
? "bg-[var(--color-text-info,#60a5fa)] text-black"
: "bg-[#111] border border-[#252525] text-[#e5e7eb]")
}
dangerouslySetInnerHTML={{ __html: m.role === "assistant" ? linkifyMarkdown(m.content) : escapeHtml(m.content) }}
/>
</div>
))}
{busy && (
<div className="text-xs text-[#6b7280] inline-flex items-center gap-2">
<span className="bb-pulse-dot" /> Thinking
</div>
)}
{err && <div className="text-sm text-red-400">{err}</div>}
</div>
<div className="p-4 border-t border-[#252525]">
<div className="flex gap-2">
<input
ref={inputRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && send()}
placeholder="Ask about an event, a vendor, a workflow…"
disabled={busy}
className="flex-1 bg-[#111] border border-[#252525] rounded px-3 py-2 text-sm text-[#e5e7eb] focus:border-[var(--color-text-info,#60a5fa)] focus:outline-none"
/>
<button
onClick={send}
disabled={busy || !input.trim()}
className="bg-[var(--color-text-info,#60a5fa)] text-black font-semibold px-4 py-2 rounded text-sm disabled:opacity-40 disabled:cursor-not-allowed"
>
Send
</button>
</div>
<p className="text-[10px] font-mono text-[#6b7280] mt-2">
30 messages/hour · cites BroadcastBeat archive
</p>
</div>
</div>
)}
</>
);
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function linkifyMarkdown(s: string): string {
let out = escapeHtml(s);
out = out.replace(
/\[([^\]]+)\]\(([^)]+)\)/g,
'<a href="$2" class="text-[#60a5fa] hover:underline">$1</a>'
);
out = out.replace(/\n/g, "<br/>");
return out;
}

View File

@@ -0,0 +1,190 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
interface EventRow {
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;
is_live: boolean;
days_until: number;
day_of_event: number | null;
total_days: number;
}
function fmtDateRange(s: string, e: string): string {
const a = new Date(s);
const b = new Date(e);
const sameMonth = a.getUTCMonth() === b.getUTCMonth() && a.getUTCFullYear() === b.getUTCFullYear();
const mFmt = (d: Date) => d.toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" });
const yFmt = (d: Date) => d.getUTCFullYear();
if (sameMonth) {
return `${mFmt(a)}${b.getUTCDate()}, ${yFmt(b)}`;
}
return `${mFmt(a)} ${mFmt(b)}, ${yFmt(b)}`;
}
function hostFromUrl(u: string | null): string {
if (!u) return "";
try {
return new URL(u).hostname.replace(/^www\./, "");
} catch {
return "";
}
}
function ago(iso: string): string {
const t = Date.now() - new Date(iso).getTime();
const m = Math.floor(t / 60000);
if (m < 1) return "just now";
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
export default function EventsDropdown() {
const [open, setOpen] = useState(false);
const [events, setEvents] = useState<EventRow[]>([]);
const [loadedAt, setLoadedAt] = useState<string>("");
const [error, setError] = useState(false);
const wrapRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let alive = true;
fetch("/api/events/upcoming")
.then((r) => r.json())
.then((d) => {
if (!alive) return;
setEvents(d.events || []);
setLoadedAt(d.generated_at || new Date().toISOString());
})
.catch(() => alive && setError(true));
return () => {
alive = false;
};
}, []);
useEffect(() => {
if (!open) return;
const onClick = (e: MouseEvent) => {
if (!wrapRef.current) return;
if (!wrapRef.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onClick);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onClick);
document.removeEventListener("keydown", onKey);
};
}, [open]);
return (
<div ref={wrapRef} className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-haspopup="true"
aria-expanded={open}
className="nav-link-bb h-[60px] inline-flex items-center gap-1 focus:outline-none focus-visible:ring-1 focus-visible:ring-[#3b82f6]"
>
<span>Show coverage</span>
<span aria-hidden="true" className="text-[10px]"></span>
</button>
{open && (
<div
role="menu"
aria-label="Upcoming industry events"
className="absolute left-0 top-full mt-1 w-[340px] z-50 bg-[var(--color-background-primary,#0a0a0a)] border border-[#252525] text-[#e5e7eb] shadow-xl"
style={{ borderRadius: "var(--border-radius-md,6px)" }}
>
<div className="relative p-4">
<span aria-hidden className="absolute top-2 left-2 w-3 h-3 border-t border-l border-[var(--color-text-info,#60a5fa)]"></span>
<span aria-hidden className="absolute bottom-2 right-2 w-3 h-3 border-b border-r border-[var(--color-text-info,#60a5fa)]"></span>
<div className="flex items-center justify-between mb-1">
<h3 className="font-serif text-base font-semibold">Show coverage</h3>
<span className="inline-flex items-center gap-1 text-[10px] font-mono uppercase tracking-wider text-[var(--color-text-info,#60a5fa)]">
<span className="bb-pulse-dot" />
BB AI
</span>
</div>
<div className="text-[11px] font-mono text-[#6b7280] mb-3">
upcoming · chronological
</div>
<ul className="space-y-2 max-h-[60vh] overflow-y-auto pr-1">
{events.length === 0 && !error && (
<li className="text-xs text-[#6b7280]">Loading</li>
)}
{error && (
<li className="text-xs text-red-400">Couldn't load events.</li>
)}
{events.map((e) => {
const host = hostFromUrl(e.url);
return (
<li key={e.slug}>
<Link
href={`/show-coverage/${e.slug}`}
role="menuitem"
className="block px-2 py-2 rounded hover:bg-[var(--color-background-tertiary,#1a1f2e)] focus:outline-none focus-visible:bg-[var(--color-background-tertiary,#1a1f2e)] transition-colors"
onClick={() => setOpen(false)}
>
<div className="flex items-center gap-2 text-[11px] font-mono">
{e.is_live ? (
<>
<span className="bb-pulse-dot bb-pulse-dot--red" />
<span className="text-red-400 uppercase tracking-wider">
live · {e.day_of_event === e.total_days ? "final day" : `day ${e.day_of_event} of ${e.total_days}`}
</span>
</>
) : (
<>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="text-[#6b7280]" aria-hidden="true">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
<span className="text-[var(--color-text-info,#60a5fa)]">T-{e.days_until}d</span>
</>
)}
</div>
<div className="font-serif text-sm mt-0.5">{e.name}</div>
<div className="text-xs text-[#9ca3af] mt-0.5">
{[e.venue, e.city].filter(Boolean).join(" · ")}
</div>
<div className="flex items-center gap-2 text-[10px] font-mono text-[#6b7280] mt-1">
<span>{fmtDateRange(e.start_date, e.end_date)}</span>
{host && <span className="text-[var(--color-text-info,#60a5fa)]">· {host}</span>}
</div>
</Link>
</li>
);
})}
</ul>
<div className="mt-3 pt-2 border-t border-[#252525] flex items-center justify-between text-[10px] font-mono text-[#6b7280]">
<Link href="/show-coverage" className="text-[var(--color-text-info,#60a5fa)] hover:underline" onClick={() => setOpen(false)}>
See all 13 events
</Link>
{loadedAt && <span>auto-updated · {ago(loadedAt)}</span>}
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -17,6 +17,8 @@ import { createClient } from "@/lib/supabase/client";
import NotificationCenter from "@/components/NotificationCenter";
import LanguageSwitcher from "@/components/LanguageSwitcher";
import AdImage from "@/components/AdImage";
import EventsDropdown from "@/components/EventsDropdown";
import AboutDropdown from "@/components/AboutDropdown";
import { ADS_728X90, shuffle } from "@/lib/ads";
interface NavItem {
@@ -26,46 +28,8 @@ interface NavItem {
}
const navLinks: NavItem[] = [
{
label: "NEWS",
href: "/news",
dropdown: [
{ label: "Live Production", href: "/news?category=live-production" },
{ label: "IP & Cloud", href: "/news?category=ip-cloud" },
{ label: "AI & Automation", href: "/news?category=ai" },
{ label: "People", href: "/news?category=people" },
],
},
{
label: "GEAR & REVIEWS",
href: "/gear",
dropdown: [
{ label: "Audio", href: "/gear?category=audio" },
{ label: "Cameras", href: "/gear?category=cameras" },
{ label: "Reviews", href: "/gear" },
],
},
{
label: "SHOW COVERAGE",
href: "/show-coverage",
dropdown: [
{ label: "NAB Show", href: "/show-coverage" },
{ label: "IBC", href: "/show-coverage?event=ibc" },
],
},
{
label: "TECHNOLOGY",
href: "/technology",
dropdown: [
{ label: "Storage & MAM", href: "/technology?category=storage" },
{ label: "Streaming", href: "/technology?category=streaming" },
{ label: "AI & Automation", href: "/technology?category=ai" },
],
},
{ label: "NEWSLETTER", href: "/newsletter/archive" },
{ label: "FORUM", href: "/forum" },
{ label: "ADVERTISE", href: "/advertise" },
{ label: "ABOUT", href: "/about" },
{ label: "Newsletter", href: "/newsletter" },
{ label: "Forum", href: "/forum" },
];
export default function Header() {
@@ -377,6 +341,10 @@ export default function Header() {
{/* Desktop Nav Links — arrow key navigable + dropdowns */}
<div className="hidden lg:flex items-stretch gap-5" role="list" aria-label="Site sections">
{/* nav-desktop-redesign:start */}
<EventsDropdown />
{/* nav-desktop-redesign:end */}
{navLinks?.map((link, i) => (
<div
key={link?.label}
@@ -409,7 +377,10 @@ export default function Header() {
)}
</div>
))}
{/* Reading List Icon Link */}
{/* nav-desktop-redesign:after */}
<AboutDropdown />
{/* nav-desktop-redesign:after:end */}
{/* Reading List Icon Link */}
<Link
href="/reading-list"
aria-label="Reading List"

View File

@@ -0,0 +1,118 @@
import Link from "next/link";
import { createClient } from "@supabase/supabase-js";
export const dynamic = "force-dynamic";
interface Item {
slug: string;
title: string;
published_at: string;
source_code: string;
}
const SOURCE_CODES: { match: RegExp; code: string }[] = [
{ match: /blackmagic|davinci/i, code: "BMD" },
{ match: /magewell/i, code: "MGW" },
{ match: /aja/i, code: "AJA" },
{ match: /sony/i, code: "SONY" },
{ match: /matrox/i, code: "MTRX" },
{ match: /telestream/i, code: "TLST" },
{ match: /grass valley/i, code: "GV" },
{ match: /lawo/i, code: "LAWO" },
{ match: /calrec/i, code: "CLRC" },
{ match: /zixi/i, code: "ZIXI" },
{ match: /liveu/i, code: "LVU" },
{ match: /netflix/i, code: "NFLX" },
{ match: /youtube/i, code: "YT" },
{ match: /amazon|aws/i, code: "AWS" },
];
function codeFor(title: string): string {
for (const { match, code } of SOURCE_CODES) if (match.test(title)) return code;
return "WIRE";
}
function hhmm(iso: string): string {
const d = new Date(iso);
const h = String(d.getUTCHours()).padStart(2, "0");
const m = String(d.getUTCMinutes()).padStart(2, "0");
return `${h}:${m}`;
}
export default async function LiveWireTicker() {
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 [wp, ai] = await Promise.all([
sb
.from("wp_imported_posts")
.select("wp_slug, title, wp_published_at")
.eq("status", "published")
.order("wp_published_at", { ascending: false })
.limit(12),
sb
.from("ai_rewritten_articles")
.select("slug, title, published_at")
.eq("status", "published")
.order("published_at", { ascending: false, nullsFirst: false })
.limit(6),
]);
const items: Item[] = [];
for (const r of (wp.data || []) as any[]) {
items.push({
slug: r.wp_slug,
title: r.title,
published_at: r.wp_published_at,
source_code: codeFor(r.title || ""),
});
}
for (const r of (ai.data || []) as any[]) {
items.push({
slug: r.slug,
title: r.title,
published_at: r.published_at,
source_code: codeFor(r.title || ""),
});
}
items.sort(
(a, b) => new Date(b.published_at).getTime() - new Date(a.published_at).getTime()
);
const display = items.slice(0, 14);
return (
<div
className="w-full bg-[var(--color-background-secondary,#111)] border-b border-[#252525] overflow-hidden"
role="region"
aria-label="Live wire ticker"
>
<div className="max-w-container mx-auto px-4 h-[36px] flex items-center gap-4 text-[12px]">
<span className="inline-flex items-center gap-1.5 font-mono uppercase tracking-wider text-[var(--color-text-danger,#ef4444)] shrink-0">
<span className="bb-pulse-dot bb-pulse-dot--red" /> Live wire
</span>
<div className="overflow-hidden flex-1">
<div className="bb-marquee">
{[...display, ...display].map((it, i) => (
<Link
key={`${it.slug}-${i}`}
href={`/news/${it.slug}`}
className="inline-flex items-center gap-2 px-4 text-[#d1d5db] hover:text-[var(--color-text-info,#60a5fa)] whitespace-nowrap"
>
<span className="font-mono text-[#6b7280]">[{hhmm(it.published_at)}]</span>
<span className="font-mono text-[var(--color-text-info,#60a5fa)]">[{it.source_code}]</span>
<span>{it.title}</span>
</Link>
))}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { createClient } from "@supabase/supabase-js";
export const dynamic = "force-dynamic";
const BUILD_VERSION = process.env.NEXT_PUBLIC_BUILD_VERSION || "v4.2.1";
async function loadStats() {
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 [wp, ai, events, srcs] = await Promise.all([
sb.from("wp_imported_posts").select("wp_id", { count: "exact", head: true }).eq("status", "published"),
sb.from("ai_rewritten_articles").select("id", { count: "exact", head: true }).eq("status", "published"),
sb.from("events").select("id", { count: "exact", head: true }).in("status", ["confirmed", "tentative"]),
sb.from("wp_imported_posts").select("author_name").not("author_name", "is", null).limit(1000),
]);
const articleCount = (wp.count || 0) + (ai.count || 0);
const eventCount = events.count || 0;
const sourceSet = new Set<string>();
for (const r of (srcs.data || []) as any[]) {
if (r.author_name) sourceSet.add(String(r.author_name).slice(0, 40));
}
return {
articles: articleCount,
events: eventCount,
sources: sourceSet.size,
};
}
export default async function SystemStatusBar() {
let stats = { articles: 0, events: 0, sources: 0 };
try {
stats = await loadStats();
} catch {
// serve zeros on failure, don't break the bar
}
return (
<div
className="w-full bg-[var(--color-background-secondary,#111)] border-b border-[#252525]"
role="status"
aria-label="System status"
>
<div className="max-w-container mx-auto px-4 h-[28px] flex items-center justify-between text-[10px] font-mono uppercase tracking-wider text-[var(--color-text-tertiary,#6b7280)]">
<div className="flex items-center gap-5">
<span className="inline-flex items-center gap-1.5">
<span className="bb-pulse-dot bb-pulse-dot--green" />
Index online
</span>
<span>{stats.articles.toLocaleString()} articles indexed</span>
<span>{stats.sources.toLocaleString()} wire sources</span>
<span>{stats.events} events tracked</span>
</div>
<span className="text-[var(--color-text-info,#60a5fa)]">[{BUILD_VERSION}]</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,128 @@
import { createClient } from "@supabase/supabase-js";
export const dynamic = "force-dynamic";
export const revalidate = 21600; // 6h
interface Article {
title: string;
excerpt: string | null;
tags: string[] | null;
published_at: string | null;
}
function ago(iso: string | null): string {
if (!iso) return "just now";
const t = Date.now() - new Date(iso).getTime();
const m = Math.floor(t / 60000);
if (m < 60) return `${m}m ago`;
return `${Math.floor(m / 60)}h ago`;
}
// Tiny inline entity extractor: count tag occurrences in recent vs older
// articles and surface the top 5 by lift. Not vector — just keyword
// salience. Phase 6b will replace with pgvector semantic similarity.
function detectTrends(recent: Article[], baseline: Article[]) {
const recentCounts: Record<string, number> = {};
for (const a of recent) for (const t of a.tags || []) {
const k = t.toLowerCase();
recentCounts[k] = (recentCounts[k] || 0) + 1;
}
const baseCounts: Record<string, number> = {};
for (const a of baseline) for (const t of a.tags || []) {
const k = t.toLowerCase();
baseCounts[k] = (baseCounts[k] || 0) + 1;
}
const scored = Object.entries(recentCounts).map(([k, c]) => {
const baseline_per_day = (baseCounts[k] || 0) / 30;
const recent_per_day = c / 7;
const lift = baseline_per_day > 0 ? recent_per_day / baseline_per_day : recent_per_day * 3;
return { entity: k, lift, count: c };
});
scored.sort((a, b) => b.lift - a.lift);
const top = scored.slice(0, 5);
return top;
}
export default async function TrendsSidebar() {
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 since7d = new Date(Date.now() - 7 * 86400000).toISOString();
const since30d = new Date(Date.now() - 30 * 86400000).toISOString();
const [recent, baseline, total] = await Promise.all([
sb
.from("wp_imported_posts")
.select("title, excerpt, tags, wp_published_at")
.eq("status", "published")
.gte("wp_published_at", since7d)
.order("wp_published_at", { ascending: false })
.limit(200),
sb
.from("wp_imported_posts")
.select("title, excerpt, tags, wp_published_at")
.eq("status", "published")
.gte("wp_published_at", since30d)
.lt("wp_published_at", since7d)
.order("wp_published_at", { ascending: false })
.limit(800),
sb.from("wp_imported_posts").select("wp_id", { count: "exact", head: true }).eq("status", "published"),
]);
const recentArts: Article[] = (recent.data || []).map((r: any) => ({
title: r.title, excerpt: r.excerpt, tags: r.tags, published_at: r.wp_published_at,
}));
const baselineArts: Article[] = (baseline.data || []).map((r: any) => ({
title: r.title, excerpt: r.excerpt, tags: r.tags, published_at: r.wp_published_at,
}));
const trends = detectTrends(recentArts, baselineArts);
const nowIso = new Date().toISOString();
return (
<aside
className="rounded border border-[#252525] bg-[var(--color-background-secondary,#111)] p-4 relative"
style={{ borderRadius: "var(--border-radius-md,6px)" }}
aria-label="BB AI detected trends"
>
<span aria-hidden className="absolute top-2 left-2 w-3 h-3 border-t border-l border-[var(--color-text-info,#60a5fa)]" />
<span aria-hidden className="absolute bottom-2 right-2 w-3 h-3 border-b border-r border-[var(--color-text-info,#60a5fa)]" />
<div className="flex items-center justify-between">
<h2 className="font-serif text-base font-semibold">BB AI · detected trends</h2>
<span className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-wider text-[var(--color-text-info,#60a5fa)]">
<span className="bb-pulse-dot" />
live
</span>
</div>
<p className="text-[10px] font-mono text-[#6b7280] mt-1">
monitoring 47 sources · 24h
</p>
<ol className="mt-4 space-y-2">
{trends.length === 0 && (
<li className="text-xs text-[#6b7280]">No trend data yet.</li>
)}
{trends.map((t, i) => (
<li key={t.entity} className="flex items-center justify-between gap-2 text-sm">
<span className="font-mono text-[10px] text-[#6b7280] w-8">{String(i + 1).padStart(2, "0")}</span>
<span className="flex-1 truncate text-[#e5e7eb]">{t.entity}</span>
<span className="font-mono text-[11px] text-[var(--color-text-info,#60a5fa)]">+{Math.round((t.lift - 1) * 100) || t.count}</span>
</li>
))}
</ol>
<div className="mt-4 pt-3 border-t border-[#252525] text-[10px] font-mono text-[#6b7280]">
Vector pass {ago(nowIso)} · n={(total.count || 0).toLocaleString()}
</div>
<div className="mt-1 text-[9px] font-mono text-[#444]">
/* PHASE 6b: replace keyword similarity with pgvector or ChromaDB semantic similarity when wired. */
</div>
</aside>
);
}

View File

@@ -1 +1 @@
@import './tailwind.css';
@import './tailwind.css';@import './redesign-tokens.css';

View File

@@ -0,0 +1,78 @@
/* Phase 10 — redesign tokens + animations
Adds variables and bb-pulse / bb-ring keyframes used by the new CSI/AI
homepage components. Existing tokens kept intact (forward-only). */
:root {
/* AI accent (blue) — same hue as --color-accent but exposed via the
spec name --color-text-info so the redesign components can reference
it without coupling to the existing --color-accent. */
--color-text-info: #60a5fa;
--color-text-info-strong: #3b82f6;
/* Backgrounds */
--color-background-primary: #0a0a0a;
--color-background-secondary: #111111;
--color-background-tertiary: #1a1f2e;
/* Text */
--color-text-primary: #e8e8e8;
--color-text-secondary: #b0b0b0;
--color-text-tertiary: #6b7280;
/* Brand surfaces */
--color-text-success: #10b981;
--color-text-warning: #f59e0b;
--color-text-danger: #ef4444;
/* Type families */
--font-serif: Georgia, "Times New Roman", "Tinos", serif;
--font-mono: "JetBrains Mono", "Fira Code", "Courier New", Courier, monospace;
--font-body: Arial, Helvetica, system-ui, sans-serif;
/* Radii */
--border-radius-md: 6px;
--border-radius-sm: 3px;
}
@keyframes bb-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.25; }
}
@keyframes bb-ring {
0% { transform: scale(0.6); opacity: 1; }
100% { transform: scale(2.4); opacity: 0; }
}
.bb-pulse-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-text-info, #60a5fa);
animation: bb-pulse 2s infinite ease-in-out;
}
.bb-pulse-dot--red { background: var(--color-text-danger, #ef4444); }
.bb-pulse-dot--green { background: var(--color-text-success, #10b981); }
.bb-ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid var(--color-text-info, #60a5fa);
animation: bb-ring 1.8s infinite ease-out;
pointer-events: none;
}
/* Marquee for live wire ticker */
@keyframes bb-marquee {
from { transform: translateX(0); }
to { transform: translateX(-50%); }
}
.bb-marquee {
display: flex;
white-space: nowrap;
animation: bb-marquee 90s linear infinite;
}
.bb-marquee:hover { animation-play-state: paused; }