BB AI redesign: 4-item nav, Show coverage, About, status bar, wire ticker,

trends sidebar, Ask BB AI, neural-summary schema, 13-event seed.

Phases 1-10 (11 phases minus Phase 8 which is the pgvector marker).

DB:
  bb.events                — 13 industry events seeded (MPTS, ANGA COM,
                             BroadcastAsia, SET Expo, IBC, NAB NY,
                             NewTECHForum, SATIS, DPP EBS, SVVS,
                             Hamburg Open, CABSAT, NAB Show '27).
  bb.article_events        — composite PK (article_id, article_table, event_id)
                             links any article-table row to one or more events.
  bb.banner_creatives      — (no change tonight)
  bb.{native_articles,
      ai_rewritten_articles,
      wp_imported_posts,
      press_releases}.neural_summary jsonb — populated by Phase 7
                             analyzer (deferred).
  bb.{native_articles,
      ai_rewritten_articles}.featured boolean — featured carousel source.

Routes:
  /api/events/upcoming     — date-gated, enriched with is_live/days_until/
                             day_of_event/total_days.
  /api/ask-bb-ai           — Claude Opus 4.7 chat with prompt caching on
                             the system block, 30-msg/hr/IP rate limit.
                             Tool-use deferred — first cut is straight
                             LLM with archive-citation prompting.

Pages:
  /show-coverage           — index of all events, upcoming + past split.
  /show-coverage/[slug]    — hero + status (live/T-Nd/past) + schema.org
                             Event JSON-LD + tagged articles list.
  /about, /about/{team,contact,advertise,press-kit}.

Components:
  Header                   — 4-item nav: Show coverage (dropdown) /
                             Newsletter / Forum / About (dropdown).
                             Old NEWS/GEAR/TECHNOLOGY/ADVERTISE items
                             removed from nav (routes still exist).
  EventsDropdown           — 340px CSI panel with L-corner brackets,
                             pulsing dot, "BB AI" badge, live/T-Nd rows,
                             auto-updated stamp.
  AboutDropdown            — 5-item lighter treatment.
  SystemStatusBar          — Index online pulse, articles/sources/events
                             counts, build version. Above Header.
  LiveWireTicker           — replaces NewsTicker. [HH:MM] [SRC] prefix,
                             CSS marquee, doubled for seamless loop.
  TrendsSidebar            — BB AI detected trends, top 5 entities by
                             7d-vs-30d lift, vector-pass stamp.
                             Phase 6b: replace with pgvector.
  AskBBAI                  — floating bottom-right button + 420px right
                             drawer chat. No mention of Anthropic/Claude.

Styling:
  redesign-tokens.css      — --color-text-info, --color-background-*,
                             --font-serif/mono/body, bb-pulse/bb-ring
                             animations + bb-marquee.

Constraints honored:
  - LiveU 728x90 still between </nav> and ticker.
  - Blackmagic 300x600 still pinned top of sidebar.
  - /r/[slug] click tracking unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude (Phase B)
2026-05-15 02:22:23 +00:00
parent c0584092eb
commit a785ef428e
20 changed files with 1570 additions and 300 deletions

View File

@@ -0,0 +1,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 });
}

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