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