- New DualSpeedTicker component (CSS keyframes, GPU-accelerated,
pause on hover, 28px rows, 17s BEAT / 40s FORUM, refresh 5min)
- /api/ticker/beat-news: last 7 days from bb.native_articles
- /api/ticker/forum-posts: latest threads + replies interleaved
- Mounted at the bottom of Header so every page gets it
- Scoped .bb-neon palette on homepage root: section heads +
text-shadow glow, neon-tinted card borders, emerald→cyan button
gradient, cyan hover state on links, subscribe boxes neon-bordered
- CSS-only — no layout changes
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- 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>
- layout.tsx: load Lora (serif) + Inter (sans) via next/font/google,
self-hosted at build time, exposed as --font-serif / --font-sans
CSS variables on <html>. No CDN fetch, no FOIT, no GDPR concern.
- tailwind.css: --font-heading and --font-body now reference the
next/font variables with Georgia / -apple-system fallbacks for the
brief window before the self-hosted face is ready. --font-mono
upgraded to IBM Plex Mono → ui-monospace → Courier New fallback
(DoubleTicker chips already specify IBM Plex Mono directly).
- ArticleFeed: drop the FEATURED filter chip (duplicates the
Staff Editorial section above), add publish date alongside the
byline on every card, lift the homepage feed cap from 200 → 5000.
- api/public/posts: lift MAX_LIMIT to 10000 so client-side
pagination + infinite-scroll see the full archive.
1. Remove SystemStatusBar from root layout. The "29,220 articles
indexed / 13 events tracked / [v2.0.0]" strip ate vertical space
above the fold without serving readers.
2. /api/newsletter/subscribe was returning 500 "Failed to save
subscription" because:
- The route used the anon-session client and tried to UPSERT on
bb.newsletter_subscribers, but the table's RLS only has an
INSERT policy for the public role. Upsert needs INSERT+UPDATE,
so the second branch failed RLS.
- Switched to createAdminClient (service-role) — bypasses RLS,
which is fine because this is a server-side endpoint with
server-side email validation.
- Welcome email now sends via nodemailer in a non-blocking
background task so a slow SMTP doesn't hold up the response.
Newer HTML template, brand-aligned, with a real CTA back to
/news.
Side work to wire SMTP (done out-of-band):
- Created newsletter@broadcastbeat.com mailbox in Mailcow via
the API (2GB quota, active, no force-pw-update).
- Set SMTP_HOST=mail.onsethost.com, SMTP_PORT=587,
SMTP_USER/PASS/FROM_EMAIL/FROM_NAME in Coolify env so the
welcome email actually fires after the next deploy.
3. FeaturedBentoFromDb section header: "Featured · staff editorial"
(small mono caps) → "Staff Editorial" (font-serif 2xl/3xl, bold,
with a thin metadata line "Selected by our newsroom" beside it
and a 2px bottom rule). Matches the editorial typography on the
article pages.
PLACEHOLDER: "Search broadcast news…" → "Search articles, gear,
vendors". Drops the weird ellipsis and the "we're a broadcast site
so 'broadcast news' is redundant" feel.
API (/api/search/suggest):
- Match expanded from {title, author_name} to
{title, excerpt, content, author_name} — surfaces hits when the
phrase lives deep in the article body.
- Returns a snippet object {text, matchStart, matchEnd} sliced
from the first hit (HTML-stripped), with ~80 chars of context on
either side and "…" markers when truncated.
- Returns matched_in: 'title' | 'excerpt' | 'content' | 'author' so
the UI can show "match in article body" / "match in byline".
- Returns date + author_name on each result.
UI dropdown (Header.tsx):
- Each suggestion now renders three rows:
1) Title (bold) + category chip
2) Snippet with the matched substring wrapped in <mark>
3) Author byline + date + (if match wasn't in title) a tag
showing where the match occurred
- Vertical stacking with a colored left-border on hover so the
active suggestion is visible.
CSS:
- Stack item layout, snippet with -webkit-line-clamp:2 so long
matches don't overflow.
- <mark> background uses the accent blue at 28% so the highlight
reads against the dark card.
USER PROFILES (/forum/user/[username]):
- New API at /api/forum/user-by-username/[username] resolves by
username OR display_name (URL-encoded), since forum threads/replies
store author_name = display_name. Returns the persona's profile,
thread history (up to 50), and recent replies (up to 20).
- New page renders the persona card with archetype badge
(Audio Engineer / Colorist / etc.) + expertise level badge
(beginner/intermediate/senior) + role title, company, location,
years_experience, join date, and an expertise-tags chip row.
- Two columns underneath: "Threads started" and "Recent replies"
with proper deep-links into each thread.
- Author names in /forum, /forum/[slug], and /forum/thread/[id] are
now <Link> elements pointing to /forum/user/<displayname>, with
onClick=stopPropagation so the card-level click handler doesn't
also fire.
FAVICON:
- Hand-built neon pulse waveform — emerald→cyan→purple gradient on
dark navy background, designed to read at 16×16 to 256×256.
- Multi-resolution favicon.ico (16/32/48/64) + SVG + PNGs at
16/32/48/64/96/192/256 under /static/favicons/.
- layout.tsx icons block now references all sizes including the
apple-touch sizes (192, 256).
No env / DB changes here — pure deploy.
The schema has upvotes / downvotes / vote_score; API + UI were
querying/reading upvote_count / downvote_count (non-existent
columns). Threads existed in the DB but didn't render.
- /api/forum/threads route.ts:
- 'top-voted' sort: order by vote_score (was upvote_count)
- 'newest' default sort: forum_threads has no 'status' column,
so order pinned-first via is_pinned, then last_reply_at desc
- UI ForumThread interface + components: rename
upvote_count → upvotes, add downvotes + vote_score so the
vote display in the thread cards and the admin moderation
list pulls live data from the existing columns.
After this lands and the deploy completes, the 50 seeded
threads + the 9 ai-seeded new threads + 253 backfilled replies
already in the DB will all render at /forum.
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)
Drops the 540-line hand-curated map and replaces it with a real
bb.author_profiles table + view (author_profiles_with_stats) that
computes first_post_date / total_articles live from
wp_imported_posts. The page now resolves any slug correctly:
- Slug looked up in author_profiles; if no row, the slug is
titleized ("desert-moon-communications" → "Desert Moon
Communications") and used as the wp_imported_posts.author_name
filter, so articles + join date still resolve.
- "Joined ..." line is now the actual MIN(wp_published_at) for that
author, formatted as Month YYYY. Fixes Desert Moon showing 2020
when its first post was Nov 2013.
- Role line defaults to "Contributor"; staff/founder roles ride on
the new role column. Ryan seeded with "Founder of Broadcast Beat"
in the migration.
- About tab renders a contact card (address / phone / email /
website) whenever any of those fields exist on the profile row,
plus a profile-facts panel with location + member-since +
articles published + role.
- Bio is from the profile row; when empty, page shows
"No biography has been added yet for {name}" instead of falling
back to another author's bio.
Bios + contact details for the remaining ~160 authors are populated
by a separate Ollama+web-fetch enrichment job (next commit).
- Replace img.rocket.new mock URLs in authors/[slug] + FeaturedBento
with /assets/images/article-placeholder.svg (live DB data drives
these pages now; the static maps are fallback only).
- Replace broadcastb5322.builtwithrocket.new with broadcastbeat.com
in admin API routes, news detail, forum layout, and the 4 Supabase
edge functions that build outbound email links.
- Swap rocket-hosted JSON-LD logo URL in root layout for the local
/assets/images/logo.png we already ship.
- Drop img.rocket.new from the Next.js image-hosts allowlist;
add supabase.onsethost.com so storage proxy URLs can render.
The DB has had zero rocket.new image refs for weeks; this finishes
the job in the codebase so no page on broadcastbeat.com can render
a rocket-hosted asset anymore.
Authors page was rendering a hardcoded mock article list per slug,
with Ryan Salazar's slug falling through to the staff-reporter mock
list (847 fake articles). New behaviour:
- GET /api/author/[slug] returns ALL real articles where
bb.wp_imported_posts.author_name matches the canonical name(s)
for that slug. Slug → names map:
ryan-salazar → ["Ryan Salazar"]
james-whitfield → ["James Whitfield"]
sarah-chen → ["Sarah Chen"]
… etc.
Unknown slugs fall back to a Title-Cased guess.
Response: { total, articles[] }, cached 5min / SWR 10min.
- /authors/[slug] now fetches that endpoint on mount and replaces
the hardcoded list with the live one. Article count shown on the
profile header + sidebar is now the live total (not the
hardcoded 1200/847/etc).
- Version badge in the status bar bumped v4.2.1 → v2.0.0 to match
the actual public release.
Ryan Salazar's profile now correctly shows his 135 real bylines from
WordPress instead of the staff-reporter mock 847.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- 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>
Replace the four accent-family hex codes site-wide with the teal palette
(keeps the dark theme; only swaps the accent family, not the dark base):
#3b82f6 (accent primary CTA) → #5B7C8D (teal) [839×]
#2563eb (accent-dark / hover) → #4A6473 (darker teal) [44×]
#60a5fa (info link, lighter) → #8FB0C3 (mid teal) [68×]
#1e3a5f (accent-muted bg) → #2F4F5F (navy) [44×]
tailwind.config.js tokens updated to match (accent/accent-dark/accent-muted).
Sweep also covers tailwind.css. 108 files touched. The dark base (#0d0d0d,
#111, #161616, #1a1a1a etc.) is intentionally untouched.
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>
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>
src/app/api/admin/review-queue/[id]/route.ts
- Call revalidatePath on /news, /news/<slug>, /articles/<slug>, /home-page,
section pages, and /sitemap.xml on approve/reject. The /news listing is
ISR-cached (revalidate=1800) so approval would otherwise be invisible
for up to 30 minutes; this flushes the cache immediately.
src/app/api/ai/submit/route.ts
- Reject submissions where rawTitle/title is whitespace or rawContent/body
is empty after stripping HTML. Previously empty bodies fell through into
the LLM and Claude dutifully wrote a 400-word piece about the empty
submission. Hard-cap at the gate to avoid the spend.
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>
Previously the route ran phpass validation against wp_legacy_users.
wp_hashed_password before checking auth_user_id, even for users who had
already been migrated. That meant any user whose Supabase password
diverged from the original WP hash (e.g., curated migration users like
ryan.salazar@relevantmediaproperties.com — wp_id=1 'broadcastbeat',
where the hash on the legacy row is from the migration template, not
the user's current password) would get 401 even with the correct
Supabase password.
Fix: if auth_user_id is set OR password_upgraded is true, skip phpass
and go directly to supabase.auth.signInWithPassword. The legacy hash
is only authoritative for the first-ever login (when neither flag is
set). Returns legacy:true so the client can distinguish from the
fully-native signInWithPassword fallback (legacy:false).
The /api/auth/wp-login route previously returned 401 immediately if the
email had no bb.wp_legacy_users entry. That made it impossible for
modern staff accounts (created directly via the Supabase Auth Admin
API, no WP migration history) to sign in via /client-login.
When the legacy lookup misses, fall through to a regular
supabase.auth.signInWithPassword call. If that succeeds, return a
success response with legacy=false so the client can distinguish
modern auth from a phpass-migrated session.
Existing legacy WP users keep working — the fallback only fires when
the legacy lookup returns no rows.
- 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).
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.