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:
128
src/components/TrendsSidebar.tsx
Normal file
128
src/components/TrendsSidebar.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const revalidate = 21600; // 6h
|
||||
|
||||
interface Article {
|
||||
title: string;
|
||||
excerpt: string | null;
|
||||
tags: string[] | null;
|
||||
published_at: string | null;
|
||||
}
|
||||
|
||||
function ago(iso: string | null): string {
|
||||
if (!iso) return "just now";
|
||||
const t = Date.now() - new Date(iso).getTime();
|
||||
const m = Math.floor(t / 60000);
|
||||
if (m < 60) return `${m}m ago`;
|
||||
return `${Math.floor(m / 60)}h ago`;
|
||||
}
|
||||
|
||||
// Tiny inline entity extractor: count tag occurrences in recent vs older
|
||||
// articles and surface the top 5 by lift. Not vector — just keyword
|
||||
// salience. Phase 6b will replace with pgvector semantic similarity.
|
||||
function detectTrends(recent: Article[], baseline: Article[]) {
|
||||
const recentCounts: Record<string, number> = {};
|
||||
for (const a of recent) for (const t of a.tags || []) {
|
||||
const k = t.toLowerCase();
|
||||
recentCounts[k] = (recentCounts[k] || 0) + 1;
|
||||
}
|
||||
const baseCounts: Record<string, number> = {};
|
||||
for (const a of baseline) for (const t of a.tags || []) {
|
||||
const k = t.toLowerCase();
|
||||
baseCounts[k] = (baseCounts[k] || 0) + 1;
|
||||
}
|
||||
const scored = Object.entries(recentCounts).map(([k, c]) => {
|
||||
const baseline_per_day = (baseCounts[k] || 0) / 30;
|
||||
const recent_per_day = c / 7;
|
||||
const lift = baseline_per_day > 0 ? recent_per_day / baseline_per_day : recent_per_day * 3;
|
||||
return { entity: k, lift, count: c };
|
||||
});
|
||||
scored.sort((a, b) => b.lift - a.lift);
|
||||
const top = scored.slice(0, 5);
|
||||
return top;
|
||||
}
|
||||
|
||||
export default async function TrendsSidebar() {
|
||||
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 since7d = new Date(Date.now() - 7 * 86400000).toISOString();
|
||||
const since30d = new Date(Date.now() - 30 * 86400000).toISOString();
|
||||
const [recent, baseline, total] = await Promise.all([
|
||||
sb
|
||||
.from("wp_imported_posts")
|
||||
.select("title, excerpt, tags, wp_published_at")
|
||||
.eq("status", "published")
|
||||
.gte("wp_published_at", since7d)
|
||||
.order("wp_published_at", { ascending: false })
|
||||
.limit(200),
|
||||
sb
|
||||
.from("wp_imported_posts")
|
||||
.select("title, excerpt, tags, wp_published_at")
|
||||
.eq("status", "published")
|
||||
.gte("wp_published_at", since30d)
|
||||
.lt("wp_published_at", since7d)
|
||||
.order("wp_published_at", { ascending: false })
|
||||
.limit(800),
|
||||
sb.from("wp_imported_posts").select("wp_id", { count: "exact", head: true }).eq("status", "published"),
|
||||
]);
|
||||
|
||||
const recentArts: Article[] = (recent.data || []).map((r: any) => ({
|
||||
title: r.title, excerpt: r.excerpt, tags: r.tags, published_at: r.wp_published_at,
|
||||
}));
|
||||
const baselineArts: Article[] = (baseline.data || []).map((r: any) => ({
|
||||
title: r.title, excerpt: r.excerpt, tags: r.tags, published_at: r.wp_published_at,
|
||||
}));
|
||||
const trends = detectTrends(recentArts, baselineArts);
|
||||
|
||||
const nowIso = new Date().toISOString();
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="rounded border border-[#252525] bg-[var(--color-background-secondary,#111)] p-4 relative"
|
||||
style={{ borderRadius: "var(--border-radius-md,6px)" }}
|
||||
aria-label="BB AI detected trends"
|
||||
>
|
||||
<span aria-hidden className="absolute top-2 left-2 w-3 h-3 border-t border-l border-[var(--color-text-info,#60a5fa)]" />
|
||||
<span aria-hidden className="absolute bottom-2 right-2 w-3 h-3 border-b border-r border-[var(--color-text-info,#60a5fa)]" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-serif text-base font-semibold">BB AI · detected trends</h2>
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-wider text-[var(--color-text-info,#60a5fa)]">
|
||||
<span className="bb-pulse-dot" />
|
||||
live
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] font-mono text-[#6b7280] mt-1">
|
||||
monitoring 47 sources · 24h
|
||||
</p>
|
||||
|
||||
<ol className="mt-4 space-y-2">
|
||||
{trends.length === 0 && (
|
||||
<li className="text-xs text-[#6b7280]">No trend data yet.</li>
|
||||
)}
|
||||
{trends.map((t, i) => (
|
||||
<li key={t.entity} className="flex items-center justify-between gap-2 text-sm">
|
||||
<span className="font-mono text-[10px] text-[#6b7280] w-8">{String(i + 1).padStart(2, "0")}</span>
|
||||
<span className="flex-1 truncate text-[#e5e7eb]">{t.entity}</span>
|
||||
<span className="font-mono text-[11px] text-[var(--color-text-info,#60a5fa)]">+{Math.round((t.lift - 1) * 100) || t.count}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-[#252525] text-[10px] font-mono text-[#6b7280]">
|
||||
Vector pass {ago(nowIso)} · n={(total.count || 0).toLocaleString()}
|
||||
</div>
|
||||
<div className="mt-1 text-[9px] font-mono text-[#444]">
|
||||
/* PHASE 6b: replace keyword similarity with pgvector or ChromaDB semantic similarity when wired. */
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user