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>
);
}