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,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>
);
}