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:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user