- Standardize "NAB Show" / "2026 NAB Show" across badges, filter chips,
and headings; never bare "NAB" in user-facing copy.
- /manufacturers/[slug]: normalize show name at render, strip
randomstring suffix on booth, hide booth unless the show is within 90
days, filter single-letter junk out of category display.
- News filter "NAB Show" chip loose-matches any nab-tagged article so
legacy "NAB 2026" tags still surface.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Was: single OLLAMA_ENDPOINT — when AI_002 saturated, every rewrite job
backed off in place waiting for it to drain.
Now: tries OLLAMA_ENDPOINT (AI_002) first, instantly falls over to
OLLAMA_FALLBACK_ENDPOINT (AI_001) on 5xx/timeout before burning a
backoff sleep. Both nodes have identical model sets (llama3.1:70b,
llama3, qwen2.5-coder, etc.) so behavior is consistent across either.
Net effect: 2× GPU capacity, lower P95 latency, rewrites stop bunching
on a single saturated queue. The bb-forum-burst script already had this
pattern — pulling it into the BB rewrite path closes the gap.
OLLAMA_FALLBACK_ENDPOINT=http://10.10.0.66:11434 already set on the BB
Coolify app env.
1. Clean image URLs
Article HTML was leaking the full Supabase Storage URL:
https://supabase.onsethost.com/storage/v1/object/public/distribute-media/<uuid>/<file>
Now rewritten on render to:
/img/distribute-media/<uuid>/<file>
Served by new /img/[...path] route handler that proxies to Supabase
with 24h public cache + 7d SWR. Better SEO (first-party images) and
stops leaking the storage host into article markup.
2. Stuck PR submissions
When the rewrite pipeline fails (Ollama 503 / bad model output), the
submission used to sit forever in ai_original_submissions without
becoming a public wp_imported_posts row. PR firms got "delivered"
status with no visible article anywhere.
/api/ai/submit now calls publishRawFallback() if rewriteSubmission
returns anything other than 'succeeded'. The fallback inserts a
wp_imported_posts row containing the raw HTML body with a visible
"Distributed as a press release — staff editorial rewrite pending"
banner at the top, so the article surfaces immediately. A future
retry job can swap in the rewritten content when Ollama recovers.
- /manufacturers footer "Contact our editors" linked to /contact which
doesn't exist → 404. Swapped to mailto:editors@broadcastbeat.com.
- hybridAI() was falling through to api.anthropic.com whenever Ollama
failed, which logs were tripping on after my earlier no-paid-AI swap
(Article suggestions error: ANTHROPIC_API_KEY not configured).
Removed both Anthropic call-paths in hybridAI + hybridAIStream.
hybridAI now retries Ollama with 0/3/12s backoff and throws a clear
error after exhaustion — callers (article-suggestions) already have
graceful non-AI fallbacks.
Two real bugs:
1. Ollama 503s killed every rewrite job. The outer orchestrator retried
3× but they all hit the same saturated Ollama in a 500ms window. New
inner retry inside ollamaProvider.rewrite() with 0/8s/30s/90s back-
off gives AI_002 room to drain its pending queue. 180s per-attempt
timeout. Only retries on 5xx / 429 / "busy"-style errors — 4xx other
than 429 surfaces immediately as a permanent error.
2. Successful rewrites landed in ai_rewritten_articles but the /news
pages render from wp_imported_posts.content. Result: rewrites were
invisible. Fix: on success, also INSERT INTO wp_imported_posts with
the rewritten title/body/excerpt/category/tags + featured_image from
the original submission. Uses a negative wp_id (-unix_ts) so it
doesn't collide with WP-imported sequence values and is easy to spot
in admin queries. Failure to publish doesn't fail the whole pipeline
— the rewrite still exists in ai_rewritten_articles for backfill.
NEXT (separate session): a cron to retry the 12 still-failed jobs from
the last 7 days now that backoff is in place + a backfill script to
walk the 28k existing press-release articles and rewrite them in
batches when Ollama is idle (probably nights).
/api/ai/submit and /api/submission-receive now scan incoming payload
title + body against the bb.disallowed_phrases allowlist (cached 3
min) and return 422 with the matching phrases if any are found. Stops
competitor mentions from re-entering the pipeline after the bulk
purge.
rewriteLegacyImageUrlsInHtml only swapped URLs that had a mapping in
legacy-image-map.json — anything missing the map was left pointing at
the dead https://www.broadcastbeat.com/wp-content/uploads/ host (404
in browsers, bad for SEO, broken-image icons everywhere).
New stripDeadLegacyImages() pass runs after the rewrite and removes:
- <figure> blocks whose only inner image is a dead WP image
- <a> wrappers around dead images
- Bare <img dead> tags
- Empty <figure> leftovers
…leaving body copy intact and figcaption-less.
cleanLegacyImages() is the one-call helper that pipes both steps.
Applied via rowToArticle so every /news/<slug> render gets it.
Feature 1 — sidebar
Adds a "Companies in this story" panel on every news article that
surfaces every directory company my linker matched in the body. Each
tile shows logo, name, Sponsor / NAB 26 / IBC badges, and a one-sentence
bio. Tiles link to /manufacturers/<slug> and carry data-company-slug so
the global hover card pops on hover too.
Extends company-mentions.ts with linkifyAndExtractMentions() that
returns both the linked HTML and the ordered list of matched slugs in
one pass, so /news/[slug] doesn't re-scan the body for the sidebar.
Feature 2 — unified typeahead
/api/search/suggest now returns { companies, items } — companies are
matched against bb.tracked_companies (directory_visible=true), capped
at 6, ordered by featured → mention_count, then bumped if they're an
active advertiser on broadcastbeat (adv schema lookup).
Header search dropdown renders a "Companies" section at the top with
logo, name, Sponsor badge (active advertiser) or NAB 26 badge, above
the existing "Stories" results. Each company link carries
data-company-slug so the hover card works in the dropdown too.
When an article body is rendered, scan it for any company name in
bb.tracked_companies (directory_visible=true) and wrap the first
mention of each company in
<a class="company-mention" data-company-slug="...">Name</a>
linking to /manufacturers/<slug>. Caps at 12 mentions per article so
heavy press-release lists don't get visually noisy.
Match rules:
- Word-boundary, case-insensitive
- Skips text already inside <a>, <script>, <style>, code/pre/headings
- Skips any element with data-no-autolink="true"
- Longest name wins on overlap (Sony Pictures beats Sony)
- One link per company per article (first occurrence)
Hover behavior: a new CompanyMentionsHover client component mounts once
per article page, listens globally for [data-company-slug] hovers, and
pops a floating card showing logo, name, 2-sentence bio, sponsor/NAB/IBC
badges, HQ city, 3 recent stories, and links to the full profile +
external website. Preview data fetched from new endpoint
/api/public/companies/[slug]/preview
with a client-side cache keyed by slug.
Includes non-advertisers — any visible directory entry gets linked,
which is the point. If a company is *also* a current banner advertiser
on broadcastbeat, the hover card adds a green "Sponsor" badge.
Styling: dotted underline that becomes solid on hover, slightly bolder
weight, accent color on hover. Subtle so multi-mention paragraphs don't
look like a link-soup.
Three-tier sort on the Industry News feed:
1. Premium — articles pinned via adv.featured_story_slots (paid slots).
Sorted by position ascending. Highlighted with an amber left-border
gradient + ★ Featured ribbon.
2. Advertiser — articles whose title contains the company name of a
current active banner advertiser on this property, AND were published
in the last 48 hours. Highlighted with a blue left-border gradient
+ ◆ Advertiser ribbon. Multiple matches ordered by publish date.
3. General — everything else, newest first.
After 48h, advertiser stories naturally drop back into the general
tier. Premium slots fall off when their ends_at passes.
Detection is title-only with case-insensitive word-boundary match; the
longest matching company name wins so "Sony Pictures" beats "Sony"
when both advertise. Backed by the new adv.active_advertisers_by_
property view.
createAdminClient() now accepts an optional schema arg so we can hit
adv from BB without juggling clients.
API revalidate dropped 300 → 60s so the feed reflects campaign flips
within a minute.
Was linking to /news?category=Featured which silently fell back to the
unfiltered /news listing because "Featured" isn't in CATEGORIES and the
filter is tag-based, not category-column-based.
Adds getLegacyFeaturedArticles() that mirrors FeaturedBento's query
(category ILIKE 'featured', featured-flag-first sort) at limit 200, and
a thin /news/featured route that reuses NewsPageClient.
- Homepage / Industry News query filters wp_imported_posts to
ai_rewritten_at IS NOT NULL so raw press releases never surface on
the editorial feed (NewsArticle JSON-LD was already in place).
- New /news-sitemap.xml route emits a Google News sitemap covering
rewritten editorial published in the last 48 hours, with
news:publication + news:publication_date + news:title + keywords.
- robots.ts now points crawlers at both sitemap.xml and
news-sitemap.xml.
After deploy, submit broadcastbeat.com to Google News Publisher Center
to start serving in News.
- API: require auth on thread+reply POST (401 if !user); lazy-create
forum_user_profile via service-role; author_name = display_name
- UI: surface API errors (kill silent catch); auto-redirect to login
on 401; gate new-thread + reply forms with "Sign in to post" CTA
- /forum/login + /forum/register: forum-themed Supabase Auth pages
with ?next= redirect support
- /forum/profile/edit + /api/forum/profile/me: edit own forum profile
(username, display_name, bio, role, company, location, avatar)
- Header: logout button + sign-in/join links when not authed
- forum/user/[username]: show Edit Profile button when viewing own
Fixes posting bug: RLS rejected anon inserts with cryptic message
that the UI silently swallowed; now blocked with friendly 401 + redirect.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Manual fire of bb-company-coverage-scan tonight failed because the
8s connect timeout was tripping on llama3.1:70b cold-load when
another model was resident in VRAM on AI_001. Bumped to 30s so
the orchestrator can wait through a model swap.
Also bumped background request timeout 120s → 180s for the same
reason — story generation against the 70B can take >2 min on a
saturated GPU.
Coolify env OLLAMA_ENDPOINT was simultaneously flipped from
AI_001 (10.10.0.66) to AI_002 (10.10.0.67) since AI_002 is less
loaded — AI_001 is doing author enrichment and scrubber work.
- layout.tsx: flip robots.index from false→true (and the matching
googleBot block). Drop the manual <meta name="robots"
content="index, follow"> in the JSX head — it was duplicating
(and conflicting with) the metadata-driven tag, so Google was
seeing both "index, follow" AND "noindex, nofollow" and taking
the more restrictive one. Now the metadata block is the single
source of truth.
- sitemap.ts: expand from 5,000 articles to up to 45,000 (catalog
is ~26k; cap leaves room for static + authors + events under
Google's 50k-per-file ceiling). Add /authors/[slug] entries
pulled from bb.author_profiles, and /events/[slug] entries
pulled from bb.events. Drop the duplicate /articles/[slug]
emissions — /news/[slug] is the canonical news URL.
- legacy-source.ts: page through the supabase query in 1,000-row
chunks via Range headers so the 25k cap actually returns the
full catalog (the previous `.limit(25000)` was getting silently
truncated at PostgREST's default 1,000 max_rows).
Phase B (Ollama rewriter) is live; the press-release scrubber +
AI featured-image jobs are running in background to clean the
catalog. User explicitly chose to open SEO now rather than wait
for those jobs to finish.
The press-release rewriter (ai_rewrite_jobs) was stuck because:
- BB_REWRITE_PROVIDER defaulted to "anthropic" in code, AND
- production env still had BB_REWRITE_PROVIDER=anthropic, AND
- ollamaProvider.rewrite() was a stub that threw "not implemented".
Every job since 2026-01-26 hit Anthropic 400 "credit balance too
low" — 12 failed_api in a row.
Changes:
- Implement ollamaProvider against /v1/chat/completions on the local
Ollama (works against AI_001:11434 / llama3.1:70b). Same JSON
contract as the Anthropic provider; gets prompt_tokens /
completion_tokens out of Ollama's usage block.
- Flip the default provider from "anthropic" → "ollama" in
getProvider(), so the rewriter works out of the box now that the
Ollama implementation exists.
- Make the BB auto-story byline configurable via env. Default flips
from a 12-name pen rotation to a single "Ryan Salazar" byline for
Broadcast Beat (per editorial directive). AV Beat keeps its own
pen pool. Set BB_AUTOSTORY_BYLINE=rotate to restore the old
behaviour.
Production env still needs (set in Coolify dashboard):
OLLAMA_ENDPOINT=http://10.10.0.66:11434
OLLAMA_MODEL_DEFAULT=llama3.1:70b
BB_REWRITE_PROVIDER=ollama
BB_REWRITE_MODEL=llama3.1:70b
(remove ANTHROPIC_API_KEY)
- searchLegacyArticles() default limit: 50 → 5000. /search page
explicitly passes 5000 too. ilike on title/excerpt/author is fast
enough on the 29k-row archive that returning everything is fine.
- New API route /api/search/suggest?q=...&limit=8 returns the top
matching article titles + category + slug, with a 30-second CDN
cache + 60-second stale-while-revalidate.
- The search input in the Browse bar now debounces (150ms) and
fires that API per keystroke once 2+ chars are typed. Matches
appear in a styled dropdown below the input — click to navigate
directly to /news/<slug>, or press Enter to fall through to the
full /search page. "See all results for …" link in the dropdown
footer.
- Keyboard: Escape closes the dropdown; aria-autocomplete + role
listbox/option for accessibility.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After the AI re-categorization, bb.wp_imported_posts.category holds the
new taxonomy: 'Industry News', 'Show Coverage', 'Cameras & Capture',
'Streaming & IP', 'Audio', 'Live Production', 'Cloud & Workflow',
'Post Production', 'Broadcast Production', 'AI & Automation'.
The map that drives /gear, /technology, /show-coverage, /news page
queries still referenced the OLD taxonomy ('Gear', 'Reviews',
'Technology', 'Streaming') — so /gear and /technology effectively
returned nothing-matched and fell through to the catch-all section,
causing the duplicate-posts-across-sections behaviour the user
reported.
/gear → Cameras & Capture, Audio
/technology → Streaming & IP, Cloud & Workflow, AI & Automation,
Live Production, Post Production, Broadcast Production
/show-coverage→ Show Coverage
/news → Industry News
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Tables (Phase B+):
bb.ad_impressions — every render = a row. is_bot + viewport flags
for filtering. No unique constraints — gross
counts per Ry an spec.
bb.ad_clicks — every /r/[slug] hit = a row.
bb.ad_campaign_clients — schema only; future client reporting.
FK target is bb.banner_creatives (the renderer table from 5/15 banner
refresh). bb.ad_campaigns is the AdOps billing schema and unrelated.
Routes:
GET /r/[slug] — log click, 302 to bb.banner_creatives.click_url
POST /api/track/impression — record impression (slug | campaign_id),
used by client beacon
AdImage rewrite:
- link href is /r/{slug} target=_blank rel=sponsored noopener noreferrer
- sendBeacon on mount fires impression (viewport=false)
- additional sendBeacon on IntersectionObserver ≥0.5 (viewport=true)
- removed direct supabase.from('banner_analytics').insert path
Ad type carries slug now; FALLBACK list + DB mapper both populate it.
/admin/banners:
per-row stats — 24h, 7d, lifetime impressions/clicks/CTR
link to /admin/banners/[id]/analytics
/admin/banners/[id]/analytics (new):
recharts line charts for impressions + clicks (30d), top page paths,
top referrer domains, bot/human toggle.
Tower Products orphan image file removed; banner was not in any active
table or in code (already off rotation).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bb.banner_creatives row also inserted (AdSanity 191740, click_url
https://telycam.com/, dates 2024-05-07 → 2026-12-31, status active). The
creative was already in public/legacy/ads/ from the pre-Phase-B banner
pool — just needed the DB row + FALLBACK entry so SidebarAdStack picks
it up before the DB cache fills.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Backend:
bb.banner_creatives (new) — image_path, click_url, size, start/end_date,
status, adsanity_source_id. Seeded with 8 banners from broadcas_temp
WP-export DB (post_type=ads): LiveU 728x90, Blackmagic DaVinci Resolve
20 (300x600), Studio Hero, Magewell Pro-Convert, LiveU PAYG, AJA
ColorBox, Zixi, Lectrosonics (all 300x250). Dates and click URLs come
from the AdSanity rows; current creatives downloaded from Wayback
snapshot 20260316123737.
src/lib/ads.ts:
- Reads bb.banner_creatives via anon supabase-js client, gated by
status='active' AND start<=NOW()<=end.
- In-process 5-min TTL cache with stale-while-revalidate. Module load
primes the cache; clients see fallback on first hit, live DB on
subsequent.
- Hardcoded FALLBACK list mirrors the seeded rows so the site keeps
rendering banners if Supabase is unreachable during deploy.
- Public helpers (rotateAll, pickAds, ADS_728X90, ADS_300X250,
FIXED_300X600) remain synchronous so existing client component
callsites work unchanged.
/admin/banners:
Admin-only list + inline editor for every banner_creatives row:
name, click URL, start/end date, status (active|scheduled|paused|
expired). PATCH via /api/admin/banners/[id] — service_role bypass for
the write, user_profiles.role admin/administrator for the gate.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Magewell was removed when Adsanity went away; PENDING_ads.md flagged it
along with AJA/Zixi/Lectrosonics/Opengear/Studio Hero for real creatives.
This restores Magewell with an in-house SVG placeholder (red/dark Magewell
brand color, wordmark + tagline + CTA chip) so the sidebar 300x250 stack
gets three rotating entries again instead of two. Real Magewell creative
to land when sourced from the vendor.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
src/lib/ai/quality-gates.ts:
- Read bb.banned_terms with the ban_level column so admins can mark
a term as hard (instant fail) or soft (score nudge).
- Hard hits join the hard-coded AI_TELLS list to instant-fail the
rewrite (status=failed_quality, regenerate).
- Soft hits each contribute +0.05 to the heuristic AI-detector score,
capped at +0.30, so a borderline-slop article can be tipped over
the 0.65 threshold without single-handedly being rejected.
- Multi-word phrases are matched as substrings; single words use a
boundary regex that handles hyphenated terms cleanly.
- QualityGateResult now exposes softWordsFound alongside the existing
bannedWordsFound; failure_reason text includes the soft list when
the score trip is what failed.
44 terms seeded in bb.banned_terms (23 hard hype words like
"revolutionary", "industry-leading", "world-class", plus 7 PR-speak
openers like "pleased to announce"; 21 soft like "innovative",
"leverages", "robust", "transform").
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
src/lib/articles/legacy-source.ts
- getLegacyArticleBySlug, getLegacyArticlesBySection, searchLegacyArticles,
getLegacyRecentSlugs, getLegacyRecentSitemapEntries: each now UNIONs
bb.ai_rewritten_articles (status=published) with bb.wp_imported_posts.
Rewrites carry persona attribution (author/avatar/title) via a
persona:ai_personas(slug,name,beat,avatar_url) embed.
- Section routing for rewrites is by category, not tags (rewrites do not
use the WP tag taxonomy — they have category strings written by Claude).
src/app/api/ai/submit/route.ts
- Bearer auth via BB_INGEST_BEARER (preferred) or x-internal-key (legacy)
- Polymorphic payload: accepts native {rawTitle, rawContent, ...} OR the
distribute-rmp shape {title, body, contributor:{...}, attachments,
submission_id, ...}. Distribute attachments + featured image survive in
bb.ai_original_submissions.metadata.
src/app/api/ai/rewrite/route.ts
- Same bearer auth pattern as submit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pluggable rewrite client (Anthropic default, ollama/openrouter stubs)
using claude-opus-4-7 with prompt caching on system prompt + style guide.
Routes the raw submission through:
1. bb.ai_original_submissions — raw never edited
2. bb.ai_rewrite_jobs — every attempt logged with token counts
3. quality gates — AI-tell banned-word check + heuristic
AI-detector score (threshold 0.65)
4. regenerate up to 3x — on banned words or detector trip
5. bb.ai_rewritten_articles — status ai_rewritten_pending_review
6. /admin/review-queue — list + detail page with approve / reject /
needs-human actions; on approve, the
original submission is FK-linked to the
published rewrite
New files only — no existing routes or contributor flow modified.
Persona auto-selection routes by beat keywords; 6 personas seeded
(marcus-halverson, elena-vasquez, darren-okafor, sarah-quinn, mike-trayton,
priya-rao) with SVG monogram avatars in public/assets/images/personas/.
Smoke test (Matrox / ST 2110 sample press release):
- persona auto-routed to darren-okafor (protocols-specs)
- anthropic 22.3s, in=2616 / out=1372 tokens
- gates passed first try, score=0.000, no banned words
- review_queue row created
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
P0-2 (Adsanity iframe path removed):
- AdImage.tsx: deleted the iframe branch and the ADSANITY_CAMPAIGN_MAP
fallback. AdImage now renders local images only; banners without a
src render nothing.
- ads.ts: removed Ad.adsanity_id from the interface. Pruned AJA, Zixi,
Lectrosonics, Opengear, Studio Hero, Magewell 300x250 and Blackmagic
300x600 — they relied on ads.broadcastbeat.com which does not resolve.
Surviving live banners: LiveU 728x90, LiveU PAYG 300x250, Telycam 300x250.
- SidebarAdStack: key no longer references the removed field.
- No banner_analytics rows reference ads.broadcastbeat.com.
Phase A (SEO baseline):
- robots.ts: fallback base URL now broadcastbeat.com (was rocket staging URL).
- sitemap.ts: fallback base URL now broadcastbeat.com (was bb-staging).
- news/[slug]/page.tsx and articles/[slug]/page.tsx: same fallback fix.
JSON-LD now uses ISO 8601 for datePublished, absolute image URL via
SITE_URL prefix, and publisher.logo points at /assets/images/logo.png
instead of the 404-ing /legacy/site/broadcastbeat-logo.png.
- layout.tsx: removed the /en /es /pt /fr /de /zh /ja /ar /hi hreflang
alternates - those routes 404 today; Phase D will reinstate. Replaced
rocket.new Organization schema URL with broadcastbeat.com. Set
robots index/follow=false until Phase B-D land and Rich Results passes.
Featured-image fallback hardening:
- legacy-source: rowToArticle now treats BB_Gray, no_image and placeholder
URLs as null and falls back to the new placeholder so dead images do not
leak through to the page.
- public/assets/images/article-placeholder.svg: new clean dark gradient
with BroadcastBeat wordmark, NOT no_image.png style.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
P0-3 (category page crash):
- Add src/app/error.tsx and src/app/global-error.tsx so any future render
error shows a real ref id instead of the bare client-exception toast.
- NewsPageClient: null-guard every field accessed during search/category/
sort filtering and article render (title, excerpt, author, tags,
category, date, image, alt). The user-visible link
/news?category=live-production now also hydrates filter state from
?category= and ?search= search-params, so the dropdown actually filters.
P0-4 (/articles/[slug] 404s):
- /articles/[slug] and /news/[slug]: on slug miss, redirect("/news")
instead of notFound(). Dead links from the hardcoded NewsTicker /
FeaturedBento / ArticleFeed arrays now land on the news index instead
of dead-ending on 404.
- legacy-source: add searchLegacyArticles() (title/excerpt/author ilike).
P0-5 (header search):
- Header: wire Enter on the desktop and mobile search inputs and make
both search icons clickable buttons. Submits to /search?q=<query>.
- New /search route: server component that runs searchLegacyArticles
and renders results in the same card style as /news.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Banner spec from Ryan:
- 728x90 header (every page): pool is now LiveU only; rotates when more
728x90 entries are added to ads.ts.
- 728x90 below main menu on article pages: removed (was the article-top
AdSlot stub).
- 728x90 footer (every page): replaces the old 300x250 rocket.new
placeholder in Footer.tsx. Rotates from the same ADS_728X90 pool.
- 300x250 sidebar: now renders EVERY entry in ADS_300X250 stacked
vertically, shuffled per page-view. Pool: AJA, Zixi, Telycam, LiveU,
Lectrosonics, Opengear, Studio Hero, Magewell. Adding more entries to
ads.ts auto-grows the stack.
- 300x600 fixed slot: Blackmagic (Adsanity ad-210571), top of sidebar,
never rotated.
Implementation:
- ads.ts: Ad interface now allows either src (local image) or adsanity_id
(iframe). New rotateAll() returns ALL ads of a size shuffled; pickAds
kept for ArticleBody bounded slots.
- AdImage.tsx: dropped the legacy ADSANITY_CAMPAIGN_MAP label fallback —
ads.ts is now the single source of truth, so Telycam (no adsanity_id)
renders its local GIF instead of the iframe.
- SidebarAdStack.tsx: rotateAll(300x250) instead of pickAds with a
count cap. count prop removed; callers updated.
- NewsArticleDetailClient.tsx: removed article-top 728x90 stub.
- Footer.tsx: 728x90 AdImage in place of the 300x250 placeholder.
- LeaderboardAd.tsx: deleted (was unused after the earlier home-page
cleanup).
- PENDING_ads.md: rewritten — Adsanity IDs to verify (Studio Hero,
Blackmagic) and optional local-file upgrades.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Remove duplicate 728x90 LeaderboardAd below news ticker (per Ryan)
- Footer: remove NAB Official Media Partner badge
- Header: replace AdSlot stub with real AdImage rotation (3 verified 728x90s)
- Header: fix logo path from dead WP URL to local /assets/images/logo.png
- ads.ts: only include ads with verified real images (3x728x90, 2x300x250)
- ads.ts: remove 22 unrecovered ads — see PENDING_ads.md for upload list
- SidebarAdStack: handle null FIXED_300X600 gracefully (Blackmagic not recovered)
- public/legacy/ads/: 5 real ad images committed (Tower, Sony, LiveU 728x90; LiveU PAYG, Telycam 300x250)
- public/assets/images/logo.png: recovered from Wayback Machine
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The browser-side createBrowserClient was writing cookies with
'Secure; SameSite=None' while the server-side createServerClient
writes the same cookie names with 'SameSite=Lax' (no Secure flag —
Next.js cookies().set() default).
When the wp-login API set the auth cookie via NextResponse, the browser
stored it with SameSite=Lax. Then the browser supabase client (via
AuthContext or any subsequent auth call) tried to re-write the cookie
via document.cookie with Secure;SameSite=None. Browsers can't reconcile
this — depending on Chrome/Safari/Firefox version, you get either:
- duplicate cookies that confuse session detection
- the JS write silently failing because Secure;SameSite=None has
additional constraints
- the existing Lax cookie getting clobbered with attributes that
conflict with same-site navigation
Net effect: AuthContext on /contributor doesn't reliably see the
session even though the cookie exists, so /contributor's
'!loading && !user → router.push(/login)' check bounces the user
back to /client-login. Looks like login failed.
Fix: align the JS-side cookie attributes with the server-side defaults
(SameSite=Lax, no Secure). Both writes now produce the same cookie
shape, no conflict.
- Add src/lib/supabase/admin.ts: server-only Supabase client constructed
with SUPABASE_SERVICE_ROLE_KEY. Uses persistSession=false. Throws if
the env var is missing.
- src/app/api/auth/wp-login/route.ts: switch the auth.admin.createUser
call (step 5, transparent migration on first login) to use the new
admin client. The anon-key client cannot call admin.createUser, which
would have made first-login attempts for legacy WP users fail at
step 5 with a permission error.
- src/app/login/page.tsx: replaced the old WP-style login form with a
server-side redirect to /client-login so the new client-login is the
canonical PR-firm/contributor entry point. Existing bookmarks for
/login keep working.
Coolify app env: SUPABASE_SERVICE_ROLE_KEY added (set out-of-band; not
NEXT_PUBLIC_-prefixed so it never ships to the client bundle).
Replace the placeholder Unsplash banner array with the live AdSanity
inventory served from /legacy/ads/ (same 27 active creatives currently
on broadcastbeat.com).
New:
- src/lib/ads.ts: ad inventory (1 fixed Blackmagic 300x600, 22 rotating
300x250s, 4 leaderboard 728x90s) + Fisher-Yates shuffle + pickAds()
helper that draws N unique ads excluding any caller-supplied src set.
- src/components/AdImage.tsx: renders one ad as <a target=_blank rel=
sponsored noopener noreferrer><AppImage>.
- src/components/SidebarAdStack.tsx: Blackmagic 300x600 fixed on top,
then count rotating 300x250s. Memoizes the rotation per render so a
refresh re-shuffles. Accepts excludeSrcs to coordinate with other
slots on the same page.
- src/components/ArticleBody.tsx: splits article HTML at </p>
boundaries, splices a 300x250 ad after every Nth paragraph (default
4). Same exclude-set pattern.
Wiring:
- ArticleFeed.tsx: remove the inline sidebarAds array (was 5 Unsplash
placeholders + 1 LiveU 300x250). Sidebar now <SidebarAdStack count=5/>.
- NewsArticleDetailClient.tsx: sidebar 300x600 + 300x250 AdSlot
placeholders replaced with <SidebarAdStack count=4/>. Article body
innerHTML replaced with <ArticleBody html=... everyN=4/> so
300x250 ads slot in between paragraphs.
Same banners as the live broadcastbeat.com (sourced from the existing
WP AdSanity install we cached locally during Phase 2). Each <a> opens
in a new tab and uses rel='sponsored noopener noreferrer'.
Eliminates 72 hardcoded rocket.new image refs in one shot. Section pages
(/gear, /technology, /show-coverage, /news) now render from
bb.wp_imported_posts via getLegacyArticlesBySection(section), which maps
each non-news section to a curated tag set (e.g. gear -> Audio, Cameras,
lighting, Blackmagic Design, monitoring; technology -> AI, Streaming,
OTT, IP, Cloud, AV; show-coverage -> NAB, IBC, BroadcastAsia).
Also:
- src/lib/articles/types.ts: Article interface lifted out of the deleted
seed file. Type-only consumers (NewsArticleDetailClient,
ArticleDetailClient, legacy-source) re-imported from here.
- /news (client component) now wrapped: server fetches articles from DB
and passes to NewsPageClient. Filter UI unchanged.
- generateStaticParams on /news/[slug] and /articles/[slug] no longer
references seed slugs; pre-renders 50 most-recent imported slugs and
relies on dynamicParams + revalidate=3600 for the rest.
- sitemap.ts now sources article URLs from
getLegacyRecentSitemapEntries() (up to 5000 most-recent imported posts).
Default BASE_URL fallback updated from the rocket.new placeholder to
bb-staging.onsethost.com.
- src/app/api/ai/article-suggestions/route.ts now pulls candidates from
the DB (top 100 recent news) and resolves AI-returned slugs via DB
lookup if not in the candidate window.
Inline rocket.new image refs in homepage components (ArticleFeed,
FeaturedBento) are unchanged in this commit; those are inline seed
arrays in the components, not imports of sampleArticles.
- new src/lib/articles/legacy-source.ts: server-side Supabase reader
(bb schema, anon key, RLS public_read policy) that maps imported rows
to the existing Article shape, applies rewriteLegacyImageUrl for the
featured image and rewriteLegacyImageUrlsInHtml across the body.
- both [slug]/page.tsx routes now: try seed sampleArticles first; on
miss fall back to getLegacyArticleBySlug. Related articles match via
same-category recent posts when imported.
- generateStaticParams pre-renders seed slugs + 50 most recent imported
posts. dynamicParams=true means anything else server-renders on first
request and ISR caches it for 1h (revalidate=3600). Avoids a 4577-page
build.
- Hardcoded builtwithrocket.new URLs in JSON-LD replaced with
process.env.NEXT_PUBLIC_SITE_URL.
src/lib/legacy-image.ts exports rewriteLegacyImageUrl(url) and
rewriteLegacyImageUrlsInHtml(html) which translate WP origin URLs
(https://www.broadcastbeat.com/wp-content/uploads/...) to local paths
under /legacy/... served from the new persistent storage volume mounted
at /app/public/legacy/.
src/lib/legacy-image-map.json holds 3,856 URL→path entries built in
Phase 2: 3,839 featured images (mostly converted to webp, q=85) plus
27 ad creatives plus the site logo.
Use this helper at server-render time when emitting <img>/<Image> for
imported WP articles, so the 707 MB on-host cache replaces external
fetches to the origin.