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