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:
45
src/app/about/advertise/page.tsx
Normal file
45
src/app/about/advertise/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/app/about/contact/page.tsx
Normal file
30
src/app/about/contact/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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'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 & 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>
|
||||
);
|
||||
}
|
||||
|
||||
33
src/app/about/press-kit/page.tsx
Normal file
33
src/app/about/press-kit/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
src/app/about/team/page.tsx
Normal file
67
src/app/about/team/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
src/app/api/ask-bb-ai/route.ts
Normal file
103
src/app/api/ask-bb-ai/route.ts
Normal 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 });
|
||||
}
|
||||
67
src/app/api/events/upcoming/route.ts
Normal file
67
src/app/api/events/upcoming/route.ts
Normal 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() });
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>);
|
||||
|
||||
|
||||
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.",
|
||||
};
|
||||
|
||||
68
src/components/AboutDropdown.tsx
Normal file
68
src/components/AboutDropdown.tsx
Normal 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
169
src/components/AskBBAI.tsx
Normal 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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
190
src/components/EventsDropdown.tsx
Normal file
190
src/components/EventsDropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
118
src/components/LiveWireTicker.tsx
Normal file
118
src/components/LiveWireTicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
src/components/SystemStatusBar.tsx
Normal file
65
src/components/SystemStatusBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
128
src/components/TrendsSidebar.tsx
Normal file
128
src/components/TrendsSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
@import './tailwind.css';
|
||||
@import './tailwind.css';@import './redesign-tokens.css';
|
||||
|
||||
78
src/styles/redesign-tokens.css
Normal file
78
src/styles/redesign-tokens.css
Normal 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; }
|
||||
Reference in New Issue
Block a user