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:
237
src/app/show-coverage/[slug]/page.tsx
Normal file
237
src/app/show-coverage/[slug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user