diff --git a/src/app/about/advertise/page.tsx b/src/app/about/advertise/page.tsx new file mode 100644 index 0000000..fac1bf4 --- /dev/null +++ b/src/app/about/advertise/page.tsx @@ -0,0 +1,45 @@ +export const metadata = { title: "Advertise — BroadcastBeat" }; + +export default function AdvertisePage() { + return ( +
+

Advertise on BroadcastBeat

+

+ BroadcastBeat reaches broadcast engineers, post supervisors, streaming + architects, and live-event professionals across the industry. +

+ +
+

Available units

+ +
+ +
+

Reporting

+

+ 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. +

+
+ +
+

Get in touch

+

+ advertising@broadcastbeat.com +

+
+
+ ); +} diff --git a/src/app/about/contact/page.tsx b/src/app/about/contact/page.tsx new file mode 100644 index 0000000..fb26ec1 --- /dev/null +++ b/src/app/about/contact/page.tsx @@ -0,0 +1,30 @@ +export const metadata = { title: "Contact — BroadcastBeat" }; + +export default function ContactPage() { + return ( +
+

Contact

+
+
+
Editorial
+
editor@broadcastbeat.com
+
+
+
Press releases (PR firms)
+
+ Route submissions through{" "} + distribution.relevantmediaproperties.com. +
+
+
+
Advertising
+
advertising@broadcastbeat.com — see media kit.
+
+
+
Publisher
+
Ryan Salazar · ryan.salazar@relevantmediaproperties.com
+
+
+
+ ); +} diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index 8c89474..84d4e2a 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -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 ( -
-
- - {/* DO NOT OVERRIDE — About page hero */} -
-
-
- About -
-
-

About BroadcastBeat

-
+
+

About BroadcastBeat

+
+

+ 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. +

+

+ 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. +

+

+ 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. +

- -
-
-
- {/* Mission */} -
-

Our Mission

-
-

- 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. -

-

- 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. -

-

- 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. -

-
-
- - {/* Team */} -
-

Editorial Team

-
- {team.map((member) => ( -
-
- {member.name[0]} -
-
-

{member.name}

-

{member.title}

-

{member.bio}

-
-
- ))} -
-
- - {/* Contact */} -
-

Contact

-
-
-

Editorial

- editorial@broadcastbeat.com -
-
-

Advertising Sales

- jeff@broadcastbeat.com -
-
-

Press & Media

- press@broadcastbeat.com -
-
-
-
- - -
-
-
-
+ ); } diff --git a/src/app/about/press-kit/page.tsx b/src/app/about/press-kit/page.tsx new file mode 100644 index 0000000..8d480f2 --- /dev/null +++ b/src/app/about/press-kit/page.tsx @@ -0,0 +1,33 @@ +export const metadata = { title: "Press kit — BroadcastBeat" }; + +export default function PressKitPage() { + return ( +
+

Press kit

+
+
+

Logos

+ +
+
+

Boilerplate

+

+ 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. +

+
+
+

Media contact

+

+ ryan.salazar@relevantmediaproperties.com +

+
+
+
+ ); +} diff --git a/src/app/about/team/page.tsx b/src/app/about/team/page.tsx new file mode 100644 index 0000000..9732a47 --- /dev/null +++ b/src/app/about/team/page.tsx @@ -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 ( +
+

Editorial team

+

+ Staff journalists by beat. We are a mix of human reporters and trained + AI personas operating under house style. +

+
    + {team.map((p) => ( +
  • + {p.avatar_url && ( + {p.name} + )} +
    +

    {p.name}

    + {p.beat && ( +
    + {p.beat.replace(/-/g, " ")} +
    + )} + {p.bio &&

    {p.bio}

    } +
    +
  • + ))} +
+
+ ); +} diff --git a/src/app/api/ask-bb-ai/route.ts b/src/app/api/ask-bb-ai/route.ts new file mode 100644 index 0000000..19c48bd --- /dev/null +++ b/src/app/api/ask-bb-ai/route.ts @@ -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(); + +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 }); +} diff --git a/src/app/api/events/upcoming/route.ts b/src/app/api/events/upcoming/route.ts new file mode 100644 index 0000000..e83d83a --- /dev/null +++ b/src/app/api/events/upcoming/route.ts @@ -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() }); +} diff --git a/src/app/home-page/page.tsx b/src/app/home-page/page.tsx index 7d3c220..58e512c 100644 --- a/src/app/home-page/page.tsx +++ b/src/app/home-page/page.tsx @@ -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 */}
}> - +
@@ -181,6 +183,7 @@ export default function HomePage() { {/* Newsletter signup */} +
diff --git a/src/app/layout.tsx b/src/app/layout.tsx index f6abace..8fb6984 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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({ +
{children}
+ ); diff --git a/src/app/show-coverage/[slug]/page.tsx b/src/app/show-coverage/[slug]/page.tsx new file mode 100644 index 0000000..3dd173e --- /dev/null +++ b/src/app/show-coverage/[slug]/page.tsx @@ -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 { + const sb = sbAnon(); + const byTable: Record = {}; + 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 ( +
+