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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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.",
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user